Skip to content

Commit 0f02f1c

Browse files
committed
wip
1 parent 2c77318 commit 0f02f1c

10 files changed

Lines changed: 326 additions & 15 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ pnpm run test # Run all workspace tests via turbo
7979

8080
Run from the repo root. **Always run `fix:formatting` before committing** — there is a pre-commit hook that will reject unformatted code.
8181

82+
Agents MUST run Prettier on every file they create or edit before handing work back, even when no commit is requested. Include Markdown, changelogs, config files, and generated files supported by Prettier—not just source code. From the repo root, run `pnpm exec prettier --write <edited-files>` followed by `pnpm exec prettier --check <edited-files>`. If further edits are made, repeat formatting and verification after the final edit. Do not rely on tests, typechecks, CI, or the pre-commit hook to catch formatting issues.
83+
8284
```bash
8385
pnpm run formatting # Check formatting (prettier)
8486
pnpm run lint # Run eslint checks

js/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# braintrust
22

3+
## Unreleased
4+
5+
### Minor Changes
6+
7+
- feat: Run configured `onSpanExport` customizers on incremental instrumentation span records, supporting field mutation, deletion, and replacement before export.
8+
39
## 3.33.0
410

511
### Minor Changes

js/src/exports.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,10 @@ export {
374374
braintrustFlueObserver,
375375
braintrustFlueInstrumentation,
376376
} from "./instrumentation";
377-
export type { InstrumentationConfig } from "./instrumentation";
377+
export type {
378+
InstrumentationConfig,
379+
SpanCustomizer,
380+
SpanExportData,
381+
} from "./instrumentation";
378382

379383
export { wrapElevenLabs } from "./wrappers/elevenlabs";

js/src/instrumentation/README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,49 @@ termination, and async context.
180180
- Use narrow vendored provider interfaces shared by wrappers and plugins.
181181
- Keep enable, disable, subscription, and patching behavior idempotent.
182182

183+
## Export Customizers
184+
185+
Configure `spanCustomizers` through the standalone instrumentation entrypoint
186+
before importing the main SDK, which enables instrumentation during platform
187+
initialization. Use a bootstrap module before any auto-instrumentation preload
188+
that initializes the SDK. Static imports of the main SDK are hoisted; use a
189+
dynamic import after configuration:
190+
191+
```ts
192+
import { configureInstrumentation } from "braintrust/instrumentation";
193+
194+
configureInstrumentation({
195+
spanCustomizers: [
196+
{
197+
onSpanExport(data) {
198+
data.tags = ["reviewed"];
199+
if ("output" in data) data.output = "[redacted]";
200+
delete data.error;
201+
return data;
202+
},
203+
},
204+
],
205+
});
206+
207+
const { initLogger } = await import("braintrust");
208+
initLogger({ projectName: "my-project" });
209+
// Import and use instrumented provider SDKs here.
210+
```
211+
212+
`onSpanExport` receives each incremental record from an instrumentation-created
213+
span after lazy values resolve, before attachment processing, merging, masking,
214+
and JSON serialization. It can run before the span ends; fields may be absent.
215+
Ordinary manually created spans, dataset rows, and feedback are not customized.
216+
217+
Callbacks run synchronously in registration order. Mutate and return the record,
218+
or return a replacement for the next callback. Preserve identity and routing
219+
fields and return JSON-serializable data. Exceptions are swallowed; remaining
220+
customizers and export continue. Export retries reuse the transformed record
221+
without invoking callbacks again. Configuration is shared across SDK bundles.
222+
223+
Customizers receive only the outgoing record, not a live span or provider
224+
instrumentation context.
225+
183226
## Testing
184227

185228
Test at the narrowest useful layers:

js/src/instrumentation/config.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
export type SpanExportData = Record<string, unknown>;
2+
3+
export interface SpanCustomizer {
4+
/**
5+
* Customize an outgoing span record after lazy values resolve, before JSON
6+
* serialization. Records are incremental and may not contain every span field.
7+
*
8+
* Add, change, or delete fields, then return the record or a replacement.
9+
* Preserve identity and routing fields, including id, span_id, root_span_id,
10+
* and span_parents.
11+
*/
12+
onSpanExport?(data: SpanExportData): SpanExportData;
13+
}
14+
115
export interface InstrumentationIntegrationsConfig {
216
openai?: boolean;
317
anthropic?: boolean;
@@ -46,6 +60,12 @@ export interface InstrumentationConfig {
4660
* Set to false to disable instrumentation for that SDK.
4761
*/
4862
integrations?: InstrumentationIntegrationsConfig;
63+
64+
/**
65+
* Instrumentation-wide customizers, in callback execution order.
66+
* Configure before instrumentation is enabled.
67+
*/
68+
spanCustomizers?: readonly SpanCustomizer[];
4969
}
5070

5171
const envIntegrationAliases: Record<

js/src/instrumentation/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,4 @@ export {
4545
// Configuration API
4646
export { configureInstrumentation } from "./registry";
4747
export type { InstrumentationConfig } from "./registry";
48+
export type { SpanCustomizer, SpanExportData } from "./config";

js/src/instrumentation/registry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type InstrumentationConfig,
1414
} from "./config";
1515
import { GLOBAL_INSTRUMENTATION_HOOKS_PROTOCOL_VERSION } from "../global-instrumentation-hooks";
16+
import { setSpanCustomizers } from "../span-customizer";
1617

1718
export type { InstrumentationConfig } from "./config";
1819

@@ -62,6 +63,9 @@ class PluginRegistry {
6263
return;
6364
}
6465
this.config = { ...this.config, ...config };
66+
if ("spanCustomizers" in config) {
67+
setSpanCustomizers(config.spanCustomizers);
68+
}
6569
}
6670

6771
/**

js/src/logger.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ import {
207207
mergeSpanOriginContext,
208208
type SpanOriginEnvironment,
209209
} from "./span-origin";
210+
import { customizeSpanExport } from "./span-customizer";
210211

211212
// Manual type definition for inline attachments (not in generated_types)
212213
const InlineAttachmentReferenceSchema = z.object({
@@ -8215,6 +8216,7 @@ export class SpanImpl implements Span {
82158216

82168217
private isMerge: boolean;
82178218
private loggedEndTime: number | undefined;
8219+
private readonly isInstrumented: boolean;
82188220
private propagatedEvent: StartSpanEventArgs | undefined;
82198221

82208222
// For internal use only.
@@ -8255,6 +8257,8 @@ export class SpanImpl implements Span {
82558257
const instrumentationName =
82568258
getSpanInstrumentationName(args) ??
82578259
INSTRUMENTATION_NAMES.BRAINTRUST_JS_LOGGER;
8260+
this.isInstrumented =
8261+
instrumentationName !== INSTRUMENTATION_NAMES.BRAINTRUST_JS_LOGGER;
82588262

82598263
const spanAttributes = args.spanAttributes ?? {};
82608264
const rawEvent = args.event ?? {};
@@ -8422,21 +8426,28 @@ export class SpanImpl implements Span {
84228426
);
84238427
}
84248428

8425-
const computeRecord = async () => ({
8426-
...partialRecord,
8427-
...Object.fromEntries(
8428-
await Promise.all(
8429-
Object.entries(lazyInternalData).map(async ([key, value]) => [
8430-
key,
8431-
await value.get(),
8432-
]),
8429+
const computeRecord = async () => {
8430+
const record = {
8431+
...partialRecord,
8432+
...Object.fromEntries(
8433+
await Promise.all(
8434+
Object.entries(lazyInternalData).map(async ([key, value]) => [
8435+
key,
8436+
await value.get(),
8437+
]),
8438+
),
84338439
),
8434-
),
8435-
...new SpanComponentsV3({
8436-
object_type: this.parentObjectType,
8437-
object_id: await this.parentObjectId.get(),
8438-
}).objectIdFields(),
8439-
});
8440+
...new SpanComponentsV3({
8441+
object_type: this.parentObjectType,
8442+
object_id: await this.parentObjectId.get(),
8443+
}).objectIdFields(),
8444+
};
8445+
// Customize inside the memoized lazy value, before attachment processing,
8446+
// merging, and masking. Retries reuse the already-customized record.
8447+
return this.isInstrumented
8448+
? (customizeSpanExport(record) as BackgroundLogEvent)
8449+
: record;
8450+
};
84408451
this._state.bgLogger().log([new LazyValue(computeRecord)]);
84418452
}
84428453

js/src/span-customizer.test.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import {
2+
afterEach,
3+
beforeEach,
4+
describe,
5+
expect,
6+
expectTypeOf,
7+
test,
8+
} from "vitest";
9+
import {
10+
_exportsForTestingOnly,
11+
initLogger,
12+
type TestBackgroundLogger,
13+
} from "./logger";
14+
import { configureInstrumentation, registry } from "./instrumentation/registry";
15+
import { configureNode } from "./node/config";
16+
import {
17+
INSTRUMENTATION_NAMES,
18+
withSpanInstrumentationName,
19+
} from "./span-origin";
20+
import type { SpanCustomizer, SpanExportData } from "./exports";
21+
22+
configureNode();
23+
24+
test("customizers expose only the outgoing-record export hook", () => {
25+
expectTypeOf<SpanCustomizer>().toEqualTypeOf<{
26+
onSpanExport?(data: SpanExportData): SpanExportData;
27+
}>();
28+
});
29+
30+
describe("onSpanExport", () => {
31+
let memoryLogger: TestBackgroundLogger;
32+
33+
beforeEach(async () => {
34+
registry.disable();
35+
await _exportsForTestingOnly.simulateLoginForTests();
36+
memoryLogger = _exportsForTestingOnly.useTestBackgroundLogger();
37+
});
38+
39+
afterEach(() => {
40+
configureInstrumentation({ spanCustomizers: [] });
41+
_exportsForTestingOnly.clearTestBackgroundLogger();
42+
});
43+
44+
function startInstrumentedSpan() {
45+
return initLogger({
46+
projectName: "customizer-project",
47+
projectId: "customizer-project",
48+
}).startSpan(
49+
withSpanInstrumentationName(
50+
{ name: "provider.call" },
51+
INSTRUMENTATION_NAMES.OPENAI,
52+
),
53+
);
54+
}
55+
56+
test("adds a field to outgoing span records", async () => {
57+
configureInstrumentation({
58+
spanCustomizers: [
59+
{
60+
onSpanExport(data) {
61+
data.custom_field = "added";
62+
return data;
63+
},
64+
},
65+
],
66+
});
67+
68+
const span = startInstrumentedSpan();
69+
span.log({ output: "result" });
70+
span.end();
71+
72+
const events = await memoryLogger.drain();
73+
expect(events).toEqual([
74+
expect.objectContaining({
75+
id: span.id,
76+
project_id: "customizer-project",
77+
output: "result",
78+
custom_field: "added",
79+
}),
80+
]);
81+
});
82+
83+
test("alters an existing field in outgoing span records", async () => {
84+
configureInstrumentation({
85+
spanCustomizers: [
86+
{
87+
onSpanExport(data) {
88+
if ("output" in data) data.output = "[redacted]";
89+
return data;
90+
},
91+
},
92+
],
93+
});
94+
95+
const span = startInstrumentedSpan();
96+
span.log({ output: "sensitive response" });
97+
span.end();
98+
99+
expect(await memoryLogger.drain()).toEqual([
100+
expect.objectContaining({ id: span.id, output: "[redacted]" }),
101+
]);
102+
});
103+
104+
test("deletes a field from outgoing span records", async () => {
105+
configureInstrumentation({
106+
spanCustomizers: [
107+
{
108+
onSpanExport(data) {
109+
delete data.error;
110+
return data;
111+
},
112+
},
113+
],
114+
});
115+
116+
const span = startInstrumentedSpan();
117+
span.log({ error: "sensitive error", output: "safe response" });
118+
span.end();
119+
120+
const events = await memoryLogger.drain();
121+
expect(events).toEqual([
122+
expect.objectContaining({ id: span.id, output: "safe response" }),
123+
]);
124+
expect(events[0]).not.toHaveProperty("error");
125+
});
126+
127+
test("passes replacement records through later customizers despite errors", async () => {
128+
configureInstrumentation({
129+
spanCustomizers: [
130+
{
131+
onSpanExport(data) {
132+
return "output" in data ? { ...data, output: "replacement" } : data;
133+
},
134+
},
135+
{
136+
onSpanExport() {
137+
throw new Error("customizer failed");
138+
},
139+
},
140+
{
141+
onSpanExport(data) {
142+
if (typeof data.output === "string") {
143+
data.output = data.output.toUpperCase();
144+
}
145+
return data;
146+
},
147+
},
148+
],
149+
});
150+
151+
const span = startInstrumentedSpan();
152+
span.log({ output: "original" });
153+
span.end();
154+
155+
expect(await memoryLogger.drain()).toEqual([
156+
expect.objectContaining({
157+
id: span.id,
158+
output: "REPLACEMENT",
159+
metrics: expect.objectContaining({ end: expect.any(Number) }),
160+
}),
161+
]);
162+
});
163+
164+
test("does not customize manually created spans", async () => {
165+
configureInstrumentation({
166+
spanCustomizers: [
167+
{
168+
onSpanExport(data) {
169+
data.tags = ["customized"];
170+
return data;
171+
},
172+
},
173+
],
174+
});
175+
176+
const instrumented = startInstrumentedSpan();
177+
const manual = instrumented.startSpan({ name: "manual child" });
178+
manual.log({ output: "manual result" });
179+
manual.end();
180+
instrumented.end();
181+
182+
const events = await memoryLogger.drain();
183+
expect(events.find((event) => event.id === instrumented.id)).toMatchObject({
184+
tags: ["customized"],
185+
});
186+
const manualEvent = events.find((event) => event.id === manual.id);
187+
expect(manualEvent).toMatchObject({ output: "manual result" });
188+
expect(manualEvent).not.toHaveProperty("tags");
189+
});
190+
});

0 commit comments

Comments
 (0)