refactor(eve): make span export policies explicit (#3493)

Signed-off-by: Chad Hietala <chad.hietala@vercel.com>
This commit is contained in:
Chad Hietala
2026-09-17 22:16:50 -04:00
committed by GitHub
parent 45df462a17
commit da82a953dc
16 changed files with 407 additions and 357 deletions
+25
View File
@@ -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 }`.
+26 -22
View File
@@ -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
@@ -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(),
@@ -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",
@@ -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()],
});
}
@@ -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,
@@ -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({
@@ -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<string, unknown> };
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<string, unknown>;
};
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<boolean>;
} = { ...recordingProcessor(), releaseConversation };
const processor = contentFilteringProcessor(
downstream,
composeSpanExportPolicies(redactSpanInputs(), redactSpanOutputs()),
) as SpanProcessor & { releaseConversation(sessionId: string): Promise<boolean> };
const processor = contentFilteringProcessor(downstream, {
span: () => ({ redact: true, inputs: true, outputs: true }),
}) as SpanProcessor & { releaseConversation(sessionId: string): Promise<boolean> };
await expect(processor.releaseConversation("session-1")).resolves.toBe(true);
expect(releaseConversation).toHaveBeenCalledExactlyOnceWith("session-1");
@@ -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<string, unknown> = {};
const events: unknown[] = [];
const status: Record<string, unknown> = {};
@@ -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<string, unknown>,
span: object,
@@ -207,8 +204,13 @@ function refreshAttributes(
const visible = kept ?? (source as Record<string, unknown>);
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<NonNullable<SpanExportPolicy["attribute"]>>[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;
}
}
@@ -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],
}),
]),
+38 -24
View File
@@ -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<Record<string, unknown>> | 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({});
});
});
+10 -14
View File
@@ -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. */
@@ -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<Record<string, unknown>> | 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<Record<string, unknown>> | 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", () => {
+17 -64
View File
@@ -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 */
+19 -63
View File
@@ -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<SpanExportPolicy, ContentRedaction>();
const composedPolicies = new WeakMap<SpanExportPolicy, readonly SpanExportPolicy[]>();
/** 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];
}
+32 -33
View File
@@ -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.