From c083a6e6339921dbeda586cef3e7a8b92ac138bf Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 18:33:07 -0400 Subject: [PATCH 1/2] feat(logger): add OpenTelemetry-compatible log emission Add `Logger.emitLog()` and severity helpers for creating independent `log` rows without manually constructing spans. logger.error("Payment failed", { paymentId: "pay_123" }); logger.emitLog("Retrying payment", "info", { attempt: 2 }); Correlate logs with active Braintrust or OpenTelemetry spans, while using a per-logger baseline trace for unscoped records: helper -> emitLog -> type="log" row |-- active span | trace ID = active trace ID | span ID = active span ID | `-- no active span trace ID = logger baseline trace ID span ID = newly generated span ID Map the six base OpenTelemetry severities into `context.otel.log`, populate `error` for string bodies at error or fatal severity, and preserve synchronous-flush behavior. --- integrations/otel-js/src/otel-compat.test.ts | 29 ++++ js/src/logger.test.ts | 154 +++++++++++++++++++ js/src/logger.ts | 129 +++++++++++++++- js/util/span_types.ts | 2 + 4 files changed, 313 insertions(+), 1 deletion(-) diff --git a/integrations/otel-js/src/otel-compat.test.ts b/integrations/otel-js/src/otel-compat.test.ts index 6564da531..c9006ebe8 100644 --- a/integrations/otel-js/src/otel-compat.test.ts +++ b/integrations/otel-js/src/otel-compat.test.ts @@ -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 + >; + 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", diff --git a/js/src/logger.test.ts b/js/src/logger.test.ts index 84781b5b8..17121fba2 100644 --- a/js/src/logger.test.ts +++ b/js/src/logger.test.ts @@ -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", diff --git a/js/src/logger.ts b/js/src/logger.ts index 5eb438ba9..ebad43ce0 100644 --- a/js/src/logger.ts +++ b/js/src/logger.ts @@ -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 = { + trace: 1, + debug: 5, + info: 9, + warn: 13, + error: 17, + fatal: 21, +}; + export type { DatasetSnapshot }; const datasetSnapshotRegisterResponseSchema = z.object({ @@ -2803,6 +2813,7 @@ export class Logger implements Exportable { private lastStartTime: number; private lazyId: LazyValue; private calledStartSpan: boolean; + private baselineTraceId: string; // For type identification. public kind = "logger" as const; @@ -2820,6 +2831,7 @@ export class Logger 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 { @@ -2886,6 +2898,121 @@ export class Logger 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, + ): PromiseUnless { + 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 } + : {}), + ...(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; + 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, + ): PromiseUnless { + return this.emitLog(body, "trace", metadata); + } + + /** Capture a log at OpenTelemetry DEBUG severity. */ + public debug( + body: unknown, + metadata?: Record, + ): PromiseUnless { + return this.emitLog(body, "debug", metadata); + } + + /** Capture a log at OpenTelemetry INFO severity. */ + public info( + body: unknown, + metadata?: Record, + ): PromiseUnless { + return this.emitLog(body, "info", metadata); + } + + /** Capture a log at OpenTelemetry WARN severity. */ + public warn( + body: unknown, + metadata?: Record, + ): PromiseUnless { + return this.emitLog(body, "warn", metadata); + } + + /** Capture a log at OpenTelemetry ERROR severity. */ + public error( + body: unknown, + metadata?: Record, + ): PromiseUnless { + return this.emitLog(body, "error", metadata); + } + + /** Capture a log at OpenTelemetry FATAL severity. */ + public fatal( + body: unknown, + metadata?: Record, + ): PromiseUnless { + return this.emitLog(body, "fatal", metadata); + } + /** * Create a new toplevel span underneath the logger. The name defaults to "root". * @@ -2937,7 +3064,7 @@ export class Logger 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`. diff --git a/js/util/span_types.ts b/js/util/span_types.ts index 2e4631540..a6717f881 100644 --- a/js/util/span_types.ts +++ b/js/util/span_types.ts @@ -10,6 +10,7 @@ export const spanTypeAttributeValues = [ "preprocessor", "classifier", "review", + "log", ] as const; // DEPRECATED: Use `spanTypeAttributeValues` instead @@ -25,6 +26,7 @@ export enum SpanTypeAttribute { PREPROCESSOR = "preprocessor", CLASSIFIER = "classifier", REVIEW = "review", + LOG = "log", } export type SpanType = (typeof spanTypeAttributeValues)[number]; From 261d5c6303338e97be03c7c8dd43e0c30280fe66 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 18:41:55 -0400 Subject: [PATCH 2/2] changeset --- .changeset/logger-log-emission.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/logger-log-emission.md diff --git a/.changeset/logger-log-emission.md b/.changeset/logger-log-emission.md new file mode 100644 index 000000000..cf3746bed --- /dev/null +++ b/.changeset/logger-log-emission.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add OpenTelemetry-compatible log emission to project loggers