From 02f1fb508f304cbc81e090911c110191aa970f62 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 7 Aug 2026 17:04:42 +0100 Subject: [PATCH 1/4] feat(eve): record the caller and accept authored instrumentation events --- .../eve-instrumentation-events-and-caller.md | 20 +++ packages/evlog/src/eve/index.ts | 57 +++++++- packages/evlog/test/eve.test.ts | 138 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 .changeset/eve-instrumentation-events-and-caller.md diff --git a/.changeset/eve-instrumentation-events-and-caller.md b/.changeset/eve-instrumentation-events-and-caller.md new file mode 100644 index 00000000..9d02d848 --- /dev/null +++ b/.changeset/eve-instrumentation-events-and-caller.md @@ -0,0 +1,20 @@ +--- +"evlog": minor +--- + +`evlog/eve` records the caller and no longer takes the instrumentation slot for itself. + +`defineEvlogInstrumentation()` now accepts `events`, merged with the runtime context it contributes. An agent has exactly one `agent/instrumentation.ts`, and other integrations want that same `step.started` slot — PostHog's links spans to the initiating user — so adopting one used to mean dropping the other. evlog's `evlog.request_id` / `evlog.session_id` are applied first and your keys win on a collision: + +```ts +export default defineEvlogInstrumentation({ + setup: ({ agentName }) => registerOTel({ serviceName: agentName }), + events: { + 'step.started': ({ session }) => ({ + runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, + }), + }, +}) +``` + +Turn and session events now carry `eve.caller` with the principal eve resolved at dispatch: `principalId`, `principalType` and `authenticator`. On a multi-user channel that is the dimension you group cost, volume and refusals by, and it was previously unreachable — the enrich hook is HTTP-shaped and exposes no path to the eve session. `subject` and `attributes` are deliberately excluded, since a channel may put a name or an email in them. diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index b953bf5c..a4c7904b 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -724,6 +724,24 @@ function flushEveMetadata(state: TurnState): void { } /** Wide-event view of the eve instance and the parent session, when there is one. */ +/** + * Who triggered this turn, from the caller principal eve resolved at dispatch. + * + * Only the identifiers eve itself routes on: `principalId` is opaque on every + * channel eve ships (`github:`, a Slack user id), and `subject` and + * `attributes` are deliberately left out because a channel may put a name or an + * email in them. + */ +function buildCaller(ctx: HookContext): Record | null { + const auth = ctx.session.auth?.current + if (!auth) return null + return { + principalId: auth.principalId, + principalType: auth.principalType, + authenticator: auth.authenticator, + } +} + function buildLineage(sessionId: string, ctx: HookContext): Record { const eve: Record = {} const runtime = sessionRuntimes().get(sessionId) @@ -732,6 +750,9 @@ function buildLineage(sessionId: string, ctx: HookContext): Record 0) eve.runtime = identity } + const caller = buildCaller(ctx) + if (caller) eve.caller = caller + const { parent } = ctx.session if (parent) { eve.parent = { @@ -1410,6 +1431,28 @@ export interface EvlogEveInstrumentationOptions { * and eve keeps writing its local traces. */ setup?: (context: InstrumentationSetupContext) => void + /** + * Your own runtime-context contributions, merged with evlog's. + * + * An agent has exactly one `agent/instrumentation.ts`, and other integrations + * want that same `step.started` slot — PostHog's, for instance, links spans to + * the initiating user. Without this, adopting one means dropping the other. + * evlog's `evlog.request_id` / `evlog.session_id` are applied first, so your + * keys win on a collision and you can attach anything the callback's + * `session`, `turn`, `step`, `channel` or `modelInput` exposes. + * + * @example + * ```ts + * defineEvlogInstrumentation({ + * events: { + * 'step.started': ({ session }) => ({ + * runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, + * }), + * }, + * }) + * ``` + */ + events?: InstrumentationDefinition['events'] } /** @@ -1467,7 +1510,19 @@ export function defineEvlogInstrumentation( : {}), ...(options.setup !== undefined ? { setup: options.setup } : {}), events: { - 'step.started': buildInstrumentationContext, + ...options.events, + 'step.started': (input) => { + const evlogContext = buildInstrumentationContext(input) + const authored = options.events?.['step.started']?.(input) + if (!evlogContext && !authored) return undefined + return { + ...authored, + runtimeContext: { + ...evlogContext?.runtimeContext, + ...authored?.runtimeContext, + }, + } + }, }, }) } diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index 78f1a0ad..ba3bcd72 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -610,6 +610,82 @@ describe('evlog/eve', () => { }) }) + it('records the caller principal that triggered the turn', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = { + ...hookContext(), + session: { + id: SESSION_ID, + auth: { + current: { + principalId: 'github:1234', + principalType: 'user', + authenticator: 'github', + subject: 'someone@example.com', + attributes: { login: 'someone' }, + }, + initiator: null, + }, + }, + } as unknown as HookContext + + await runTurn(hook, { ctx }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).toMatchObject({ + caller: { + principalId: 'github:1234', + principalType: 'user', + authenticator: 'github', + }, + }) + }) + + it('keeps the caller subject and attributes off the event', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + const ctx = { + ...hookContext(), + session: { + id: SESSION_ID, + auth: { + current: { + principalId: 'github:1234', + principalType: 'user', + authenticator: 'github', + subject: 'someone@example.com', + attributes: { login: 'someone' }, + }, + initiator: null, + }, + }, + } as unknown as HookContext + + await runTurn(hook, { ctx }) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + const { caller } = event?.eve as { caller?: Record } + expect(Object.keys(caller ?? {}).sort()).toEqual([ + 'authenticator', + 'principalId', + 'principalType', + ]) + }) + + it('omits the caller when the session carries no authenticated principal', async () => { + const spies = createPipelineSpies() + const hook = defineEvlogHook({ drain: spies.drain }) + + await runTurn(hook) + + await waitForDrainCalls(spies.drain) + const event = findEventViaDrain(spies.drain, () => true) + expect(event?.eve).not.toHaveProperty('caller') + }) + it('prefers the cost reported by eve over the configured pricing map', async () => { const spies = createPipelineSpies() const hook = defineEvlogHook({ @@ -1705,4 +1781,66 @@ describe('defineEvlogInstrumentation', () => { it('declares nothing beyond the event hook when unconfigured', () => { expect(Object.keys(defineEvlogInstrumentation())).toEqual(['events']) }) + + it('merges an authored runtime context with its own', () => { + const hook = defineEvlogHook({}) + const instrumentation = defineEvlogInstrumentation({ + events: { + 'step.started': ({ session }) => ({ + runtimeContext: { 'caller.id': session.auth.current?.principalId ?? 'anonymous' }, + }), + }, + }) + + hook.events!['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, hookContext()) + + expect(instrumentation.events!['step.started']!(stepStartedInput())).toEqual({ + runtimeContext: { + 'evlog.request_id': TURN_ID, + 'evlog.session_id': SESSION_ID, + 'caller.id': 'anonymous', + }, + }) + }) + + it('lets an authored key win over its own on a collision', () => { + const hook = defineEvlogHook({}) + const instrumentation = defineEvlogInstrumentation({ + events: { + 'step.started': () => ({ runtimeContext: { 'evlog.request_id': 'authored' } }), + }, + }) + + hook.events!['turn.started']!({ + type: 'turn.started', + data: { sequence: 0, turnId: TURN_ID }, + }, hookContext()) + + expect(instrumentation.events!['step.started']!(stepStartedInput())).toMatchObject({ + runtimeContext: { 'evlog.request_id': 'authored' }, + }) + }) + + it('still contributes the authored context outside a tracked turn', () => { + const instrumentation = defineEvlogInstrumentation({ + events: { + 'step.started': () => ({ runtimeContext: { 'caller.id': 'anonymous' } }), + }, + }) + + expect(instrumentation.events!['step.started']!(stepStartedInput())).toEqual({ + runtimeContext: { 'caller.id': 'anonymous' }, + }) + }) + + it('contributes nothing when neither side has context to add', () => { + const instrumentation = defineEvlogInstrumentation({ + events: { 'step.started': () => undefined }, + }) + + expect(instrumentation.events!['step.started']!(stepStartedInput())).toBeUndefined() + }) }) From 72df56a540a71b957cdc5db464c6ea8dc7d35403 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 7 Aug 2026 17:17:00 +0100 Subject: [PATCH 2/4] docs(eve): document the caller field and authored instrumentation events --- apps/docs/content/5.use-cases/5.eve.md | 19 +++++++++++++++++++ packages/evlog/README.md | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/docs/content/5.use-cases/5.eve.md b/apps/docs/content/5.use-cases/5.eve.md index 65171515..c3db455f 100644 --- a/apps/docs/content/5.use-cases/5.eve.md +++ b/apps/docs/content/5.use-cases/5.eve.md @@ -187,6 +187,7 @@ Beyond the identifiers above, a turn records what eve reports about it. Every fi | Field | What it tells you | | --- | --- | | `eve.runtime` | eve version, agent id, model, and the deployed `gitSha` / `gitBranch` / `deployedAt` | +| `eve.caller` | Who triggered the turn: `principalId`, `principalType` and `authenticator`. On a multi-user channel this is what you group cost and volume by. `subject` and `attributes` are never recorded — a channel may put a name or an email in them | | `eve.parent` | Parent and root session ids for a subagent run — rebuild the delegation tree with `rootSessionId` | | `eve.authorizations` | Connection sign-ins with their `outcome`, `reason` and duration | | `eve.compaction` | How many compactions ran, on which model, and `inputTokensAtTrigger` — how full the context was when the first one fired | @@ -223,6 +224,24 @@ export default defineEvlogInstrumentation({ `functionId`, `recordInputs`, `recordOutputs` and `traceChannelRequests` pass straight through to eve's `defineInstrumentation`. +### Combine with another integration + +An agent has exactly one `agent/instrumentation.ts`, and other integrations want the same `step.started` slot — PostHog's links spans to the initiating user. Pass `events` and yours is merged with evlog's rather than replacing it: + +```typescript [agent/instrumentation.ts] +import { defineEvlogInstrumentation } from 'evlog/eve' + +export default defineEvlogInstrumentation({ + events: { + 'step.started': ({ session }) => ({ + runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, + }), + }, +}) +``` + +`evlog.request_id` and `evlog.session_id` are applied first, so your keys win on a collision. The callback receives eve's `session`, `turn`, `step`, `channel` and `modelInput`, and runs whether or not a turn is being tracked. + ## Production Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain: diff --git a/packages/evlog/README.md b/packages/evlog/README.md index b5e1525b..a6b66d09 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -594,7 +594,9 @@ export default defineEvlogInstrumentation() `defineEvlogHook()` maps eve turn lifecycle events to one wide event per turn. Call `useLogger()` in tools — the logger is bound via AsyncLocalStorage on `turn.started`. Pass `ctx` only when ALS is unavailable (`useLogger(ctx)`). Pretty-printing follows `isDev()` by default (tree locally, JSON in production); set `init.pretty: false` explicitly if you need to override. -`defineEvlogInstrumentation()` is optional: it stamps `evlog.request_id` onto eve's AI SDK spans so a trace joins back to its wide event, and back. Requires eve 0.30 or later. Complements eve Agent Runs — see the [eve use case](https://evlog.dev/use-cases/eve). +`defineEvlogInstrumentation()` is optional: it stamps `evlog.request_id` onto eve's AI SDK spans so a trace joins back to its wide event, and back. Pass `events` to merge your own runtime context with evlog's, so another integration can share the single `agent/instrumentation.ts` slot. Requires eve 0.30 or later. Complements eve Agent Runs — see the [eve use case](https://evlog.dev/use-cases/eve). + +Every turn event carries `eve.caller` — `principalId`, `principalType` and `authenticator` — so cost and volume group by who triggered the turn. See the full [eve example](https://github.com/HugoRCD/evlog/tree/main/examples/eve) for a complete agent layout. From 0cdac108ee55e9c65a1640f125c9425a98ecb74e Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 7 Aug 2026 17:22:02 +0100 Subject: [PATCH 3/4] docs(eve): explain how to run several observability backends at once --- apps/docs/content/5.use-cases/5.eve.md | 37 ++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/apps/docs/content/5.use-cases/5.eve.md b/apps/docs/content/5.use-cases/5.eve.md index c3db455f..06881ebe 100644 --- a/apps/docs/content/5.use-cases/5.eve.md +++ b/apps/docs/content/5.use-cases/5.eve.md @@ -242,6 +242,43 @@ export default defineEvlogInstrumentation({ `evlog.request_id` and `evlog.session_id` are applied first, so your keys win on a collision. The callback receives eve's `session`, `turn`, `step`, `channel` and `modelInput`, and runs whether or not a turn is being tracked. +### Combine several exporters + +Every observability item in eve's registry writes the same `agent/instrumentation.ts`, so `eve add instrumentation/sentry` after `eve add instrumentation/posthog` refuses rather than clobbering the first — there is one slot and one default export. Running several backends means writing that file yourself. + +OpenTelemetry allows one provider with many span processors, so register them together. Items that generate `traceExporter` (Sentry, Datadog, Arize, Jaeger, Braintrust, Honeycomb) become a span processor in the array; PostHog already generates the array form, and is the only one that also uses `events`: + +```typescript [agent/instrumentation.ts] +import { BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' +import { PostHogTraceExporter } from '@posthog/ai/otel' +import { OTLPHttpProtoTraceExporter, registerOTel } from '@vercel/otel' +import { defineEvlogInstrumentation } from 'evlog/eve' + +export default defineEvlogInstrumentation({ + setup: ({ agentName }) => registerOTel({ + serviceName: agentName, + spanProcessors: [ + new SimpleSpanProcessor(new PostHogTraceExporter({ + projectToken: process.env.POSTHOG_PROJECT_TOKEN!, + })), + new BatchSpanProcessor(new OTLPHttpProtoTraceExporter({ + url: process.env.SENTRY_OTLP_TRACES_ENDPOINT!, + headers: { 'x-sentry-auth': `sentry sentry_key=${process.env.SENTRY_PUBLIC_KEY}` }, + })), + ], + }), + events: { + 'step.started'(input) { + const distinctId = input.session.auth.initiator?.principalId + ?? input.session.auth.current?.principalId + return distinctId ? { runtimeContext: { posthog_distinct_id: distinctId } } : undefined + }, + }, +}) +``` + +Call `registerOTel` once, not once per backend. Keep each generated file open while you merge — the env vars and exporter options are the parts worth copying exactly. + ## Production Long-running eve agents should disable terminal pretty-printing and use a non-blocking drain: From 0405b7cd721e392f9e9ffa554d028e1679ab6379 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 7 Aug 2026 19:10:33 +0100 Subject: [PATCH 4/4] refactor(eve): compose evlog span context instead of owning instrumentation --- .../eve-instrumentation-events-and-caller.md | 20 ++-- apps/docs/content/5.use-cases/5.eve.md | 47 ++++------ packages/evlog/README.md | 2 +- packages/evlog/src/eve/index.ts | 91 ++++++++++--------- packages/evlog/test/eve.test.ts | 77 ++++++---------- .../__snapshots__/api-surface.test.ts.snap | 1 + 6 files changed, 111 insertions(+), 127 deletions(-) diff --git a/.changeset/eve-instrumentation-events-and-caller.md b/.changeset/eve-instrumentation-events-and-caller.md index 9d02d848..7be251ee 100644 --- a/.changeset/eve-instrumentation-events-and-caller.md +++ b/.changeset/eve-instrumentation-events-and-caller.md @@ -2,19 +2,27 @@ "evlog": minor --- -`evlog/eve` records the caller and no longer takes the instrumentation slot for itself. +`evlog/eve` records the caller, and composes with another observability backend. -`defineEvlogInstrumentation()` now accepts `events`, merged with the runtime context it contributes. An agent has exactly one `agent/instrumentation.ts`, and other integrations want that same `step.started` slot — PostHog's links spans to the initiating user — so adopting one used to mean dropping the other. evlog's `evlog.request_id` / `evlog.session_id` are applied first and your keys win on a collision: +An agent has exactly one `agent/instrumentation.ts`, and every observability item in eve's registry writes it. `defineEvlogInstrumentation()` owns that file, so it only fits an agent whose instrumentation is evlog's alone. The new `evlogRuntimeContext` contributes evlog's span attributes to instrumentation you already have, the way the other integrations do: ```ts -export default defineEvlogInstrumentation({ - setup: ({ agentName }) => registerOTel({ serviceName: agentName }), +import { defineInstrumentation } from 'eve/instrumentation' +import { evlogRuntimeContext } from 'evlog/eve' + +export default defineInstrumentation({ + setup: ({ agentName }) => registerOTel({ serviceName: agentName, spanProcessors: [...] }), events: { - 'step.started': ({ session }) => ({ - runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, + 'step.started': input => ({ + runtimeContext: { + ...evlogRuntimeContext(input), + posthog_distinct_id: input.session.auth.current?.principalId ?? '', + }, }), }, }) ``` +It returns `undefined` outside a tracked turn, so spreading it adds nothing. + Turn and session events now carry `eve.caller` with the principal eve resolved at dispatch: `principalId`, `principalType` and `authenticator`. On a multi-user channel that is the dimension you group cost, volume and refusals by, and it was previously unreachable — the enrich hook is HTTP-shaped and exposes no path to the eve session. `subject` and `attributes` are deliberately excluded, since a channel may put a name or an email in them. diff --git a/apps/docs/content/5.use-cases/5.eve.md b/apps/docs/content/5.use-cases/5.eve.md index 06881ebe..afacd70d 100644 --- a/apps/docs/content/5.use-cases/5.eve.md +++ b/apps/docs/content/5.use-cases/5.eve.md @@ -224,37 +224,23 @@ export default defineEvlogInstrumentation({ `functionId`, `recordInputs`, `recordOutputs` and `traceChannelRequests` pass straight through to eve's `defineInstrumentation`. -### Combine with another integration +### Alongside another observability backend -An agent has exactly one `agent/instrumentation.ts`, and other integrations want the same `step.started` slot — PostHog's links spans to the initiating user. Pass `events` and yours is merged with evlog's rather than replacing it: +`defineEvlogInstrumentation()` is the shortcut for an agent whose instrumentation is evlog's alone. It owns the file, and an agent has exactly one `agent/instrumentation.ts` — every observability item in eve's registry writes that same path, which is why `eve add instrumentation/sentry` after `eve add instrumentation/posthog` refuses rather than clobbering the first. -```typescript [agent/instrumentation.ts] -import { defineEvlogInstrumentation } from 'evlog/eve' - -export default defineEvlogInstrumentation({ - events: { - 'step.started': ({ session }) => ({ - runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, - }), - }, -}) -``` - -`evlog.request_id` and `evlog.session_id` are applied first, so your keys win on a collision. The callback receives eve's `session`, `turn`, `step`, `channel` and `modelInput`, and runs whether or not a turn is being tracked. - -### Combine several exporters - -Every observability item in eve's registry writes the same `agent/instrumentation.ts`, so `eve add instrumentation/sentry` after `eve add instrumentation/posthog` refuses rather than clobbering the first — there is one slot and one default export. Running several backends means writing that file yourself. - -OpenTelemetry allows one provider with many span processors, so register them together. Items that generate `traceExporter` (Sentry, Datadog, Arize, Jaeger, Braintrust, Honeycomb) become a span processor in the array; PostHog already generates the array form, and is the only one that also uses `events`: +Once another backend is in play, write the file yourself with eve's own `defineInstrumentation` and spread `evlogRuntimeContext` into your runtime context. evlog contributes attributes to your instrumentation instead of wrapping it: ```typescript [agent/instrumentation.ts] import { BatchSpanProcessor, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base' import { PostHogTraceExporter } from '@posthog/ai/otel' import { OTLPHttpProtoTraceExporter, registerOTel } from '@vercel/otel' -import { defineEvlogInstrumentation } from 'evlog/eve' +import { defineInstrumentation } from 'eve/instrumentation' +import { evlogRuntimeContext } from 'evlog/eve' -export default defineEvlogInstrumentation({ +export default defineInstrumentation({ + // One provider, one span processor per backend. Items that generate + // `traceExporter` (Sentry, Datadog, Arize, Jaeger, Braintrust, Honeycomb) + // become an entry in this array. Call registerOTel once, not once per backend. setup: ({ agentName }) => registerOTel({ serviceName: agentName, spanProcessors: [ @@ -268,16 +254,19 @@ export default defineEvlogInstrumentation({ ], }), events: { - 'step.started'(input) { - const distinctId = input.session.auth.initiator?.principalId - ?? input.session.auth.current?.principalId - return distinctId ? { runtimeContext: { posthog_distinct_id: distinctId } } : undefined - }, + 'step.started': input => ({ + runtimeContext: { + ...evlogRuntimeContext(input), + posthog_distinct_id: input.session.auth.initiator?.principalId + ?? input.session.auth.current?.principalId + ?? '', + }, + }), }, }) ``` -Call `registerOTel` once, not once per backend. Keep each generated file open while you merge — the env vars and exporter options are the parts worth copying exactly. +`evlogRuntimeContext` returns `undefined` outside a tracked turn, and spreading that adds nothing. PostHog is the only registry item that also uses `events`; the rest only need their span processor. Keep each generated file open while you merge — the env vars and exporter options are the parts worth copying exactly. ## Production diff --git a/packages/evlog/README.md b/packages/evlog/README.md index a6b66d09..398add9c 100644 --- a/packages/evlog/README.md +++ b/packages/evlog/README.md @@ -594,7 +594,7 @@ export default defineEvlogInstrumentation() `defineEvlogHook()` maps eve turn lifecycle events to one wide event per turn. Call `useLogger()` in tools — the logger is bound via AsyncLocalStorage on `turn.started`. Pass `ctx` only when ALS is unavailable (`useLogger(ctx)`). Pretty-printing follows `isDev()` by default (tree locally, JSON in production); set `init.pretty: false` explicitly if you need to override. -`defineEvlogInstrumentation()` is optional: it stamps `evlog.request_id` onto eve's AI SDK spans so a trace joins back to its wide event, and back. Pass `events` to merge your own runtime context with evlog's, so another integration can share the single `agent/instrumentation.ts` slot. Requires eve 0.30 or later. Complements eve Agent Runs — see the [eve use case](https://evlog.dev/use-cases/eve). +`defineEvlogInstrumentation()` is optional: it stamps `evlog.request_id` onto eve's AI SDK spans so a trace joins back to its wide event, and back. It owns `agent/instrumentation.ts`, so when another observability backend needs that file, use eve's own `defineInstrumentation` and spread `evlogRuntimeContext(input)` into your runtime context instead. Requires eve 0.30 or later. Complements eve Agent Runs — see the [eve use case](https://evlog.dev/use-cases/eve). Every turn event carries `eve.caller` — `principalId`, `principalType` and `authenticator` — so cost and volume group by who triggered the turn. diff --git a/packages/evlog/src/eve/index.ts b/packages/evlog/src/eve/index.ts index a4c7904b..ba7c03dc 100644 --- a/packages/evlog/src/eve/index.ts +++ b/packages/evlog/src/eve/index.ts @@ -1431,49 +1431,59 @@ export interface EvlogEveInstrumentationOptions { * and eve keeps writing its local traces. */ setup?: (context: InstrumentationSetupContext) => void - /** - * Your own runtime-context contributions, merged with evlog's. - * - * An agent has exactly one `agent/instrumentation.ts`, and other integrations - * want that same `step.started` slot — PostHog's, for instance, links spans to - * the initiating user. Without this, adopting one means dropping the other. - * evlog's `evlog.request_id` / `evlog.session_id` are applied first, so your - * keys win on a collision and you can attach anything the callback's - * `session`, `turn`, `step`, `channel` or `modelInput` exposes. - * - * @example - * ```ts - * defineEvlogInstrumentation({ - * events: { - * 'step.started': ({ session }) => ({ - * runtimeContext: { 'caller.id': session.auth.current?.principalId ?? '' }, - * }), - * }, - * }) - * ``` - */ - events?: InstrumentationDefinition['events'] } /** - * Per-model-call context linking an AI SDK span back to the evlog wide event - * for the same turn. Returns `undefined` outside a tracked turn, which - * contributes no context rather than a half-filled one. + * Per-model-call attributes linking an AI SDK span back to the evlog wide event + * for the same turn, ready to spread into your own runtime context. + * + * Use this when the agent already has an `agent/instrumentation.ts` — from + * `eve add instrumentation/...` or written by hand. Every observability + * integration writes that one file, so evlog contributes attributes to yours + * rather than asking you to nest it inside a wrapper: + * + * @example + * ```ts + * // agent/instrumentation.ts + * import { defineInstrumentation } from 'eve/instrumentation' + * import { evlogRuntimeContext } from 'evlog/eve' + * + * export default defineInstrumentation({ + * setup: ({ agentName }) => registerOTel({ serviceName: agentName }), + * events: { + * 'step.started': input => ({ + * runtimeContext: { + * ...evlogRuntimeContext(input), + * posthog_distinct_id: input.session.auth.current?.principalId ?? '', + * }, + * }), + * }, + * }) + * ``` + * + * Returns `undefined` outside a tracked turn — spreading that adds nothing, + * which is the point. {@link defineEvlogInstrumentation} is the shortcut for an + * agent with no other instrumentation to compose with. */ -function buildInstrumentationContext( +export function evlogRuntimeContext( input: InstrumentationStepStartedEventInput, -): InstrumentationStepStartedEventResult | undefined { +): Record | undefined { const state = getTurnState(input.session.id, input.turn.id) if (!state) return undefined return { - runtimeContext: { - 'evlog.request_id': state.turnId, - 'evlog.session_id': state.sessionId, - }, + 'evlog.request_id': state.turnId, + 'evlog.session_id': state.sessionId, } } +function buildInstrumentationContext( + input: InstrumentationStepStartedEventInput, +): InstrumentationStepStartedEventResult | undefined { + const runtimeContext = evlogRuntimeContext(input) + return runtimeContext ? { runtimeContext } : undefined +} + /** * Create an eve instrumentation definition that stamps evlog's turn identity * onto the AI SDK telemetry spans. @@ -1497,6 +1507,11 @@ function buildInstrumentationContext( * setup: ({ agentName }) => registerOTel({ serviceName: agentName }), * }) * ``` + * + * This is the shortcut for an agent whose instrumentation is evlog's alone. It + * owns the file's `events` slot, so once another integration needs it — PostHog + * links spans to the initiating user there — drop the wrapper and spread + * {@link evlogRuntimeContext} into your own `defineInstrumentation` instead. */ export function defineEvlogInstrumentation( options: EvlogEveInstrumentationOptions = {}, @@ -1510,19 +1525,7 @@ export function defineEvlogInstrumentation( : {}), ...(options.setup !== undefined ? { setup: options.setup } : {}), events: { - ...options.events, - 'step.started': (input) => { - const evlogContext = buildInstrumentationContext(input) - const authored = options.events?.['step.started']?.(input) - if (!evlogContext && !authored) return undefined - return { - ...authored, - runtimeContext: { - ...evlogContext?.runtimeContext, - ...authored?.runtimeContext, - }, - } - }, + 'step.started': buildInstrumentationContext, }, }) } diff --git a/packages/evlog/test/eve.test.ts b/packages/evlog/test/eve.test.ts index ba3bcd72..75198007 100644 --- a/packages/evlog/test/eve.test.ts +++ b/packages/evlog/test/eve.test.ts @@ -5,6 +5,7 @@ import { resetEvlogEveForTests, defineEvlogHook, defineEvlogInstrumentation, + evlogRuntimeContext, useLogger, detachActiveTurnLoggerForTests, } from '../src/eve/index' @@ -1781,66 +1782,48 @@ describe('defineEvlogInstrumentation', () => { it('declares nothing beyond the event hook when unconfigured', () => { expect(Object.keys(defineEvlogInstrumentation())).toEqual(['events']) }) +}) - it('merges an authored runtime context with its own', () => { - const hook = defineEvlogHook({}) - const instrumentation = defineEvlogInstrumentation({ - events: { - 'step.started': ({ session }) => ({ - runtimeContext: { 'caller.id': session.auth.current?.principalId ?? 'anonymous' }, - }), - }, - }) - - hook.events!['turn.started']!({ - type: 'turn.started', - data: { sequence: 0, turnId: TURN_ID }, - }, hookContext()) +describe('evlogRuntimeContext', () => { + beforeEach(() => { + resetEvlogEveForTests() + initLogger({ env: { service: 'eve-test' } }) + }) - expect(instrumentation.events!['step.started']!(stepStartedInput())).toEqual({ - runtimeContext: { - 'evlog.request_id': TURN_ID, - 'evlog.session_id': SESSION_ID, - 'caller.id': 'anonymous', - }, - }) + afterEach(() => { + resetEvlogEveForTests() }) - it('lets an authored key win over its own on a collision', () => { + function stepStartedInput(turnId = TURN_ID) { + return { + channel: { kind: 'http' }, + modelInput: { instructions: undefined, messages: [] }, + session: { id: SESSION_ID, auth: { current: null, initiator: null } }, + step: { index: 0 }, + turn: { id: turnId, sequence: 0 }, + } as Parameters[0] + } + + it('returns attributes that spread into an authored runtime context', () => { const hook = defineEvlogHook({}) - const instrumentation = defineEvlogInstrumentation({ - events: { - 'step.started': () => ({ runtimeContext: { 'evlog.request_id': 'authored' } }), - }, - }) hook.events!['turn.started']!({ type: 'turn.started', data: { sequence: 0, turnId: TURN_ID }, }, hookContext()) - expect(instrumentation.events!['step.started']!(stepStartedInput())).toMatchObject({ - runtimeContext: { 'evlog.request_id': 'authored' }, + expect({ + ...evlogRuntimeContext(stepStartedInput()), + posthog_distinct_id: 'user_1', + }).toEqual({ + 'evlog.request_id': TURN_ID, + 'evlog.session_id': SESSION_ID, + posthog_distinct_id: 'user_1', }) }) - it('still contributes the authored context outside a tracked turn', () => { - const instrumentation = defineEvlogInstrumentation({ - events: { - 'step.started': () => ({ runtimeContext: { 'caller.id': 'anonymous' } }), - }, - }) - - expect(instrumentation.events!['step.started']!(stepStartedInput())).toEqual({ - runtimeContext: { 'caller.id': 'anonymous' }, - }) - }) - - it('contributes nothing when neither side has context to add', () => { - const instrumentation = defineEvlogInstrumentation({ - events: { 'step.started': () => undefined }, - }) - - expect(instrumentation.events!['step.started']!(stepStartedInput())).toBeUndefined() + it('spreads to nothing outside a tracked turn', () => { + expect(evlogRuntimeContext(stepStartedInput())).toBeUndefined() + expect({ ...evlogRuntimeContext(stepStartedInput()) }).toEqual({}) }) }) diff --git a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap index 344b476d..352c6a4a 100644 --- a/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap +++ b/packages/evlog/test/toolkit/__snapshots__/api-surface.test.ts.snap @@ -117,6 +117,7 @@ exports[`public API surface > matches snapshot for all subpath exports 1`] = ` "defineEvlogHook", "defineEvlogInstrumentation", "detachActiveTurnLoggerForTests", + "evlogRuntimeContext", "resetEvlogEveForTests", "useLogger", ],