diff --git a/.changeset/order-span-export-policies.md b/.changeset/order-span-export-policies.md new file mode 100644 index 000000000..d52307e2b --- /dev/null +++ b/.changeset/order-span-export-policies.md @@ -0,0 +1,25 @@ +--- +"eve": minor +--- + +Make managed destination `exportPolicy` accept one policy or an ordered policy array, with explicit span `emit` and redaction decisions. Attribute policies now return `emit` or `replace` decisions; and the deprecated `redactSpanInputs()`, `redactSpanOutputs()`, destination `recordInputs`, destination `recordOutputs`, `content`, and `composeSpanExportPolicies()` APIs are removed. + +Use these replacements: + +| Before | After | +| --------------------------------- | ------------------------------------------------------------------------------- | +| `span: () => false` | `span: () => ({ emit: false })` | +| `redactSpanInputs(when)` | `span: (span) => when(span) ? { redact: true, inputs: true } : { emit: true }` | +| `redactSpanOutputs(when)` | `span: (span) => when(span) ? { redact: true, outputs: true } : { emit: true }` | +| `recordInputs: false` | `exportPolicy: { span: () => ({ redact: true, inputs: true }) }` | +| `recordOutputs: false` | `exportPolicy: { span: () => ({ redact: true, outputs: true }) }` | +| `{ action: "keep" }` | `{ emit: true }` | +| `{ action: "drop" }` | `{ emit: false }` | +| `{ action: "replace", value }` | `{ replace: true, value }` | +| `composeSpanExportPolicies(a, b)` | `exportPolicy: [a, b]` | + +Passing the removed destination `recordInputs` or `recordOutputs` options now +throws during declaration instead of silently exporting content. + +Returning a boolean from `span` still works but is deprecated; return +`{ emit: boolean }`. diff --git a/docs/guides/instrumentation/otel.mdx b/docs/guides/instrumentation/otel.mdx index 610ea6569..18a8e5714 100644 --- a/docs/guides/instrumentation/otel.mdx +++ b/docs/guides/instrumentation/otel.mdx @@ -100,52 +100,56 @@ export default disableInstrumentation(); ## Filter a managed destination -`localTraces()` and `agentRuns()` accept an `exportPolicy`. It filters spans -and attributes before that destination's processors receive them: +`localTraces()` and `agentRuns()` accept one `exportPolicy` object or an array +applied in order. Each policy filters spans and attributes before that +destination's processors receive them: ```ts title="agent/instrumentation/agent-runs.ts" import { agentRuns } from "eve/instrumentation/otel"; export default agentRuns({ exportPolicy: { - span: ({ name }) => name !== "internal.cache.refresh", - attribute: ({ key }) => (key === "customer.id" ? { action: "drop" } : { action: "keep" }), + span: ({ name }) => ({ emit: name !== "internal.cache.refresh" }), + attribute: ({ key }) => (key === "customer.id" ? { emit: false } : { emit: true }), }, }); ``` -Return `false` from `span` to omit that span from this destination. Return -`{ action: "keep" }`, `{ action: "drop" }`, or -`{ action: "replace", value }` from `attribute` to retain, remove, or change +Return `{ emit: true }` from `span` to retain a span unchanged, or +`{ emit: false }` to omit it from this destination. To retain the span while +redacting content, return `{ redact: true }` with `inputs: true`, +`outputs: true`, or both. A redaction decision implies emission and requires +at least one direction. For example, return +`{ redact: true, inputs: true, outputs: true }` to redact both directions. +A throwing `span` callback drops the span from this destination. Return +`{ emit: true }`, `{ emit: false }`, or +`{ replace: true, value }` from `attribute` to retain, remove, or change one attribute. ### Further narrow a built-in destination -Use the content redactors to further narrow what a built-in destination +Return a redaction decision to further narrow what a built-in destination receives after the process-wide `otel({ tracePolicy })` has admitted a trace. For example, keep the spans in Agent Runs while redacting their inputs and outputs unless the session is public: ```ts title="agent/instrumentation/agent-runs.ts" -import { - agentRuns, - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, -} from "eve/instrumentation/otel"; +import { agentRuns } from "eve/instrumentation/otel"; export default agentRuns({ - exportPolicy: composeSpanExportPolicies( - redactSpanInputs(({ audience }) => audience !== "public"), - redactSpanOutputs(({ audience }) => audience !== "public"), - ), + exportPolicy: { + span: ({ audience }) => + audience === "public" ? { emit: true } : { redact: true, inputs: true, outputs: true }, + }, }); ``` -`redactSpanInputs()` removes eve's known prompt, instruction, document, and -tool-argument attributes. `redactSpanOutputs()` removes response, reasoning, -tool-result, exception, and status attributes. They narrow only this -destination; they do not mutate spans sent to another destination. +Input redaction removes eve's known prompt, instruction, document, and +tool-argument attributes. Output redaction removes response, reasoning, +tool-result, exception, and status attributes. Redaction narrows only this +destination; it does not mutate spans sent to another destination. Policies run +in array order, and each policy receives the facade produced by the policies +before it. ## What to read next diff --git a/packages/eve/src/harness/tool-loop.test.ts b/packages/eve/src/harness/tool-loop.test.ts index edc9a9ce3..b9386a785 100644 --- a/packages/eve/src/harness/tool-loop.test.ts +++ b/packages/eve/src/harness/tool-loop.test.ts @@ -213,6 +213,8 @@ function declareTelemetry( hooks: createInstrumentationHooks([]), otelSettings: { ...config, + recordInputs: config.recordInputs === true, + recordOutputs: config.recordOutputs === true, traceChannelRequests: config["traceChannelRequests"] === true, }, runInContext: (_operation, execute) => execute(), diff --git a/packages/eve/src/instrumentation/providers.test.ts b/packages/eve/src/instrumentation/providers.test.ts index a0b284a6f..a8d58d4e2 100644 --- a/packages/eve/src/instrumentation/providers.test.ts +++ b/packages/eve/src/instrumentation/providers.test.ts @@ -170,7 +170,7 @@ describe("seedInstrumentationProviders", () => { it("lets an authored reserved slot reconfigure or disable its default", async () => { seedInstrumentationProviders(); - const authored = localTraces({ exportPolicy: { span: () => false } }); + const authored = localTraces({ exportPolicy: { span: () => ({ emit: false }) } }); await register("local", authored); expect(getInstrumentationProviders()).toEqual([{ provider: authored, slot: "local" }]); @@ -182,7 +182,7 @@ describe("seedInstrumentationProviders", () => { vi.stubEnv(DEVELOPMENT_WORKER_APP_ROOT_ENV, undefined); vi.stubEnv("VERCEL_ENV", "production"); seedInstrumentationProviders(); - const authored = agentRuns({ exportPolicy: { span: () => false } }); + const authored = agentRuns({ exportPolicy: { span: () => ({ emit: false }) } }); await register("agent-runs", authored); @@ -194,7 +194,7 @@ describe("seedInstrumentationProviders", () => { seedInstrumentationProviders(); await register("zeta", defineInstrumentation({})); await register("audit", defineInstrumentation({})); - await register("local", localTraces({ exportPolicy: { span: () => false } })); + await register("local", localTraces({ exportPolicy: { span: () => ({ emit: false }) } })); expect(getInstrumentationProviders().map(({ slot }) => slot)).toEqual([ "agent-runs", diff --git a/packages/eve/src/public/instrumentation/otel.ts b/packages/eve/src/public/instrumentation/otel.ts index 99e5ad878..e3d08f623 100644 --- a/packages/eve/src/public/instrumentation/otel.ts +++ b/packages/eve/src/public/instrumentation/otel.ts @@ -9,7 +9,10 @@ * off nothing discovers that directory, so these compile but never run. */ -import { createLocalTracesProcessor, resolveLocalTracesContent } from "#tracing/local-traces.js"; +import { + createLocalTracesProcessor, + resolveLocalTracesExportPolicy, +} from "#tracing/local-traces.js"; import { agentRunsIntegration, managedOtelIntegration, @@ -22,10 +25,6 @@ export { isOtelIntegration, otel, otelIntegration, - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, - type ContentOptions, type OtelDeclaration, type OtelIntegration, type OtelIntegrationOptions, @@ -34,8 +33,8 @@ export { type SpanAttributeDecision, type SpanExportAttributeValue, type SpanExportContext, + type SpanExportDecision, type SpanExportPolicy, - type SpanExportPredicate, type TraceCaptureContext, type TraceCapturePolicy, type TracePolicyDecision, @@ -63,7 +62,7 @@ export function agentRuns(options: ManagedTraceOptions = {}): OtelIntegration { export function localTraces(options: ManagedTraceOptions = {}): OtelIntegration { return managedOtelIntegration({ ...options, - ...resolveLocalTracesContent(options), + exportPolicy: resolveLocalTracesExportPolicy(options.exportPolicy), spanProcessors: [createLocalTracesProcessor()], }); } diff --git a/packages/eve/src/tracing/agent-otel-provider.test.ts b/packages/eve/src/tracing/agent-otel-provider.test.ts index 78b5570f1..676e475db 100644 --- a/packages/eve/src/tracing/agent-otel-provider.test.ts +++ b/packages/eve/src/tracing/agent-otel-provider.test.ts @@ -51,11 +51,6 @@ import type { ChannelAudience } from "#shared/channel-audience.js"; import { channelAudienceFromContext } from "#tracing/channel-audience-context.js"; import { contentFilteringProcessor } from "#tracing/content-span-processor.js"; import { parseLocalTraceSegment } from "#tracing/local-trace-reader.js"; -import { - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, -} from "#tracing/span-export-policy.js"; import { CONTENT_ATTRIBUTE_LIMIT } from "#tracing/agent-otel-content.js"; import type { TraceCapturePolicy } from "#tracing/otel-declaration.js"; import type { TraceCaptureContext } from "#shared/trace-policy.js"; @@ -412,10 +407,9 @@ describe("createAgentOtelInstrumentation", () => { async (channelAudience) => { const metadata = new InMemorySpanExporter(); const runtime = createRuntime(undefined, undefined, [ - contentFilteringProcessor( - new SimpleSpanProcessor(metadata), - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ), + contentFilteringProcessor(new SimpleSpanProcessor(metadata), { + span: () => ({ redact: true, inputs: true, outputs: true }), + }), ]); for (const sequence of [0, 1]) { await emitAttempt({ @@ -461,10 +455,9 @@ describe("createAgentOtelInstrumentation", () => { async (outcome) => { const metadata = new InMemorySpanExporter(); const runtime = createRuntime(undefined, undefined, [ - contentFilteringProcessor( - new SimpleSpanProcessor(metadata), - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ), + contentFilteringProcessor(new SimpleSpanProcessor(metadata), { + span: () => ({ redact: true, inputs: true, outputs: true }), + }), ]); await publishTurnStarted({ hooks: runtime.hooks, diff --git a/packages/eve/src/tracing/agent-telemetry-contract.integration.test.ts b/packages/eve/src/tracing/agent-telemetry-contract.integration.test.ts index eee4d7ca0..d840c2361 100644 --- a/packages/eve/src/tracing/agent-telemetry-contract.integration.test.ts +++ b/packages/eve/src/tracing/agent-telemetry-contract.integration.test.ts @@ -50,11 +50,6 @@ import { import { summarizeLocalTrace } from "#cli/commands/trace-detail.js"; import { buildConversationItems } from "#cli/dev/tui/traces/trace-conversation.js"; import { contentFilteringProcessor } from "#tracing/content-span-processor.js"; -import { - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, -} from "#tracing/span-export-policy.js"; import { ConversationContextKey } from "#shared/conversation-context.js"; const traceContext = (agentName: string, audience: "public" | "private") => ({ @@ -74,10 +69,9 @@ function createRuntime() { idGenerator, spanProcessors: [ new SimpleSpanProcessor(exporter), - contentFilteringProcessor( - new SimpleSpanProcessor(metadata), - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ), + contentFilteringProcessor(new SimpleSpanProcessor(metadata), { + span: () => ({ redact: true, inputs: true, outputs: true }), + }), ], }); const agent = createAgentOtelInstrumentation({ diff --git a/packages/eve/src/tracing/content-span-processor.test.ts b/packages/eve/src/tracing/content-span-processor.test.ts index 8f384eeec..390d88e2e 100644 --- a/packages/eve/src/tracing/content-span-processor.test.ts +++ b/packages/eve/src/tracing/content-span-processor.test.ts @@ -7,11 +7,6 @@ import { import type { SpanProcessor } from "#compiled/@vercel/otel/index.js"; import { contentFilteringProcessor } from "#tracing/content-span-processor.js"; -import { - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, -} from "#tracing/span-export-policy.js"; function recordingProcessor(): SpanProcessor & { readonly ended: unknown[]; @@ -53,7 +48,9 @@ describe("contentFilteringProcessor", () => { it("forwards a copy without what the destination declined", () => { const downstream = recordingProcessor(); - contentFilteringProcessor(downstream, redactSpanInputs()).onEnd( + contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }).onEnd( span({ "gen_ai.input.messages": "what the user said", "ai.response.text": "what the model said", @@ -65,6 +62,56 @@ describe("contentFilteringProcessor", () => { }); }); + it("drops the span when its policy throws", () => { + const downstream = recordingProcessor(); + + contentFilteringProcessor(downstream, { + span: () => { + throw new Error("policy failed"); + }, + }).onEnd( + span({ + "gen_ai.input.messages": "what the user said", + "service.name": "weather", + }) as never, + ); + + expect(downstream.ended).toEqual([]); + }); + + it.each([ + [true, 1], + [false, 0], + ])("preserves the legacy boolean span decision %s", (decision, exported) => { + const downstream = recordingProcessor(); + + contentFilteringProcessor(downstream, { + span: () => decision, + }).onEnd(span({ "service.name": "weather" }) as never); + + expect(downstream.ended).toHaveLength(exported); + }); + + it("drops the span when a redaction decision names no direction", () => { + const downstream = recordingProcessor(); + + contentFilteringProcessor(downstream, { + span: () => ({ redact: true }) as never, + }).onEnd(span({ "gen_ai.input.messages": "what the user said" }) as never); + + expect(downstream.ended).toEqual([]); + }); + + it("drops the span when one decision combines emission and redaction", () => { + const downstream = recordingProcessor(); + + contentFilteringProcessor(downstream, { + span: () => ({ emit: true, redact: true, inputs: true }) as never, + }).onEnd(span({ "gen_ai.input.messages": "what the user said" }) as never); + + expect(downstream.ended).toEqual([]); + }); + it("reports the content policy visible to each destination", () => { const downstream = recordingProcessor(); const original = span({ @@ -73,7 +120,9 @@ describe("contentFilteringProcessor", () => { "gen_ai.input.messages": "what the user said", }); - contentFilteringProcessor(downstream, redactSpanInputs()).onEnd(original as never); + contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }).onEnd(original as never); expect((downstream.ended[0] as { attributes: unknown }).attributes).toEqual({ "agent.trace.content.input": false, @@ -91,10 +140,9 @@ describe("contentFilteringProcessor", () => { const declined = recordingProcessor(); const original = span({ "gen_ai.input.messages": "what the user said" }); - contentFilteringProcessor( - declined, - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ).onEnd(original as never); + contentFilteringProcessor(declined, { + span: () => ({ redact: true, inputs: true, outputs: true }), + }).onEnd(original as never); kept.onEnd(original as never); expect((declined.ended[0] as { attributes: unknown }).attributes).toEqual({}); @@ -106,10 +154,9 @@ describe("contentFilteringProcessor", () => { it("keeps the rest of the span surface reachable on the copy", () => { const downstream = recordingProcessor(); - contentFilteringProcessor( - downstream, - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ).onEnd(span({ "gen_ai.input.messages": "what the user said" }) as never); + contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true, outputs: true }), + }).onEnd(span({ "gen_ai.input.messages": "what the user said" }) as never); expect((downstream.ended[0] as { spanContext: () => unknown }).spanContext()).toEqual({ spanId: "span", @@ -128,7 +175,9 @@ describe("contentFilteringProcessor", () => { status: { code: 2, message: "private failure detail" }, }; - contentFilteringProcessor(downstream, redactSpanOutputs()).onEnd(original as never); + contentFilteringProcessor(downstream, { + span: ({ name }) => ({ redact: true, outputs: name !== "metadata" }), + }).onEnd(original as never); const visible = downstream.ended[0] as { events: unknown[]; @@ -147,10 +196,9 @@ describe("contentFilteringProcessor", () => { "service.name": "weather", }); - contentFilteringProcessor(downstream, redactSpanInputs()).onStart( - original as never, - undefined as never, - ); + contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }).onStart(original as never, undefined as never); expect(downstream.started[0]).not.toBe(original); expect((downstream.started[0] as { attributes: unknown }).attributes).toEqual({ @@ -168,7 +216,9 @@ describe("contentFilteringProcessor", () => { "gen_ai.input.messages": "what the user said", "service.name": "weather", }) as { attributes: Record }; - const processor = contentFilteringProcessor(downstream, redactSpanInputs()); + const processor = contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }); processor.onStart(original as never, undefined as never); const retainedAttributes = (downstream.started[0] as { attributes: unknown }).attributes; @@ -205,7 +255,9 @@ describe("contentFilteringProcessor", () => { }, }; const downstream = recordingProcessor(); - const processor = contentFilteringProcessor(downstream, redactSpanInputs()); + const processor = contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }); processor.onStart(original as never, undefined as never); @@ -228,7 +280,9 @@ describe("contentFilteringProcessor", () => { const original = span({ "gen_ai.input.messages": "what the user said" }) as { attributes: Record; }; - const processor = contentFilteringProcessor(downstream, redactSpanInputs()); + const processor = contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }); processor.onStart(original as never, undefined as never); Object.freeze(downstream.started[0]); @@ -242,7 +296,9 @@ describe("contentFilteringProcessor", () => { it("facades a real OpenTelemetry span across both callbacks", () => { const downstream = recordingProcessor(); - const filtering = contentFilteringProcessor(downstream, redactSpanInputs()); + const filtering = contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true }), + }); const provider = new BasicTracerProvider({ spanProcessors: [filtering as OpenTelemetrySpanProcessor], }); @@ -268,13 +324,10 @@ describe("contentFilteringProcessor", () => { ["unknown", false], ] as const)("retains content for the %s audience: %s", (audience, retained) => { const downstream = recordingProcessor(); - contentFilteringProcessor( - downstream, - composeSpanExportPolicies( - redactSpanInputs(({ audience }) => audience !== "public"), - redactSpanOutputs(({ audience }) => audience !== "public"), - ), - ).onEnd( + contentFilteringProcessor(downstream, { + span: ({ audience }) => + audience === "public" ? { emit: true } : { redact: true, inputs: true, outputs: true }, + }).onEnd( span({ "agent.channel.audience": audience, "gen_ai.input.messages": "input", @@ -294,13 +347,10 @@ describe("contentFilteringProcessor", () => { it("fails closed when audience attributes disagree", () => { const downstream = recordingProcessor(); - contentFilteringProcessor( - downstream, - composeSpanExportPolicies( - redactSpanInputs(({ audience }) => audience !== "public"), - redactSpanOutputs(({ audience }) => audience !== "public"), - ), - ).onEnd( + contentFilteringProcessor(downstream, { + span: ({ audience }) => + audience === "public" ? { emit: true } : { redact: true, inputs: true, outputs: true }, + }).onEnd( span({ "agent.channel.audience": "public", "gen_ai.input.messages": "private", @@ -313,9 +363,37 @@ describe("contentFilteringProcessor", () => { ).not.toHaveProperty("gen_ai.input.messages"); }); + it("passes only attributes visible after earlier policy stages", () => { + const downstream = recordingProcessor(); + const keys: string[] = []; + + contentFilteringProcessor(downstream, [ + { span: () => ({ redact: true, inputs: true }) }, + { + attribute: ({ key }) => { + keys.push(key); + return { emit: true }; + }, + }, + ]).onEnd( + span({ + "gen_ai.input.messages": "private input", + "service.name": "weather", + }) as never, + ); + + expect(keys).not.toContain("gen_ai.input.messages"); + expect(keys).toContain("service.name"); + expect((downstream.ended[0] as { attributes: unknown }).attributes).toEqual({ + "service.name": "weather", + }); + }); + it("can drop an individual span", () => { const downstream = recordingProcessor(); - contentFilteringProcessor(downstream, { span: ({ name }) => name !== "private-work" }).onEnd({ + contentFilteringProcessor(downstream, { + span: ({ name }) => ({ emit: name !== "private-work" }), + }).onEnd({ ...(span({}) as object), name: "private-work", } as never); @@ -328,11 +406,22 @@ describe("contentFilteringProcessor", () => { contentFilteringProcessor(downstream, { attribute: ({ key }) => key === "secret" - ? { action: "drop" } + ? { emit: false } : key === "email" - ? { action: "replace", value: "[redacted]" } - : { action: "keep" }, - }).onEnd(span({ email: "ada@example.com", secret: "value" }) as never); + ? { replace: true, value: "[redacted]" } + : key === "ambiguous" + ? ({ emit: false, replace: true, value: "replacement" } as never) + : key === "missing" + ? ({ replace: true } as never) + : { emit: true }, + }).onEnd( + span({ + ambiguous: "original", + email: "ada@example.com", + missing: "value", + secret: "value", + }) as never, + ); expect((downstream.ended[0] as { attributes: unknown }).attributes).toEqual({ email: "[redacted]", @@ -344,10 +433,9 @@ describe("contentFilteringProcessor", () => { const downstream: SpanProcessor & { releaseConversation(sessionId: string): Promise; } = { ...recordingProcessor(), releaseConversation }; - const processor = contentFilteringProcessor( - downstream, - composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()), - ) as SpanProcessor & { releaseConversation(sessionId: string): Promise }; + const processor = contentFilteringProcessor(downstream, { + span: () => ({ redact: true, inputs: true, outputs: true }), + }) as SpanProcessor & { releaseConversation(sessionId: string): Promise }; await expect(processor.releaseConversation("session-1")).resolves.toBe(true); expect(releaseConversation).toHaveBeenCalledExactlyOnceWith("session-1"); diff --git a/packages/eve/src/tracing/content-span-processor.ts b/packages/eve/src/tracing/content-span-processor.ts index bb0e207df..7b2e3ad58 100644 --- a/packages/eve/src/tracing/content-span-processor.ts +++ b/packages/eve/src/tracing/content-span-processor.ts @@ -8,9 +8,9 @@ import { hasConversationRelease, type LocalTracesProcessor } from "#tracing/loca import { normalizeChannelAudience } from "#shared/channel-audience.js"; import type { ChannelAudience } from "#shared/channel-audience.js"; import { - contentRedactionForSpan, - spanExportPolicyStages, + normalizeSpanExportPolicies, type SpanExportContext, + type SpanExportDecision, type SpanExportPolicy, } from "#tracing/span-export-policy.js"; import { channelAudienceFromContext } from "#tracing/channel-audience-context.js"; @@ -33,12 +33,13 @@ import { channelAudienceFromContext } from "#tracing/channel-audience-context.js */ export function contentFilteringProcessor( downstream: SpanProcessor, - exportPolicy?: SpanExportPolicy, + exportPolicy?: SpanExportPolicy | readonly SpanExportPolicy[], ): SpanProcessor { - if (exportPolicy === undefined) return downstream; + const policies = normalizeSpanExportPolicies(exportPolicy); + if (policies.length === 0) return downstream; let processor = downstream; - for (const policy of spanExportPolicyStages(exportPolicy).toReversed()) { + for (const policy of policies.toReversed()) { processor = policyFilteringProcessor(processor, policy); } return processor; @@ -114,7 +115,11 @@ function facadeFor( if (existing !== undefined) return existing; const context = spanExportContext(span, inheritedAudience); - const effectiveContent = contentForSpan(context, exportPolicy); + const decision = spanExportDecision(context, exportPolicy); + const effectiveContent = { + recordInputs: !decision.redactInputs, + recordOutputs: !decision.redactOutputs, + }; const attributes: Record = {}; const events: unknown[] = []; const status: Record = {}; @@ -174,7 +179,7 @@ function facadeFor( }); const facade = { context, - exported: shouldExport(context, exportPolicy), + exported: decision.exported, refresh, value, }; @@ -183,14 +188,6 @@ function facadeFor( return facade; } -function contentForSpan(span: SpanExportContext, policy: SpanExportPolicy): ResolvedContentOptions { - const redaction = contentRedactionForSpan(policy, span); - return { - recordInputs: !redaction.redactInputs, - recordOutputs: !redaction.redactOutputs, - }; -} - function refreshAttributes( destination: Record, span: object, @@ -207,8 +204,13 @@ function refreshAttributes( const visible = kept ?? (source as Record); for (const [key, value] of Object.entries(visible)) { const decision = attributeDecision(policy, { key, span: context, value }); - if (decision.action === "keep") destination[key] = value; - else if (decision.action === "replace") destination[key] = decision.value; + if (typeof decision !== "object" || decision === null) continue; + if ("emit" in decision && "replace" in decision) continue; + if ("replace" in decision && decision.replace === true) { + if (decision.value !== undefined) destination[key] = decision.value; + } else if ("emit" in decision && decision.emit === true) { + destination[key] = value; + } } } @@ -247,24 +249,60 @@ function spanExportContext( }; } -function shouldExport(context: SpanExportContext, policy: SpanExportPolicy | undefined): boolean { - if (policy === undefined) return true; +function spanExportDecision( + context: SpanExportContext, + policy: SpanExportPolicy, +): { + readonly exported: boolean; + readonly redactInputs: boolean; + readonly redactOutputs: boolean; +} { + let decision: SpanExportDecision; try { - return policy.span?.(context) !== false; + decision = policy.span?.(context) ?? { emit: true }; } catch { - return false; + return { exported: false, redactInputs: false, redactOutputs: false }; } + if (typeof decision === "boolean") { + return { exported: decision, redactInputs: false, redactOutputs: false }; + } + if (typeof decision !== "object" || decision === null) { + return { exported: false, redactInputs: false, redactOutputs: false }; + } + if ("emit" in decision) { + if ("redact" in decision) { + return { exported: false, redactInputs: false, redactOutputs: false }; + } + return { + exported: typeof decision.emit === "boolean" && decision.emit, + redactInputs: false, + redactOutputs: false, + }; + } + if (decision.redact !== true) { + return { exported: false, redactInputs: false, redactOutputs: false }; + } + const redactInputs = decision.inputs === true; + const redactOutputs = decision.outputs === true; + if (!redactInputs && !redactOutputs) { + return { exported: false, redactInputs: false, redactOutputs: false }; + } + return { + exported: true, + redactInputs, + redactOutputs, + }; } function attributeDecision( policy: SpanExportPolicy | undefined, input: Parameters>[0], ) { - if (policy?.attribute === undefined) return { action: "keep" } as const; + if (policy?.attribute === undefined) return { emit: true } as const; try { return policy.attribute(input); } catch { - return { action: "drop" } as const; + return { emit: false } as const; } } diff --git a/packages/eve/src/tracing/local-instrumentation-runtime.ts b/packages/eve/src/tracing/local-instrumentation-runtime.ts index d394cafa2..69e891291 100644 --- a/packages/eve/src/tracing/local-instrumentation-runtime.ts +++ b/packages/eve/src/tracing/local-instrumentation-runtime.ts @@ -3,7 +3,10 @@ import { type InstrumentationRuntime, } from "#instrumentation/runtime.js"; import { installInstrumentationRuntime } from "#tracing/install-instrumentation-runtime.js"; -import { createLocalTracesProcessor, resolveLocalTracesContent } from "#tracing/local-traces.js"; +import { + createLocalTracesProcessor, + resolveLocalTracesExportPolicy, +} from "#tracing/local-traces.js"; import { collectOtelPipeline, managedOtelIntegration, @@ -28,7 +31,7 @@ export function installLocalInstrumentationRuntime(input: { collected: collectOtelPipeline([ otel({ tracePolicy: localTracePolicy }), managedOtelIntegration({ - ...resolveLocalTracesContent(), + exportPolicy: resolveLocalTracesExportPolicy(), spanProcessors: [spool], }), ]), diff --git a/packages/eve/src/tracing/local-traces.test.ts b/packages/eve/src/tracing/local-traces.test.ts index d24d0a1c1..bf7c7a303 100644 --- a/packages/eve/src/tracing/local-traces.test.ts +++ b/packages/eve/src/tracing/local-traces.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { defaultEveAudience } from "#eve-channel/audience.js"; -import { createLocalTracesProcessor, resolveLocalTracesContent } from "#tracing/local-traces.js"; +import { contentFilteringProcessor } from "#tracing/content-span-processor.js"; import { localTracePolicy } from "#tracing/local-instrumentation-runtime.js"; +import { + createLocalTracesProcessor, + resolveLocalTracesExportPolicy, +} from "#tracing/local-traces.js"; import { resolveTracePolicy } from "#tracing/sampled-trace.js"; import { localTraces } from "#public/instrumentation/otel.js"; @@ -76,37 +80,47 @@ describe("createLocalTracesProcessor", () => { }); }); -describe("resolveLocalTracesContent", () => { - it("retains content by default", () => { - expect(resolveLocalTracesContent()).toEqual({ - recordInputs: true, - recordOutputs: true, - }); +describe("resolveLocalTracesExportPolicy", () => { + it("preserves the authored policy by default", () => { + const exportPolicy = { span: () => ({ emit: true }) } as const; + + expect(resolveLocalTracesExportPolicy(exportPolicy)).toBe(exportPolicy); }); - it("preserves explicit legacy redaction", () => { - expect(resolveLocalTracesContent({ recordInputs: false })).toEqual({ - recordInputs: false, - recordOutputs: true, - }); - }); - - it("keeps EVE_TRACES_CONTENT=on compatible with the new default", () => { + it("preserves the authored policy when EVE_TRACES_CONTENT=on", () => { vi.stubEnv("EVE_TRACES_CONTENT", "on"); + const exportPolicy = { span: () => ({ emit: true }) } as const; - expect(resolveLocalTracesContent()).toEqual({ - recordInputs: true, - recordOutputs: true, - }); + expect(resolveLocalTracesExportPolicy(exportPolicy)).toBe(exportPolicy); }); - it("maps EVE_TRACES_CONTENT=off to full local redaction", () => { + it("prepends full redaction when EVE_TRACES_CONTENT=off", () => { vi.stubEnv("EVE_TRACES_CONTENT", "off"); - - expect(resolveLocalTracesContent({ recordInputs: true, recordOutputs: true })).toEqual({ - recordInputs: false, - recordOutputs: false, + let visibleAttributes: Readonly> | undefined; + const exportPolicy = resolveLocalTracesExportPolicy({ + span: ({ attributes }) => { + visibleAttributes = attributes; + return { emit: true }; + }, }); + + contentFilteringProcessor( + { + forceFlush: async () => undefined, + onEnd: () => undefined, + onStart: () => undefined, + shutdown: async () => undefined, + }, + exportPolicy, + ).onEnd({ + attributes: { + "ai.response.text": "private output", + "gen_ai.input.messages": "private input", + }, + spanContext: () => ({ spanId: "span", traceId: "trace" }), + } as never); + + expect(visibleAttributes).toEqual({}); }); }); diff --git a/packages/eve/src/tracing/local-traces.ts b/packages/eve/src/tracing/local-traces.ts index c0465adfa..050a3fbde 100644 --- a/packages/eve/src/tracing/local-traces.ts +++ b/packages/eve/src/tracing/local-traces.ts @@ -6,6 +6,7 @@ import { requestLocalTraceStorePrune, resolveLocalTraceRetentionSettings, } from "#tracing/local-trace-retention.js"; +import { normalizeSpanExportPolicies, type SpanExportPolicy } from "#tracing/span-export-policy.js"; /** * The local spool, as a span processor. @@ -93,20 +94,15 @@ export function createLocalTracesProcessor( }; } -/** Maps legacy local content configuration onto destination redaction. @internal */ -export function resolveLocalTracesContent( - options: { - readonly recordInputs?: boolean; - readonly recordOutputs?: boolean; - } = {}, -): { readonly recordInputs: boolean; readonly recordOutputs: boolean } { - if (process.env.EVE_TRACES_CONTENT === "off") { - return { recordInputs: false, recordOutputs: false }; - } - return { - recordInputs: options.recordInputs !== false, - recordOutputs: options.recordOutputs !== false, - }; +/** Applies the local content environment override before authored policies. @internal */ +export function resolveLocalTracesExportPolicy( + exportPolicy?: SpanExportPolicy | readonly SpanExportPolicy[], +): SpanExportPolicy | readonly SpanExportPolicy[] | undefined { + if (process.env.EVE_TRACES_CONTENT !== "off") return exportPolicy; + return [ + { span: () => ({ redact: true, inputs: true, outputs: true }) }, + ...normalizeSpanExportPolicies(exportPolicy), + ]; } /** A production-authored `localTraces()` has no local development store. */ diff --git a/packages/eve/src/tracing/otel-declaration.test.ts b/packages/eve/src/tracing/otel-declaration.test.ts index 370d7d19d..92872b506 100644 --- a/packages/eve/src/tracing/otel-declaration.test.ts +++ b/packages/eve/src/tracing/otel-declaration.test.ts @@ -10,7 +10,6 @@ import { otel, otelIntegration, } from "#tracing/otel-declaration.js"; -import { composeSpanExportPolicies, redactSpanInputs } from "#tracing/span-export-policy.js"; /** Collection only ever moves processors, so a fresh no-op is identity enough. */ function processor(): SpanProcessor { @@ -69,12 +68,16 @@ describe("otelIntegration", () => { expect(integration.spanProcessors[0]).toBe(first); }); - it("maps deprecated capture switches to destination redaction", () => { - const first = processor(); - const integration = otelIntegration({ recordInputs: false, spanProcessors: [first] }); - - expect(integration.content).toEqual({ recordInputs: false, recordOutputs: true }); - expect(integration.spanProcessors[0]).not.toBe(first); + it("rejects removed destination content options", () => { + expect(() => otelIntegration({ recordInputs: false } as never)).toThrow( + /no longer support `recordInputs` or `recordOutputs`/u, + ); + expect(() => managedOtelIntegration({ recordOutputs: false } as never)).toThrow( + /use an `exportPolicy` span decision/iu, + ); + expect(() => agentRunsIntegration({ recordInputs: false } as never)).toThrow( + /use an `exportPolicy` span decision/iu, + ); }); }); @@ -94,7 +97,7 @@ describe("managed export policy", () => { exportPolicy: { span: ({ attributes }) => { visibleAttributes = attributes; - return true; + return { emit: true }; }, }, spanProcessors: [processor()], @@ -112,18 +115,21 @@ describe("managed export policy", () => { expect(visibleAttributes).toHaveProperty("gen_ai.input.messages", "private input"); }); - it("runs composed export policies in declaration order", () => { + it("runs export policy arrays in declaration order", () => { let visibleAttributes: Readonly> | undefined; const integration = managedOtelIntegration({ - exportPolicy: composeSpanExportPolicies( - redactSpanInputs(({ audience }) => audience !== "public"), + exportPolicy: [ + { + span: ({ audience }) => + audience === "public" ? { emit: true } : { redact: true, inputs: true }, + }, { span: ({ attributes }) => { visibleAttributes = attributes; - return true; + return { emit: true }; }, }, - ), + ], spanProcessors: [processor()], }); @@ -138,26 +144,6 @@ describe("managed export policy", () => { expect(visibleAttributes).toEqual({ "agent.channel.audience": "private" }); }); - - it("applies deprecated content switches before the configured export policy", () => { - let visibleAttributes: Readonly> | undefined; - const integration = managedOtelIntegration({ - exportPolicy: { - span: ({ attributes }) => { - visibleAttributes = attributes; - return true; - }, - }, - recordInputs: false, - spanProcessors: [processor()], - }); - - const spanProcessor = integration.spanProcessors[0]; - if (spanProcessor === undefined || spanProcessor === "auto") throw new Error("Expected policy"); - spanProcessor.onEnd(testSpan({ "gen_ai.input.messages": "private input" })); - - expect(visibleAttributes).toEqual({}); - }); }); describe("collectOtelPipeline", () => { diff --git a/packages/eve/src/tracing/otel-declaration.ts b/packages/eve/src/tracing/otel-declaration.ts index 5089c67b2..9e9c96879 100644 --- a/packages/eve/src/tracing/otel-declaration.ts +++ b/packages/eve/src/tracing/otel-declaration.ts @@ -11,7 +11,6 @@ import { PROVIDER, type InstrumentationProvider } from "#public/instrumentation/ import type { InstrumentationRuntimeContextInput } from "#public/instrumentation/index.js"; import type { JsonObject } from "#shared/json.js"; import { batchSpanProcessor } from "#tracing/batch-span-processor.js"; -import type { ResolvedContentOptions } from "#tracing/content-attributes.js"; import { contentFilteringProcessor } from "#tracing/content-span-processor.js"; import { vercelRuntimeSpanProcessor } from "#tracing/vercel-runtime-span-exporter.js"; import type { TraceCapturePolicy } from "#shared/trace-policy.js"; @@ -20,24 +19,14 @@ export type { TraceCapturePolicy, TracePolicyDecision, } from "#shared/trace-policy.js"; -import { - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, - type SpanExportPolicy, -} from "#tracing/span-export-policy.js"; +import type { SpanExportPolicy } from "#tracing/span-export-policy.js"; export type { SpanAttributeDecision, SpanExportAttributeValue, SpanExportContext, + SpanExportDecision, SpanExportPolicy, - SpanExportPredicate, -} from "#tracing/span-export-policy.js"; -export { - composeSpanExportPolicies, - redactSpanInputs, - redactSpanOutputs, } from "#tracing/span-export-policy.js"; /** @@ -108,22 +97,12 @@ export interface OtelOptions { } export interface ManagedTraceOptions { - /** Destination policy applied before spans are exported. */ - readonly exportPolicy?: SpanExportPolicy; - /** @deprecated Use `exportPolicy: redactSpanInputs()` instead. */ - readonly recordInputs?: boolean; - /** @deprecated Use `exportPolicy: redactSpanOutputs()` instead. */ - readonly recordOutputs?: boolean; -} - -/** @deprecated Compose `redactSpanInputs()` and `redactSpanOutputs()` into an export policy. */ -export interface ContentOptions { - readonly recordInputs?: boolean; - readonly recordOutputs?: boolean; + /** One destination policy, or policies applied in declaration order before export. */ + readonly exportPolicy?: SpanExportPolicy | readonly SpanExportPolicy[]; } /** Where one `otelIntegration()` sends spans and metrics. */ -export interface OtelIntegrationOptions extends ContentOptions { +export interface OtelIntegrationOptions { /** Merged into the pipeline in declaration order. */ readonly spanProcessors?: readonly SpanProcessor[]; /** Wrapped in eve's batching processor and appended after `spanProcessors`. */ @@ -163,8 +142,6 @@ export interface OtelDeclaration extends InstrumentationProvider { /** One declared destination. A process may have as many as it has files. */ export interface OtelIntegration extends InstrumentationProvider { readonly [OTEL_INTEGRATION]: true; - /** @deprecated Content is captured upstream and redacted by destination policies. */ - readonly content: ResolvedContentOptions; readonly metricReaders: readonly MetricReader[]; readonly runtimeContext?: (input: InstrumentationRuntimeContextInput) => JsonObject | undefined; readonly spanProcessors: readonly SpanProcessorOrName[]; @@ -204,8 +181,9 @@ export function managedOtelIntegration( function createOtelIntegration( options: OtelIntegrationOptions, - exportPolicy?: SpanExportPolicy, + exportPolicy?: SpanExportPolicy | readonly SpanExportPolicy[], ): OtelIntegration { + assertNoRemovedContentOptions(options); const declared = options.spanProcessors ?? []; const spanProcessors = options.traceExporter === undefined @@ -215,55 +193,30 @@ function createOtelIntegration( return { [OTEL_INTEGRATION]: true, [PROVIDER]: true, - content: resolveContentOptions(options), metricReaders: options.metricReaders ?? [], runtimeContext: options.runtimeContext, spanProcessors: spanProcessors.map((processor) => - withExportPolicies(processor, legacyContentRedactionPolicy(options), exportPolicy), + contentFilteringProcessor(processor, exportPolicy), ), }; } /** Vercel Agent Runs through the hosted request-context transport. @internal */ export function agentRunsIntegration(options: ManagedTraceOptions = {}): OtelIntegration { + assertNoRemovedContentOptions(options); return { [OTEL_INTEGRATION]: true, [PROVIDER]: true, - content: resolveContentOptions(options), metricReaders: [], - spanProcessors: [ - withExportPolicies( - vercelRuntimeSpanProcessor(), - legacyContentRedactionPolicy(options), - options.exportPolicy, - ), - ], + spanProcessors: [contentFilteringProcessor(vercelRuntimeSpanProcessor(), options.exportPolicy)], }; } -function legacyContentRedactionPolicy(options: ContentOptions): SpanExportPolicy | undefined { - const policies: SpanExportPolicy[] = []; - if (options.recordInputs === false) policies.push(redactSpanInputs()); - if (options.recordOutputs === false) policies.push(redactSpanOutputs()); - return policies.length === 0 ? undefined : composeSpanExportPolicies(...policies); -} - -export function resolveContentOptions(options: ContentOptions): ResolvedContentOptions { - return { - recordInputs: options.recordInputs !== false, - recordOutputs: options.recordOutputs !== false, - }; -} - -function withExportPolicies( - downstream: SpanProcessor, - ...policies: readonly (SpanExportPolicy | undefined)[] -): SpanProcessor { - let processor = downstream; - for (const policy of policies.toReversed()) { - processor = contentFilteringProcessor(processor, policy); - } - return processor; +function assertNoRemovedContentOptions(options: object): void { + if (!Object.hasOwn(options, "recordInputs") && !Object.hasOwn(options, "recordOutputs")) return; + throw new Error( + "OpenTelemetry destination options no longer support `recordInputs` or `recordOutputs`. Use an `exportPolicy` span decision with `{ redact: true, inputs: true }`, `{ redact: true, outputs: true }`, or both.", + ); } export function isOtelDeclaration(value: unknown): value is OtelDeclaration { @@ -298,8 +251,8 @@ export interface OtelHarnessSettings { readonly traceChannelRequests: boolean; readonly tracePolicy?: TraceCapturePolicy; /** Legacy `defineInstrumentation()` capture settings. Provider destinations capture fully. */ - readonly recordInputs?: boolean; - readonly recordOutputs?: boolean; + readonly recordInputs: boolean; + readonly recordOutputs: boolean; } /** @internal */ diff --git a/packages/eve/src/tracing/span-export-policy.ts b/packages/eve/src/tracing/span-export-policy.ts index 116008179..9bb960c52 100644 --- a/packages/eve/src/tracing/span-export-policy.ts +++ b/packages/eve/src/tracing/span-export-policy.ts @@ -8,7 +8,15 @@ export interface SpanExportContext { readonly traceId: string; } -export type SpanExportPredicate = (span: SpanExportContext) => boolean; +export type SpanExportDecision = + /** @deprecated Return `{ emit: boolean }` instead. */ + | boolean + | { readonly emit: boolean } + | { + readonly redact: true; + readonly inputs?: boolean; + readonly outputs?: boolean; + }; export type SpanExportAttributeValue = | string @@ -19,14 +27,13 @@ export type SpanExportAttributeValue = | readonly boolean[]; export type SpanAttributeDecision = - | { readonly action: "keep" } - | { readonly action: "drop" } - | { readonly action: "replace"; readonly value: SpanExportAttributeValue }; + | { readonly emit: boolean } + | { readonly replace: true; readonly value: SpanExportAttributeValue }; export interface SpanExportPolicy { - /** Return false to block one span without blocking its trace. */ - readonly span?: SpanExportPredicate; - /** Drop or replace individual span attributes after content filtering. */ + /** Drop one span or redact its directional content for this destination. */ + readonly span?: (span: SpanExportContext) => SpanExportDecision; + /** Emit or replace individual span attributes after content filtering. */ readonly attribute?: (input: { readonly key: string; readonly span: SpanExportContext; @@ -34,61 +41,10 @@ export interface SpanExportPolicy { }) => SpanAttributeDecision; } -interface ContentRedaction { - readonly inputs: readonly SpanExportPredicate[]; - readonly outputs: readonly SpanExportPredicate[]; -} - -const contentRedactions = new WeakMap(); -const composedPolicies = new WeakMap(); - -/** Redact input content from every matching span. */ -export function redactSpanInputs(when: SpanExportPredicate = () => true): SpanExportPolicy { - const policy = {}; - contentRedactions.set(policy, { inputs: [when], outputs: [] }); - return policy; -} - -/** Redact output content, exception details, and status messages from every matching span. */ -export function redactSpanOutputs(when: SpanExportPredicate = () => true): SpanExportPolicy { - const policy = {}; - contentRedactions.set(policy, { inputs: [], outputs: [when] }); - return policy; -} - -/** Compose policies in declaration order. A drop or redaction from any policy is final. */ -export function composeSpanExportPolicies( - ...policies: readonly SpanExportPolicy[] -): SpanExportPolicy { - if (policies.length === 0) return {}; - if (policies.length === 1) return policies[0]!; - const composed = {}; - composedPolicies.set(composed, policies.flatMap(spanExportPolicyStages)); - return composed; -} - /** @internal */ -export function spanExportPolicyStages(policy: SpanExportPolicy): readonly SpanExportPolicy[] { - return composedPolicies.get(policy) ?? [policy]; -} - -/** @internal */ -export function contentRedactionForSpan( - policy: SpanExportPolicy | undefined, - span: SpanExportContext, -): { readonly redactInputs: boolean; readonly redactOutputs: boolean } { - const redaction = policy === undefined ? undefined : contentRedactions.get(policy); - return { - redactInputs: redaction?.inputs.some((when) => matches(when, span)) ?? false, - redactOutputs: redaction?.outputs.some((when) => matches(when, span)) ?? false, - }; -} - -function matches(predicate: SpanExportPredicate, span: SpanExportContext): boolean { - try { - return predicate(span); - } catch { - // Redaction predicates fail closed. - return true; - } +export function normalizeSpanExportPolicies( + policy: SpanExportPolicy | readonly SpanExportPolicy[] | undefined, +): readonly SpanExportPolicy[] { + if (policy === undefined) return []; + return Array.isArray(policy) ? policy : [policy as SpanExportPolicy]; } diff --git a/research/channel-audience-content-policy.md b/research/channel-audience-content-policy.md index c91e8e562..85e785278 100644 --- a/research/channel-audience-content-policy.md +++ b/research/channel-audience-content-policy.md @@ -1,7 +1,7 @@ --- issue: https://github.com/vercel/eve/issues/2331 status: implemented -last_updated: "2026-09-15" +last_updated: "2026-09-17" --- # Audience-aware trace content policy @@ -99,19 +99,26 @@ interface SpanExportContext { readonly traceId: string; } -type SpanExportPredicate = (span: SpanExportContext) => boolean; +type SpanExportDecision = + /** @deprecated Return `{ emit: boolean }` instead. */ + | boolean + | { readonly emit: boolean } + | { + readonly redact: true; + readonly inputs?: boolean; + readonly outputs?: boolean; + }; type SpanAttributeDecision = - | { readonly action: "keep" } - | { readonly action: "drop" } + | { readonly emit: boolean } | { - readonly action: "replace"; + readonly replace: true; readonly value: string | number | boolean | readonly string[] | readonly number[] | readonly boolean[]; }; interface SpanExportPolicy { - readonly span?: SpanExportPredicate; + readonly span?: (span: SpanExportContext) => SpanExportDecision; readonly attribute?: (input: { readonly key: string; readonly span: SpanExportContext; @@ -119,27 +126,17 @@ interface SpanExportPolicy { }) => SpanAttributeDecision; } -declare function redactSpanInputs(when?: SpanExportPredicate): SpanExportPolicy; -declare function redactSpanOutputs(when?: SpanExportPredicate): SpanExportPolicy; -declare function composeSpanExportPolicies( - ...policies: readonly SpanExportPolicy[] -): SpanExportPolicy; - interface ManagedTraceOptions { - readonly exportPolicy?: SpanExportPolicy; - /** @deprecated Use redactSpanInputs() in exportPolicy. */ - readonly recordInputs?: boolean; - /** @deprecated Use redactSpanOutputs() in exportPolicy. */ - readonly recordOutputs?: boolean; + readonly exportPolicy?: SpanExportPolicy | readonly SpanExportPolicy[]; } declare function agentRuns(options?: ManagedTraceOptions): OtelIntegration; declare function localTraces(options?: ManagedTraceOptions): OtelIntegration; ``` -`redactSpanInputs()` removes known prompt, instruction, document, and tool-argument attributes from matching spans. `redactSpanOutputs()` removes known response, reasoning, embedding, ranking, and tool-result attributes, plus exception details, event attributes, and status messages. Neither mutates the shared OpenTelemetry span; each destination receives a filtered facade. +A span callback can return `{ redact: true, inputs: true }`, `{ redact: true, outputs: true }`, or both directions together. The direction fields accept booleans, but at least one must resolve to `true`; otherwise the span is dropped. Input redaction removes known prompt, instruction, document, and tool-argument attributes. Output redaction removes known response, reasoning, embedding, ranking, and tool-result attributes, plus exception details, event attributes, and status messages. A redaction decision implies emission and does not mutate the shared OpenTelemetry span; each destination receives a filtered facade. -`composeSpanExportPolicies()` applies policies in declaration order. A later span or attribute policy sees the facade produced by earlier redactors. A span predicate returning `false` removes that span from one destination without suppressing the rest of its trace. Attribute policies run once for each attribute still visible at their stage. +`exportPolicy` accepts one policy or an array applied in declaration order. A later span or attribute policy sees the facade produced by earlier redactors. A span callback returning `{ emit: false }` removes that span from one destination without suppressing the rest of its trace. Attribute policies run once for each attribute still visible at their stage. For example, this retains every conversation while capturing content only for public audiences: @@ -155,11 +152,17 @@ export default otel({ // agent/instrumentation/agent-runs.ts export default agentRuns({ - exportPolicy: composeSpanExportPolicies({ - span: ({ name }) => name !== "internal.cache.refresh", - attribute: ({ key }) => - key === "user.email" ? { action: "replace", value: "[redacted]" } : { action: "keep" }, - }), + exportPolicy: [ + { + span: ({ name }) => ({ emit: name !== "internal.cache.refresh" }), + attribute: ({ key }) => + key === "user.email" ? { replace: true, value: "[redacted]" } : { emit: true }, + }, + { + span: ({ audience }) => + audience === "public" ? { emit: true } : { redact: true, inputs: true, outputs: true }, + }, + ], }); ``` @@ -199,26 +202,22 @@ The runtime order is: 1. Build and persist the conversation context, deriving and normalizing the channel audience once. 2. Evaluate the process-wide `tracePolicy` before creating `agent.session`. 3. For accepted traces, capture complete eve and AI SDK spans. -4. Run each managed destination's composed export policies in declaration order. Custom integrations run their declared span processors. +4. Run each managed destination's export policy pipeline in declaration order. Custom integrations run their declared span processors. 5. Hand the resulting facade to that destination's processors or exporter. The lifecycle bus separately evaluates each instrumentation provider's policy against the same agent and channel context, skips rejected providers, and applies directional content projection before invoking accepted handlers. -There is no implicit content redaction after a custom trace policy admits an audience. Redaction occurs only when the export pipeline includes `redactSpanInputs()` or `redactSpanOutputs()` (or when a retained compatibility option explicitly requests the equivalent redaction). +There is no implicit content redaction after a custom trace policy admits an audience. Redaction occurs only when an export policy returns a redaction decision. -Policies fail closed at their boundary: a throwing trace policy rejects the trace, a throwing span policy drops the span, a throwing attribute policy drops the attribute, and a throwing content-redaction predicate redacts that content direction. Missing, malformed, or conflicting audience evidence normalizes to `unknown`. +Policies fail closed at their boundary: a throwing trace policy rejects the trace, a throwing span policy drops the span, a malformed or directionless redaction decision drops the span, and a throwing or malformed attribute policy drops the attribute. Missing, malformed, or conflicting audience evidence normalizes to `unknown`. ## Compatibility Instrumentation providers deprecate the experimental `capture` field in favor of `tracePolicy`; `"content"` and `"metadata"` are mapped to equivalent fixed -policies while integrations migrate. The existing OTel destination -`recordInputs` and `recordOutputs` options remain accepted as deprecated -source-compatible aliases. An explicit `false` prepends the corresponding -redaction policy; these options no longer prevent accepted spans from capturing -content upstream. `EVE_TRACES_CONTENT=off` similarly prepends both redactors for -local traces. +policies while integrations migrate. `EVE_TRACES_CONTENT=off` prepends a +full-content redaction policy for local traces. Filtering remains a span-processor responsibility because local trace persistence and authored processors are processors rather than uniform exporters. Keeping the filtering boundary immediately above each destination prevents one destination's policy from mutating what another destination receives.