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
23 changes: 15 additions & 8 deletions .agents/skills/instrumentation/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
---
name: instrumentation
description: Add or update Braintrust SDK instrumentation. Use when working on instrumentation of any kind - like wrappers, auto-instrumentation configs, tracing channels, provider plugins, vendored SDK typings, or instrumentation-specific tests.
description: Add or update Braintrust SDK instrumentation. Use when working on instrumentation of any kind - like wrappers, auto-instrumentation configs, invocation hooks, provider plugins, vendored SDK typings, or instrumentation-specific tests.
---

# Instrumentation Rules

Read first based on the task:

- `js/src/instrumentation/README.md` for plugin and tracing-channel architecture
- Closest file in `js/src/instrumentation/core/` when changing shared channel semantics
- `js/src/instrumentation/README.md` for wrapping and tracing architecture
- Closest file in `js/src/instrumentation/core/` when changing shared invocation semantics
- Closest file in `js/src/instrumentation/plugins/` when changing provider-specific extraction or span mapping
- Closest file in `js/src/wrappers/` when manual wrappers and auto-instrumentation need to stay aligned
- Closest test in `js/tests/auto-instrumentations/` when changing hook, loader, bundler, or transform behavior
- Closest e2e scenario in `e2e/scenarios/*instrumentation*` or `e2e/scenarios/*node-hook*` when the user-visible trace contract changes

Map the change before editing:

- `js/src/instrumentation/core/` - tracing-channel helpers, stream patching, shared types
- `js/src/instrumentation/plugins/` - provider-specific channel subscriptions and event-to-span conversion
- `js/src/instrumentation/core/` - invocation definitions, independent tracing helpers, stream patching, shared types
- `js/src/instrumentation/plugins/` - explicit interceptor registration and provider-specific tracing functions
- `js/src/wrappers/` - manual instrumentation entrypoints that should mirror the same logical contracts
- `js/src/auto-instrumentations/` - loader and bundler instrumentation config
- `js/tests/auto-instrumentations/` - functional coverage for transformed code
Expand All @@ -27,10 +27,17 @@ Map the change before editing:
- Inputs are untrusted: treat args, results, events, headers, and metadata as hostile. Prototype pollution is a concrete risk here. Avoid unsafe property access patterns, prototype-sensitive operations, and unnecessary mutation of third-party objects.
- Support both auto-instrumentation and manual instrumentation. Auto-instrumentation does not cover every environment, loader, or framework.
- For orchestrion auto-instrumentation, prefer targeting public API functions. Instrumenting internal helpers is more likely to break across library versions.
- Auto and manual paths should share logic through the same typed channel. For new and migrated instrumentation, prefer `invoke` in manual wrappers and `intercept` in provider plugins so the target can be scoped with `AsyncLocalStorage.run()` and its arguments, receiver, or output can be patched. Keep tracing-style hooks only as the compatibility path for instrumentation that has not migrated yet. Manual wrappers should not directly emit observability data.
- Keep wrapping and tracing separate, including internal APIs.
Define typed invocation hooks with `defineInterceptor`; `intercept` and `invoke` only compose and execute wrappers.
Definitions describe call arguments, return values, and opaque additional data, without span provenance or tracing methods.
The wrapping runtime must work without SDK initialization and have no dependency on spans, logging, or tracing lifecycle events.
Put span creation, context propagation, and finalization in separate tracing functions.
Plugins explicitly register those functions through `intercept`; shared tracing helpers accept callables and tracing configuration, never hooks or registration responsibilities.
Do not add combined helpers such as `traceInvocation` or `interceptAndTrace`, or retain a tracing-event compatibility path.
Manual wrappers and generated wrappers use the same hook through `invoke` and do not directly emit observability data.
- Reuse shared repo utilities before introducing local helpers. Check `js/util/index.ts`, neighboring instrumentation files, and existing plugins/wrappers for utilities like `isObject`, merge helpers, and sanitizers before adding ad hoc replacements.
- If a public instrumentation surface changes, check whether the export surface also needs updates in `js/src/instrumentation/index.ts` or `js/src/exports.ts`.
- Preserve async context propagation. Changes around tracing channels, stream patching, or loader hooks must keep the current span context across awaits and stream consumption.
- Preserve async context propagation. Changes around invocation hooks, stream patching, or loader hooks must keep the current span context across awaits and stream consumption.
- Maintain isomorphic behavior. Node and browser/bundled paths must use compatible channel implementations and avoid channel-registry mismatches.
- Setup, teardown, and patching must be idempotent. Enabling twice, disabling twice, or applying a patch twice should remain safe.
- Promise/stream behavior must be preserved. Patches need to keep subclass/helper semantics intact.
Expand All @@ -44,7 +51,7 @@ Map the change before editing:
Preserve useful text, metadata, metrics, and remote references when attachment capture is disabled, and omit inline bytes from logged payloads.
- Do not modify package READMEs during instrumentation work unless the user specifically requests it or the change corrects outdated information.
- We want to limit our instrumentation to operations that are relevant for AI generations and operations (LLMs, embeddings, media generation, ...). Things like creating entities on platforms (CRUD for Workflows of Agent entities) is irrelevant to us.
- When building instrumentation, we should always have a vendored type/interface for what we are wrapping. The type or interface should not be larger than what is relevant to the instrumentation. The type or interface should be used for typing tracing channels and also should be used to assert the type on whatever is passed into wrappers as soon as the wrapper has verified that the passed in value is plausibly what should be wrapped.
- When building instrumentation, we should always have a vendored type/interface for what we are wrapping. The type or interface should not be larger than what is relevant to the instrumentation. The type or interface should be used for typing invocation hooks and also should be used to assert the type on whatever is passed into wrappers as soon as the wrapper has verified that the passed in value is plausibly what should be wrapped.

## Process

Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ Keep public exports minimal. Generally, export only the requested runtime APIs a

Use the normal Orchestrion config plus plugin/channel path by default. Special-case source patches should be rare exceptions only when the target SDK cannot be instrumented through the standard transformer path, and the reason should be documented next to the patch.

API wrapping and span instrumentation are separate concepts.
Use `defineInterceptor` to define invocation hooks and `intercept`/`invoke` exclusively as generic wrapping machinery.
Wrapping definitions and runtime code must not depend on spans, tracing lifecycle events, provenance, or SDK initialization.
Keep span creation, context propagation, and finalization in separate tracing functions.
Provider plugins explicitly register those functions through interceptors; tracing helpers accept callables and tracing configuration, never hooks or registration responsibilities.
Do not introduce combined APIs such as `traceInvocation` or `interceptAndTrace`, including internal convenience APIs.
Manual wrappers and generated wrappers only invoke hooks.

Instrumentation patches generally do not need to be removed during teardown. Prefer leaving behavior-preserving patches installed when they are idempotent; do not add unpatching machinery by default.

Span names should generally remain stable across calls and versions. Do not include dynamic values such as model names in span names; record those values in metadata instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import type { AddressInfo } from "node:net";
export const dynamic = "force-dynamic";

type InstrumentationHook = {
subscribe(handlers: InstrumentationHookHandlers): void;
unsubscribe(handlers: InstrumentationHookHandlers): boolean;
};

type InstrumentationHookHandlers = {
start(): void;
intercept(
interceptor: (
target: (...args: unknown[]) => unknown,
receiver: unknown,
args: unknown[],
) => unknown,
): () => void;
};

export async function GET() {
Expand Down Expand Up @@ -43,18 +44,15 @@ export async function GET() {

const hooks = (
globalThis as typeof globalThis & {
__braintrust_instrumentation_hooks?: Map<string, InstrumentationHook>;
__braintrust_invocation_hooks_v2?: Map<string, InstrumentationHook>;
}
).__braintrust_instrumentation_hooks;
).__braintrust_invocation_hooks_v2;
const hook = hooks?.get("orchestrion:openai:chat.completions.create");
let hookFired = false;
const subscriber = {
start: () => {
hookFired = true;
},
};

hook?.subscribe(subscriber);
const remove = hook?.intercept((target, receiver, args) => {
hookFired = true;
return Reflect.apply(target, receiver, args);
});

try {
const client = new OpenAI({
Expand All @@ -67,7 +65,7 @@ export async function GET() {
messages: [{ role: "user", content: "hi" }],
});
} finally {
hook?.unsubscribe(subscriber);
remove?.();
mockServer.close();
}

Expand Down
65 changes: 16 additions & 49 deletions js/src/auto-instrumentations/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,58 +35,26 @@ same identifier.

## Generated Runtime Contract

For every configured channel, transformed modules lazily look up:
Generated wrappers lazily look up invocation hooks in `globalThis.__braintrust_invocation_hooks_v2`.
They pass the original target, receiver, complete arguments, and module version to `invoke`.
They do not emit tracing events, create spans, or select tracing operators.
Legacy `functionQuery.kind` and `callbackIndex` fields remain accepted for source compatibility but do not select runtime tracing behavior.

```js
globalThis.__braintrust_instrumentation_hooks?.get(
"orchestrion:openai:chat.completions.create",
);
```

The lookup is retried until a hook exists, then cached. This has two important
properties:

- Loading an instrumented provider before Braintrust is safe; calls run normally.
- Registering Braintrust later enables tracing without retransformation.

The generated code only applies `traceInvocation`, passing the configured
operator, original target, receiver, complete arguments, and `moduleVersion`.
The normal hook runtime handles interceptor composition, the no-listener fast
path, tracing context construction, and the legacy `tracePromise`, `traceSync`,
or `traceCallback` dispatch around the effective intercepted call.

The hook lifecycle mirrors tracing channels:

1. `start` before the target call
2. `end` after its synchronous portion
3. `asyncStart` and `asyncEnd` when an asynchronous result settles
4. `error` for synchronous throws, promise rejections, or callback errors

The same context object is passed through every phase. Subscribers may mutate
arguments or returned streams before user code continues.

Invocation interceptors compose as nested middleware and may replace arguments,
the receiver, the returned value, or the entire implementation. Tracing remains
the outer compatibility layer, so tracing subscribers observe the interceptor's
effective result.
Calls run normally before a hook is registered.
Lookup retries until registration succeeds, then caches the hook.
Interceptors compose in registration order and may replace arguments, receivers, results, or the complete implementation.

## Global Registry

The SDK installs `globalThis.__braintrust_instrumentation_hooks` with a
non-enumerable, non-writable property descriptor. Its value is a mutable
`Map<string, TracingHook>` shared by all Braintrust SDK copies in the realm.

The implementation lives in `src/global-instrumentation-hooks.ts`. It supports:

- composable `invoke` / `intercept` wrappers
- all five lifecycle phases
- multiple subscribers and complete unsubscription
- `bindStore` / `unbindStore` for async-context propagation
- sync, promise, and callback tracing operators
- preservation of Promise subclasses, thenables, and non-Promise return values
The invocation registry is a non-enumerable, non-writable global property containing a shared map.
It is independent of SDK initialization, async-context storage, and tracing.
Manual wrappers use the same hooks through `defineInterceptor` and `invoke`.
Provider plugins explicitly register separate tracing functions through `intercept`.
Do not combine wrapping and tracing in a runtime or convenience API.

Manual wrappers use the same registry through typed channel definitions, so
manual and auto-instrumented paths share lifecycle and span behavior.
Protocol version 2 uses a separately keyed registry so it does not mutate an older SDK's registry.
Previously transformed bundles must be rebuilt with the updated SDK to retain instrumentation.
There is no legacy tracing-event compatibility layer.

## Loaders and Bundlers

Expand All @@ -110,8 +78,7 @@ bundles.
1. Add the narrowest supported package/version/file/function config under
`configs/`.
2. Define a typed channel with the same package and operation identifier.
3. Add or update a plugin that intercepts the typed channel; use the tracing
helpers only for existing instrumentation awaiting migration.
3. Write a separate tracing function and explicitly register it through `intercept` in the provider plugin.
4. Keep manual wrappers on that same typed channel through `invoke`.
5. Add transformation/runtime coverage and a provider e2e scenario when the
user-visible trace contract changes.
Expand Down
18 changes: 2 additions & 16 deletions js/src/auto-instrumentations/orchestrion-js/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@
* licensed under Apache-2.0. Modified by Braintrust.
*/

import esquery from "esquery";
import { generate } from "astring";
import esquery from "esquery";
import { parse } from "meriyah";
import { SourceMapGenerator } from "source-map";
import { transforms, type TransformState } from "./transforms";
import type {
FunctionKind,
FunctionQuery,
InstrumentationConfig,
ModuleType,
Expand All @@ -18,7 +17,6 @@ import type {

type AnyNode = any;
type ExportAliases = Record<string, string>;
type TraceOperator = "traceCallback" | "tracePromise" | "traceSync";

/**
* Applies instrumentation configs to JavaScript source by parsing it into an
Expand Down Expand Up @@ -90,7 +88,6 @@ export class Transformer {
...config,
moduleVersion: this.version,
functionQuery: resolvedFunctionQuery,
operator: this.getOperator(resolvedFunctionQuery.kind),
};

esquery.traverse(ast, esquery.parse(query), (...args: any[]) => {
Expand Down Expand Up @@ -141,7 +138,7 @@ export class Transformer {
free(): void {}

private visit(state: TransformState, ...args: any[]): void {
const transform = transforms[state.operator];
const transform = transforms.invoke;
const { index = 0 } = state.functionQuery as any;
const [node] = args;
const type = node.init?.type || node.type;
Expand All @@ -164,17 +161,6 @@ export class Transformer {
(transform as (...args: any[]) => void)(state, ...args);
}

private getOperator(kind: FunctionKind): TraceOperator {
switch (kind) {
case "Async":
return "tracePromise";
case "Callback":
return "traceCallback";
case "Sync":
return "traceSync";
}
}

private collectExportAliases(ast: AnyNode): ExportAliases {
const aliases: ExportAliases = {};
for (const node of ast.body) {
Expand Down
31 changes: 12 additions & 19 deletions js/src/auto-instrumentations/orchestrion-js/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import esquery from "esquery";
import { parse } from "meriyah";
import {
GLOBAL_INSTRUMENTATION_HOOK_BRAND,
GLOBAL_INSTRUMENTATION_HOOKS_KEY,
GLOBAL_INSTRUMENTATION_HOOKS_PROTOCOL_VERSION,
GLOBAL_INSTRUMENTATION_HOOKS_REGISTRY_BRAND,
GLOBAL_INVOCATION_HOOK_BRAND,
} from "../../global-instrumentation-hooks";
import type { FunctionQuery, InstrumentationConfig } from "./types";

Expand All @@ -20,12 +20,10 @@ type TransformFn = (
parent: AnyNode,
ancestry: AnyNode[],
) => void;
type TraceOperator = "traceCallback" | "tracePromise" | "traceSync";

export interface TransformState extends InstrumentationConfig {
moduleVersion: string;
functionQuery: FunctionQuery;
operator: TraceOperator;
functionIndex?: number;
}

Expand All @@ -41,7 +39,7 @@ function formatChannelGetter(channelName: string): string {
}

export const transforms: Record<string, TransformFn> = {
tracingHookDeclaration(state, node) {
invocationHookDeclaration(state, node) {
const {
channelName,
module: { name },
Expand Down Expand Up @@ -84,9 +82,9 @@ export const transforms: Record<string, TransformFn> = {
(typeof __bt$hook !== "object" &&
typeof __bt$hook !== "function")) ||
__bt$hook[Symbol.for(${JSON.stringify(
GLOBAL_INSTRUMENTATION_HOOK_BRAND,
GLOBAL_INVOCATION_HOOK_BRAND,
)})] !== ${GLOBAL_INSTRUMENTATION_HOOKS_PROTOCOL_VERSION} ||
typeof __bt$hook.traceInvocation !== "function"
typeof __bt$hook.invoke !== "function"
) return undefined;
return __bt$hook;
} catch {
Expand Down Expand Up @@ -116,9 +114,7 @@ export const transforms: Record<string, TransformFn> = {
node.body.splice(index + 1, 0, ...parse(code).body);
},

traceCallback: traceAny,
tracePromise: traceAny,
traceSync: traceAny,
invoke: traceAny,
};

function traceAny(
Expand All @@ -141,7 +137,7 @@ function traceFunction(
node: AnyNode,
program: AnyNode,
): void {
transforms.tracingHookDeclaration(state, program, null, []);
transforms.invocationHookDeclaration(state, program, null, []);

const isArrowFunction = node.type === "ArrowFunctionExpression";

Expand Down Expand Up @@ -195,7 +191,7 @@ function traceInstanceMethod(
node: AnyNode,
program: AnyNode,
): void {
const { functionQuery, operator } = state;
const { functionQuery } = state;
const { methodName } = functionQuery as any;

if (!methodName) {
Expand All @@ -210,7 +206,7 @@ function traceInstanceMethod(

let ctor = classBody.body.find(({ kind }: AnyNode) => kind === "constructor");

transforms.tracingHookDeclaration(state, program, null, []);
transforms.invocationHookDeclaration(state, program, null, []);

if (!ctor) {
ctor = (
Expand All @@ -233,7 +229,7 @@ function traceInstanceMethod(

const fn = ctorBody[1].expression.right;

fn.async = operator === "tracePromise";
fn.async = false;
fn.body = wrap(
state,
{
Expand Down Expand Up @@ -331,21 +327,18 @@ function wrapInvocation(
state: TransformState,
argsExpression: string,
): AnyNode {
const { channelName, moduleVersion, operator, functionQuery } = state;
const { channelName, moduleVersion } = state;
const channelGetter = formatChannelGetter(channelName);
const callbackIndex = functionQuery.callbackIndex ?? -1;

return parse(`
function wrapper () {
const __bt$hook = ${channelGetter}();
if (!__bt$hook) return __bt$target.apply(this, ${argsExpression});
return __bt$hook.traceInvocation(
${JSON.stringify(operator)},
return __bt$hook.invoke(
__bt$target,
this,
${argsExpression},
{ moduleVersion: ${JSON.stringify(moduleVersion)} },
${callbackIndex}
{ moduleVersion: ${JSON.stringify(moduleVersion)} }
);
}
`);
Expand Down
Loading
Loading