From 186b30d550bd9a25c33192376eddc6029e6dc47d Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 18:41:55 -0400 Subject: [PATCH 1/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 From 7c9afd9a4d4be34a158d1549c6b5ae659c081607 Mon Sep 17 00:00:00 2001 From: Abhijeet Prasad Date: Thu, 10 Sep 2026 18:54:47 -0400 Subject: [PATCH 2/2] feat(instrumentation): add opt-in console log capture Add `instrumentConsole()` to forward console calls through the current project logger while preserving their original behavior. Callers can select levels and stop forwarding with the returned cleanup function: initLogger({ projectName: "my-project" }); const stop = instrumentConsole({ levels: ["warn", "error"] }); console.warn("Retrying payment", { attempt: 2 }); console.error("Payment failed"); stop(); Support console substitutions, safe object and error formatting, failed assertions, repeated setup, and both synchronous and asynchronous loggers. Contain instrumentation failures so console calls continue unchanged. --- .changeset/console-log-instrumentation.md | 5 + js/README.md | 22 ++ js/src/exports.ts | 2 +- js/src/instrumentation/console.test.ts | 180 +++++++++++ js/src/instrumentation/console.ts | 349 ++++++++++++++++++++++ js/src/instrumentation/index.ts | 1 + 6 files changed, 558 insertions(+), 1 deletion(-) create mode 100644 .changeset/console-log-instrumentation.md create mode 100644 js/src/instrumentation/console.test.ts create mode 100644 js/src/instrumentation/console.ts diff --git a/.changeset/console-log-instrumentation.md b/.changeset/console-log-instrumentation.md new file mode 100644 index 000000000..4fd8f1b46 --- /dev/null +++ b/.changeset/console-log-instrumentation.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: Add opt-in console log instrumentation diff --git a/js/README.md b/js/README.md index 595ff5b68..286a11022 100644 --- a/js/README.md +++ b/js/README.md @@ -104,6 +104,28 @@ If you use TypeScript or other transpilation plugins, place the Braintrust plugi For deeper details, see the [auto-instrumentation architecture docs](src/auto-instrumentations/README.md). +### Console logging + +Console logging instrumentation is opt-in. It forwards console calls to a project logger while preserving the original console behavior: + +```typescript +import { initLogger, instrumentConsole } from "braintrust"; + +const logger = initLogger({ projectName: "my-project" }); +const stop = instrumentConsole({ + logger, + levels: ["info", "warn", "error"], +}); + +console.info("Payment %s", "started"); +console.error("Payment failed", { paymentId: "pay_123" }); + +// Stop forwarding console calls when they no longer need to be captured. +stop(); +``` + +By default, `instrumentConsole` captures `debug`, `info`, `warn`, `error`, `log`, `trace`, and failed `assert` calls. If `logger` is omitted, calls are sent to the current project logger. + ### LangSmith tracing Braintrust supports LangSmith `>=0.3.30 <1.0.0`. LangSmith tracing remains authoritative: LangSmith must be enabled, and it continues exporting traces to LangSmith while Braintrust mirrors the same run lifecycle. This integration covers tracing only; LangSmith eval, Jest, and Vitest APIs are not instrumented. diff --git a/js/src/exports.ts b/js/src/exports.ts index f7a4c3026..e975d1e67 100644 --- a/js/src/exports.ts +++ b/js/src/exports.ts @@ -355,7 +355,7 @@ export { } from "../dev/types"; // Auto-instrumentation configuration -export { configureInstrumentation } from "./instrumentation"; +export { configureInstrumentation, instrumentConsole } from "./instrumentation"; export { braintrustFlueObserver, braintrustFlueInstrumentation, diff --git a/js/src/instrumentation/console.test.ts b/js/src/instrumentation/console.test.ts new file mode 100644 index 000000000..b351d25c6 --- /dev/null +++ b/js/src/instrumentation/console.test.ts @@ -0,0 +1,180 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { _exportsForTestingOnly, initLogger } from "../logger"; +import { + _resetConsoleInstrumentationForTests, + instrumentConsole, +} from "./console"; + +const CONSOLE_METHODS = [ + "debug", + "info", + "warn", + "error", + "log", + "trace", + "assert", +] as const; + +describe("instrumentConsole", () => { + let memoryLogger: ReturnType< + typeof _exportsForTestingOnly.useTestBackgroundLogger + >; + const consoleSpies: Partial< + Record<(typeof CONSOLE_METHODS)[number], ReturnType> + > = {}; + + beforeEach(async () => { + _resetConsoleInstrumentationForTests(); + await _exportsForTestingOnly.simulateLoginForTests(); + memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger(); + for (const method of CONSOLE_METHODS) { + consoleSpies[method] = vi + .spyOn(globalThis.console, method) + .mockImplementation(() => undefined); + } + }); + + afterEach(async () => { + _resetConsoleInstrumentationForTests(); + vi.restoreAllMocks(); + await memoryLogger.flush(); + _exportsForTestingOnly.clearTestBackgroundLogger(); + _exportsForTestingOnly.simulateLogoutForTests(); + }); + + test("forwards supported console methods without changing console calls", async () => { + const logger = initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + const stop = instrumentConsole({ logger }); + + globalThis.console.debug("debug", { value: 1 }); + globalThis.console.info("hello %s", "world"); + globalThis.console.warn("warn", 2); + globalThis.console.error(new Error("failed")); + globalThis.console.log("plain"); + globalThis.console.trace("trace"); + globalThis.console.assert(true, "not captured"); + globalThis.console.assert(false, "missing %s", "value"); + stop(); + + for (const method of CONSOLE_METHODS) { + expect(consoleSpies[method]).toHaveBeenCalled(); + } + + await memoryLogger.flush(); + const rows = (await memoryLogger.drain()) as any[]; + expect(rows.map((row) => row.output)).toEqual([ + 'debug {"value":1}', + "hello world", + "warn 2", + expect.stringContaining("Error: failed"), + "plain", + "trace", + "Assertion failed: missing value", + ]); + expect(rows.map((row) => row.context.otel.log.severity_number)).toEqual([ + 5, 9, 13, 17, 9, 1, 17, + ]); + expect(rows.map((row) => row.span_attributes.type)).toEqual( + Array(7).fill("log"), + ); + expect(rows[3].error).toEqual(expect.stringContaining("Error: failed")); + expect(rows[6].error).toBe("Assertion failed: missing value"); + }); + + test("does not capture console calls made internally by another console method", async () => { + consoleSpies.assert?.mockImplementation( + (condition: unknown, ...args: unknown[]) => { + if (!condition) { + globalThis.console.warn("Assertion failed:", ...args); + } + }, + ); + const logger = initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + const stop = instrumentConsole({ logger }); + + globalThis.console.assert(false, "nested warning"); + stop(); + + expect(consoleSpies.warn).toHaveBeenCalledWith( + "Assertion failed:", + "nested warning", + ); + await memoryLogger.flush(); + const rows = (await memoryLogger.drain()) as any[]; + expect(rows.map((row) => row.output)).toEqual([ + "Assertion failed: nested warning", + ]); + }); + + test("captures only selected levels and stops after cleanup", async () => { + const logger = initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + const stop = instrumentConsole({ logger, levels: ["warn", "error"] }); + + globalThis.console.info("ignored"); + globalThis.console.warn("captured"); + stop(); + globalThis.console.error("stopped"); + + await memoryLogger.flush(); + const rows = (await memoryLogger.drain()) as any[]; + expect(rows.map((row) => row.output)).toEqual(["captured"]); + }); + + test("uses the current logger when none is provided", async () => { + initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + const stop = instrumentConsole({ levels: ["log"] }); + + globalThis.console.log("current logger"); + stop(); + + await memoryLogger.flush(); + const [row] = (await memoryLogger.drain()) as any[]; + expect(row.output).toBe("current logger"); + }); + + test("is idempotent for the same logger and levels", async () => { + const logger = initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + const firstStop = instrumentConsole({ logger, levels: ["log"] }); + const secondStop = instrumentConsole({ logger, levels: ["log"] }); + + globalThis.console.log("once"); + firstStop(); + globalThis.console.log("still active"); + secondStop(); + globalThis.console.log("stopped"); + + await memoryLogger.flush(); + const rows = (await memoryLogger.drain()) as any[]; + expect(rows.map((row) => row.output)).toEqual(["once", "still active"]); + }); + + test("contains logging failures", () => { + const logger = initLogger({ + projectName: "test", + projectId: "test-project-id", + }); + vi.spyOn(logger, "info").mockImplementation(() => { + throw new Error("logging failed"); + }); + const stop = instrumentConsole({ logger, levels: ["log"] }); + + expect(() => globalThis.console.log("still works")).not.toThrow(); + expect(consoleSpies.log).toHaveBeenCalledWith("still works"); + stop(); + }); +}); diff --git a/js/src/instrumentation/console.ts b/js/src/instrumentation/console.ts new file mode 100644 index 000000000..cb17308cc --- /dev/null +++ b/js/src/instrumentation/console.ts @@ -0,0 +1,349 @@ +/// + +import { currentLogger, type Logger } from "../logger"; + +const CONSOLE_LEVELS = [ + "debug", + "info", + "warn", + "error", + "log", + "trace", + "assert", +] as const; + +type ConsoleLevel = (typeof CONSOLE_LEVELS)[number]; +type ConsoleLogger = Logger; + +type ConsoleInstrumentationOptions = { + /** Console methods to capture. Defaults to all supported methods. */ + levels?: readonly ConsoleLevel[]; + /** Logger that receives console records. Defaults to the current logger. */ + logger?: ConsoleLogger; +}; + +type ConsoleHandler = { + levels: Set; + logger: ConsoleLogger | undefined; + references: number; +}; + +type ConsoleInstrumentationState = { + handlers: ConsoleHandler[]; + originals: Map unknown>; + wrappers: Map unknown>; + forwarding: boolean; + callingOriginal: boolean; +}; + +const CONSOLE_INSTRUMENTATION_STATE = Symbol.for( + "braintrust.console-instrumentation.v1", +); + +function getState(): ConsoleInstrumentationState { + const globalObject = globalThis as typeof globalThis & { + [CONSOLE_INSTRUMENTATION_STATE]?: ConsoleInstrumentationState; + }; + globalObject[CONSOLE_INSTRUMENTATION_STATE] ??= { + handlers: [], + originals: new Map(), + wrappers: new Map(), + forwarding: false, + callingOriginal: false, + }; + return globalObject[CONSOLE_INSTRUMENTATION_STATE]; +} + +function isConsoleLevel(value: string): value is ConsoleLevel { + return (CONSOLE_LEVELS as readonly string[]).includes(value); +} + +function normalizeLevels( + levels: readonly ConsoleLevel[] | undefined, +): Set { + if (levels === undefined) { + return new Set(CONSOLE_LEVELS); + } + return new Set(levels.filter(isConsoleLevel)); +} + +function levelsMatch( + left: Set, + right: Set, +): boolean { + return ( + left.size === right.size && + Array.from(left).every((level) => right.has(level)) + ); +} + +function safeFormatValue(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (value instanceof Error) { + return value.stack ?? value.message; + } + if (value === null || typeof value !== "object") { + try { + return String(value); + } catch { + return "[Unserializable]"; + } + } + + const seen = new WeakSet(); + try { + const serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + if (typeof nestedValue === "bigint") { + return String(nestedValue); + } + if (typeof nestedValue === "object" && nestedValue !== null) { + if (seen.has(nestedValue)) { + return "[Circular]"; + } + seen.add(nestedValue); + } + return nestedValue; + }); + return serialized ?? String(value); + } catch { + try { + return String(value); + } catch { + return "[Unserializable]"; + } + } +} + +function formatConsoleArgs(args: unknown[]): string { + if (args.length === 0) { + return ""; + } + + const [first, ...following] = args; + if (typeof first !== "string") { + return args.map(safeFormatValue).join(" "); + } + + let nextArgument = 0; + const formatted = first.replace( + /%([%sdifjoOc])/g, + (placeholder, specifier: string) => { + if (specifier === "%") { + return "%"; + } + if (nextArgument >= following.length) { + return placeholder; + } + + const value = following[nextArgument++]; + switch (specifier) { + case "d": + case "i": + return String(Number.parseInt(String(value), 10)); + case "f": + return String(Number.parseFloat(String(value))); + case "c": + return ""; + default: + return safeFormatValue(value); + } + }, + ); + + const remaining = following.slice(nextArgument).map(safeFormatValue); + return [formatted, ...remaining].join(" "); +} + +function ignoreRejectedLog(result: unknown): void { + if ( + typeof result === "object" && + result !== null && + "then" in result && + typeof result.then === "function" + ) { + void Promise.resolve(result).catch(() => undefined); + } +} + +function forwardConsoleCall( + logger: ConsoleLogger, + level: ConsoleLevel, + args: unknown[], +): void { + if (level === "assert") { + const [condition, ...messageArgs] = args; + if (condition) { + return; + } + const message = + messageArgs.length === 0 + ? "Assertion failed" + : `Assertion failed: ${formatConsoleArgs(messageArgs)}`; + ignoreRejectedLog(logger.error(message)); + return; + } + + const body = formatConsoleArgs(args); + switch (level) { + case "log": + ignoreRejectedLog(logger.info(body)); + break; + case "debug": + ignoreRejectedLog(logger.debug(body)); + break; + case "info": + ignoreRejectedLog(logger.info(body)); + break; + case "warn": + ignoreRejectedLog(logger.warn(body)); + break; + case "error": + ignoreRejectedLog(logger.error(body)); + break; + case "trace": + ignoreRejectedLog(logger.trace(body)); + break; + } +} + +function notifyHandlers(level: ConsoleLevel, args: unknown[]): void { + const state = getState(); + if (state.forwarding) { + return; + } + + state.forwarding = true; + try { + for (const handler of [...state.handlers]) { + if (!handler.levels.has(level)) { + continue; + } + const logger = handler.logger ?? currentLogger(); + if (!logger) { + continue; + } + try { + forwardConsoleCall(logger, level, args); + } catch { + // Console instrumentation must never affect the original call. + } + } + } finally { + state.forwarding = false; + } +} + +function patchConsole(): void { + if (!("console" in globalThis)) { + return; + } + + const state = getState(); + for (const level of CONSOLE_LEVELS) { + if (state.wrappers.has(level)) { + continue; + } + + const consoleMethod = globalThis.console[level]; + if (typeof consoleMethod !== "function") { + continue; + } + const original = consoleMethod as unknown as ( + ...args: unknown[] + ) => unknown; + + const wrapper = function (this: Console, ...args: unknown[]): unknown { + if (!state.callingOriginal) { + notifyHandlers(level, args); + } + + const wasCallingOriginal = state.callingOriginal; + state.callingOriginal = true; + try { + return original.apply(this, args); + } finally { + state.callingOriginal = wasCallingOriginal; + } + }; + + try { + globalThis.console[level] = wrapper as never; + state.originals.set(level, original); + state.wrappers.set(level, wrapper); + } catch { + // Some runtimes expose non-writable console methods. Leave those alone. + } + } +} + +/** + * Capture calls to the console API as Braintrust log records. + * + * This instrumentation is opt-in and leaves the original console behavior + * unchanged. By default it captures `debug`, `info`, `warn`, `error`, `log`, + * `trace`, and failed `assert` calls. Calling the function repeatedly with the + * same logger and levels is idempotent. + * + * @returns A function that stops forwarding calls for this registration. + * + * @example + * ```ts + * const logger = initLogger({ projectName: "my-project" }); + * const stop = instrumentConsole({ logger, levels: ["warn", "error"] }); + * ``` + */ +export function instrumentConsole( + options: ConsoleInstrumentationOptions = {}, +): () => void { + const state = getState(); + const levels = normalizeLevels(options.levels); + let handler = state.handlers.find( + (candidate) => + candidate.logger === options.logger && + levelsMatch(candidate.levels, levels), + ); + + if (handler) { + handler.references++; + } else { + handler = { + levels, + logger: options.logger, + references: 1, + }; + state.handlers.push(handler); + } + patchConsole(); + + let active = true; + return () => { + if (!active || !handler) { + return; + } + active = false; + handler.references--; + if (handler.references === 0) { + const index = state.handlers.indexOf(handler); + if (index !== -1) { + state.handlers.splice(index, 1); + } + } + }; +} + +/** Restore console methods and clear handlers. For tests only. */ +export function _resetConsoleInstrumentationForTests(): void { + const state = getState(); + for (const [level, original] of state.originals) { + if (globalThis.console[level] === state.wrappers.get(level)) { + globalThis.console[level] = original as never; + } + } + state.handlers.length = 0; + state.originals.clear(); + state.wrappers.clear(); + state.forwarding = false; + state.callingOriginal = false; +} diff --git a/js/src/instrumentation/index.ts b/js/src/instrumentation/index.ts index a1dbd990c..76fc1e8c8 100644 --- a/js/src/instrumentation/index.ts +++ b/js/src/instrumentation/index.ts @@ -25,6 +25,7 @@ export { } from "./plugins/flue-plugin"; export { braintrustEveHook } from "./plugins/eve-plugin"; export { braintrustEveInstrumentation } from "./plugins/eve-instrumentation"; +export { instrumentConsole } from "./console"; // Re-export core types for external instrumentation packages export type {