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/console-log-instrumentation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"braintrust": minor
---

feat: Add opt-in console log instrumentation
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
22 changes: 22 additions & 0 deletions js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion js/src/exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export {
} from "../dev/types";

// Auto-instrumentation configuration
export { configureInstrumentation } from "./instrumentation";
export { configureInstrumentation, instrumentConsole } from "./instrumentation";
export {
braintrustFlueObserver,
braintrustFlueInstrumentation,
Expand Down
180 changes: 180 additions & 0 deletions js/src/instrumentation/console.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>>
> = {};

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();
});
});
Loading
Loading