Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/olive-otters-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"eve": patch
---

Content capture is now declared per trace destination. `otelIntegration()` takes
`recordInputs` and `recordOutputs`, so a local spool and a hosted backend no
longer have to agree on whether they see prompts and tool results — a
destination that declines never exports them. `EVE_TRACES_CONTENT=off` now
narrows `localTraces()` alone rather than the whole process.
19 changes: 7 additions & 12 deletions packages/eve/src/public/instrumentation/otel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
*/

import { createLocalTracesProcessor, resolveLocalTracesContent } from "#tracing/local-traces.js";
import { contentFilteringProcessor } from "#tracing/content-span-processor.js";
import {
agentRunsIntegration,
otelIntegration,
Expand Down Expand Up @@ -43,17 +42,13 @@ export function agentRuns(options: ContentOptions = {}): OtelIntegration {
* Export it from `agent/instrumentation/local.ts` to keep it alongside a hosted
* backend, or export `disableInstrumentation()` from that file to turn it off.
* Omitting the file leaves eve's default in place.
* `EVE_TRACES_CONTENT=off` narrows this destination only.
*
* `EVE_TRACES_CONTENT=off` narrows this destination and no other, so declining
* content locally leaves what a hosted backend receives alone.
*/
export function localTraces(options: ContentOptions = {}): OtelIntegration {
const content = resolveLocalTracesContent(options);
const spool = createLocalTracesProcessor();
return {
...otelIntegration(),
content,
spanProcessors:
content.recordInputs && content.recordOutputs
? [spool]
: [contentFilteringProcessor(spool, content)],
};
return otelIntegration({
...resolveLocalTracesContent(options),
spanProcessors: [createLocalTracesProcessor()],
});
}
4 changes: 2 additions & 2 deletions packages/eve/src/tracing/agent-otel-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,10 +450,10 @@ describe("createAgentOtelInstrumentation", () => {
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
const agentOtel = createAgentOtelInstrumentation({
recordInputs: false,
recordOutputs: false,
frameworkVersion: "test",
idGenerator,
recordInputs: false,
recordOutputs: false,
stateStore: new InMemoryAgentTraceStateStore(),
tracer: provider.getTracer("eve.agent"),
});
Expand Down
6 changes: 4 additions & 2 deletions packages/eve/src/tracing/agent-otel-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,12 @@ interface ToolSpanState extends SpanState {

export interface AgentOtelInstrumentationInput {
/**
* The union of what this process's destinations requested. Each destination
* independently removes declined content before export.
* Whether to write model prompts and tool call inputs onto spans at all.
* This is the union across destinations, not one destination's policy: a
* destination that declined drops these on its way out instead.
*/
readonly recordInputs?: boolean;
/** The same, for model responses and tool call outputs. */
readonly recordOutputs?: boolean;
readonly frameworkVersion: string;
readonly idGenerator: AgentSpanIdGenerator;
Expand Down
73 changes: 73 additions & 0 deletions packages/eve/src/tracing/content-attributes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";

import { withoutDeclinedContent } from "#tracing/content-attributes.js";

const ATTRIBUTES = {
"ai.prompt.messages": "what the user said",
"ai.response.finish_reason": "stop",
"ai.response.text": "what the model said",
"gen_ai.request.model": "test-model",
"gen_ai.tool.call.arguments": "{}",
"gen_ai.tool.call.result": "42",
"gen_ai.tool.name": "weather",
};

describe("withoutDeclinedContent", () => {
it("keeps everything when the destination declined nothing", () => {
expect(
withoutDeclinedContent(ATTRIBUTES, { recordInputs: true, recordOutputs: true }),
).toBeUndefined();
});

it("keeps everything when the span carries none of what was declined", () => {
expect(
withoutDeclinedContent(
{ "gen_ai.request.model": "test-model" },
{ recordInputs: false, recordOutputs: false },
),
).toBeUndefined();
});

it("drops inputs alone", () => {
expect(
withoutDeclinedContent(ATTRIBUTES, { recordInputs: false, recordOutputs: true }),
).toEqual({
"ai.response.finish_reason": "stop",
"ai.response.text": "what the model said",
"gen_ai.request.model": "test-model",
"gen_ai.tool.call.result": "42",
"gen_ai.tool.name": "weather",
});
});

it("drops outputs alone", () => {
expect(
withoutDeclinedContent(ATTRIBUTES, { recordInputs: true, recordOutputs: false }),
).toEqual({
"ai.prompt.messages": "what the user said",
"ai.response.finish_reason": "stop",
"gen_ai.request.model": "test-model",
"gen_ai.tool.call.arguments": "{}",
"gen_ai.tool.name": "weather",
});
});

// The prefixes are shared: `ai.response.finish_reason` and `gen_ai.tool.name`
// say what happened rather than what was said, so declining content cannot
// cost a destination the ability to read its own traces.
it("keeps metadata that shares a prefix with content", () => {
expect(
withoutDeclinedContent(ATTRIBUTES, { recordInputs: false, recordOutputs: false }),
).toEqual({
"ai.response.finish_reason": "stop",
"gen_ai.request.model": "test-model",
"gen_ai.tool.name": "weather",
});
});

it("leaves the attributes it was handed alone", () => {
const attributes = { ...ATTRIBUTES };
withoutDeclinedContent(attributes, { recordInputs: false, recordOutputs: false });
expect(attributes).toEqual(ATTRIBUTES);
});
});
33 changes: 30 additions & 3 deletions packages/eve/src/tracing/content-attributes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
/** Prompts, instructions, and tool arguments. */
/**
* Which span attributes carry conversation content, and in which direction.
*
* Two vocabularies land on the same spans: the ones eve sets on its own
* `agent.*` spans, and the ones the AI SDK's OpenTelemetry integration sets on
* the model-call spans beneath them. A destination that declined content has to
* be rid of both, so both are listed here rather than in the module that writes
* each.
*
* Listed by name rather than by prefix because the prefixes are shared with
* metadata that must survive: `ai.response.finish_reason` and
* `gen_ai.tool.name` say what happened, not what was said. The cost is that a
* new content attribute in a dependency is not covered until it is added here.
*/

/** Prompts, instructions, tool arguments — what went in. */
const INPUT_CONTENT_ATTRIBUTES: ReadonlySet<string> = new Set([
"ai.documents",
"ai.prompt",
Expand All @@ -14,7 +29,12 @@ const INPUT_CONTENT_ATTRIBUTES: ReadonlySet<string> = new Set([
"gen_ai.tool.definitions",
]);

/** Responses, reasoning, and tool results. */
/**
* Responses, reasoning, tool results — what came out.
*
* `ai.toolCall.args` is here rather than above because the AI SDK gates it on
* `recordOutputs`; matching that is what keeps one destination's view coherent.
*/
const OUTPUT_CONTENT_ATTRIBUTES: ReadonlySet<string> = new Set([
"ai.embedding",
"ai.embeddings",
Expand Down Expand Up @@ -43,7 +63,14 @@ function isDeclined(key: string, content: ResolvedContentOptions): boolean {
return !content.recordOutputs && OUTPUT_CONTENT_ATTRIBUTES.has(key);
}

/** Returns a copy without declined content, or undefined when no copy is needed. */
/**
* The attributes with the declined content removed, or `undefined` when there
* was none to remove.
*
* The `undefined` return is what lets the caller forward the original span
* untouched in the common case, so a destination that declined a direction the
* span never carried costs one pass over its keys and no allocation.
*/
export function withoutDeclinedContent(
attributes: Readonly<Record<string, unknown>>,
content: ResolvedContentOptions,
Expand Down
Loading