From 2ee44eacd4529b9f8fcd642ef39662a59a08187d Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:10:19 +0000 Subject: [PATCH 1/3] feat: Add `_createInProjectGroup` to logger options --- js/src/logger-caching.test.ts | 135 ++++++++++++++++++++++++++++++++++ js/src/logger.ts | 22 +++++- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index d0ac482fc..5bb0fff85 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -56,6 +56,141 @@ 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); + 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("keeps scoped registrations separate from the shared cache", 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", "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(); + expect( + await spanComponentsToObjectId({ state: receivingState, components }), + ).toBe(projectId); + const request = fetch.mock.calls.find(([url]) => + String(url).endsWith("/api/project/register"), + ); + expect(JSON.parse(request?.[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..583f9698c 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({}); @@ -5066,6 +5068,8 @@ async function computeLoggerMetadata( } const cache = state.projectMetadataCache; + // Scoped registrations must check current group membership on the server. + const useCache = !isEmpty(project_id) || project_group_name == null; const key = JSON.stringify([ state.appUrl, org_id, @@ -5074,7 +5078,7 @@ async function computeLoggerMetadata( ? ["name", project_name || GLOBAL_PROJECT] : ["id", project_id], ]); - const cached = cache.get(key); + const cached = useCache ? cache.get(key) : undefined; if (cached && Date.now() < cached.expiresAt) { return structuredClone(await cached.promise); } @@ -5093,6 +5097,7 @@ async function computeLoggerMetadata( { project_name: project_name || GLOBAL_PROJECT, org_id, + ...(project_group_name != null ? { project_group_name } : {}), }, controller.signal, ); @@ -5131,7 +5136,9 @@ async function computeLoggerMetadata( }), ]), }; - cache.set(key, entry); + if (useCache) { + cache.set(key, entry); + } try { const metadata = await entry.promise; entry.expiresAt = Date.now() + PROJECT_CACHE_TTL_MS; @@ -5153,6 +5160,12 @@ type AsyncFlushArg = { export type InitLoggerOptions = FullLoginOptions & { projectName?: string; projectId?: string; + /** + * Experimental: create the project in this existing project group, by name. + * An existing project must already belong to the group, otherwise registration fails with a 409. + * Ignored when projectId is provided. + */ + _createInProjectGroup?: string; environment?: SpanOriginEnvironment; setCurrent?: boolean; state?: BraintrustState; @@ -5165,6 +5178,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. 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 +5195,7 @@ export function initLogger( const { projectName, projectId, + _createInProjectGroup, asyncFlush: asyncFlushArg, appUrl, apiKey, @@ -5198,6 +5213,9 @@ export function initLogger( const computeMetadataArgs = { project_name: projectName, project_id: projectId, + ...(_createInProjectGroup != null + ? { project_group_name: _createInProjectGroup } + : {}), }; const linkArgs = { From a2bff73727b575a62865a1fd56ce1f85faefba2a Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:14:16 +0000 Subject: [PATCH 2/3] chore: Add changeset --- .changeset/create-in-project-group.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/create-in-project-group.md 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 From 0363f2da6bec1d53938031491964bc4f7ada2b1d Mon Sep 17 00:00:00 2001 From: lforst <8118419+lforst@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:56:02 +0000 Subject: [PATCH 3/3] Update PR #2558 --- js/src/logger-caching.test.ts | 18 ++++++++++-------- js/src/logger.ts | 24 +++++++----------------- 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/js/src/logger-caching.test.ts b/js/src/logger-caching.test.ts index 5bb0fff85..5af2481a2 100644 --- a/js/src/logger-caching.test.ts +++ b/js/src/logger-caching.test.ts @@ -69,7 +69,6 @@ describe("project group registration", () => { }); expect(fetch).not.toHaveBeenCalled(); expect(await logger.id).toBe(projectId); - expect(await logger.id).toBe(projectId); const requests = fetch.mock.calls.filter(([url]) => String(url).endsWith("/api/project/register"), ); @@ -82,7 +81,7 @@ describe("project group registration", () => { }, ); - test("keeps scoped registrations separate from the shared cache", async () => { + test("caches scoped registrations separately from unscoped ones", async () => { const { state, fetch } = createState(); const options = { state, projectName: "project", setCurrent: false }; await initLogger(options).id; @@ -97,7 +96,7 @@ describe("project group registration", () => { requests.map(([, init]) => JSON.parse(init?.body as string)), ).toEqual([ { project_name: "project", org_id: "org-id" }, - ...["my-group", "my-group", "other-group"].map((project_group_name) => ({ + ...["my-group", "other-group"].map((project_group_name) => ({ project_name: "project", org_id: "org-id", project_group_name, @@ -150,13 +149,16 @@ describe("project group registration", () => { ...(components === pythonExport ? { project_id: null } : {}), }); const { state: receivingState, fetch } = createState(); - expect( - await spanComponentsToObjectId({ state: receivingState, components }), - ).toBe(projectId); - const request = fetch.mock.calls.find(([url]) => + 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(JSON.parse(request?.[1]?.body as string)).toEqual({ + 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", diff --git a/js/src/logger.ts b/js/src/logger.ts index 583f9698c..3a5be45ca 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -5067,18 +5067,17 @@ async function computeLoggerMetadata( }; } + const groupName = project_group_name || undefined; const cache = state.projectMetadataCache; - // Scoped registrations must check current group membership on the server. - const useCache = !isEmpty(project_id) || project_group_name == null; 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 = useCache ? cache.get(key) : undefined; + const cached = cache.get(key); if (cached && Date.now() < cached.expiresAt) { return structuredClone(await cached.promise); } @@ -5097,7 +5096,7 @@ async function computeLoggerMetadata( { project_name: project_name || GLOBAL_PROJECT, org_id, - ...(project_group_name != null ? { project_group_name } : {}), + project_group_name: groupName, }, controller.signal, ); @@ -5136,9 +5135,7 @@ async function computeLoggerMetadata( }), ]), }; - if (useCache) { - cache.set(key, entry); - } + cache.set(key, entry); try { const metadata = await entry.promise; entry.expiresAt = Date.now() + PROJECT_CACHE_TTL_MS; @@ -5160,11 +5157,6 @@ type AsyncFlushArg = { export type InitLoggerOptions = FullLoginOptions & { projectName?: string; projectId?: string; - /** - * Experimental: create the project in this existing project group, by name. - * An existing project must already belong to the group, otherwise registration fails with a 409. - * Ignored when projectId is provided. - */ _createInProjectGroup?: string; environment?: SpanOriginEnvironment; setCurrent?: boolean; @@ -5178,7 +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. Ignored when projectId is provided. + * @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, @@ -5213,9 +5205,7 @@ export function initLogger( const computeMetadataArgs = { project_name: projectName, project_id: projectId, - ...(_createInProjectGroup != null - ? { project_group_name: _createInProjectGroup } - : {}), + project_group_name: _createInProjectGroup, }; const linkArgs = {