Skip to content
Open
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/logger-log-emission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat: Add OpenTelemetry-compatible log emission to project loggers
29 changes: 29 additions & 0 deletions integrations/otel-js/src/otel-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,35 @@ describe("OTEL compatibility mode", () => {
expect(otelSpans.length).toBeGreaterThanOrEqual(1);
});

test("Logger.emitLog reuses the active OTEL span and trace IDs", async () => {
const { tracer } = setupOtelFixture("emit-log-otel-parent");
const memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger();
const logger = initLogger({
projectName: "emit-log-otel-parent",
projectId: "emit-log-otel-parent-id",
});

let logId: string | undefined;
let ownerSpanId: string | undefined;
let ownerTraceId: string | undefined;
await tracer.startActiveSpan("owner", async (owner: any) => {
logId = logger.emitLog("Inside OTel span", "info");
const ownerContext = owner.spanContext();
ownerSpanId = ownerContext.spanId;
ownerTraceId = ownerContext.traceId;
owner.end();
});

await memoryLogger.flush();
const [logRow] = (await memoryLogger.drain()) as Array<
Record<string, unknown>
>;
expect(logRow.id).toBe(logId);
expect(logRow.span_id).toBe(ownerSpanId);
expect(logRow.root_span_id).toBe(ownerTraceId);
expect(logRow.span_parents ?? []).toEqual([]);
});

test("mixed BT/OTEL with startSpan (matching Python pattern)", async () => {
const { tracer, exporter, processor } = setupOtelFixture(
"mixed-start-span-test",
Expand Down
154 changes: 154 additions & 0 deletions js/src/logger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,160 @@ test("verify MemoryBackgroundLogger intercepts logs", async () => {
_exportsForTestingOnly.clearTestBackgroundLogger(); // can go back to normal
});

describe("Logger.emitLog", () => {
let memoryLogger: ReturnType<
typeof _exportsForTestingOnly.useTestBackgroundLogger
>;

beforeEach(async () => {
await _exportsForTestingOnly.simulateLoginForTests();
memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger();
});

afterEach(async () => {
await memoryLogger.flush();
_exportsForTestingOnly.clearTestBackgroundLogger();
_exportsForTestingOnly.simulateLogoutForTests();
});

test("emits independent log rows on a logger baseline trace", async () => {
const logger = initLogger({
projectName: "test",
projectId: "test-project-id",
});

const firstId = logger.emitLog("Payment failed", "error", {
payment_id: "pay_123",
});
const secondId = logger.emitLog("Retrying payment", "info");

await memoryLogger.flush();
const [first, second] = (await memoryLogger.drain()) as any[];

expect(first.id).toBe(firstId);
expect(second.id).toBe(secondId);
expect(first.id).not.toBe(second.id);
expect(first.span_id).not.toBe(second.span_id);
expect(first.root_span_id).toBe(second.root_span_id);
expect(first.span_parents ?? []).toEqual([]);
expect(first.output).toBe("Payment failed");
expect(first.error).toBe("Payment failed");
expect(first.metadata).toEqual({ payment_id: "pay_123" });
expect(first.span_attributes).toMatchObject({ name: "Log", type: "log" });
expect(first.metrics.start).toBe(first.metrics.end);
expect(first.context.otel).toEqual({
signal: "logs",
log: {
time_unix_nano: String(Math.round(first.metrics.start * 1_000_000_000)),
severity_number: 17,
severity_text: "ERROR",
},
});
expect(second.error).toBeUndefined();
expect(second.context.otel.log.severity_number).toBe(9);
});

test("uses a distinct baseline trace for each logger", async () => {
const firstLogger = initLogger({
projectName: "first",
projectId: "first-project-id",
});
const secondLogger = initLogger({
projectName: "second",
projectId: "second-project-id",
});

firstLogger.info("first");
secondLogger.info("second");

await memoryLogger.flush();
const [first, second] = (await memoryLogger.drain()) as any[];
expect(first.root_span_id).not.toBe(second.root_span_id);
});

test("reuses the active Braintrust span and trace IDs", async () => {
const logger = initLogger({
projectName: "test",
projectId: "test-project-id",
});

let logId: string | undefined;
logger.traced(
(owner) => {
logId = logger.emitLog("Inside span", "debug", { attempt: 1 });
expect(logId).not.toBe(owner.id);
},
{ name: "owner" },
);

await memoryLogger.flush();
const rows = (await memoryLogger.drain()) as any[];
const logRow = rows.find((row) => row.id === logId);
const ownerRow = rows.find((row) => row.span_attributes?.name === "owner");

expect(logRow.span_id).toBe(ownerRow.span_id);
expect(logRow.root_span_id).toBe(ownerRow.root_span_id);
expect(logRow.span_parents ?? []).toEqual([]);
expect(logRow.metadata).toEqual({ attempt: 1 });
expect(logRow.context.otel.log.severity_number).toBe(5);
});

test.each([
["trace", 1],
["debug", 5],
["info", 9],
["warn", 13],
["error", 17],
["fatal", 21],
] as const)(
"maps the %s helper to OTel severity %i",
async (method, level) => {
const logger = initLogger({
projectName: "test",
projectId: "test-project-id",
});

const logId = logger[method]("message", { source: method });

await memoryLogger.flush();
const [row] = (await memoryLogger.drain()) as any[];
expect(row.id).toBe(logId);
expect(row.output).toBe("message");
expect(row.metadata).toEqual({ source: method });
expect(row.context.otel.log).toMatchObject({
severity_number: level,
severity_text: method.toUpperCase(),
});
},
);

test("rejects invalid levels without logging", async () => {
const logger = initLogger({
projectName: "test",
projectId: "test-project-id",
});

expect(() => logger.emitLog("message", "warning" as never)).toThrow(
"Invalid log level",
);

await memoryLogger.flush();
expect(await memoryLogger.drain()).toEqual([]);
});

test("flushes before resolving for synchronous-flush loggers", async () => {
const logger = initLogger({
projectName: "test",
projectId: "test-project-id",
asyncFlush: false,
});

const logId = await logger.info("message");
const [row] = (await memoryLogger.drain()) as any[];
expect(row.id).toBe(logId);
});
});

test("init validation", () => {
expect(() => init({})).toThrow(
"Must specify at least one of project or projectId",
Expand Down
129 changes: 128 additions & 1 deletion js/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ const RESET_CONTEXT_MANAGER_STATE = Symbol.for(
// 6 MB for the AWS lambda gateway (from our own testing).
export const DEFAULT_MAX_REQUEST_SIZE = 6 * 1024 * 1024;

type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
const OTEL_LOG_LEVELS: Record<LogLevel, number> = {
trace: 1,
debug: 5,
info: 9,
warn: 13,
error: 17,
fatal: 21,
};

export type { DatasetSnapshot };

const datasetSnapshotRegisterResponseSchema = z.object({
Expand Down Expand Up @@ -2803,6 +2813,7 @@ export class Logger<IsAsyncFlush extends boolean> implements Exportable {
private lastStartTime: number;
private lazyId: LazyValue<string>;
private calledStartSpan: boolean;
private baselineTraceId: string;

// For type identification.
public kind = "logger" as const;
Expand All @@ -2820,6 +2831,7 @@ export class Logger<IsAsyncFlush extends boolean> implements Exportable {
this.lazyId = new LazyValue(async () => await this.id);
this.calledStartSpan = false;
this.state = state;
this.baselineTraceId = state.idGenerator.getTraceId();
}

public get org_id(): Promise<string> {
Expand Down Expand Up @@ -2886,6 +2898,121 @@ export class Logger<IsAsyncFlush extends boolean> implements Exportable {
}
}

/**
* Capture a log record, associating it with the active span when one exists.
*
* The log is stored as an independent row. If a Braintrust or OpenTelemetry
* span is active, the row reuses its span and trace IDs for correlation.
* Otherwise, the row uses this logger's baseline trace ID.
*
* @param body The JSON-serializable log body.
* @param level The OpenTelemetry log severity.
* @param metadata Optional JSON-serializable attributes for the log.
* @returns The unique ID of the captured log row.
*/
public emitLog(
body: unknown,
level: LogLevel,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
if (!Object.prototype.hasOwnProperty.call(OTEL_LOG_LEVELS, level)) {
throw new Error(
`Invalid log level ${JSON.stringify(level)}. Expected one of: ${Object.keys(OTEL_LOG_LEVELS).join(", ")}`,
);
}

const capturedAt = getCurrentUnixTimestamp();
const spanInfo = this.state.contextManager.getParentSpanIds();
const activeSpanId = spanInfo?.spanParents[0];
const severityNumber = OTEL_LOG_LEVELS[level];
const span = this.startSpanImpl({
name: "Log",
type: SpanTypeAttribute.LOG,
startTime: capturedAt,
spanId: activeSpanId,
parentSpanIds: {
parentSpanIds: [],
rootSpanId: activeSpanId ? spanInfo.rootSpanId : this.baselineTraceId,
},
event: {
output: body,
...(severityNumber >= OTEL_LOG_LEVELS.error && typeof body === "string"
? { error: body }
: {}),
Comment on lines +2939 to +2941

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-string error bodies in the error field

When callers use the natural logger.error(new Error("Payment failed")) form—or pass any structured error body—this condition omits the Braintrust error field and records the value only as output, unlike an equivalent string body. Consequently, consumers and UI behavior that identify failures through the error column will not recognize these error/fatal records as failures; the existing serializer already supports Error values, so error-severity bodies should be assigned to error regardless of whether they are strings.

Useful? React with 👍 / 👎.

...(metadata === undefined ? {} : { metadata }),
},
[INTERNAL_SPAN_CONTEXT]: {
otel: {
signal: "logs",
log: {
time_unix_nano: String(Math.round(capturedAt * 1_000_000_000)),
severity_number: severityNumber,
severity_text: level.toUpperCase(),
},
},
},
});
span.end({ endTime: capturedAt });

const ret = span.id;
type Ret = PromiseUnless<IsAsyncFlush, string>;
if (this.asyncFlush === true) {
return ret as Ret;
}
return (async () => {
await this.flush();
return ret;
})() as Ret;
}

/** Capture a log at OpenTelemetry TRACE severity. */
public trace(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "trace", metadata);
}

/** Capture a log at OpenTelemetry DEBUG severity. */
public debug(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "debug", metadata);
}

/** Capture a log at OpenTelemetry INFO severity. */
public info(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "info", metadata);
}

/** Capture a log at OpenTelemetry WARN severity. */
public warn(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "warn", metadata);
}

/** Capture a log at OpenTelemetry ERROR severity. */
public error(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "error", metadata);
}

/** Capture a log at OpenTelemetry FATAL severity. */
public fatal(
body: unknown,
metadata?: Record<string, unknown>,
): PromiseUnless<IsAsyncFlush, string> {
return this.emitLog(body, "fatal", metadata);
}

/**
* Create a new toplevel span underneath the logger. The name defaults to "root".
*
Expand Down Expand Up @@ -2937,7 +3064,7 @@ export class Logger<IsAsyncFlush extends boolean> implements Exportable {
return this.startSpanImpl(args);
}

private startSpanImpl(args?: StartSpanArgs): Span {
private startSpanImpl(args?: StartSpanArgs & InternalSpanContextArg): Span {
return new SpanImpl({
...args,
// Sometimes `args` gets passed directly into this function, and it contains an undefined value for `state`.
Expand Down
2 changes: 2 additions & 0 deletions js/util/span_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const spanTypeAttributeValues = [
"preprocessor",
"classifier",
"review",
"log",
] as const;

// DEPRECATED: Use `spanTypeAttributeValues` instead
Expand All @@ -25,6 +26,7 @@ export enum SpanTypeAttribute {
PREPROCESSOR = "preprocessor",
CLASSIFIER = "classifier",
REVIEW = "review",
LOG = "log",
}

export type SpanType = (typeof spanTypeAttributeValues)[number];
Expand Down
Loading