Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/great-moles-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Records Vercel AI Gateway cost on local trace spans: `agent.step` spans now carry `gen_ai.usage.cost`, `gen_ai.usage.gateway_cost`, `gen_ai.usage.input_cost`/`output_cost`, and `gen_ai.generation.id` when the gateway reports them. The attributes only exist for gateway-served calls — other providers emit nothing.
2 changes: 2 additions & 0 deletions docs/guides/instrumentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ The directory is an immutable OTLP/JSON spool and remains available after `eve d

Use `eve trace ls` to list captured traces and `eve trace <trace>` to inspect a session's span tree.

When a model call is served by Vercel AI Gateway, its `agent.step` span also carries the cost the gateway reported: `gen_ai.usage.cost` (raw inference, USD), `gen_ai.usage.gateway_cost` (with the gateway surcharge), `gen_ai.usage.input_cost` / `gen_ai.usage.output_cost` (the split), and `gen_ai.generation.id` for reconciliation with the gateway dashboard. These attributes only exist for gateway-served calls — other providers emit nothing. Cost per turn is the sum across the turn's step spans.

The local writer is an internal development default, not a second provider layered over authored instrumentation. When `instrumentation.ts` exists, its setup retains control and the zero-config writer is not installed.

### Local trace retention
Expand Down
58 changes: 58 additions & 0 deletions packages/eve/src/harness/agent-otel-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async function emitAttempt(input: {
readonly attemptIndex?: number;
readonly hooks: InstrumentationHooks;
readonly runInContext: InstrumentationContextRunner;
readonly providerMetadata?: Readonly<Record<string, unknown>>;
readonly sessionId: string;
readonly toolError?: Error;
readonly turnAlreadyStarted?: boolean;
Expand Down Expand Up @@ -112,6 +113,14 @@ async function emitAttempt(input: {
},
]);

if (input.providerMetadata !== undefined) {
await input.hooks.publish({
providerMetadata: input.providerMetadata,
scope,
type: "attempt.metadata",
});
}

await input.hooks.publish({ scope, type: "attempt.completed" });
await input.hooks.publish({
sessionId: input.sessionId,
Expand Down Expand Up @@ -203,6 +212,55 @@ describe("createAgentOtelInstrumentation", () => {
expect(JSON.stringify(spans.map((span) => span.attributes))).not.toContain("private");
});

it("writes gateway cost attributes on the step span when the gateway reports them", async () => {
const runtime = createRuntime();
await emitAttempt({
hooks: runtime.hooks,
providerMetadata: {
gateway: {
cost: "0.000082",
gatewayCost: "0.000182",
generationId: "gen_01KYR80F7ZV4RM3PJ635KMXB5V",
inputInferenceCost: "0.000042",
outputInferenceCost: "0.00004",
},
},
runInContext: runtime.runInContext,
sessionId: "session-1",
turnId: "turn-1",
turnSequence: 0,
});
await runtime.provider.forceFlush();

const step = byName(runtime.exporter.getFinishedSpans(), "agent.step")[0]!;
expect(step.attributes).toMatchObject({
"gen_ai.generation.id": "gen_01KYR80F7ZV4RM3PJ635KMXB5V",
"gen_ai.usage.cost": 0.000082,
"gen_ai.usage.gateway_cost": 0.000182,
"gen_ai.usage.input_cost": 0.000042,
"gen_ai.usage.output_cost": 0.00004,
});
});

it("emits no cost attributes when the provider is not the gateway", async () => {
const runtime = createRuntime();
await emitAttempt({
hooks: runtime.hooks,
providerMetadata: { anthropic: { cacheCreationInputTokens: 0 } },
runInContext: runtime.runInContext,
sessionId: "session-1",
turnId: "turn-1",
turnSequence: 0,
});
await runtime.provider.forceFlush();

const step = byName(runtime.exporter.getFinishedSpans(), "agent.step")[0]!;
const keys = Object.keys(step.attributes);
for (const key of keys) {
expect(key).not.toContain("cost");
}
});

it("reuses one trace id across turns", async () => {
const stateStore = new InMemoryAgentTraceStateStore();
const firstRuntime = createRuntime(stateStore);
Expand Down
52 changes: 52 additions & 0 deletions packages/eve/src/harness/agent-otel-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "#compiled/@opentelemetry/api/index.js";

import type {
InstrumentationAttemptMetadataEvent,
InstrumentationAttemptScope,
InstrumentationAttemptStartedEvent,
InstrumentationAttemptTerminalEvent,
Expand Down Expand Up @@ -387,11 +388,25 @@ export function createAgentOtelInstrumentation(
return state;
};

const onAttemptMetadata = (event: InstrumentationAttemptMetadataEvent): void => {
const attempt = steps.get(event.scope);
if (attempt === undefined) return;
// Vercel AI Gateway reports per-call cost in providerMetadata.gateway;
// attributes exist only when it was actually the gateway serving the call.
const costAttributes = readGatewayCost(event.providerMetadata);
if (costAttributes === undefined) return;
// The vendored OTel Span surface only has singular setAttribute.
for (const [key, value] of Object.entries(costAttributes)) {
attempt.step.span.setAttribute(key, value);
}
};

return {
hook: {
events: {
"attempt.completed": onAttemptTerminal,
"attempt.failed": onAttemptTerminal,
"attempt.metadata": onAttemptMetadata,
"attempt.started": onAttemptStarted,
"model.call": { after: afterModelCall, before: beforeModelCall },
"session.completed": onSessionTransition,
Expand Down Expand Up @@ -448,6 +463,39 @@ function contextFromSpanContext(spanContext: SpanContext): Context {
return trace.setSpan(ROOT_CONTEXT, trace.wrapSpanContext(spanContext));
}

/**
* Extracts cost data from a step result's provider metadata. Only Vercel AI
* Gateway reports it (`providerMetadata.gateway`): raw inference cost, the
* gateway's surcharged total, the input/output split, and the generation id
* for dashboard reconciliation. Values arrive as USD strings; anything
* missing or non-numeric is skipped, so non-gateway providers get nothing.
*/
function readGatewayCost(
providerMetadata: Readonly<Record<string, unknown>>,
): Record<string, string | number> | undefined {
const gateway = providerMetadata.gateway;
if (!isRecord(gateway)) return undefined;
const attributes: Record<string, string | number> = {};
const cost = readUsd(gateway.cost);
if (cost !== undefined) attributes["gen_ai.usage.cost"] = cost;
const gatewayCost = readUsd(gateway.gatewayCost);
if (gatewayCost !== undefined) attributes["gen_ai.usage.gateway_cost"] = gatewayCost;
const inputCost = readUsd(gateway.inputInferenceCost);
if (inputCost !== undefined) attributes["gen_ai.usage.input_cost"] = inputCost;
const outputCost = readUsd(gateway.outputInferenceCost);
if (outputCost !== undefined) attributes["gen_ai.usage.output_cost"] = outputCost;
if (typeof gateway.generationId === "string" && gateway.generationId.length > 0) {
attributes["gen_ai.generation.id"] = gateway.generationId;
}
return Object.keys(attributes).length === 0 ? undefined : attributes;
}

function readUsd(value: unknown): number | undefined {
if (typeof value !== "string") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}

function isSpanState(value: unknown): value is SpanState {
return typeof value === "object" && value !== null && "context" in value && "span" in value;
}
Expand All @@ -456,6 +504,10 @@ function isToolSpanState(value: unknown): value is ToolSpanState {
return isSpanState(value) && "toolSpan" in value;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function modelSpanName(operationName: string): string {
return operationName === "ai.generateText"
? "ai.generateText.doGenerate"
Expand Down
28 changes: 28 additions & 0 deletions packages/eve/src/harness/ai-sdk-hook-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { createAiSdkHookBridge } from "#harness/ai-sdk-hook-bridge.js";
import {
createInstrumentationHooks,
type InstrumentationAttemptMetadataEvent,
type InstrumentationAttemptScope,
type InstrumentationProviderDefinition,
} from "#harness/instrumentation-lifecycle.js";
Expand Down Expand Up @@ -119,6 +120,33 @@ describe("createAiSdkHookBridge", () => {
expect(adapterCalls).toBe(0);
});

it("publishes step provider metadata as attempt.metadata, skipping steps without any", async () => {
const events: InstrumentationAttemptMetadataEvent[] = [];
const hooks = createInstrumentationHooks([
{
events: {
"attempt.metadata": (event) => {
events.push(event);
},
},
},
]);
const bridge = createAiSdkHookBridge(scope, hooks);

await Reflect.apply(bridge.onStepEnd!, bridge, [
{ providerMetadata: { gateway: { cost: "0.000082" } } },
]);
await Reflect.apply(bridge.onStepEnd!, bridge, [{ providerMetadata: undefined }]);

expect(events).toEqual([
{
providerMetadata: { gateway: { cost: "0.000082" } },
scope,
type: "attempt.metadata",
},
]);
});

it("isolates a failing provider from the remaining providers", async () => {
const after = vi.fn();
const hooks = createInstrumentationHooks([
Expand Down
11 changes: 11 additions & 0 deletions packages/eve/src/harness/ai-sdk-hook-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,17 @@ export function createAiSdkHookBridge(
const completed = toModelCallCompleted(state, id, event);
await hooks.after("model.call", completed);
},
async onStepEnd(event) {
// Step results carry provider metadata (e.g. Vercel AI Gateway cost)
// that the per-call telemetry events don't. Publish it for providers
// that know what to do with it; skip when there is none.
if (event.providerMetadata === undefined) return;
await hooks.publish({
providerMetadata: event.providerMetadata,
scope: state.scope,
type: "attempt.metadata",
});
},
async onToolExecutionStart(event) {
const id = createToolCallIdentity(state, event.toolCall.toolCallId);
state.toolIds.set(event.toolCall.toolCallId, id);
Expand Down
15 changes: 15 additions & 0 deletions packages/eve/src/harness/instrumentation-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,17 @@ export interface InstrumentationAttemptTerminalEvent {
readonly scope: InstrumentationAttemptScope;
}

/**
* Provider metadata for one completed step, as reported by the AI SDK
* (`StepResult.providerMetadata`). Carries Vercel AI Gateway cost data when
* the request went through the gateway; absent for other providers.
*/
export interface InstrumentationAttemptMetadataEvent {
readonly type: "attempt.metadata";
readonly scope: InstrumentationAttemptScope;
readonly providerMetadata: Readonly<Record<string, unknown>>;
}

export interface InstrumentationModelCallStartedEvent {
readonly type: "model.call.started";
readonly id: string;
Expand Down Expand Up @@ -129,6 +140,9 @@ export interface InstrumentationProviderDefinition {
readonly "attempt.failed"?: (
event: InstrumentationAttemptTerminalEvent,
) => void | PromiseLike<void>;
readonly "attempt.metadata"?: (
event: InstrumentationAttemptMetadataEvent,
) => void | PromiseLike<void>;
readonly "session.completed"?: (
event: InstrumentationSessionTransitionEvent,
) => void | PromiseLike<void>;
Expand Down Expand Up @@ -171,6 +185,7 @@ export type InstrumentationRelatedEventName = keyof InstrumentationRelatedEventM

export type InstrumentationPointEvent =
| InstrumentationAttemptStartedEvent
| InstrumentationAttemptMetadataEvent
| InstrumentationAttemptTerminalEvent
| InstrumentationSessionStartedEvent
| InstrumentationSessionTransitionEvent
Expand Down
Loading