Skip to content

feat(logs): add application logging and console capture - #2639

Merged
Abhijeet Prasad (AbhiPrasad) merged 6 commits into
mainfrom
implement-js-logs-api-e5cb1240
Oct 20, 2026
Merged

Abhijeet Prasad (AbhiPrasad) merged 6 commits into
mainfrom
implement-js-logs-api-e5cb1240

Conversation

@AbhiPrasad

Copy link
Copy Markdown
Member

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

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.fmttext ${value} Prepare a message that preserves its template and values.
wrapConsole(target = console, { logger } = {}) Capture future console calls; return a cleanup function.

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

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

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

const stopCapturing = wrapConsole(console, { logger });

console.log("Worker started");
console.warn("Payment %s will retry", "pay_123");

stopCapturing();
await logger.flush();

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.

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. |
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-20T16:57:27.700021Z cc87ba0 New commits
ℹ️ About Codex in GitHub

Your 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread js/src/logger.ts Outdated
this.lazyId = new LazyValue(async () => await this.id);
this.calledStartSpan = false;
this.state = state;
this.baselineTraceId = state.idGenerator.getTraceId();

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 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread js/src/logs/context.ts Outdated
export function internalFetch(
fetch: typeof globalThis.fetch,
): typeof globalThis.fetch {
return (...args) => suppressApplicationLogs(() => fetch(...args));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread js/src/logs/index.ts Outdated
args: unknown[],
): void {
if (logger) {
const body = formatConsole(args);

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 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread js/src/logs/index.ts Outdated
value === null ||
["string", "number", "boolean"].includes(typeof value)
? value
: formatConsole([value]);

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 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 👍 / 👎.

Comment thread js/src/logs/index.ts
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);

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 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment thread js/src/logs/index.ts
const formattedArgs = iso.formatLogMessage
? undefined
: args.map((value) => formatConsole([value]));
const body = formattedArgs ? formattedArgs.join(" ") : formatConsole(args);

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 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.
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) merged commit d203696 into main Oct 20, 2026
57 checks passed
@AbhiPrasad
Abhijeet Prasad (AbhiPrasad) deleted the implement-js-logs-api-e5cb1240 branch October 20, 2026 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants