Repository navigation
feat(logs): add application logging and console capture - #2639
Conversation
Send application messages to Braintrust alongside your traces. Use the logger
returned by initLogger() directly, or capture future console calls with
wrapConsole(). Each call records one complete log event without a follow-up
update. Existing console output stays visible.
## Public API
```js
import { initLogger, wrapConsole } from "braintrust";
const logger = initLogger({ projectName: "application-logs" });
```
| API | Behavior |
| --- | --- |
| logger.trace(body, options?) | Record a trace-level message; does not start a trace. |
| logger.debug(body, options?) | Record a debug-level message. |
| logger.info(body, options?) | Record an info-level message. |
| logger.warn(body, options?) | Record a warning. |
| logger.error(body, options?) | Record an error-level message. |
| logger.fatal(body, options?) | Record a fatal-level message. |
| logger.fmt`text ${value}` | Prepare a message that preserves its template and values. |
| wrapConsole(target = console, { logger } = {}) | Capture future console calls; return a cleanup function. |
| await logger.flush() | Wait for queued messages to upload. |
Bodies can be strings or JSON-serializable values. All severity methods accept
{ metadata: { ... } } as their second argument. By default they return a row ID
immediately. With initLogger({ asyncFlush: false, ... }), they return a promise
that resolves to the row ID after uploading.
## Track request activity with structured messages
```js
await logger.traced(async () => {
logger.info("Checkout started", {
metadata: { request_id: "req_123", customer_id: "customer_456" },
});
logger.warn({ event: "payment_retry", attempt: 2 });
}, { name: "checkout" });
await logger.flush();
```
Messages emitted inside an active Braintrust or compatible OpenTelemetry span
share its span and trace IDs, while retaining their own log row IDs. They do
not create child spans or change the active span. Outside an active span,
messages from a logger share that logger's baseline trace ID.
## Keep templates for grouping related messages
```js
const userId = "user_123";
const amount = 12.5;
logger.info(logger.fmt`User ${userId} paid ${amount.toFixed(2)}`, {
metadata: { source: "checkout" },
});
```
The displayed message is "User user_123 paid 12.50". JavaScript evaluates each
expression normally, so use expressions such as amount.toFixed(2) for formatting.
The native template tag preserves these metadata fields:
| Metadata key | Value |
| --- | --- |
| braintrust.template | User ${0} paid ${1} |
| braintrust.template.parameter.0 | "user_123" |
| braintrust.template.parameter.1 | "12.50" |
| source | "checkout" |
Parameters use positional keys because JavaScript template tags receive values,
not variable names. JSON-compatible values retain their types; undefined and
bigint become strings. Ordinary strings, including interpolated strings passed
without logger.fmt, are recorded verbatim without template metadata.
## Capture an application's existing console calls
```js
const stopCapturing = wrapConsole(console, { logger });
console.log("Worker started");
console.warn("Payment %s will retry", "pay_123");
stopCapturing();
await logger.flush();
```
| Console method | Braintrust severity |
| --- | --- |
| log, info | info |
| trace | trace |
| debug | debug |
| warn | warn |
| error | error |
wrapConsole() without arguments instruments the global console and uses the
current project logger at call time. Before a logger is initialized, console
calls keep their normal output without uploading logs. Pass a console instance
and { logger } to select a specific destination.
Node uses its native console message formatter. Other runtimes preserve the
arguments as text in Braintrust while the original console retains its native
rendering. SDK diagnostics and internal HTTP transport logs are excluded from
capture to prevent recursion.
Repeated wrapping does not duplicate messages. The cleanup function is safe to
call repeatedly and does not disable a newer wrap or reactivate an older wrap.
It stops future forwarding; messages already queued still upload, and direct
logger calls remain available.
## Possible extensions
These are future directions, not APIs included in this change:
| Extension | Use case |
| --- | --- |
| Minimum severity filters | Reduce verbose production logs while retaining warnings and errors. |
| Child loggers with shared metadata | Attach request, tenant, or worker context to every message. |
| Adapters for Pino and Winston | Forward existing structured logs without changing application call sites. |
| Log processing hooks | Redact sensitive fields or enrich messages before upload. |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fa45bfa69c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| this.lazyId = new LazyValue(async () => await this.id); | ||
| this.calledStartSpan = false; | ||
| this.state = state; | ||
| this.baselineTraceId = state.idGenerator.getTraceId(); |
There was a problem hiding this comment.
Delay baseline trace ID creation until first log emission
When initLogger() runs before @braintrust/otel calls setupOtelCompat()—particularly with BRAINTRUST_LEGACY_IDS=true—this eagerly caches a UUID baseline and initializes the old ID generator. setupOtelCompat() deliberately resets BraintrustState's generator to support late setup, but it cannot replace this private cached value, so a later standalone logger.info() combines the stale UUID root_span_id with a new OTEL-format span_id and V4 span encoding. Lazily create the baseline when the first standalone log is emitted, after compatibility setup has selected the generator.
Useful? React with 👍 / 👎.
Honor late OpenTelemetry setup when choosing the baseline trace ID, and add a minor changeset for application logging and console capture.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7cf7a43222
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| export function internalFetch( | ||
| fetch: typeof globalThis.fetch, | ||
| ): typeof globalThis.fetch { | ||
| return (...args) => suppressApplicationLogs(() => fetch(...args)); |
There was a problem hiding this comment.
Preserve transport suppression across browser awaits
In browsers without a global AsyncLocalStorage, configureBrowser() leaves the default no-op store in place, so this wrapper decrements the only suppression state as soon as an async fetch returns its promise. If a custom or instrumented fetch logs after an await, wrapConsole() captures that transport message; because uploading the captured row invokes the same fetch again, this can create a continuing self-generated upload loop. The suppression mechanism needs a browser-safe way to prevent transport logs from being recaptured without suppressing unrelated concurrent application logs.
AGENTS.md reference: AGENTS.md:L32-L38
Useful? React with 👍 / 👎.
Save console methods before instrumentation and share the original references across SDK copies. Route SDK diagnostics and evaluation output through those references so they stay visible without being captured as application logs. Remove async suppression and fetch wrappers. Keep a synchronous reentrancy guard so nested console calls emit a single log and cleanup preserves later wraps. Console calls from custom transports follow normal application logging behavior.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f5fbd228be
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| args: unknown[], | ||
| ): void { | ||
| if (logger) { | ||
| const body = formatConsole(args); |
There was a problem hiding this comment.
Avoid formatting console arguments multiple times
In Node, formatConsole(args) invokes util.format before call() forwards the untouched arguments to the native console; when the first argument is a format string, the metadata path also formats each nonprimitive parameter again. Consequently, objects implementing util.inspect.custom can be inspected two or three times per console call, causing duplicated side effects and allowing the stored message to differ from the visible output. Extract the captured text without repeatedly invoking user-defined inspectors.
AGENTS.md reference: AGENTS.md:L38-L38
Useful? React with 👍 / 👎.
Capture structural object text without invoking application inspectors for messages or metadata. Forward original arguments so native console rendering still runs its custom inspector once.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fe96589103
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| value === null || | ||
| ["string", "number", "boolean"].includes(typeof value) | ||
| ? value | ||
| : formatConsole([value]); |
There was a problem hiding this comment.
Serialize browser console parameters only once
Although the Node custom-inspector path from the earlier review is now disabled, in browser and edge builds a call such as console.info("value %s", objectWithToJSON) still serializes the object once while building the body and again here while building template metadata. This executes application-defined toJSON code twice before the native console call and can make the stored body disagree with its parameter metadata; derive both fields from a single formatting pass.
AGENTS.md reference: AGENTS.md:L38-L38
Useful? React with 👍 / 👎.
| function display(value: unknown): string { | ||
| if (value instanceof Error) return value.stack ?? value.message; | ||
| if (typeof value === "string") return value; | ||
| return JSON.stringify(value) ?? String(value); |
There was a problem hiding this comment.
Preserve BigInt arguments in browser console capture
In browser, workerd, and edge builds where iso.formatLogMessage is unset, JSON.stringify throws for a BigInt value, so a valid call such as console.info("count", 1n) is uploaded as [Unformattable console message] even though the native console renders it normally. Handle BigInt before JSON serialization, as the template formatter already does, so one supported argument does not discard the entire captured message.
Useful? React with 👍 / 👎.
Reuse formatted browser arguments in both messages and template metadata so toJSON runs once per argument. Preserve BigInt values as text before JSON serialization.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 047606121a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "Codex (@codex) review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".
| const formattedArgs = iso.formatLogMessage | ||
| ? undefined | ||
| : args.map((value) => formatConsole([value])); | ||
| const body = formattedArgs ? formattedArgs.join(" ") : formatConsole(args); |
There was a problem hiding this comment.
Avoid invoking Node coercion hooks before the native console
When Node code passes an object with stateful toString, valueOf, or toJSON behavior to %s, %d, or %j, this formatting pass invokes that hook once, and the subsequent native console call invokes it again. For example, console.info("%j", value) can upload the first serialization while displaying the second, as well as duplicating application side effects; format the captured representation without executing these coercion hooks before forwarding the original call.
AGENTS.md reference: AGENTS.md:L32-L38
Useful? React with 👍 / 👎.
Render object and function parameters structurally before format substitution, leaving toString, valueOf, and toJSON execution to the native console. Preserve original console arguments and primitive formatting.
resolves https://linear.app/braintrustdata/issue/SDK-345/add-logging-api-to-javascript-sdk
resolves https://linear.app/braintrustdata/issue/SDK-346/instrument-consolelog-to-emit-logs-in-js-sdk
AI Summary
Public API
text ${value}Bodies can be strings or JSON-serializable values. All severity methods accept { metadata: { ... } } as their second argument. By default they return a row ID immediately. With initLogger({ asyncFlush: false, ... }), they return a promise that resolves to the row ID after uploading.
Track request activity with structured messages
Messages emitted inside an active Braintrust or compatible OpenTelemetry span share its span and trace IDs, while retaining their own log row IDs. They do not create child spans or change the active span. Outside an active span, messages from a logger share that logger's baseline trace ID.
Keep templates for grouping related messages
The displayed message is "User user_123 paid 12.50". JavaScript evaluates each expression normally, so use expressions such as amount.toFixed(2) for formatting. The native template tag preserves these metadata fields:
Parameters use positional keys because JavaScript template tags receive values, not variable names. JSON-compatible values retain their types; undefined and bigint become strings. Ordinary strings, including interpolated strings passed without logger.fmt, are recorded verbatim without template metadata.
Capture an application's existing console calls
wrapConsole() without arguments instruments the global console and uses the current project logger at call time. Before a logger is initialized, console calls keep their normal output without uploading logs. Pass a console instance and { logger } to select a specific destination.
Node uses its native console message formatter. Other runtimes preserve the arguments as text in Braintrust while the original console retains its native rendering. SDK diagnostics and internal HTTP transport logs are excluded from capture to prevent recursion.
Repeated wrapping does not duplicate messages. The cleanup function is safe to call repeatedly and does not disable a newer wrap or reactivate an older wrap. It stops future forwarding; messages already queued still upload, and direct logger calls remain available.