Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/create-in-project-group.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat: Add `_createInProjectGroup` to logger options
137 changes: 137 additions & 0 deletions js/src/logger-caching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion js/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand All @@ -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);
Expand All @@ -5093,6 +5096,7 @@ async function computeLoggerMetadata(
{
project_name: project_name || GLOBAL_PROJECT,
org_id,
project_group_name: groupName,
},
controller.signal,
);
Expand Down Expand Up @@ -5153,6 +5157,7 @@ type AsyncFlushArg<IsAsyncFlush> = {
export type InitLoggerOptions<IsAsyncFlush> = FullLoginOptions & {
projectName?: string;
projectId?: string;
_createInProjectGroup?: string;
environment?: SpanOriginEnvironment;
setCurrent?: boolean;
state?: BraintrustState;
Expand All @@ -5165,6 +5170,7 @@ export type InitLoggerOptions<IsAsyncFlush> = 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,
Expand All @@ -5181,6 +5187,7 @@ export function initLogger<IsAsyncFlush extends boolean = true>(
const {
projectName,
projectId,
_createInProjectGroup,
asyncFlush: asyncFlushArg,
appUrl,
apiKey,
Expand All @@ -5198,6 +5205,7 @@ export function initLogger<IsAsyncFlush extends boolean = true>(
const computeMetadataArgs = {
project_name: projectName,
project_id: projectId,
project_group_name: _createInProjectGroup,
};

const linkArgs = {
Expand Down
Loading