From 07f219663797e4d64e2758dfdda31a236fa0a677 Mon Sep 17 00:00:00 2001 From: Chad Hietala Date: Sat, 8 Aug 2026 09:45:19 -0400 Subject: [PATCH] feat(eve): let a provider say how much of an event it needs Signed-off-by: Chad Hietala --- .changeset/capture-below-the-otel-layer.md | 10 ++ docs/guides/instrumentation-providers.md | 47 +++++- .../src/harness/ai-sdk-hook-bridge.test.ts | 155 ++++++++++++++++-- .../eve/src/harness/ai-sdk-hook-bridge.ts | 37 +++-- .../src/harness/instrumentation-content.ts | 52 ++++++ .../harness/instrumentation-lifecycle.test.ts | 101 ++++++++++++ .../src/harness/instrumentation-lifecycle.ts | 107 ++++++++---- .../instrumentation-native-events.test.ts | 8 +- .../harness/instrumentation-native-events.ts | 8 +- .../src/harness/instrumentation-providers.ts | 1 + packages/eve/src/harness/tool-loop.test.ts | 6 +- .../src/public/instrumentation/provider.ts | 15 +- .../eve/src/tracing/agent-otel-provider.ts | 15 +- .../tracing/content-span-processor.test.ts | 25 +++ .../eve/src/tracing/content-span-processor.ts | 55 ++++++- 15 files changed, 568 insertions(+), 74 deletions(-) create mode 100644 .changeset/capture-below-the-otel-layer.md create mode 100644 packages/eve/src/harness/instrumentation-content.ts diff --git a/.changeset/capture-below-the-otel-layer.md b/.changeset/capture-below-the-otel-layer.md new file mode 100644 index 000000000..f04a8e398 --- /dev/null +++ b/.changeset/capture-below-the-otel-layer.md @@ -0,0 +1,10 @@ +--- +"eve": patch +--- + +Let an instrumentation provider declare how much of each event it wants. Under +the experimental provider layout, `capture: "content"` opts a provider into the +prompt, the response, and tool payloads; the default `"metadata"` leaves it +structure, usage, and timing. Content is now built only when something asked for +it, so an agent whose providers and destinations all decline never serializes a +prompt at all. diff --git a/docs/guides/instrumentation-providers.md b/docs/guides/instrumentation-providers.md index 830f84356..8c5b89495 100644 --- a/docs/guides/instrumentation-providers.md +++ b/docs/guides/instrumentation-providers.md @@ -82,6 +82,46 @@ Pass `spanProcessors` instead when the destination needs its own batching, sampl `otel()` and `otelIntegration()` come from `eve/instrumentation/otel`, a separate entrypoint from `eve/instrumentation`. +## Content is per provider + +A provider declares how much of each event it wants. The default is +`"metadata"`: structure, identity, usage, and timing, but not what the +conversation said. `"content"` adds the prompt, the response, tool arguments, +and tool results. + +```ts title="agent/instrumentation/audit.ts" +export default defineInstrumentation({ + capture: "content", + events: { + "model.call.completed": (event) => { + console.log(event.content); + }, + }, +}); +``` + +Asking is what makes eve build the projection at all. A directory in which no +provider asked — and whose destinations all declined — never serializes a +prompt, so declining is cheaper than filtering as well as safer. Providers that +did not ask receive the same events with the content fields absent; a +`capture: "content"` provider beside them changes nothing about what they see. + +Structure survives declining. `action.completed` still says whether the tool +returned or threw, and `model.call.completed` still carries its finish reason +and token usage — a provider counting failures or cost never has to ask for +content to get them. + +Failure details and opaque provider metadata can contain prompts, tool output, +search queries, or retrieved text, so metadata providers do not receive them. +`step.attempt.metadata` keeps only the gateway cost and generation ID by +default; `capture: "content"` exposes the complete provider payload and failure +objects. + +Content fields are therefore optional on the event types that carry them: +`input` on `model.call.started`, `action.started`, and `tool.call.started`; +`content` on `model.call.completed`; and the payloads inside action and tool-call +outputs. + ## Content is per destination `recordInputs` and `recordOutputs` belong to a destination, not to the process. Content is written onto a span if any destination wants it, and each destination that declined never exports it. A local spool and a hosted backend no longer have to agree: @@ -94,7 +134,7 @@ export default otelIntegration({ }); ``` -An agent whose every destination declines still never materializes a prompt — the union of nothing is nothing. Declining wraps every processor in that file, an author's included: they are this destination, and the point of declining is that nothing under it sees what was said. The wrapper copies the span rather than editing it, because the span it is handed is shared with every other destination in the pipeline. +An agent whose every destination declines still never materializes a prompt — the OpenTelemetry pipeline is itself one provider, and a pipeline whose destinations all declined asks for `"metadata"` like any other. Declining wraps every processor in that file, an author's included: they are this destination, and the point of declining is that nothing under it sees what was said. The wrapper copies the span rather than editing it, because the span it is handed is shared with every other destination in the pipeline. For sensitive, regulated, or production data, decline content on any destination whose retention path you have not reviewed. You are responsible for ensuring an observability or eval provider is approved for what is exported to it. @@ -147,7 +187,10 @@ A provider's `events` map takes one handler per event type, each called with `(e | `action.started`, `action.completed`, `action.failed` | Every eve dispatch: tool call, skill load, subagent, or remote agent | | `tool.call.started`, `tool.call.completed`, `tool.call.failed` | The AI SDK execution boundary for an ordinary tool call | -An ordinary tool emits both families. `action.*` is eve's durable dispatch boundary and covers work that can settle in another worker; `tool.call.*` is the model SDK's in-process execution boundary. Handle one unless you intentionally want both views. +An ordinary tool emits both families. `action.*` is eve's durable dispatch +boundary and covers work that can settle in another worker; `tool.call.*` is the +model SDK's in-process execution boundary. Handle one unless you intentionally +want both views. Every event carries an `idempotencyKey` naming the operation it is about. A start and its terminal share a key when the terminal arrives. An incomplete model stream can close without one, so live resources need step-attempt cleanup or their own expiry. diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts index 7654319cb..9161c8d70 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "vitest"; import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js"; import { + attemptIdempotencyKey, createInstrumentationHooks, + modelCallIdempotencyKey, type InstrumentationAttemptScope, type InstrumentationModelCallStartedEvent, type InstrumentationModelCallTerminalEvent, @@ -58,7 +60,7 @@ describe("createAiSdkHookBridge", () => { }, ]); - const id = `model:${scope.attemptId}:0`; + const id = modelCallIdempotencyKey(scope, 0); expect(calls).toEqual([ `a:started:${id}`, `b:started:${id}`, @@ -94,7 +96,10 @@ describe("createAiSdkHookBridge", () => { it("passes the identity captured at model-call start to the context runner", async () => { const ids: string[] = []; const hooks = createInstrumentationHooks([ - { events: { "model.call.started": (event) => void ids.push(event.idempotencyKey) } }, + { + events: { "model.call.started": (event) => void ids.push(event.idempotencyKey) }, + name: "recorder", + }, ]); const bridge = createAiSdkHookBridge(scope, hooks, (operation, execute) => { ids.push(operation.idempotencyKey); @@ -111,7 +116,7 @@ describe("createAiSdkHookBridge", () => { await bridge.executeLanguageModelCall!({ callId: "call-1", execute: async () => "result" }); - const expected = `model:${scope.attemptId}:0`; + const expected = modelCallIdempotencyKey(scope, 0); expect(ids).toEqual([expected, expected]); }); @@ -132,7 +137,10 @@ describe("createAiSdkHookBridge", () => { it("derives replay-stable model identity without the AI SDK call ID", async () => { const keys: string[] = []; const hooks = createInstrumentationHooks([ - { events: { "model.call.started": (event) => void keys.push(event.idempotencyKey) } }, + { + events: { "model.call.started": (event) => void keys.push(event.idempotencyKey) }, + name: "keys", + }, ]); for (const callId of ["sdk-random-1", "sdk-random-2"]) { @@ -143,7 +151,7 @@ describe("createAiSdkHookBridge", () => { ]); } - expect(keys).toEqual([`model:${scope.attemptId}:2`, `model:${scope.attemptId}:2`]); + expect(keys).toEqual([modelCallIdempotencyKey(scope, 2), modelCallIdempotencyKey(scope, 2)]); }); it("publishes step provider metadata as step.metadata, skipping steps without any", async () => { @@ -155,19 +163,29 @@ describe("createAiSdkHookBridge", () => { events.push(event); }, }, + name: "metadata", }, ]); const bridge = createAiSdkHookBridge(scope, hooks); await Reflect.apply(bridge.onStepEnd!, bridge, [ - { providerMetadata: { gateway: { cost: "0.000082" } } }, + { + providerMetadata: { + gateway: { + cost: "0.000082", + generationId: "generation-1", + groundingSegments: ["private result"], + }, + google: { searchQueries: ["private query"] }, + }, + }, ]); await Reflect.apply(bridge.onStepEnd!, bridge, [{ providerMetadata: undefined }]); expect(events).toEqual([ { - idempotencyKey: `step:${scope.attemptId}`, - providerMetadata: { gateway: { cost: "0.000082" } }, + idempotencyKey: attemptIdempotencyKey(scope), + providerMetadata: { gateway: { cost: "0.000082", generationId: "generation-1" } }, scope, type: "step.attempt.metadata", }, @@ -183,11 +201,13 @@ describe("createAiSdkHookBridge", () => { throw new Error("provider failed"); }, }, + name: "thrower", }, { events: { "model.call.completed": after, }, + name: "after", }, ]); const bridge = createAiSdkHookBridge(scope, hooks); @@ -211,7 +231,9 @@ describe("createAiSdkHookBridge", () => { it("terminalizes started operations when the attempt errors", async () => { const after = vi.fn(); - const hooks = createInstrumentationHooks([{ events: { "model.call.failed": after } }]); + const hooks = createInstrumentationHooks([ + { capture: "content", events: { "model.call.failed": after }, name: "after" }, + ]); const bridge = createAiSdkHookBridge(scope, hooks); await Reflect.apply(bridge.onLanguageModelCallStart!, bridge, [ @@ -236,8 +258,8 @@ describe("createAiSdkHookBridge", () => { }); const started = vi.fn(); const hooks = createInstrumentationHooks([ - { events: { "step.attempt.started": mutator } }, - { events: { "step.attempt.started": started } }, + { events: { "step.attempt.started": mutator }, name: "mutator" }, + { events: { "step.attempt.started": started }, name: "started" }, ]); const bridge = createAiSdkHookBridge(scope, hooks); @@ -247,7 +269,7 @@ describe("createAiSdkHookBridge", () => { await Reflect.apply(bridge.onStepStart!, bridge, [{ callId: "call-1", stepNumber: 0 }]); const expected = { - idempotencyKey: `step:${scope.attemptId}`, + idempotencyKey: attemptIdempotencyKey(scope), operation: { modelId: "model", operationId: "ai.streamText", provider: "test" }, scope, type: "step.attempt.started", @@ -258,6 +280,7 @@ describe("createAiSdkHookBridge", () => { it("projects the model call callbacks onto eve fields only", async () => { const before = vi.fn((event: InstrumentationModelCallStartedEvent) => { + if (event.input === undefined) throw new Error("expected model input"); expect(Object.isFrozen(event)).toBe(true); expect(Object.isFrozen(event.input)).toBe(true); expect(Object.isFrozen(event.input.messages)).toBe(true); @@ -265,6 +288,7 @@ describe("createAiSdkHookBridge", () => { }); const after = vi.fn((event: InstrumentationModelCallTerminalEvent) => { if (event.type !== "model.call.completed") throw new Error("expected completed model call"); + if (event.content === undefined) throw new Error("expected model content"); expect(Object.isFrozen(event)).toBe(true); expect(Object.isFrozen(event.content)).toBe(true); expect(event.content.every((part) => Object.isFrozen(part))).toBe(true); @@ -272,7 +296,11 @@ describe("createAiSdkHookBridge", () => { expect(Object.isFrozen(event.usage.inputTokenDetails)).toBe(true); }); const hooks = createInstrumentationHooks([ - { events: { "model.call.completed": after, "model.call.started": before } }, + { + capture: "content", + events: { "model.call.completed": after, "model.call.started": before }, + name: "spy", + }, ]); const bridge = createAiSdkHookBridge(scope, hooks); @@ -310,7 +338,7 @@ describe("createAiSdkHookBridge", () => { expect(before).toHaveBeenCalledExactlyOnceWith( { - idempotencyKey: `model:${scope.attemptId}:0`, + idempotencyKey: modelCallIdempotencyKey(scope, 0), input: { instructions: "be brief", messages: [{ content: "hi", role: "user" }] }, model: { modelId: "model", provider: "test" }, scope, @@ -330,7 +358,7 @@ describe("createAiSdkHookBridge", () => { { error: "boom", input: { a: 2 }, toolName: "search", type: "tool-error" }, ], finishReason: "tool-calls", - idempotencyKey: `model:${scope.attemptId}:0`, + idempotencyKey: modelCallIdempotencyKey(scope, 0), scope, type: "model.call.completed", usage: { @@ -363,8 +391,17 @@ describe("createAiSdkHookBridge", () => { expect(Object.isFrozen(event)).toBe(true); expect(Object.isFrozen(event.output)).toBe(true); }); + const actionStarted = vi.fn(); const hooks = createInstrumentationHooks([ - { events: { "tool.call.completed": after, "tool.call.started": before } }, + { + capture: "content", + events: { + "action.started": actionStarted, + "tool.call.completed": after, + "tool.call.started": before, + }, + name: "spy", + }, ]); const bridge = createAiSdkHookBridge(scope, hooks); const toolCall = { input: { q: "eve" }, toolCallId: "tool-1", toolName: "search" }; @@ -394,9 +431,90 @@ describe("createAiSdkHookBridge", () => { }, expect.anything(), ); + expect(actionStarted).not.toHaveBeenCalled(); }, ); + it("omits content from the projection when no provider asked for it", async () => { + const modelStarted = vi.fn(); + const modelCompleted = vi.fn(); + const toolStarted = vi.fn(); + const toolCompleted = vi.fn(); + const hooks = createInstrumentationHooks([ + { + events: { + "model.call.completed": modelCompleted, + "model.call.started": modelStarted, + "tool.call.completed": toolCompleted, + "tool.call.started": toolStarted, + }, + name: "metadata-only", + }, + ]); + const bridge = createAiSdkHookBridge(scope, hooks); + const toolCall = { input: { q: "eve" }, toolCallId: "tool-1", toolName: "search" }; + + await Reflect.apply(bridge.onLanguageModelCallStart!, bridge, [ + { + callId: "call-1", + instructions: "be brief", + messages: [{ content: "hi", role: "user" }], + modelId: "model", + provider: "test", + tools: undefined, + }, + ]); + await Reflect.apply(bridge.onLanguageModelCallEnd!, bridge, [ + { + callId: "call-1", + content: [{ text: "hello", type: "text" }], + finishReason: "stop", + performance: { responseTimeMs: 1 }, + responseId: "response-1", + usage: { inputTokens: 1, outputTokens: 2 }, + }, + ]); + await Reflect.apply(bridge.onToolExecutionStart!, bridge, [{ callId: "call-1", toolCall }]); + await Reflect.apply(bridge.onToolExecutionEnd!, bridge, [ + { + callId: "call-1", + toolCall, + toolExecutionMs: 1, + toolOutput: { output: "ok", type: "tool-result" }, + }, + ]); + + expect(modelStarted.mock.calls[0]?.[0].input).toBeUndefined(); + expect(modelCompleted.mock.calls[0]?.[0].content).toBeUndefined(); + // Structure survives: usage, the finish reason, and the tool's identity are + // not what was said. + expect(modelCompleted.mock.calls[0]?.[0].finishReason).toBe("stop"); + expect(toolStarted.mock.calls[0]?.[0].input).toBeUndefined(); + expect(toolStarted.mock.calls[0]?.[0].toolName).toBe("search"); + expect(toolCompleted.mock.calls[0]?.[0].output).toEqual({ type: "result" }); + }); + + it("withholds content from a metadata provider sharing a bus with a content one", async () => { + const metadataOnly = vi.fn(); + const wantsContent = vi.fn(); + const hooks = createInstrumentationHooks([ + { events: { "tool.call.started": metadataOnly }, name: "metadata-only" }, + { + capture: "content", + events: { "tool.call.started": wantsContent }, + name: "wants-content", + }, + ]); + const bridge = createAiSdkHookBridge(scope, hooks); + + await Reflect.apply(bridge.onToolExecutionStart!, bridge, [ + { callId: "call-1", toolCall: { input: { q: "eve" }, toolCallId: "t", toolName: "search" } }, + ]); + + expect(wantsContent.mock.calls[0]?.[0].input).toEqual({ q: "eve" }); + expect(metadataOnly.mock.calls[0]?.[0].input).toBeUndefined(); + expect(metadataOnly.mock.calls[0]?.[0].toolName).toBe("search"); + }); it("keeps each provider's state to itself", async () => { const observed = new Map(); const provider = (name: string): InstrumentationProviderDefinition => { @@ -438,7 +556,9 @@ describe("createAiSdkHookBridge", () => { it("skips a terminal handler when the operation never started", async () => { const completed = vi.fn(); - const hooks = createInstrumentationHooks([{ events: { "model.call.completed": completed } }]); + const hooks = createInstrumentationHooks([ + { events: { "model.call.completed": completed }, name: "completed" }, + ]); const bridge = createAiSdkHookBridge(scope, hooks); // No onLanguageModelCallStart, so the bridge holds no id and publishes @@ -476,6 +596,7 @@ describe("createAiSdkHookBridge", () => { terminalStates.set(event.idempotencyKey, started.get(event.idempotencyKey)); }, }, + name: "parallel", }, ]); const bridge = createAiSdkHookBridge(scope, hooks); diff --git a/packages/eve/src/harness/ai-sdk-hook-bridge.ts b/packages/eve/src/harness/ai-sdk-hook-bridge.ts index ce6a39d58..3aabb21cb 100644 --- a/packages/eve/src/harness/ai-sdk-hook-bridge.ts +++ b/packages/eve/src/harness/ai-sdk-hook-bridge.ts @@ -19,10 +19,13 @@ import { modelCallIdempotencyKey, toolCallIdempotencyKey, } from "#harness/instrumentation-lifecycle.js"; +import { structuralProviderMetadata } from "#harness/instrumentation-content.js"; type TelemetryEvent = Parameters>[0]; interface AttemptState { + /** False when no provider asked for content, so none is projected at all. */ + readonly capturesContent: boolean; readonly modelKeys: Map; readonly scope: InstrumentationAttemptScope; readonly toolKeys: Map; @@ -38,6 +41,7 @@ export function createAiSdkHookBridge( runInContext: InstrumentationContextRunner = directRunInContext, ): Telemetry { const state: AttemptState = { + capturesContent: hooks.capturesContent, modelKeys: new Map(), scope, toolKeys: new Map(), @@ -80,10 +84,13 @@ export function createAiSdkHookBridge( // that the per-call telemetry events don't. Publish it for providers // that know what to do with it; skip when there is none. if (event.providerMetadata === undefined) return; + const providerMetadata = state.capturesContent + ? event.providerMetadata + : structuralProviderMetadata(event.providerMetadata); await hooks.publish( Object.freeze({ idempotencyKey: attemptIdempotencyKey(state.scope), - providerMetadata: event.providerMetadata, + providerMetadata, scope: state.scope, type: "step.attempt.metadata", }), @@ -160,10 +167,12 @@ function toModelCallStarted( ): InstrumentationModelCallStartedEvent { return Object.freeze({ idempotencyKey, - input: Object.freeze({ - instructions: source.instructions, - messages: Object.freeze([...source.messages]), - }), + input: state.capturesContent + ? Object.freeze({ + instructions: source.instructions, + messages: Object.freeze([...source.messages]), + }) + : undefined, model: Object.freeze({ modelId: source.modelId, provider: source.provider }), scope: state.scope, type: "model.call.started", @@ -176,7 +185,7 @@ function toModelCallCompleted( source: TelemetryEvent<"onLanguageModelCallEnd">, ): InstrumentationModelCallCompletedEvent { return Object.freeze({ - content: toContentParts(source.content), + content: state.capturesContent ? toContentParts(source.content) : undefined, finishReason: source.finishReason, idempotencyKey, scope: state.scope, @@ -247,7 +256,7 @@ function toToolCallStarted( return Object.freeze({ callId: source.toolCall.toolCallId, idempotencyKey, - input: source.toolCall.input, + input: state.capturesContent ? source.toolCall.input : undefined, scope: state.scope, toolName: source.toolCall.toolName, type: "tool.call.started", @@ -261,7 +270,7 @@ function toToolCallCompleted( ): InstrumentationToolCallCompletedEvent { return Object.freeze({ idempotencyKey, - output: toToolOutput(source.toolOutput), + output: toToolOutput(source.toolOutput, state.capturesContent), scope: state.scope, type: "tool.call.completed", }); @@ -269,8 +278,14 @@ function toToolCallCompleted( function toToolOutput( toolOutput: TelemetryEvent<"onToolExecutionEnd">["toolOutput"], + capturesContent: boolean, ): InstrumentationToolOutput { - return toolOutput.type === "tool-result" - ? Object.freeze({ output: toolOutput.output, type: "result" }) - : Object.freeze({ error: toolOutput.error, type: "error" }); + if (toolOutput.type === "tool-result") { + return Object.freeze( + capturesContent ? { output: toolOutput.output, type: "result" } : { type: "result" }, + ); + } + return Object.freeze( + capturesContent ? { error: toolOutput.error, type: "error" } : { type: "error" }, + ); } diff --git a/packages/eve/src/harness/instrumentation-content.ts b/packages/eve/src/harness/instrumentation-content.ts new file mode 100644 index 000000000..90e1faf31 --- /dev/null +++ b/packages/eve/src/harness/instrumentation-content.ts @@ -0,0 +1,52 @@ +import type { InstrumentationEvent } from "#harness/instrumentation-lifecycle.js"; + +/** Returns an immutable event projection with conversation content removed. */ +export function withoutInstrumentationContent(event: InstrumentationEvent): InstrumentationEvent { + switch (event.type) { + case "action.started": + return Object.freeze({ ...event, input: undefined }); + case "action.completed": + return Object.freeze({ ...event, output: Object.freeze({ type: event.output.type }) }); + case "tool.call.started": + return Object.freeze({ ...event, input: undefined }); + case "tool.call.completed": + return Object.freeze({ ...event, output: Object.freeze({ type: event.output.type }) }); + case "model.call.started": + return Object.freeze({ ...event, input: undefined }); + case "model.call.completed": + return Object.freeze({ ...event, content: undefined }); + case "step.attempt.metadata": + return Object.freeze({ + ...event, + providerMetadata: structuralProviderMetadata(event.providerMetadata), + }); + case "action.failed": + case "model.call.failed": + case "session.failed": + case "step.attempt.failed": + case "tool.call.failed": + case "turn.failed": + return Object.freeze({ ...event, error: undefined }); + default: + return event; + } +} + +/** Provider metadata fields that describe cost/identity rather than content. */ +export function structuralProviderMetadata( + metadata: Readonly>, +): Readonly> { + const gateway = metadata["gateway"]; + if (typeof gateway !== "object" || gateway === null || Array.isArray(gateway)) { + return Object.freeze({}); + } + const source = gateway as Readonly>; + const structural: Record = {}; + for (const key of ["cost", "generationId"] as const) { + const value = source[key]; + if (typeof value === "string" || typeof value === "number") structural[key] = value; + } + return Object.freeze( + Object.keys(structural).length === 0 ? {} : { gateway: Object.freeze(structural) }, + ); +} diff --git a/packages/eve/src/harness/instrumentation-lifecycle.test.ts b/packages/eve/src/harness/instrumentation-lifecycle.test.ts index c070e6226..e2e3ae535 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.test.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.test.ts @@ -11,6 +11,7 @@ import { toolCallIdempotencyKey, turnIdempotencyKey, type InstrumentationAttemptScope, + type InstrumentationToolCallCompletedEvent, } from "#harness/instrumentation-lifecycle.js"; import { findInstrumentationActionScopeForCall, @@ -293,3 +294,103 @@ describe("provider handler deadlines", () => { expect(terminal).toHaveBeenCalledOnce(); }); }); + +describe("capture", () => { + it("reports content capture only when some provider asked for it", () => { + expect(createInstrumentationHooks([{ name: "quiet" }]).capturesContent).toBe(false); + expect( + createInstrumentationHooks([{ capture: "metadata", name: "quiet" }, { name: "also-quiet" }]) + .capturesContent, + ).toBe(false); + expect( + createInstrumentationHooks([{ name: "quiet" }, { capture: "content", name: "loud" }]) + .capturesContent, + ).toBe(true); + }); + + it("allows only structural provider metadata by default", async () => { + const metadataOnly = vi.fn(); + const wantsContent = vi.fn(); + const hooks = createInstrumentationHooks([ + { events: { "step.attempt.metadata": metadataOnly }, name: "metadata" }, + { + capture: "content", + events: { "step.attempt.metadata": wantsContent }, + name: "content", + }, + ]); + const providerMetadata = { + gateway: { + cost: "0.01", + generationId: "generation-1", + groundingSegments: ["private result"], + }, + google: { searchQueries: ["private query"], thoughtSignature: "private signature" }, + }; + + await hooks.publish({ + idempotencyKey: attemptIdempotencyKey(scope), + providerMetadata, + scope, + type: "step.attempt.metadata", + }); + + const visible = metadataOnly.mock.calls[0]?.[0]; + expect(visible.providerMetadata).toEqual({ + gateway: { cost: "0.01", generationId: "generation-1" }, + }); + expect(Object.isFrozen(visible)).toBe(true); + expect(Object.isFrozen(visible.providerMetadata)).toBe(true); + expect(Object.isFrozen(visible.providerMetadata.gateway)).toBe(true); + expect(wantsContent.mock.calls[0]?.[0].providerMetadata).toBe(providerMetadata); + }); + + it("withholds failure details from metadata providers", async () => { + const metadataOnly = vi.fn(); + const wantsContent = vi.fn(); + const hooks = createInstrumentationHooks([ + { events: { "action.failed": metadataOnly }, name: "metadata" }, + { capture: "content", events: { "action.failed": wantsContent }, name: "content" }, + ]); + const error = { output: "private tool output", requestBody: "private request" }; + const actionScope = { ...scope }; + + await hooks.publish({ + error, + idempotencyKey: actionIdempotencyKey(scope.sessionId, scope.turnId, "call-1"), + scope: actionScope, + type: "action.failed", + }); + + expect(metadataOnly.mock.calls[0]?.[0]).toMatchObject({ + error: undefined, + type: "action.failed", + }); + expect(Object.isFrozen(metadataOnly.mock.calls[0]?.[0])).toBe(true); + expect(wantsContent.mock.calls[0]?.[0].error).toBe(error); + }); + + it("freezes one stripped projection shared by metadata providers", async () => { + const observed = vi.fn(); + const mutator = vi.fn((event: InstrumentationToolCallCompletedEvent) => { + expect(Reflect.set(event, "finishReason", "corrupted")).toBe(false); + expect(Reflect.set(event.output, "type", "error")).toBe(false); + }); + const hooks = createInstrumentationHooks([ + { events: { "tool.call.completed": mutator }, name: "first" }, + { events: { "tool.call.completed": observed }, name: "second" }, + { capture: "content", name: "content" }, + ]); + + await hooks.publish({ + idempotencyKey: toolCallIdempotencyKey(scope, "call-1", 0), + output: { output: "private", type: "result" }, + scope, + type: "tool.call.completed", + }); + + expect(observed.mock.calls[0]?.[0].output).toEqual({ type: "result" }); + expect(Object.isFrozen(observed.mock.calls[0]?.[0])).toBe(true); + expect(Object.isFrozen(observed.mock.calls[0]?.[0].output)).toBe(true); + }); +}); diff --git a/packages/eve/src/harness/instrumentation-lifecycle.ts b/packages/eve/src/harness/instrumentation-lifecycle.ts index 072b9e895..8cc21bdc5 100644 --- a/packages/eve/src/harness/instrumentation-lifecycle.ts +++ b/packages/eve/src/harness/instrumentation-lifecycle.ts @@ -10,6 +10,7 @@ import { releaseInstrumentationState, } from "#harness/instrumentation-state.js"; import { createLogger, formatError } from "#internal/logging.js"; +import { withoutInstrumentationContent } from "#harness/instrumentation-content.js"; /** * Stable eve identity for one actual model attempt. @@ -91,10 +92,30 @@ export type InstrumentationActionKind = | "subagent-call" | "tool-call"; -/** How one action ended. */ +/** + * How one action ended. + * + * `type` survives a provider that declined content, so whether the tool errored + * is answerable without seeing what it returned. + */ export type InstrumentationActionOutput = - | { readonly type: "result"; readonly output: unknown } - | { readonly type: "error"; readonly error: unknown }; + | { readonly type: "result"; readonly output?: unknown } + | { readonly type: "error"; readonly error?: unknown }; + +/** + * How much of an event a provider is handed. + * + * `"metadata"` — the default — is structure, identity, usage, and timing: every + * field except what the conversation actually said. `"content"` adds the + * prompt, the response, tool arguments, and tool results. + * + * Declared per provider rather than per process, because two consumers of one + * bus rarely have the same retention path. Content is built at all only when + * some provider asked for it, and a provider that did not ask never receives + * it — which is the same guarantee a destination that declines content gets, + * one layer lower and without an OpenTelemetry pipeline to route it through. + */ +export type InstrumentationCapture = "content" | "metadata"; /** * Every event carries an `idempotencyKey` naming the operation it is about: a @@ -188,7 +209,8 @@ export interface InstrumentationSessionSettledEvent { export interface InstrumentationSessionFailedEvent { readonly type: "session.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly sessionId: string; readonly turnId?: string; @@ -225,7 +247,8 @@ export interface InstrumentationTurnSettledEvent { export interface InstrumentationTurnFailedEvent { readonly type: "turn.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly sessionId: string; readonly turnId: string; @@ -243,7 +266,8 @@ export interface InstrumentationStepAttemptCompletedEvent { export interface InstrumentationStepAttemptFailedEvent { readonly type: "step.attempt.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly scope: InstrumentationAttemptScope; } @@ -267,14 +291,16 @@ export interface InstrumentationStepAttemptMetadataEvent { export interface InstrumentationModelCallStartedEvent { readonly type: "model.call.started"; readonly idempotencyKey: string; - readonly input: InstrumentationModelInput; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly input?: InstrumentationModelInput; readonly model: InstrumentationModelRef; readonly scope: InstrumentationAttemptScope; } export interface InstrumentationModelCallCompletedEvent { readonly type: "model.call.completed"; - readonly content: readonly InstrumentationContentPart[]; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly content?: readonly InstrumentationContentPart[]; readonly finishReason: string; readonly idempotencyKey: string; readonly scope: InstrumentationAttemptScope; @@ -283,7 +309,8 @@ export interface InstrumentationModelCallCompletedEvent { export interface InstrumentationModelCallFailedEvent { readonly type: "model.call.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly scope: InstrumentationAttemptScope; } @@ -312,7 +339,8 @@ export interface InstrumentationToolCallCompletedEvent { export interface InstrumentationToolCallFailedEvent { readonly type: "tool.call.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly scope: InstrumentationAttemptScope; } @@ -330,7 +358,8 @@ export interface InstrumentationActionStartedEvent { readonly type: "action.started"; readonly callId: string; readonly idempotencyKey: string; - readonly input: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly input?: unknown; readonly kind: InstrumentationActionKind; readonly name: string; readonly scope: InstrumentationAttemptScope; @@ -345,7 +374,8 @@ export interface InstrumentationActionCompletedEvent { export interface InstrumentationActionFailedEvent { readonly type: "action.failed"; - readonly error: unknown; + /** Content. Absent unless this provider declared `capture: "content"`. */ + readonly error?: unknown; readonly idempotencyKey: string; readonly scope: InstrumentationAttemptScope; } @@ -374,6 +404,8 @@ export type InstrumentationEventHandler = ( /** Internal provider shape mirrored by the future public hook contract. */ export interface InstrumentationProviderDefinition { readonly name: string; + /** Defaults to `"metadata"`. See {@link InstrumentationCapture}. */ + readonly capture?: InstrumentationCapture; readonly events?: { readonly "step.attempt.started"?: InstrumentationEventHandler; readonly "step.attempt.completed"?: InstrumentationEventHandler; @@ -403,10 +435,6 @@ export interface InstrumentationProviderDefinition { readonly shutdown?: () => void | PromiseLike; } -type InstrumentationProviderInput = Omit & { - readonly name?: string; -}; - /** Events that pair a start with its terminal under one `idempotencyKey`. */ export type InstrumentationCorrelatedEvent = | InstrumentationActionStartedEvent @@ -448,6 +476,14 @@ export type InstrumentationExecutionOperation = /** Provider-neutral hook operations consumed by the AI SDK bridge. */ export interface InstrumentationHooks { + /** + * Whether any registered provider declared `capture: "content"`. + * + * False means nothing downstream can read what was said, so the publisher + * should not serialize it in the first place. This is the only way the + * projection is skipped rather than merely withheld. + */ + readonly capturesContent: boolean; publish(event: InstrumentationEvent): Promise; } @@ -465,11 +501,13 @@ export interface CreateInstrumentationHooksOptions { /** Creates failure-isolated hooks backed by an ordered provider list. */ export function createInstrumentationHooks( - providers: readonly InstrumentationProviderInput[], + providers: readonly InstrumentationProviderDefinition[], options: CreateInstrumentationHooksOptions = {}, ): InstrumentationHooks { const handlerTimeoutMs = options.handlerTimeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS; + const capturesContent = providers.some((provider) => provider.capture === "content"); + const publish = async (event: InstrumentationEvent): Promise => { const terminal = isTerminal(event.type); const startedBoundary = event.type.endsWith(".started"); @@ -495,22 +533,25 @@ export function createInstrumentationHooks( } } - for (const [providerIndex, provider] of providers.entries()) { - const providerName = provider.name ?? `provider-${String(providerIndex)}`; + // Built at most once per event and shared by every metadata-only provider. + // Publishers still avoid constructing optional content when nobody asked. + let stripped: InstrumentationEvent | undefined; + + for (const provider of providers) { // The operation is over for this provider either way, so release what it // staged at the start. Nothing downstream can read it now, and a provider // that was abandoned or has no terminal handler could never release it // itself. const release = (): void => { - if (terminal) releaseInstrumentationState(providerName, event.idempotencyKey); + if (terminal) releaseInstrumentationState(provider.name, event.idempotencyKey); if (attemptTerminal) - releaseInstrumentationAttemptState(providerName, event.scope.attemptId); - if (cleanupSession) releaseInstrumentationTurnState(providerName, event.sessionId); + releaseInstrumentationAttemptState(provider.name, event.scope.attemptId); + if (cleanupSession) releaseInstrumentationTurnState(provider.name, event.sessionId); if (cleanupTurn) - releaseInstrumentationTurnState(providerName, event.sessionId, event.turnId); + releaseInstrumentationTurnState(provider.name, event.sessionId, event.turnId); }; - if (isInstrumentationStateAbandoned(providerName, event.idempotencyKey)) { + if (isInstrumentationStateAbandoned(provider.name, event.idempotencyKey)) { release(); continue; } @@ -521,17 +562,23 @@ export function createInstrumentationHooks( continue; } - const state = instrumentationStateSlot(providerName, event.idempotencyKey, owner); + const state = instrumentationStateSlot(provider.name, event.idempotencyKey, owner); const ctx: InstrumentationHandlerContext = { state }; + let visible = event; + if (provider.capture !== "content") { + stripped ??= withoutInstrumentationContent(event); + visible = stripped; + } + try { const settled = await withTimeout( - () => (handler as InstrumentationEventHandler)(event, ctx), + () => (handler as InstrumentationEventHandler)(visible, ctx), handlerTimeoutMs, () => { state.revoke(); if (startedBoundary) { - abandonInstrumentationState(providerName, event.idempotencyKey, owner); + abandonInstrumentationState(provider.name, event.idempotencyKey, owner); } }, ); @@ -541,7 +588,7 @@ export function createInstrumentationHooks( if (!settled && startedBoundary) { log.warn("instrumentation provider timed out", { boundary: event.type, - provider: providerName, + provider: provider.name, timeoutMs: handlerTimeoutMs, }); } @@ -549,7 +596,7 @@ export function createInstrumentationHooks( log.warn("instrumentation provider failed", { boundary: event.type, error: formatError(error), - provider: providerName, + provider: provider.name, }); } finally { state.revoke(); @@ -558,7 +605,7 @@ export function createInstrumentationHooks( } }; - return { publish }; + return { capturesContent, publish }; } /** Resolves false when the deadline wins; rejects with whatever the handler threw. */ diff --git a/packages/eve/src/harness/instrumentation-native-events.test.ts b/packages/eve/src/harness/instrumentation-native-events.test.ts index 3581e7a88..8a3b16977 100644 --- a/packages/eve/src/harness/instrumentation-native-events.test.ts +++ b/packages/eve/src/harness/instrumentation-native-events.test.ts @@ -24,6 +24,7 @@ describe("createInstrumentationHandleEvent", () => { it("publishes native lifecycle transitions after durable handling", async () => { const order: string[] = []; const hooks: InstrumentationHooks = { + capturesContent: false, publish: async (event) => { order.push(`lifecycle:${event.type}`); }, @@ -69,6 +70,7 @@ describe("createInstrumentationHandleEvent", () => { expect( createInstrumentationHandleEvent({ hooks: { + capturesContent: false, publish: async () => {}, }, sessionId: "session-1", @@ -81,6 +83,7 @@ describe("createInstrumentationHandleEvent", () => { const handleEvent = createInstrumentationHandleEvent({ handleEvent: async () => {}, hooks: { + capturesContent: false, publish: async (event) => { events.push(event); }, @@ -112,6 +115,7 @@ describe("createInstrumentationHandleEvent", () => { const handleEvent = createInstrumentationHandleEvent({ handleEvent: async () => {}, hooks: { + capturesContent: false, publish: async (event) => { events.push(event); }, @@ -190,7 +194,7 @@ describe("createInstrumentationHandleEvent", () => { const handleEvent = createInstrumentationHandleEvent({ getAttemptScope: () => scope, handleEvent: async () => {}, - hooks: { publish: async (event) => void events.push(event) }, + hooks: { capturesContent: true, publish: async (event) => void events.push(event) }, sessionId: "session-1", })!; await handleEvent(requested); @@ -201,7 +205,7 @@ describe("createInstrumentationHandleEvent", () => { await contextStorage.run(restored, async () => { const handleEvent = createInstrumentationHandleEvent({ handleEvent: async () => {}, - hooks: { publish: async (event) => void events.push(event) }, + hooks: { capturesContent: true, publish: async (event) => void events.push(event) }, sessionId: "session-1", })!; await handleEvent( diff --git a/packages/eve/src/harness/instrumentation-native-events.ts b/packages/eve/src/harness/instrumentation-native-events.ts index 63a06ec48..bfb5e8f0e 100644 --- a/packages/eve/src/harness/instrumentation-native-events.ts +++ b/packages/eve/src/harness/instrumentation-native-events.ts @@ -74,7 +74,7 @@ async function publishActionStarts( Object.freeze({ callId: action.callId, idempotencyKey, - input: action.input, + input: hooks.capturesContent ? action.input : undefined, kind: action.kind, name: actionName(action), scope, @@ -100,7 +100,11 @@ async function publishActionTerminal( await hooks.publish( Object.freeze({ idempotencyKey, - output: Object.freeze({ output: event.data.result.output, type: "result" }), + output: Object.freeze( + hooks.capturesContent + ? { output: event.data.result.output, type: "result" } + : { type: "result" }, + ), scope, type: "action.completed", }), diff --git a/packages/eve/src/harness/instrumentation-providers.ts b/packages/eve/src/harness/instrumentation-providers.ts index 2ac86c4c2..49ccf2520 100644 --- a/packages/eve/src/harness/instrumentation-providers.ts +++ b/packages/eve/src/harness/instrumentation-providers.ts @@ -142,6 +142,7 @@ function toProviderDefinition( entry: RegisteredInstrumentationProvider, ): InstrumentationProviderDefinition { return { + capture: entry.provider.capture, events: entry.provider.events as InstrumentationProviderDefinition["events"], flush: entry.provider.flush, // The file the provider came from, which is the only name an author can diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index 280d79445..ff903191e 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -9596,7 +9596,7 @@ describe("createToolLoopHarness", () => { }); const attemptCompleted = vi.fn(); const hooks = createInstrumentationHooks([ - { events: { "step.attempt.completed": attemptCompleted } }, + { events: { "step.attempt.completed": attemptCompleted }, name: "attempt" }, ]); const runInContext: InstrumentationContextRunner = (_operation, execute) => execute(); const config = createTestConfig("conversation", undefined, { @@ -9669,7 +9669,9 @@ describe("createToolLoopHarness", () => { toolResults: [], }); const started = vi.fn(); - const hooks = createInstrumentationHooks([{ events: { "action.started": started } }]); + const hooks = createInstrumentationHooks([ + { events: { "action.started": started }, name: "actions" }, + ]); const { emit } = createEventCollector(); const runStep = createToolLoopHarness( createTestConfig("conversation", emit, { diff --git a/packages/eve/src/public/instrumentation/provider.ts b/packages/eve/src/public/instrumentation/provider.ts index b97eaa28b..cacf5df51 100644 --- a/packages/eve/src/public/instrumentation/provider.ts +++ b/packages/eve/src/public/instrumentation/provider.ts @@ -9,7 +9,10 @@ // runtime. The event shapes are eve's own vocabulary; deriving the handler map // from the union below is what keeps the public contract from drifting away // from the bus that feeds it. -import type { InstrumentationEvent } from "#harness/instrumentation-lifecycle.js"; +import type { + InstrumentationCapture, + InstrumentationEvent, +} from "#harness/instrumentation-lifecycle.js"; import type { JsonValue } from "#public/types/json.js"; export type { JsonValue } from "#public/types/json.js"; @@ -21,6 +24,7 @@ export type { InstrumentationActionOutput, InstrumentationActionStartedEvent, InstrumentationAttemptScope, + InstrumentationCapture, InstrumentationContentPart, InstrumentationEvent, InstrumentationModelCallCompletedEvent, @@ -131,6 +135,15 @@ export type ProviderEvents = { * throws is logged and the next provider still runs. */ export interface ProviderDefinition { + /** + * How much of each event this provider is handed. Defaults to `"metadata"`: + * structure, identity, usage, and timing, but not what was said. + * + * `"content"` adds the prompt, the response, tool arguments, and tool + * results. Asking is what makes eve build the projection at all, so a + * directory in which nobody asks never serializes a prompt. + */ + readonly capture?: InstrumentationCapture; readonly events?: ProviderEvents; /** Runs once at server startup, before any event is published. */ readonly setup?: (context: ProviderSetupContext) => void | PromiseLike; diff --git a/packages/eve/src/tracing/agent-otel-provider.ts b/packages/eve/src/tracing/agent-otel-provider.ts index 0839dd601..af20ea837 100644 --- a/packages/eve/src/tracing/agent-otel-provider.ts +++ b/packages/eve/src/tracing/agent-otel-provider.ts @@ -295,7 +295,7 @@ export function createAgentOtelInstrumentation( }, attempt.operation.context, ); - if (recordInputs) { + if (recordInputs && event.input !== undefined) { const messages = messagesContentAttribute(event.input.messages); if (messages !== undefined) span.setAttribute("ai.prompt.messages", messages); const system = systemPromptAttribute(event.input.instructions); @@ -318,8 +318,9 @@ export function createAgentOtelInstrumentation( if (attempt !== undefined) setUsage(attempt.step.span, event.usage); if (recordOutputs) { state.span.setAttribute("ai.response.finish_reason", event.finishReason); + const content = event.content ?? []; const reasoning = textContentAttribute( - event.content + content .filter((part) => part.type === "reasoning") .map((part) => part.text) .filter((part) => part.trim().length > 0) @@ -327,13 +328,13 @@ export function createAgentOtelInstrumentation( ); if (reasoning !== undefined) state.span.setAttribute("ai.response.reasoning", reasoning); const text = textContentAttribute( - event.content + content .filter((part) => part.type === "text") .map((part) => part.text) .join(""), ); if (text !== undefined) state.span.setAttribute("ai.response.text", text); - const toolCalls = event.content + const toolCalls = content .filter((part) => part.type === "tool-call") .map((part) => ({ input: part.input, toolName: part.toolName })); if (toolCalls.length > 0) { @@ -343,7 +344,7 @@ export function createAgentOtelInstrumentation( // Provider-executed tools (e.g. web_search) run inside the model call, // never reach eve's tool loop, and so never get an ai.toolCall span. // Their results only exist as content parts on the model response. - const toolResults = event.content + const toolResults = content .filter((part) => part.type === "tool-result" || part.type === "tool-error") .map((part) => part.type === "tool-result" @@ -490,6 +491,10 @@ export function createAgentOtelInstrumentation( return { hook: { + // The destinations behind this pipeline filter content per exporter, but + // that filter only runs on a span that has it. Declining both here is + // what stops the projection from being built upstream. + capture: recordInputs || recordOutputs ? "content" : "metadata", events: { ...actions.events, "step.attempt.completed": onStepTerminal, diff --git a/packages/eve/src/tracing/content-span-processor.test.ts b/packages/eve/src/tracing/content-span-processor.test.ts index b899ee369..628e87e70 100644 --- a/packages/eve/src/tracing/content-span-processor.test.ts +++ b/packages/eve/src/tracing/content-span-processor.test.ts @@ -91,6 +91,31 @@ describe("contentFilteringProcessor", () => { }); }); + it("withholds exception details when outputs are declined", () => { + const downstream = recordingProcessor(); + const original = { + ...(span({ "service.name": "weather" }) as object), + events: [ + { attributes: { "exception.message": "private output" }, name: "exception" }, + { attributes: { detail: "private event data" }, name: "turn.completed" }, + ], + status: { code: 2, message: "private failure detail" }, + }; + + contentFilteringProcessor(downstream, { recordInputs: true, recordOutputs: false }).onEnd( + original as never, + ); + + const visible = downstream.ended[0] as { + events: unknown[]; + status: unknown; + }; + expect(visible.events).toEqual([{ attributes: undefined, name: "turn.completed" }]); + expect(visible.status).toEqual({ code: 2 }); + expect(original.events).toHaveLength(2); + expect(original.status).toEqual({ code: 2, message: "private failure detail" }); + }); + it("redacts initial attributes before onStart without exposing the original", () => { const downstream = recordingProcessor(); const original = span({ diff --git a/packages/eve/src/tracing/content-span-processor.ts b/packages/eve/src/tracing/content-span-processor.ts index 3ea836281..9465eb844 100644 --- a/packages/eve/src/tracing/content-span-processor.ts +++ b/packages/eve/src/tracing/content-span-processor.ts @@ -77,9 +77,15 @@ function facadeFor( if (existing !== undefined) return existing; const attributes: Record = {}; + const events: unknown[] = []; + const status: Record = {}; const target = Object.create(Reflect.getPrototypeOf(span)) as Record; const boundMethods = new Map(); - const refresh = (): void => refreshAttributes(attributes, span, content); + const refresh = (): void => { + refreshAttributes(attributes, span, content); + refreshEvents(events, span, content); + refreshStatus(status, span, content); + }; let value: object; const readOriginal = (property: PropertyKey): unknown => { const original = Reflect.get(span, property, span) as unknown; @@ -101,8 +107,18 @@ function facadeFor( enumerable: true, value: attributes, }); + Object.defineProperty(target, "events", { + configurable: true, + enumerable: true, + value: events, + }); + Object.defineProperty(target, "status", { + configurable: true, + enumerable: true, + value: status, + }); for (const property of Reflect.ownKeys(span)) { - if (property === "attributes") continue; + if (property === "attributes" || property === "events" || property === "status") continue; const descriptor = Reflect.getOwnPropertyDescriptor(span, property); Object.defineProperty(target, property, { configurable: true, @@ -139,3 +155,38 @@ function refreshAttributes( const kept = withoutDeclinedContent(source as Record, content); Object.assign(destination, kept ?? source); } + +function refreshEvents( + destination: unknown[], + span: object, + content: ResolvedContentOptions, +): void { + destination.length = 0; + const source = (span as { readonly events?: unknown }).events; + if (!Array.isArray(source)) return; + if (content.recordOutputs) { + destination.push(...source); + return; + } + for (const event of source) { + if (typeof event !== "object" || event === null) continue; + const record = event as Readonly>; + if (record["name"] === "exception") continue; + destination.push({ ...record, attributes: undefined }); + } +} + +function refreshStatus( + destination: Record, + span: object, + content: ResolvedContentOptions, +): void { + for (const key of Object.keys(destination)) delete destination[key]; + const source = (span as { readonly status?: unknown }).status; + if (typeof source !== "object" || source === null) return; + const record = source as Readonly>; + if (record["code"] !== undefined) destination["code"] = record["code"]; + if (content.recordOutputs && record["message"] !== undefined) { + destination["message"] = record["message"]; + } +}