diff --git a/.changeset/create-in-project-group.md b/.changeset/create-in-project-group.md new file mode 100644 index 000000000..425bb1a29 --- /dev/null +++ b/.changeset/create-in-project-group.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add `_createInProjectGroup` to logger options diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index d0ac482fc..5af2481a2 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -56,6 +56,143 @@ afterEach(() => { vi.useRealTimers(); }); +describe("project group registration", () => { + test.each(["project", undefined])( + "registers %s in the requested group and resolves metadata lazily", + async (projectName) => { + const { state, fetch } = createState(); + const logger = initLogger({ + state, + projectName, + _createInProjectGroup: "my-group", + setCurrent: false, + }); + expect(fetch).not.toHaveBeenCalled(); + expect(await logger.id).toBe(projectId); + const requests = fetch.mock.calls.filter(([url]) => + String(url).endsWith("/api/project/register"), + ); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0][1]?.body as string)).toEqual({ + project_name: projectName ?? "Global", + org_id: "org-id", + project_group_name: "my-group", + }); + }, + ); + + test("caches scoped registrations separately from unscoped ones", async () => { + const { state, fetch } = createState(); + const options = { state, projectName: "project", setCurrent: false }; + await initLogger(options).id; + for (const group of ["my-group", "my-group", "other-group"]) { + await initLogger({ ...options, _createInProjectGroup: group }).id; + } + await initLogger(options).id; + const requests = fetch.mock.calls.filter(([url]) => + String(url).endsWith("/api/project/register"), + ); + expect( + requests.map(([, init]) => JSON.parse(init?.body as string)), + ).toEqual([ + { project_name: "project", org_id: "org-id" }, + ...["my-group", "other-group"].map((project_group_name) => ({ + project_name: "project", + org_id: "org-id", + project_group_name, + })), + ]); + }); + + test.each(["project", undefined])( + "preserves project ID precedence with project name %s", + async (projectName) => { + const { state, fetch } = createState(); + const logger = initLogger({ + state, + projectId, + projectName, + _createInProjectGroup: "my-group", + setCurrent: false, + }); + expect(await logger.id).toBe(projectId); + expect( + fetch.mock.calls.map(([url]) => new URL(String(url)).pathname), + ).toEqual( + projectName + ? ["/api/apikey/login"] + : ["/api/apikey/login", "/api/project"], + ); + }, + ); + + test("preserves the group in unresolved logger and span exports", async () => { + const { state } = createState(); + const logger = initLogger({ + state, + projectName: "project", + _createInProjectGroup: "my-group", + setCurrent: false, + }); + const exportedLogger = SpanComponentsV4.fromStr(await logger.export()); + const exportedSpan = SpanComponentsV4.fromStr( + await logger.startSpan({ name: "parent" }).export(), + ); + // Generated by Python's SpanComponentsV4 with the same logger options. + const pythonExport = SpanComponentsV4.fromStr( + "BAIAeyJjb21wdXRlX29iamVjdF9tZXRhZGF0YV9hcmdzIjp7InByb2plY3RfbmFtZSI6InByb2plY3QiLCJwcm9qZWN0X2lkIjpudWxsLCJwcm9qZWN0X2dyb3VwX25hbWUiOiJteS1ncm91cCJ9fQ==", + ); + for (const components of [exportedLogger, exportedSpan, pythonExport]) { + expect(components.data.compute_object_metadata_args).toEqual({ + project_name: "project", + project_group_name: "my-group", + ...(components === pythonExport ? { project_id: null } : {}), + }); + const { state: receivingState, fetch } = createState(); + for (let i = 0; i < 2; i++) { + expect( + await spanComponentsToObjectId({ state: receivingState, components }), + ).toBe(projectId); + } + const requests = fetch.mock.calls.filter(([url]) => + String(url).endsWith("/api/project/register"), + ); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0][1]?.body as string)).toEqual({ + project_name: "project", + org_id: "org-id", + project_group_name: "my-group", + }); + } + }); + + test("propagates a group conflict without falling back to unscoped registration", async () => { + const { state, fetch } = createState(); + await state.login({}); + fetch.mockResolvedValue( + new Response("Project already exists outside project group my-group", { + status: 409, + statusText: "Conflict", + }), + ); + await expect( + initLogger({ + state, + projectName: "project", + _createInProjectGroup: "my-group", + setCurrent: false, + }).id, + ).rejects.toThrow("409"); + const requests = fetch.mock.calls.filter(([url]) => + String(url).endsWith("/api/project/register"), + ); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0][1]?.body as string).project_group_name).toBe( + "my-group", + ); + }); +}); + describe("project metadata caching", () => { test.each(["reset", "forceLogin", "fetch"])( "shares lookups across SDK copies and invalidates on %s", diff --git a/js/src/logger.ts b/js/src/logger.ts index ce98c1be8..3a5be45ca 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -5051,9 +5051,11 @@ async function computeLoggerMetadata( { project_name, project_id, + project_group_name, }: { project_name?: string; project_id?: string; + project_group_name?: string; }, ) { await state.login({}); @@ -5065,13 +5067,14 @@ async function computeLoggerMetadata( }; } + const groupName = project_group_name || undefined; const cache = state.projectMetadataCache; const key = JSON.stringify([ state.appUrl, org_id, state.loginToken, isEmpty(project_id) - ? ["name", project_name || GLOBAL_PROJECT] + ? ["name", project_name || GLOBAL_PROJECT, groupName] : ["id", project_id], ]); const cached = cache.get(key); @@ -5093,6 +5096,7 @@ async function computeLoggerMetadata( { project_name: project_name || GLOBAL_PROJECT, org_id, + project_group_name: groupName, }, controller.signal, ); @@ -5153,6 +5157,7 @@ type AsyncFlushArg = { export type InitLoggerOptions = FullLoginOptions & { projectName?: string; projectId?: string; + _createInProjectGroup?: string; environment?: SpanOriginEnvironment; setCurrent?: boolean; state?: BraintrustState; @@ -5165,6 +5170,7 @@ export type InitLoggerOptions = FullLoginOptions & { * @param options Additional options for configuring init(). * @param options.projectName The name of the project to log into. If unspecified, will default to the Global project. * @param options.projectId The id of the project to log into. This takes precedence over projectName if specified. + * @param options._createInProjectGroup Experimental: the name of an existing project group to create the project in. Existing projects must already belong to the group, otherwise registration fails with a 409. Membership is re-checked on each fresh project lookup, so moving the project out of the group makes new loggers fail with a 409. Ignored when projectId is provided. * @param options.asyncFlush If true, will log asynchronously in the background. Otherwise, will log synchronously. (true by default) * @param options.appUrl The URL of the Braintrust App. Defaults to https://www.braintrust.dev. * @param options.apiKey The API key to use. If the parameter is not specified, will try to use the `BRAINTRUST_API_KEY` environment variable. In Node.js, @@ -5181,6 +5187,7 @@ export function initLogger( const { projectName, projectId, + _createInProjectGroup, asyncFlush: asyncFlushArg, appUrl, apiKey, @@ -5198,6 +5205,7 @@ export function initLogger( const computeMetadataArgs = { project_name: projectName, project_id: projectId, + project_group_name: _createInProjectGroup, }; const linkArgs = {