diff --git a/.changeset/vercel-session-id-attribute.md b/.changeset/vercel-session-id-attribute.md new file mode 100644 index 0000000000..86d9ce4566 --- /dev/null +++ b/.changeset/vercel-session-id-attribute.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Agent spans now carry `vercel.session_id` (set to the root session id) when running on Vercel, so traces can be equality-looked-up across all session windows via an indexed column. The attribute is omitted in local `eve dev`. diff --git a/packages/eve/src/tracing/agent-action-instrumentation.ts b/packages/eve/src/tracing/agent-action-instrumentation.ts index 83de88e60e..79574e2410 100644 --- a/packages/eve/src/tracing/agent-action-instrumentation.ts +++ b/packages/eve/src/tracing/agent-action-instrumentation.ts @@ -45,6 +45,7 @@ export interface AgentActionContext { /** Builds durable `agent.action` spans around eve's runtime dispatch boundary. */ export function createAgentActionInstrumentation(input: { + readonly emitVercelSessionId?: boolean; readonly frameworkVersion: string; readonly idGenerator: AgentSpanIdGenerator; readonly recordInputs: boolean; @@ -55,6 +56,7 @@ export function createAgentActionInstrumentation(input: { readonly stateStore: AgentTraceStateStore; readonly tracer: Tracer; }): AgentActionInstrumentation { + const emitVercelSessionId = input.emitVercelSessionId ?? false; const byAttempt = new Map>(); const onStarted = async (event: InstrumentationActionStartedEvent): Promise => { @@ -131,6 +133,9 @@ export function createAgentActionInstrumentation(input: { "agent.step.attempt": state.attemptIndex, "agent.step.index": state.stepIndex, "agent.turn.id": state.turnId, + ...(emitVercelSessionId + ? { "vercel.session_id": state.rootSessionId } + : {}), }, startTime: state.startTimeMs, }, diff --git a/packages/eve/src/tracing/agent-approval-instrumentation.ts b/packages/eve/src/tracing/agent-approval-instrumentation.ts index cff37245bb..2150d09c6a 100644 --- a/packages/eve/src/tracing/agent-approval-instrumentation.ts +++ b/packages/eve/src/tracing/agent-approval-instrumentation.ts @@ -41,6 +41,7 @@ export function createAgentApprovalInstrumentation(input: { turnId: string, callId: string, ) => Promise; + readonly emitVercelSessionId?: boolean; readonly frameworkVersion: string; readonly idGenerator: AgentSpanIdGenerator; readonly recordInputs: boolean; @@ -50,6 +51,7 @@ export function createAgentApprovalInstrumentation(input: { NonNullable, "input.requested" | "input.resolved" > { + const emitVercelSessionId = input.emitVercelSessionId ?? false; const onRequested = async ( event: InstrumentationInputRequestedEvent, ctx: InstrumentationHandlerContext, @@ -110,6 +112,9 @@ export function createAgentApprovalInstrumentation(input: { "agent.step.attempt": state.attemptIndex, "agent.step.index": state.stepIndex, "agent.turn.id": state.turnId, + ...(emitVercelSessionId + ? { "vercel.session_id": state.rootSessionId } + : {}), }, startTime: state.startTimeMs, }, diff --git a/packages/eve/src/tracing/agent-channel-delivery-instrumentation.ts b/packages/eve/src/tracing/agent-channel-delivery-instrumentation.ts index d363e9ac50..d388a6a896 100644 --- a/packages/eve/src/tracing/agent-channel-delivery-instrumentation.ts +++ b/packages/eve/src/tracing/agent-channel-delivery-instrumentation.ts @@ -35,6 +35,7 @@ interface ChannelDeliverySpanState { /** Builds durable channel delivery spans around the turn that consumes each request. */ export function createAgentChannelDeliveryInstrumentation(input: { + readonly emitVercelSessionId?: boolean; readonly ensureSessionContext: ( event: InstrumentationSessionStartedEvent, ) => Promise; @@ -50,6 +51,7 @@ export function createAgentChannelDeliveryInstrumentation(input: { | "channel.delivery.failed" | "channel.delivery.started" > { + const emitVercelSessionId = input.emitVercelSessionId ?? false; const onStarted = async ( event: InstrumentationChannelDeliveryStartedEvent, ctx: InstrumentationHandlerContext, @@ -130,6 +132,9 @@ export function createAgentChannelDeliveryInstrumentation(input: { "agent.session.window": session?.window ?? state.window, "agent.turn.id": event.turnId, "agent.turn.sequence": event.sequence, + ...(emitVercelSessionId + ? { "vercel.session_id": event.rootSessionId } + : {}), }, kind: SpanKind.CONSUMER, links: diff --git a/packages/eve/src/tracing/agent-otel-provider.test.ts b/packages/eve/src/tracing/agent-otel-provider.test.ts index 849d4e8497..da481a2e7d 100644 --- a/packages/eve/src/tracing/agent-otel-provider.test.ts +++ b/packages/eve/src/tracing/agent-otel-provider.test.ts @@ -62,6 +62,7 @@ interface TestRuntime { function createRuntime( stateStore: AgentTraceStateStore = new InMemoryAgentTraceStateStore(), tracePolicy: TraceCapturePolicy | null = () => true, + options: { readonly emitVercelSessionId?: boolean } = {}, ): TestRuntime { const exporter = new InMemorySpanExporter(); const idGenerator = new AgentSpanIdGenerator(); @@ -73,6 +74,7 @@ function createRuntime( const agentOtelInput: Omit & { tracePolicy?: TraceCapturePolicy; } = { + emitVercelSessionId: options.emitVercelSessionId, frameworkVersion: "test", idGenerator, recordInputs: true, @@ -1847,4 +1849,46 @@ describe("createAgentOtelInstrumentation", () => { }); expect(byName(spans, "agent.turn")[0]!.status.code).toBe(SpanStatusCode.UNSET); }); + + describe("emitVercelSessionId", () => { + it("emits vercel.session_id on session, turn, step, and action spans when enabled", async () => { + const runtime = createRuntime(undefined, undefined, { emitVercelSessionId: true }); + await emitAttempt({ + hooks: runtime.hooks, + runInContext: runtime.runInContext, + sessionId: "session-1", + turnId: "turn-1", + turnSequence: 0, + }); + await runtime.provider.forceFlush(); + + const spans = runtime.exporter.getFinishedSpans(); + const session = byName(spans, "agent.session")[0]!; + const turn = byName(spans, "agent.turn")[0]!; + const step = byName(spans, "agent.step")[0]!; + const action = byName(spans, "agent.action")[0]!; + + expect(session.attributes["vercel.session_id"]).toBe("session-1"); + expect(turn.attributes["vercel.session_id"]).toBe("session-1"); + expect(step.attributes["vercel.session_id"]).toBe("session-1"); + expect(action.attributes["vercel.session_id"]).toBe("session-1"); + }); + + it("does not emit vercel.session_id by default", async () => { + const runtime = createRuntime(); + await emitAttempt({ + hooks: runtime.hooks, + runInContext: runtime.runInContext, + sessionId: "session-1", + turnId: "turn-1", + turnSequence: 0, + }); + await runtime.provider.forceFlush(); + + const spans = runtime.exporter.getFinishedSpans(); + for (const span of spans) { + expect(span.attributes["vercel.session_id"]).toBeUndefined(); + } + }); + }); }); diff --git a/packages/eve/src/tracing/agent-otel-provider.ts b/packages/eve/src/tracing/agent-otel-provider.ts index 2900345e04..f706bb7d4a 100644 --- a/packages/eve/src/tracing/agent-otel-provider.ts +++ b/packages/eve/src/tracing/agent-otel-provider.ts @@ -73,6 +73,14 @@ export interface AgentOtelInstrumentationInput { readonly stateStore: AgentTraceStateStore; readonly tracer: Tracer; readonly tracePolicy?: TraceCapturePolicy; + /** + * When true, every agent span also carries `vercel.session_id` set to the + * root session id. The VDP trace ingestion materialized view extracts this + * into the indexed `sessionId` column so Agent Runs can equality-lookup a + * session across all trace windows without scanning the attribute map. + * Emitted only on Vercel (not local `eve dev`). + */ + readonly emitVercelSessionId?: boolean; } /** OTel event definition and its trusted framework context runner. */ @@ -93,6 +101,7 @@ export function createAgentOtelInstrumentation( ): AgentOtelInstrumentation { const recordInputs = input.recordInputs ?? false; const recordOutputs = input.recordOutputs ?? false; + const emitVercelSessionId = input.emitVercelSessionId ?? false; const executionContexts = new WeakMap>(); const attemptScopes = new Map(); // A serverless turn runs inside one `turnStep` "use step" invocation. If @@ -101,6 +110,7 @@ export function createAgentOtelInstrumentation( const steps = new WeakMap(); const modelSpans = new WeakMap>(); const actions = createAgentActionInstrumentation({ + emitVercelSessionId, frameworkVersion: input.frameworkVersion, idGenerator: input.idGenerator, recordInputs, @@ -114,6 +124,7 @@ export function createAgentOtelInstrumentation( }); const approvals = createAgentApprovalInstrumentation({ actionContextFor: actions.contextFor, + emitVercelSessionId, frameworkVersion: input.frameworkVersion, idGenerator: input.idGenerator, recordInputs, @@ -168,6 +179,9 @@ export function createAgentOtelInstrumentation( "agent.step.index": event.scope.stepIndex, "agent.turn.id": event.scope.turnId, "agent.name": event.scope.functionId, + ...(emitVercelSessionId + ? { "vercel.session_id": event.scope.rootSessionId ?? event.scope.sessionId } + : {}), ...runtimeContextAttributes(event.runtimeContext), }, links: @@ -274,6 +288,9 @@ export function createAgentOtelInstrumentation( "agent.session.window": session?.window, "agent.turn.id": event.turnId, "agent.turn.sequence": turn.sequence, + ...(emitVercelSessionId + ? { "vercel.session_id": turn.rootSessionId } + : {}), }, startTime: turn.startTimeMs, }, @@ -411,6 +428,7 @@ export function createAgentOtelInstrumentation( }; const channelDeliveries = createAgentChannelDeliveryInstrumentation({ + emitVercelSessionId, ensureSessionContext, frameworkVersion: input.frameworkVersion, idGenerator: input.idGenerator, diff --git a/packages/eve/src/tracing/agent-otel-session-context.ts b/packages/eve/src/tracing/agent-otel-session-context.ts index c0f2dbeac1..3f0a1526ee 100644 --- a/packages/eve/src/tracing/agent-otel-session-context.ts +++ b/packages/eve/src/tracing/agent-otel-session-context.ts @@ -17,6 +17,7 @@ import { } from "#tracing/agent-trace-state.js"; interface AgentOtelSessionContextInput { + readonly emitVercelSessionId?: boolean; readonly frameworkVersion: string; readonly idGenerator: AgentSpanIdGenerator; readonly stateStore: AgentTraceStateStore; @@ -39,6 +40,7 @@ interface AgentOtelSessionContext { export function createAgentOtelSessionContext( input: AgentOtelSessionContextInput, ): AgentOtelSessionContext { + const emitVercelSessionId = input.emitVercelSessionId ?? false; const openSessionWindow = (window: { readonly agentName?: string; readonly channelAudience: ChannelAudience; @@ -65,6 +67,9 @@ export function createAgentOtelSessionContext( "agent.session.id": window.sessionId, "agent.session.window": window.index, "agent.trace.schema.version": 1, + ...(emitVercelSessionId + ? { "vercel.session_id": window.rootSessionId } + : {}), ...(window.previousTraceId === undefined ? {} : { "agent.session.window.previous.trace.id": window.previousTraceId }), diff --git a/packages/eve/src/tracing/install-instrumentation-runtime.ts b/packages/eve/src/tracing/install-instrumentation-runtime.ts index 7c78c5e3f9..adbc734f78 100644 --- a/packages/eve/src/tracing/install-instrumentation-runtime.ts +++ b/packages/eve/src/tracing/install-instrumentation-runtime.ts @@ -10,6 +10,7 @@ import { type InstrumentationRuntime, } from "#harness/instrumentation/runtime.js"; import { createLogger, formatError } from "#internal/logging.js"; +import { isEveDevEnvironment } from "#internal/application/dev-environment.js"; import { ContextAgentTraceStateStore } from "#tracing/agent-trace-context-store.js"; import { createAgentOtelInstrumentation } from "#tracing/agent-otel-provider.js"; import { hasSessionRelease, type LocalTracesProcessor } from "#tracing/local-traces.js"; @@ -48,6 +49,7 @@ export function installInstrumentationRuntime(input: { serviceName: input.serviceName, }); const agentOtel = createAgentOtelInstrumentation({ + emitVercelSessionId: process.env.VERCEL_ENV !== undefined && !isEveDevEnvironment(), frameworkVersion: input.frameworkVersion, idGenerator: otelRuntime.idGenerator, recordInputs: input.collected.settings.recordInputs,