@telemetry-dev/eve 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ lifecycle-hook, and HTTP-client surfaces.
10
10
  npm install @telemetry-dev/eve eve
11
11
  ```
12
12
 
13
- Requires `eve >=0.19.0 <1` and Node.js 24 or newer.
13
+ Requires `eve >=0.47.0 <1` and Node.js 24 or newer.
14
14
 
15
15
  ## Environment
16
16
 
@@ -93,17 +93,21 @@ const client = wrapEveClient(new Client({ host: "http://127.0.0.1:3000" }), {
93
93
  agentName: "support-agent",
94
94
  });
95
95
 
96
- const session = client.session();
97
- const response = await session.send("Summarize this incident.");
96
+ const { response, session } = await client.sessions.create({
97
+ message: "Summarize this incident.",
98
+ });
98
99
  const result = await response.result();
100
+ const followUp = await session.send("What do we do next?");
99
101
  ```
100
102
 
101
- `wrapEveClient()` creates one caller-side `invoke_agent` span per `ClientSession.send()` turn. It
102
- records the outgoing message or HITL input responses as span input, sets `gen_ai.conversation.id` from
103
- Eve's response session id, streams TTFT from the first message/reasoning delta, accumulates usage from
104
- `step.completed`, records final message/result output, and marks failures/cancellations as errors.
103
+ `wrapEveClient()` makes one caller-side `invoke_agent` span for each turn: `sessions.create()`,
104
+ `ClientSession.send()`, and `ClientSession.respond()`. It records the outgoing message or the HITL
105
+ input responses as span input, sets `gen_ai.conversation.id` from
106
+ the session state or Eve's response session id (so all turns of one session share one trace), streams
107
+ TTFT from the first message/reasoning delta, accumulates usage from `step.completed`, records final
108
+ message/result output, and marks failures/cancellations as errors.
105
109
 
106
- `ClientSession.stream()`, `Client.info()`, and `Client.health()` are intentionally not spanned.
110
+ `ClientSession.stream()`, `sessions.attach()`, `Client.info()`, and `Client.health()` are not spanned.
107
111
  Attach-streams are unbounded and the probe routes are not agent turns.
108
112
 
109
113
  ## Trace shape
@@ -120,25 +124,62 @@ A typical turn contains:
120
124
  context and any custom runtime context you return from `step.started`.
121
125
  - Lifecycle logs joined to the same session via `gen_ai.conversation.id`.
122
126
 
123
- Eve's internal workflow-engine spans (`workflow.execute`, `step.execute`, `world.*`, `hook.resume`,
124
- etc., tracer scope `workflow`) are excluded by default so traces stay AI-centric. Pass
125
- `spanFilter: () => true` to export them too.
127
+ By default, only AI spans are exported: tracer scopes `eve`, `eve.agent`, `gen_ai`, and this SDK. Spans from
128
+ other libraries on the same global tracer provider (for example better-auth, Nitro, or eve's
129
+ `workflow` engine) are dropped. Pass `spanFilter: () => true` to export every span.
126
130
 
127
131
  ## Content capture
128
132
 
129
- Server-side model content follows Eve's `recordInputs` / `recordOutputs` settings. Caller-side wrapper
130
- content follows the telemetry.dev SDK `captureInput` / `captureOutput` settings. The SDK mask and
131
- truncation options apply to captured client-wrapper inputs, outputs, and logs.
133
+ Server-side model content depends on Eve's trace policy and the destination capture settings.
134
+ `telemetryDevInstrumentation()` maps `captureInput` / `captureOutput` to `recordInputs` /
135
+ `recordOutputs`. The defaults are `true`, but these flags cannot override Eve's trace policy.
136
+ In Eve 0.47.0 and 0.50.0, the default policy records content only for a `public` audience,
137
+ even if `EVE_DEV=1`. HTTP clients, web chat, and schedules have an `unknown` audience.
138
+
139
+ For approved local content capture, use Eve's provider layout.
140
+ Remove `agent/instrumentation.ts`.
141
+ Set `experimental.instrumentationProviders` to `true` in the Eve configuration.
142
+ Add these two files:
143
+
144
+ ```ts
145
+ // agent/instrumentation/otel.ts
146
+ import { otel } from "eve/instrumentation/otel";
147
+
148
+ export default otel({ tracePolicy: () => true });
149
+ ```
150
+
151
+ ```ts
152
+ // agent/instrumentation/telemetry-dev.ts
153
+ import { telemetryDevOtelIntegration } from "@telemetry-dev/eve";
154
+
155
+ export default telemetryDevOtelIntegration();
156
+ ```
157
+
158
+ This policy selects Eve's audience-aware capture.
159
+ For local `unknown`-audience channels, run `eve dev` or set `EVE_DEV=1` in the Eve server process.
160
+ Without `EVE_DEV=1`, local unknown-audience channels export metadata only.
161
+ Local private channels always export metadata only.
162
+ Destination settings and forwarded parent policies can decrease content capture.
163
+
164
+ The legacy single-file `telemetryDevInstrumentation()` API has no `tracePolicy` option.
165
+ Thus, it cannot record model content for new unknown-audience sessions on these Eve versions.
166
+ The single-file layout remains suitable for metadata-only traces.
167
+ Do not combine the two layouts.
168
+ This package cannot restore content that Eve removed.
169
+
170
+ Caller-side wrapper content follows the telemetry.dev SDK `captureInput` / `captureOutput`
171
+ settings. The SDK mask and truncation options apply to captured client-wrapper inputs, outputs,
172
+ and logs.
132
173
 
133
174
  ## Limitations
134
175
 
135
176
  - Pass options to exactly one entry point in a process. Initialization is one-shot so duplicate entry
136
177
  points do not replace the first configuration.
137
178
  - `ClientSession.stream()` is not spanned.
138
- - Cost is computed server-side by telemetry.dev from usage and pricing data; Eve stream events carry
139
- no cost fields, so the client wrapper records usage only.
140
- - The client wrapper's `invoke_agent` span ends when the response stream is consumed (iteration or
141
- `result()`); an unconsumed response leaves the span unexported.
179
+ - The client wrapper exports a total cost only after an error-free terminal event. Each completed step must report a finite, non-negative `usage.costUsd` value, including zero. Otherwise, the wrapper omits the total but still records token usage. Model information is also necessary for server-side pricing.
180
+ - When caller and server spans share a trace and matching Eve turn metadata, the caller cost replaces the server's main model-call costs. Tool and subagent costs stay separate. Without that match, the two reported totals stay unchanged.
181
+ - The client wrapper ends its `invoke_agent` span after stream use or an abort signal. Without these actions, the span stays open.
182
+ - Eve 0.50 gives `telemetryDevOtelIntegration()` no tracer-provider or parent-context hook. Thus, Eve provider spans cannot join the deterministic session trace.
142
183
  - Inline subagent child streams (`subagent.event`) are not fanned out in full; the hook logs
143
184
  `subagent.started`, `subagent.called`, `subagent.completed`, and child failure events.
144
185
  - Eve workflow `$eve.*` run tags are a Vercel-dashboard surface and are not visible to OpenTelemetry,
package/dist/index.d.mts CHANGED
@@ -1,11 +1,12 @@
1
- import { ClientOverrides, ClientOverrides as ClientOverrides$1, TelemetryOptions } from "@telemetry-dev/sdk";
1
+ import { ClientOverrides, ClientOverrides as ClientOverrides$1, TelemetryOptions, TelemetrySpanProcessorOptions } from "@telemetry-dev/sdk";
2
2
  import { Client } from "eve/client";
3
+ import { OtelIntegration } from "eve/instrumentation/otel";
3
4
  import { HookDefinition } from "eve/hooks";
4
5
  import { InstrumentationDefinition, InstrumentationEvents, InstrumentationRuntimeContext } from "eve/instrumentation";
5
6
 
6
7
  //#region src/config.d.ts
7
8
  /** Options accepted by every @telemetry-dev/eve entry point. */
8
- type TelemetryDevEveOptions = Omit<TelemetryOptions, "registerGlobal">;
9
+ type TelemetryDevEveOptions = Omit<TelemetryOptions, "registerGlobal" | "sdkName">;
9
10
  //#endregion
10
11
  //#region src/client.d.ts
11
12
  interface WrapEveClientOptions extends TelemetryDevEveOptions {
@@ -34,4 +35,16 @@ interface TelemetryDevInstrumentationOptions extends TelemetryDevEveOptions {
34
35
  }
35
36
  declare function telemetryDevInstrumentation(options?: TelemetryDevInstrumentationOptions, overrides?: ClientOverrides$1): InstrumentationDefinition;
36
37
  //#endregion
37
- export { type TelemetryDevEveOptions, type TelemetryDevInstrumentationOptions, type WrapEveClientOptions, telemetryDevHook, telemetryDevInstrumentation, wrapEveClient };
38
+ //#region src/otel.d.ts
39
+ interface TelemetryDevOtelIntegrationOptions extends Omit<TelemetrySpanProcessorOptions, "spanExporter"> {
40
+ recordInputs?: boolean;
41
+ recordOutputs?: boolean;
42
+ }
43
+ /**
44
+ * telemetry.dev as a destination in eve's `agent/instrumentation/` provider layout
45
+ * (`experimental.instrumentationProviders`). Export it from one file in that directory.
46
+ * eve owns the tracer provider there, so this attaches a processor instead of registering one.
47
+ */
48
+ declare function telemetryDevOtelIntegration(options?: TelemetryDevOtelIntegrationOptions): OtelIntegration;
49
+ //#endregion
50
+ export { type TelemetryDevEveOptions, type TelemetryDevInstrumentationOptions, type TelemetryDevOtelIntegrationOptions, type WrapEveClientOptions, telemetryDevHook, telemetryDevInstrumentation, telemetryDevOtelIntegration, wrapEveClient };
package/dist/index.mjs CHANGED
@@ -1,36 +1,49 @@
1
- import { init, log, startSpan } from "@telemetry-dev/sdk";
1
+ import { SCOPE_NAME, TelemetrySpanProcessor, init, log, startSpan } from "@telemetry-dev/sdk";
2
2
  import { MessageResponse, isCurrentTurnBoundaryEvent } from "eve/client";
3
+ import { otelIntegration } from "eve/instrumentation/otel";
3
4
  //#region src/config.ts
4
5
  let initialized = false;
6
+ const AI_SCOPES = {
7
+ eve: true,
8
+ "eve.agent": true,
9
+ gen_ai: true,
10
+ [SCOPE_NAME]: true
11
+ };
12
+ const isAiScope = (scope) => Object.hasOwn(AI_SCOPES, scope);
5
13
  /**
6
14
  * Initializes the telemetry.dev SDK exactly once per process for this integration.
7
- * registerGlobal:true lets eve's tracers (scopes "eve" and "gen_ai") resolve to
8
- * the SDK provider. spanFilter defaults to exporting everything except eve's
9
- * internal workflow-engine spans (scope "workflow"); pass spanFilter: () => true
10
- * to export those too.
15
+ * registerGlobal:true lets eve's tracers resolve to the SDK provider. spanFilter defaults to
16
+ * exporting only AI scopes (see AI_SCOPES); pass spanFilter: () => true to export every span.
17
+ * eve starts each `ai.eve.turn` under a workflow-engine span of its own trace, so the turn is
18
+ * re-rooted under the session parent and every turn of a session lands in one trace.
11
19
  */
12
20
  function ensureInit(options = {}, overrides) {
13
21
  if (initialized) return;
14
22
  initialized = true;
15
23
  init({
16
24
  ...options,
25
+ sdkName: "@telemetry-dev/eve",
17
26
  registerGlobal: true,
18
- spanFilter: options.spanFilter ?? ((span) => span.instrumentationScope.name !== "workflow")
27
+ spanFilter: options.spanFilter ?? ((span) => isAiScope(span.instrumentationScope.name)),
28
+ sessionRootOf: (name, attributes) => {
29
+ const sessionId = attributes["eve.session.id"];
30
+ return name === "ai.eve.turn" && typeof sessionId === "string" ? sessionId : void 0;
31
+ }
19
32
  }, overrides);
20
33
  }
21
34
  //#endregion
22
35
  //#region src/client.ts
23
36
  function asRecord$1(value) {
24
- if (value === null || typeof value !== "object") return void 0;
37
+ if (value === null || Array.isArray(value) || !(value instanceof Object)) return void 0;
25
38
  return value;
26
39
  }
27
40
  function stringField$1(record, key) {
28
41
  const value = record?.[key];
29
- return typeof value === "string" && value.length > 0 ? value : void 0;
42
+ return value?.constructor === String && value.length > 0 ? value : void 0;
30
43
  }
31
44
  function numberField$1(record, key) {
32
45
  const value = record?.[key];
33
- return typeof value === "number" ? value : void 0;
46
+ return value?.constructor === Number ? value : void 0;
34
47
  }
35
48
  function eventData$1(event) {
36
49
  if (!("data" in event)) return {};
@@ -51,12 +64,9 @@ function failureError(code, message) {
51
64
  if (code) error.name = code;
52
65
  return error;
53
66
  }
54
- function inputForSpan(payload) {
55
- return payload.message ?? payload.inputResponses;
56
- }
57
67
  function bindOrReturn(target, prop) {
58
- const value = Reflect.get(target, prop, target);
59
- return typeof value === "function" ? value.bind(target) : value;
68
+ const value = target[prop];
69
+ return value instanceof Function ? value.bind(target) : value;
60
70
  }
61
71
  function addLifecycleEvent(span, event) {
62
72
  const data = eventData$1(event);
@@ -84,16 +94,21 @@ function addLifecycleEvent(span, event) {
84
94
  default: return;
85
95
  }
86
96
  }
87
- async function* instrumentedStream(response, span, start, payload) {
97
+ async function* instrumentedStream(response, span, start, signal, endCancelled) {
88
98
  const usage = {};
89
99
  let sawUsage = false;
90
100
  let output;
91
101
  let finishReason;
102
+ let costUsd;
103
+ let allCostsKnown = true;
92
104
  let timeToFirstChunkMs;
93
105
  let error;
106
+ let done = false;
94
107
  let sawTerminalEvent = false;
95
108
  const finish = () => {
96
- if (!sawTerminalEvent && error === void 0) if (payload.signal?.aborted) {
109
+ if (done) return;
110
+ done = true;
111
+ if (!sawTerminalEvent && error === void 0) if (signal?.aborted) {
97
112
  error = failureError("cancelled", "cancelled");
98
113
  finishReason ??= "cancelled";
99
114
  } else {
@@ -103,14 +118,25 @@ async function* instrumentedStream(response, span, start, payload) {
103
118
  span.end({
104
119
  usage: sawUsage ? usage : void 0,
105
120
  finishReason,
121
+ costUsd: sawTerminalEvent && error === void 0 && allCostsKnown && Number.isFinite(costUsd) ? costUsd : void 0,
106
122
  output,
107
123
  timeToFirstChunkMs,
108
- error
124
+ error: error instanceof Error ? error : error === void 0 ? void 0 : /* @__PURE__ */ new Error("error")
109
125
  });
110
126
  };
111
127
  try {
112
128
  for await (const event of response) {
113
129
  const data = eventData$1(event);
130
+ if (event.type === "turn.started") {
131
+ const turnTrace = asRecord$1(data.trace);
132
+ const traceId = stringField$1(turnTrace, "traceId");
133
+ const spanId = stringField$1(turnTrace, "spanId");
134
+ const turnId = stringField$1(data, "turnId");
135
+ if (traceId && spanId && turnId) {
136
+ span.span.setAttribute("td.eve.turn_root", `${traceId}/${spanId}`);
137
+ span.span.setAttribute("eve.turn.id", turnId);
138
+ }
139
+ }
114
140
  if (isCurrentTurnBoundaryEvent(event)) sawTerminalEvent = true;
115
141
  if (timeToFirstChunkMs === void 0 && (event.type === "message.appended" || event.type === "reasoning.appended")) timeToFirstChunkMs = performance.now() - start;
116
142
  if (event.type === "step.completed") {
@@ -119,6 +145,9 @@ async function* instrumentedStream(response, span, start, payload) {
119
145
  sawUsage = addUsage(usage, "outputTokens", numberField$1(eventUsage, "outputTokens")) || sawUsage;
120
146
  sawUsage = addUsage(usage, "cacheReadInputTokens", numberField$1(eventUsage, "cacheReadTokens")) || sawUsage;
121
147
  sawUsage = addUsage(usage, "cacheCreationInputTokens", numberField$1(eventUsage, "cacheWriteTokens")) || sawUsage;
148
+ const stepCost = numberField$1(eventUsage, "costUsd");
149
+ if (stepCost === void 0 || !Number.isFinite(stepCost) || stepCost < 0) allCostsKnown = false;
150
+ else costUsd = (costUsd ?? 0) + stepCost;
122
151
  finishReason = stringField$1(data, "finishReason") ?? finishReason;
123
152
  }
124
153
  if (event.type === "message.completed" && stringField$1(data, "finishReason") !== "tool-calls") output = data.message;
@@ -131,49 +160,104 @@ async function* instrumentedStream(response, span, start, payload) {
131
160
  }
132
161
  finishReason = "error";
133
162
  }
163
+ if (event.type === "turn.cancelled") {
164
+ error = failureError("cancelled", "cancelled");
165
+ finishReason = "cancelled";
166
+ }
134
167
  addLifecycleEvent(span, event);
135
168
  yield event;
136
169
  }
137
170
  } catch (caught) {
138
- if (payload.signal?.aborted) {
171
+ if (signal?.aborted) {
139
172
  error = failureError("cancelled", "cancelled");
140
173
  finishReason ??= "cancelled";
141
- } else error = caught;
174
+ } else error = caught instanceof Error ? caught : new Error(String(caught));
142
175
  throw caught;
143
176
  } finally {
144
177
  finish();
178
+ if (endCancelled) signal?.removeEventListener("abort", endCancelled);
179
+ }
180
+ }
181
+ async function tracedTurn(options, input, turnOptions, knownSessionId, send) {
182
+ const startTime = /* @__PURE__ */ new Date();
183
+ const start = performance.now();
184
+ const signal = turnOptions?.signal;
185
+ const spanFor = (sessionId) => startSpan(options.spanName ?? "invoke_agent", {
186
+ type: "agent",
187
+ agentName: options.agentName,
188
+ input,
189
+ startTime,
190
+ attributes: sessionId === void 0 ? void 0 : { "gen_ai.conversation.id": sessionId }
191
+ });
192
+ let response;
193
+ try {
194
+ response = await send();
195
+ } catch (error) {
196
+ const span = spanFor(knownSessionId);
197
+ if (signal?.aborted) span.end({
198
+ error: failureError("cancelled", "cancelled"),
199
+ finishReason: "cancelled"
200
+ });
201
+ else span.end({ error: error instanceof Error ? error : new Error(String(error)) });
202
+ throw error;
145
203
  }
204
+ const span = spanFor(response.sessionId);
205
+ let streamStarted = false;
206
+ let endedBeforeStream = false;
207
+ const endCancelled = () => {
208
+ if (streamStarted) return;
209
+ endedBeforeStream = true;
210
+ span.end({
211
+ error: failureError("cancelled", "cancelled"),
212
+ finishReason: "cancelled"
213
+ });
214
+ };
215
+ if (signal?.aborted) endCancelled();
216
+ else signal?.addEventListener("abort", endCancelled, { once: true });
217
+ return new MessageResponse({
218
+ cancelTurn: () => response.cancel(),
219
+ createStream: () => {
220
+ streamStarted = true;
221
+ if (endedBeforeStream) return (async function* () {
222
+ yield* response;
223
+ })();
224
+ return instrumentedStream(response, span, start, signal, endCancelled);
225
+ },
226
+ sessionId: response.sessionId
227
+ });
146
228
  }
147
229
  function wrapSession(session, options) {
148
230
  return new Proxy(session, { get(target, prop) {
149
231
  if (prop === "send") {
150
- const wrappedSend = async (input) => {
151
- const payload = typeof input === "string" ? { message: input } : input;
152
- const span = startSpan(options.spanName ?? "invoke_agent", {
153
- type: "agent",
154
- agentName: options.agentName,
155
- input: inputForSpan(payload)
156
- });
157
- const start = performance.now();
158
- let response;
159
- try {
160
- response = await target.send(input);
161
- } catch (error) {
162
- if (payload.signal?.aborted) span.end({
163
- error: failureError("cancelled", "cancelled"),
164
- finishReason: "cancelled"
165
- });
166
- else span.end({ error });
167
- throw error;
168
- }
169
- span.span.setAttribute("gen_ai.conversation.id", response.sessionId);
170
- return new MessageResponse({
171
- continuationToken: response.continuationToken,
172
- sessionId: response.sessionId,
173
- createStream: () => instrumentedStream(response, span, start, payload)
174
- });
232
+ const send = (message, turnOptions) => tracedTurn(options, message, turnOptions, target.state.sessionId, () => target.send(message, turnOptions));
233
+ return send;
234
+ }
235
+ if (prop === "respond") {
236
+ const respond = (inputResponses, turnOptions) => tracedTurn(options, inputResponses, turnOptions, target.state.sessionId, () => target.respond(inputResponses, turnOptions));
237
+ return respond;
238
+ }
239
+ return bindOrReturn(target, prop);
240
+ } });
241
+ }
242
+ function wrapSessions(sessions, options) {
243
+ return new Proxy(sessions, { get(target, prop) {
244
+ if (prop === "create") {
245
+ const create = async (input) => {
246
+ let session;
247
+ return {
248
+ response: await tracedTurn(options, input.message, input, void 0, async () => {
249
+ const created = await target.create(input);
250
+ session = created.session;
251
+ return created.response;
252
+ }),
253
+ session: wrapSession(session, options)
254
+ };
175
255
  };
176
- return wrappedSend;
256
+ return create;
257
+ }
258
+ if (prop === "attach") {
259
+ const attach = (sessionId, attachOptions) => wrapSession(target.attach(sessionId, attachOptions), options);
260
+ return attach;
177
261
  }
178
262
  return bindOrReturn(target, prop);
179
263
  } });
@@ -181,15 +265,16 @@ function wrapSession(session, options) {
181
265
  function wrapEveClient(client, options = {}, overrides) {
182
266
  const { agentName: _agentName, spanName: _spanName, ...sdkOptions } = options;
183
267
  ensureInit(sdkOptions, overrides);
268
+ const sessions = wrapSessions(client.sessions, options);
184
269
  return new Proxy(client, { get(target, prop) {
185
- if (prop === "session") return (state) => wrapSession(target.session(state), options);
270
+ if (prop === "sessions") return sessions;
186
271
  return bindOrReturn(target, prop);
187
272
  } });
188
273
  }
189
274
  //#endregion
190
275
  //#region src/hook.ts
191
276
  function asRecord(value) {
192
- if (value === null || typeof value !== "object") return void 0;
277
+ if (value === null || Array.isArray(value) || !(value instanceof Object)) return void 0;
193
278
  return value;
194
279
  }
195
280
  function eventData(event) {
@@ -198,11 +283,11 @@ function eventData(event) {
198
283
  }
199
284
  function stringField(record, key) {
200
285
  const value = record?.[key];
201
- return typeof value === "string" && value.length > 0 ? value : void 0;
286
+ return value?.constructor === String && value.length > 0 ? value : void 0;
202
287
  }
203
288
  function numberField(record, key) {
204
289
  const value = record?.[key];
205
- return typeof value === "number" ? value : void 0;
290
+ return value?.constructor === Number ? value : void 0;
206
291
  }
207
292
  function jsonField(record, key) {
208
293
  const value = record?.[key];
@@ -239,7 +324,7 @@ function reportError$1(onError, error) {
239
324
  } catch {}
240
325
  }
241
326
  function emit(event, ctx, level, message, attributes = {}, baseEvent = event) {
242
- const at = event.meta?.at;
327
+ const at = event.meta.at;
243
328
  log(message, {
244
329
  level,
245
330
  eventName: event.type,
@@ -263,10 +348,11 @@ function logEvent(event, ctx) {
263
348
  case "session.started": {
264
349
  const runtime = asRecord(data.runtime);
265
350
  const invocation = asRecord(data.invocation);
351
+ const trace = asRecord(data.trace);
266
352
  emit(event, ctx, "info", "Session started", {
267
353
  "eve.version": stringField(runtime, "eveVersion"),
268
- "gen_ai.request.model": stringField(runtime, "modelId"),
269
354
  "eve.agent.id": stringField(runtime, "agentId"),
355
+ "eve.trace.id": stringField(trace, "traceId"),
270
356
  "eve.parent.session_id": stringField(invocation, "parentSessionId"),
271
357
  "eve.parent.call_id": stringField(invocation, "parentCallId"),
272
358
  "eve.parent.turn_id": stringField(invocation, "parentTurnId"),
@@ -280,6 +366,9 @@ function logEvent(event, ctx) {
280
366
  case "message.received":
281
367
  emit(event, ctx, "debug", "User message received");
282
368
  return;
369
+ case "step.started":
370
+ emit(event, ctx, "debug", "Step started", { "gen_ai.request.model": stringField(data, "modelId") });
371
+ return;
283
372
  case "step.completed": {
284
373
  const usage = asRecord(data.usage);
285
374
  emit(event, ctx, "info", stepCompletedMessage(data), {
@@ -335,27 +424,15 @@ function logEvent(event, ctx) {
335
424
  "eve.subagent.name": stringField(data, "subagentName"),
336
425
  "gen_ai.tool.call.id": stringField(data, "callId")
337
426
  };
338
- if (childType === "step.failed") {
339
- emit(event, ctx, "error", `Subagent step failed: ${stringField(childData, "message") ?? ""}`, {
340
- ...attrs,
341
- "error.code": stringField(childData, "code"),
342
- "eve.error.details": jsonField(childData, "details")
343
- }, child);
344
- return;
345
- }
346
- if (childType === "turn.failed") {
347
- emit(event, ctx, "error", `Subagent turn failed: ${stringField(childData, "message") ?? ""}`, {
348
- ...attrs,
349
- "error.code": stringField(childData, "code"),
350
- "eve.error.details": jsonField(childData, "details")
351
- }, child);
352
- return;
353
- }
354
- if (childType === "session.failed") emit(event, ctx, "error", `Subagent session failed: ${stringField(childData, "message") ?? ""}`, {
427
+ if (childType === "step.failed" || childType === "turn.failed" || childType === "session.failed") emit(event, ctx, "error", `Subagent ${childType.replace(".", " ")}: ${stringField(childData, "message") ?? ""}`, {
355
428
  ...attrs,
356
429
  "error.code": stringField(childData, "code"),
357
430
  "eve.error.details": jsonField(childData, "details")
358
- }, child);
431
+ }, {
432
+ ...child,
433
+ data: childData,
434
+ type: childType
435
+ });
359
436
  return;
360
437
  }
361
438
  case "subagent.called": {
@@ -388,6 +465,9 @@ function logEvent(event, ctx) {
388
465
  case "turn.completed":
389
466
  emit(event, ctx, "info", "Turn completed");
390
467
  return;
468
+ case "turn.cancelled":
469
+ emit(event, ctx, "warn", "Turn cancelled");
470
+ return;
391
471
  case "turn.failed":
392
472
  emit(event, ctx, "error", `Turn failed: ${stringField(data, "message") ?? ""}`, {
393
473
  "error.code": stringField(data, "code"),
@@ -415,13 +495,13 @@ function telemetryDevHook(options = {}, overrides) {
415
495
  ensureInit(options, overrides);
416
496
  logEvent(event, ctx);
417
497
  } catch (error) {
418
- reportError$1(options.onError, error);
498
+ reportError$1(options.onError, error instanceof Error ? error : new Error(String(error)));
419
499
  }
420
500
  } } };
421
501
  }
422
502
  //#endregion
423
503
  //#region src/instrumentation.ts
424
- const envServiceName = () => typeof process !== "undefined" ? process.env.OTEL_SERVICE_NAME : void 0;
504
+ const envServiceName = () => globalThis.process !== void 0 ? process.env.OTEL_SERVICE_NAME : void 0;
425
505
  function reportError(onError, error) {
426
506
  try {
427
507
  onError?.(error);
@@ -439,20 +519,23 @@ function telemetryDevInstrumentation(options = {}, overrides) {
439
519
  serviceName: sdkOptions.serviceName ?? envServiceName() ?? agentName
440
520
  }, overrides);
441
521
  } catch (error) {
442
- reportError(sdkOptions.onError, error);
522
+ reportError(sdkOptions.onError, error instanceof Error ? error : new Error(String(error)));
443
523
  }
444
524
  },
445
525
  events: { "step.started"(input) {
446
526
  const ctx = { ...runtimeContext };
447
527
  const userId = input.session.auth.initiator?.principalId ?? input.session.auth.current?.principalId;
448
- if (userId) ctx["user.id"] = userId;
528
+ const runtimeContextWithUser = userId ? {
529
+ ...ctx,
530
+ "user.id": userId
531
+ } : ctx;
449
532
  try {
450
533
  const userResult = stepStarted?.(input);
451
- if (userResult?.runtimeContext) Object.assign(ctx, userResult.runtimeContext);
534
+ if (userResult?.runtimeContext) Object.assign(runtimeContextWithUser, userResult.runtimeContext);
452
535
  } catch (error) {
453
- reportError(sdkOptions.onError, error);
536
+ reportError(sdkOptions.onError, error instanceof Error ? error : new Error(String(error)));
454
537
  }
455
- return Object.keys(ctx).length > 0 ? { runtimeContext: ctx } : void 0;
538
+ return Object.keys(runtimeContextWithUser).length > 0 ? { runtimeContext: runtimeContextWithUser } : void 0;
456
539
  } }
457
540
  };
458
541
  if (functionId !== void 0) return {
@@ -462,4 +545,22 @@ function telemetryDevInstrumentation(options = {}, overrides) {
462
545
  return definition;
463
546
  }
464
547
  //#endregion
465
- export { telemetryDevHook, telemetryDevInstrumentation, wrapEveClient };
548
+ //#region src/otel.ts
549
+ /**
550
+ * telemetry.dev as a destination in eve's `agent/instrumentation/` provider layout
551
+ * (`experimental.instrumentationProviders`). Export it from one file in that directory.
552
+ * eve owns the tracer provider there, so this attaches a processor instead of registering one.
553
+ */
554
+ function telemetryDevOtelIntegration(options = {}) {
555
+ const { recordInputs, recordOutputs, ...processorOptions } = options;
556
+ return otelIntegration({
557
+ recordInputs,
558
+ recordOutputs,
559
+ spanProcessors: [new TelemetrySpanProcessor({
560
+ ...processorOptions,
561
+ spanFilter: processorOptions.spanFilter ?? ((span) => isAiScope(span.instrumentationScope.name))
562
+ })]
563
+ });
564
+ }
565
+ //#endregion
566
+ export { telemetryDevHook, telemetryDevInstrumentation, telemetryDevOtelIntegration, wrapEveClient };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/eve",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Eve (Vercel agent framework) telemetry integration for telemetry.dev.",
5
5
  "keywords": [
6
6
  "agents",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@opentelemetry/api": "^1.9.1",
40
- "@telemetry-dev/sdk": "^0.1.0"
40
+ "@telemetry-dev/sdk": "^0.1.2"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@opentelemetry/sdk-logs": "^0.218.0",
@@ -45,14 +45,14 @@
45
45
  "@types/node": "^25.5.0",
46
46
  "@typescript/native-preview": "7.0.0-dev.20260328.1",
47
47
  "ai": "7.0.0",
48
- "eve": "0.19.0",
48
+ "eve": "0.50.0",
49
49
  "typescript": "^6.0.2",
50
50
  "vite-plus": "0.1.20",
51
51
  "vitest": "npm:@voidzero-dev/vite-plus-test@0.1.20",
52
52
  "zod": "^4.4.3"
53
53
  },
54
54
  "peerDependencies": {
55
- "eve": ">=0.19.0 <1"
55
+ "eve": ">=0.47.0 <1"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "pnpm exec vp pack",
package/src/client.ts CHANGED
@@ -8,10 +8,11 @@ import {
8
8
  import type {
9
9
  Client,
10
10
  ClientSession,
11
- HandleMessageStreamEvent,
11
+ ClientSessions,
12
+ InputResponse,
13
+ MessageStreamEvent,
12
14
  SendTurnInput,
13
- SendTurnPayload,
14
- SessionState,
15
+ SendTurnOptions,
15
16
  } from "eve/client";
16
17
  import { isCurrentTurnBoundaryEvent, MessageResponse } from "eve/client";
17
18
 
@@ -24,24 +25,33 @@ export interface WrapEveClientOptions extends TelemetryDevEveOptions {
24
25
  spanName?: string;
25
26
  }
26
27
 
27
- function asRecord(value: unknown): Record<string, unknown> | undefined {
28
- if (value === null || typeof value !== "object") return undefined;
29
- return value as Record<string, unknown>;
28
+ type EventRecord = { [key: string]: EventValue };
29
+ type EventValue =
30
+ | string
31
+ | number
32
+ | boolean
33
+ | null
34
+ | undefined
35
+ | readonly EventValue[]
36
+ | EventRecord;
37
+ function asRecord(value: EventValue): EventRecord | undefined {
38
+ if (value === null || Array.isArray(value) || !(value instanceof Object)) return undefined;
39
+ return value as EventRecord;
30
40
  }
31
41
 
32
- function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
42
+ function stringField(record: EventRecord | undefined, key: string): string | undefined {
33
43
  const value = record?.[key];
34
- return typeof value === "string" && value.length > 0 ? value : undefined;
44
+ return value?.constructor === String && value.length > 0 ? value : undefined;
35
45
  }
36
46
 
37
- function numberField(record: Record<string, unknown> | undefined, key: string): number | undefined {
47
+ function numberField(record: EventRecord | undefined, key: string): number | undefined {
38
48
  const value = record?.[key];
39
- return typeof value === "number" ? value : undefined;
49
+ return value?.constructor === Number ? value : undefined;
40
50
  }
41
51
 
42
- function eventData(event: HandleMessageStreamEvent): Record<string, unknown> {
52
+ function eventData(event: MessageStreamEvent): EventRecord {
43
53
  if (!("data" in event)) return {};
44
- return asRecord(event.data) ?? {};
54
+ return asRecord(event.data as EventValue) ?? {};
45
55
  }
46
56
 
47
57
  function eventAttributes(
@@ -66,16 +76,12 @@ function failureError(code: string | undefined, message: string | undefined): Er
66
76
  return error;
67
77
  }
68
78
 
69
- function inputForSpan<TOutput>(payload: SendTurnPayload<TOutput>): unknown {
70
- return payload.message ?? payload.inputResponses;
79
+ function bindOrReturn<T extends object>(target: T, prop: string | symbol) {
80
+ const value = target[prop as keyof T];
81
+ return value instanceof Function ? value.bind(target) : value;
71
82
  }
72
83
 
73
- function bindOrReturn<T extends object>(target: T, prop: string | symbol): unknown {
74
- const value = Reflect.get(target, prop, target);
75
- return typeof value === "function" ? value.bind(target) : value;
76
- }
77
-
78
- function addLifecycleEvent(span: SpanHandle, event: HandleMessageStreamEvent): void {
84
+ function addLifecycleEvent(span: SpanHandle, event: MessageStreamEvent): void {
79
85
  const data = eventData(event);
80
86
  switch (event.type) {
81
87
  case "subagent.called": {
@@ -116,19 +122,25 @@ async function* instrumentedStream<TOutput>(
116
122
  response: MessageResponse<TOutput>,
117
123
  span: SpanHandle,
118
124
  start: number,
119
- payload: SendTurnPayload<TOutput>,
120
- ): AsyncGenerator<HandleMessageStreamEvent> {
125
+ signal: AbortSignal | undefined,
126
+ endCancelled: (() => void) | undefined,
127
+ ): AsyncGenerator<MessageStreamEvent> {
121
128
  const usage: TokenUsage = {};
122
129
  let sawUsage = false;
123
130
  let output: unknown;
124
131
  let finishReason: string | undefined;
132
+ let costUsd: number | undefined;
133
+ let allCostsKnown = true;
125
134
  let timeToFirstChunkMs: number | undefined;
126
135
  let error: unknown;
136
+ let done = false;
127
137
  let sawTerminalEvent = false;
128
138
 
129
139
  const finish = (): void => {
140
+ if (done) return;
141
+ done = true;
130
142
  if (!sawTerminalEvent && error === undefined) {
131
- if (payload.signal?.aborted) {
143
+ if (signal?.aborted) {
132
144
  error = failureError("cancelled", "cancelled");
133
145
  finishReason ??= "cancelled";
134
146
  } else {
@@ -139,15 +151,29 @@ async function* instrumentedStream<TOutput>(
139
151
  span.end({
140
152
  usage: sawUsage ? usage : undefined,
141
153
  finishReason,
154
+ costUsd:
155
+ sawTerminalEvent && error === undefined && allCostsKnown && Number.isFinite(costUsd)
156
+ ? costUsd
157
+ : undefined,
142
158
  output,
143
159
  timeToFirstChunkMs,
144
- error,
160
+ error: error instanceof Error ? error : error === undefined ? undefined : new Error("error"),
145
161
  });
146
162
  };
147
163
 
148
164
  try {
149
165
  for await (const event of response) {
150
166
  const data = eventData(event);
167
+ if (event.type === "turn.started") {
168
+ const turnTrace = asRecord(data.trace);
169
+ const traceId = stringField(turnTrace, "traceId");
170
+ const spanId = stringField(turnTrace, "spanId");
171
+ const turnId = stringField(data, "turnId");
172
+ if (traceId && spanId && turnId) {
173
+ span.span.setAttribute("td.eve.turn_root", `${traceId}/${spanId}`);
174
+ span.span.setAttribute("eve.turn.id", turnId);
175
+ }
176
+ }
151
177
  if (isCurrentTurnBoundaryEvent(event)) sawTerminalEvent = true;
152
178
  if (
153
179
  timeToFirstChunkMs === undefined &&
@@ -171,6 +197,12 @@ async function* instrumentedStream<TOutput>(
171
197
  "cacheCreationInputTokens",
172
198
  numberField(eventUsage, "cacheWriteTokens"),
173
199
  ) || sawUsage;
200
+ const stepCost = numberField(eventUsage, "costUsd");
201
+ if (stepCost === undefined || !Number.isFinite(stepCost) || stepCost < 0) {
202
+ allCostsKnown = false;
203
+ } else {
204
+ costUsd = (costUsd ?? 0) + stepCost;
205
+ }
174
206
  finishReason = stringField(data, "finishReason") ?? finishReason;
175
207
  }
176
208
 
@@ -195,63 +227,134 @@ async function* instrumentedStream<TOutput>(
195
227
  }
196
228
  finishReason = "error";
197
229
  }
230
+ if (event.type === "turn.cancelled") {
231
+ error = failureError("cancelled", "cancelled");
232
+ finishReason = "cancelled";
233
+ }
198
234
 
199
235
  addLifecycleEvent(span, event);
200
236
  yield event;
201
237
  }
202
238
  } catch (caught) {
203
- if (payload.signal?.aborted) {
239
+ if (signal?.aborted) {
204
240
  error = failureError("cancelled", "cancelled");
205
241
  finishReason ??= "cancelled";
206
242
  } else {
207
- error = caught;
243
+ error = caught instanceof Error ? caught : new Error(String(caught));
208
244
  }
209
245
  throw caught;
210
246
  } finally {
211
247
  finish();
248
+ if (endCancelled) signal?.removeEventListener("abort", endCancelled);
212
249
  }
213
250
  }
214
251
 
252
+ async function tracedTurn<TOutput>(
253
+ options: WrapEveClientOptions,
254
+ input: SendTurnInput["message"] | readonly InputResponse[],
255
+ turnOptions: SendTurnOptions<TOutput> | undefined,
256
+ knownSessionId: string | undefined,
257
+ send: () => Promise<MessageResponse<TOutput>>,
258
+ ): Promise<MessageResponse<TOutput>> {
259
+ // The session id is only known after the POST (first turn), so the span starts once the
260
+ // request resolves with the original start time; that lets it join the session trace.
261
+ const startTime = new Date();
262
+ const start = performance.now();
263
+ const signal = turnOptions?.signal;
264
+ const spanFor = (sessionId: string | undefined) =>
265
+ startSpan(options.spanName ?? "invoke_agent", {
266
+ type: "agent",
267
+ agentName: options.agentName,
268
+ input,
269
+ startTime,
270
+ attributes: sessionId === undefined ? undefined : { "gen_ai.conversation.id": sessionId },
271
+ });
272
+ let response: MessageResponse<TOutput>;
273
+ try {
274
+ response = await send();
275
+ } catch (error) {
276
+ const span = spanFor(knownSessionId);
277
+ if (signal?.aborted) {
278
+ span.end({ error: failureError("cancelled", "cancelled"), finishReason: "cancelled" });
279
+ } else {
280
+ span.end({ error: error instanceof Error ? error : new Error(String(error)) });
281
+ }
282
+ throw error;
283
+ }
284
+ const span = spanFor(response.sessionId);
285
+ let streamStarted = false;
286
+ let endedBeforeStream = false;
287
+ const endCancelled = () => {
288
+ if (streamStarted) return;
289
+ endedBeforeStream = true;
290
+ span.end({ error: failureError("cancelled", "cancelled"), finishReason: "cancelled" });
291
+ };
292
+ if (signal?.aborted) {
293
+ endCancelled();
294
+ } else {
295
+ signal?.addEventListener("abort", endCancelled, { once: true });
296
+ }
297
+ // MessageResponse's constructor is @internal in eve; re-wrapping the stream has no
298
+ // public alternative. cancel() forwards to the original response, whose turn id
299
+ // resolves because instrumentedStream consumes it.
300
+ return new MessageResponse<TOutput>({
301
+ cancelTurn: () => response.cancel(),
302
+ createStream: () => {
303
+ streamStarted = true;
304
+ // The span already ended on abort; hand back the raw stream instead of ending it twice.
305
+ if (endedBeforeStream)
306
+ return (async function* () {
307
+ yield* response;
308
+ })();
309
+ return instrumentedStream(response, span, start, signal, endCancelled);
310
+ },
311
+ sessionId: response.sessionId,
312
+ });
313
+ }
314
+
215
315
  function wrapSession(session: ClientSession, options: WrapEveClientOptions): ClientSession {
216
316
  return new Proxy(session, {
217
317
  get(target, prop) {
218
318
  if (prop === "send") {
219
- const wrappedSend: ClientSession["send"] = async <TOutput = unknown>(
319
+ const send: ClientSession["send"] = (message, turnOptions) =>
320
+ tracedTurn(options, message, turnOptions, target.state.sessionId, () =>
321
+ target.send(message, turnOptions),
322
+ );
323
+ return send;
324
+ }
325
+ if (prop === "respond") {
326
+ const respond: ClientSession["respond"] = (inputResponses, turnOptions) =>
327
+ tracedTurn(options, inputResponses, turnOptions, target.state.sessionId, () =>
328
+ target.respond(inputResponses, turnOptions),
329
+ );
330
+ return respond;
331
+ }
332
+ return bindOrReturn(target, prop);
333
+ },
334
+ });
335
+ }
336
+
337
+ function wrapSessions(sessions: ClientSessions, options: WrapEveClientOptions): ClientSessions {
338
+ return new Proxy(sessions, {
339
+ get(target, prop) {
340
+ if (prop === "create") {
341
+ const create: ClientSessions["create"] = async <TOutput = unknown>(
220
342
  input: SendTurnInput<TOutput>,
221
343
  ) => {
222
- const payload: SendTurnPayload<TOutput> =
223
- typeof input === "string" ? { message: input } : input;
224
- const span = startSpan(options.spanName ?? "invoke_agent", {
225
- type: "agent",
226
- agentName: options.agentName,
227
- input: inputForSpan(payload),
228
- });
229
- const start = performance.now();
230
- let response: MessageResponse<TOutput>;
231
- try {
232
- response = await target.send(input);
233
- } catch (error) {
234
- if (payload.signal?.aborted) {
235
- span.end({
236
- error: failureError("cancelled", "cancelled"),
237
- finishReason: "cancelled",
238
- });
239
- } else {
240
- span.end({ error });
241
- }
242
- throw error;
243
- }
244
-
245
- span.span.setAttribute("gen_ai.conversation.id", response.sessionId);
246
- // MessageResponse's constructor is @internal in eve; re-wrapping the stream has no
247
- // public alternative. Revisit on eve upgrades.
248
- return new MessageResponse<TOutput>({
249
- continuationToken: response.continuationToken,
250
- sessionId: response.sessionId,
251
- createStream: () => instrumentedStream(response, span, start, payload),
344
+ let session: ClientSession | undefined;
345
+ const response = await tracedTurn(options, input.message, input, undefined, async () => {
346
+ const created = await target.create(input);
347
+ session = created.session;
348
+ return created.response;
252
349
  });
350
+ return { response, session: wrapSession(session!, options) };
253
351
  };
254
- return wrappedSend;
352
+ return create;
353
+ }
354
+ if (prop === "attach") {
355
+ const attach: ClientSessions["attach"] = (sessionId, attachOptions) =>
356
+ wrapSession(target.attach(sessionId, attachOptions), options);
357
+ return attach;
255
358
  }
256
359
  return bindOrReturn(target, prop);
257
360
  },
@@ -265,12 +368,10 @@ export function wrapEveClient<C extends Client>(
265
368
  ): C {
266
369
  const { agentName: _agentName, spanName: _spanName, ...sdkOptions } = options;
267
370
  ensureInit(sdkOptions, overrides);
268
-
371
+ const sessions = wrapSessions(client.sessions, options);
269
372
  return new Proxy(client, {
270
373
  get(target, prop) {
271
- if (prop === "session") {
272
- return (state?: SessionState | string) => wrapSession(target.session(state), options);
273
- }
374
+ if (prop === "sessions") return sessions;
274
375
  return bindOrReturn(target, prop);
275
376
  },
276
377
  }) as C;
package/src/config.ts CHANGED
@@ -1,18 +1,37 @@
1
- import { init, shutdown, type ClientOverrides, type TelemetryOptions } from "@telemetry-dev/sdk";
1
+ import {
2
+ init,
3
+ SCOPE_NAME,
4
+ shutdown,
5
+ type ClientOverrides,
6
+ type TelemetryOptions,
7
+ } from "@telemetry-dev/sdk";
2
8
 
3
9
  /** Options accepted by every @telemetry-dev/eve entry point. */
4
- export type TelemetryDevEveOptions = Omit<TelemetryOptions, "registerGlobal">;
10
+ export type TelemetryDevEveOptions = Omit<TelemetryOptions, "registerGlobal" | "sdkName">;
5
11
 
6
12
  export type { ClientOverrides };
7
13
 
8
14
  let initialized = false;
9
15
 
16
+ // eve's agent spans ("eve" in agent/instrumentation.ts, "eve.agent" in the provider layout), the
17
+ // AI SDK's model-call and tool spans ("gen_ai", emitted by @ai-sdk/otel), and this SDK's own
18
+ // client-wrapper spans. Everything else on the global provider (better-auth, Nitro, eve's
19
+ // "workflow" engine) is not AI telemetry.
20
+ const AI_SCOPES = {
21
+ eve: true,
22
+ "eve.agent": true,
23
+ gen_ai: true,
24
+ [SCOPE_NAME]: true,
25
+ } satisfies Record<string, true>;
26
+
27
+ export const isAiScope = (scope: string): boolean => Object.hasOwn(AI_SCOPES, scope);
28
+
10
29
  /**
11
30
  * Initializes the telemetry.dev SDK exactly once per process for this integration.
12
- * registerGlobal:true lets eve's tracers (scopes "eve" and "gen_ai") resolve to
13
- * the SDK provider. spanFilter defaults to exporting everything except eve's
14
- * internal workflow-engine spans (scope "workflow"); pass spanFilter: () => true
15
- * to export those too.
31
+ * registerGlobal:true lets eve's tracers resolve to the SDK provider. spanFilter defaults to
32
+ * exporting only AI scopes (see AI_SCOPES); pass spanFilter: () => true to export every span.
33
+ * eve starts each `ai.eve.turn` under a workflow-engine span of its own trace, so the turn is
34
+ * re-rooted under the session parent and every turn of a session lands in one trace.
16
35
  */
17
36
  export function ensureInit(
18
37
  options: TelemetryDevEveOptions = {},
@@ -23,8 +42,13 @@ export function ensureInit(
23
42
  init(
24
43
  {
25
44
  ...options,
45
+ sdkName: "@telemetry-dev/eve",
26
46
  registerGlobal: true,
27
- spanFilter: options.spanFilter ?? ((span) => span.instrumentationScope.name !== "workflow"),
47
+ spanFilter: options.spanFilter ?? ((span) => isAiScope(span.instrumentationScope.name)),
48
+ sessionRootOf: (name, attributes) => {
49
+ const sessionId = attributes["eve.session.id"];
50
+ return name === "ai.eve.turn" && typeof sessionId === "string" ? sessionId : undefined;
51
+ },
28
52
  },
29
53
  overrides,
30
54
  );
package/src/hook.ts CHANGED
@@ -1,32 +1,33 @@
1
1
  import { log, type LogLevel } from "@telemetry-dev/sdk";
2
- import type { HandleMessageStreamEvent } from "eve/client";
2
+ import type { MessageStreamEvent } from "eve/client";
3
3
  import type { HookContext, HookDefinition } from "eve/hooks";
4
4
 
5
5
  import { ensureInit, type ClientOverrides, type TelemetryDevEveOptions } from "./config.ts";
6
6
 
7
- type Attrs = Record<string, unknown>;
7
+ type Attrs = { [key: string]: Value };
8
+ type Value = string | number | boolean | null | undefined | readonly Value[] | Attrs;
8
9
 
9
- function asRecord(value: unknown): Record<string, unknown> | undefined {
10
- if (value === null || typeof value !== "object") return undefined;
11
- return value as Record<string, unknown>;
10
+ function asRecord(value: Value): Attrs | undefined {
11
+ if (value === null || Array.isArray(value) || !(value instanceof Object)) return undefined;
12
+ return value as Attrs;
12
13
  }
13
14
 
14
- function eventData(event: HandleMessageStreamEvent): Record<string, unknown> {
15
+ function eventData(event: MessageStreamEvent): Attrs {
15
16
  if (!("data" in event)) return {};
16
- return asRecord(event.data) ?? {};
17
+ return asRecord(event.data as Value) ?? {};
17
18
  }
18
19
 
19
- function stringField(record: Record<string, unknown> | undefined, key: string): string | undefined {
20
+ function stringField(record: Attrs | undefined, key: string): string | undefined {
20
21
  const value = record?.[key];
21
- return typeof value === "string" && value.length > 0 ? value : undefined;
22
+ return value?.constructor === String && value.length > 0 ? value : undefined;
22
23
  }
23
24
 
24
- function numberField(record: Record<string, unknown> | undefined, key: string): number | undefined {
25
+ function numberField(record: Attrs | undefined, key: string): number | undefined {
25
26
  const value = record?.[key];
26
- return typeof value === "number" ? value : undefined;
27
+ return value?.constructor === Number ? value : undefined;
27
28
  }
28
29
 
29
- function jsonField(record: Record<string, unknown> | undefined, key: string): string | undefined {
30
+ function jsonField(record: Attrs | undefined, key: string): string | undefined {
30
31
  const value = record?.[key];
31
32
  if (value === undefined) return undefined;
32
33
  try {
@@ -36,7 +37,7 @@ function jsonField(record: Record<string, unknown> | undefined, key: string): st
36
37
  }
37
38
  }
38
39
 
39
- function baseAttributes(event: HandleMessageStreamEvent, ctx: HookContext): Attrs {
40
+ function baseAttributes(event: MessageStreamEvent, ctx: HookContext) {
40
41
  const data = eventData(event);
41
42
  return {
42
43
  "gen_ai.conversation.id": ctx.session.id,
@@ -48,7 +49,7 @@ function baseAttributes(event: HandleMessageStreamEvent, ctx: HookContext): Attr
48
49
  };
49
50
  }
50
51
 
51
- function toolAttrs(result: unknown): Attrs {
52
+ function toolAttrs(result: Value) {
52
53
  const record = asRecord(result);
53
54
  const subagentName = stringField(record, "subagentName");
54
55
  return {
@@ -59,7 +60,7 @@ function toolAttrs(result: unknown): Attrs {
59
60
  "eve.subagent.name": subagentName,
60
61
  };
61
62
  }
62
- function reportError(onError: ((error: unknown) => void) | undefined, error: unknown): void {
63
+ function reportError(onError: ((error: Error) => void) | undefined, error: Error): void {
63
64
  try {
64
65
  onError?.(error);
65
66
  } catch {
@@ -68,14 +69,14 @@ function reportError(onError: ((error: unknown) => void) | undefined, error: unk
68
69
  }
69
70
 
70
71
  function emit(
71
- event: HandleMessageStreamEvent,
72
+ event: MessageStreamEvent,
72
73
  ctx: HookContext,
73
74
  level: LogLevel,
74
75
  message: string,
75
76
  attributes: Attrs = {},
76
- baseEvent: HandleMessageStreamEvent = event,
77
+ baseEvent: MessageStreamEvent = event,
77
78
  ): void {
78
- const at = event.meta?.at;
79
+ const at = event.meta.at;
79
80
  log(message, {
80
81
  level,
81
82
  eventName: event.type,
@@ -84,7 +85,7 @@ function emit(
84
85
  });
85
86
  }
86
87
 
87
- function stepCompletedMessage(data: Record<string, unknown>): string {
88
+ function stepCompletedMessage(data: Attrs): string {
88
89
  const finishReason = stringField(data, "finishReason") ?? "unknown";
89
90
  const usage = asRecord(data.usage);
90
91
  const inputTokens = numberField(usage, "inputTokens");
@@ -96,17 +97,18 @@ function stepCompletedMessage(data: Record<string, unknown>): string {
96
97
  return `Step completed (${finishReason})${suffix}`;
97
98
  }
98
99
 
99
- function logEvent(event: HandleMessageStreamEvent, ctx: HookContext): void {
100
+ function logEvent(event: MessageStreamEvent, ctx: HookContext): void {
100
101
  const data = eventData(event);
101
102
 
102
103
  switch (event.type) {
103
104
  case "session.started": {
104
105
  const runtime = asRecord(data.runtime);
105
106
  const invocation = asRecord(data.invocation);
107
+ const trace = asRecord(data.trace);
106
108
  emit(event, ctx, "info", "Session started", {
107
109
  "eve.version": stringField(runtime, "eveVersion"),
108
- "gen_ai.request.model": stringField(runtime, "modelId"),
109
110
  "eve.agent.id": stringField(runtime, "agentId"),
111
+ "eve.trace.id": stringField(trace, "traceId"),
110
112
  "eve.parent.session_id": stringField(invocation, "parentSessionId"),
111
113
  "eve.parent.call_id": stringField(invocation, "parentCallId"),
112
114
  "eve.parent.turn_id": stringField(invocation, "parentTurnId"),
@@ -120,6 +122,11 @@ function logEvent(event: HandleMessageStreamEvent, ctx: HookContext): void {
120
122
  case "message.received":
121
123
  emit(event, ctx, "debug", "User message received");
122
124
  return;
125
+ case "step.started":
126
+ emit(event, ctx, "debug", "Step started", {
127
+ "gen_ai.request.model": stringField(data, "modelId"),
128
+ });
129
+ return;
123
130
  case "step.completed": {
124
131
  const usage = asRecord(data.usage);
125
132
  emit(event, ctx, "info", stepCompletedMessage(data), {
@@ -186,55 +193,33 @@ function logEvent(event: HandleMessageStreamEvent, ctx: HookContext): void {
186
193
  });
187
194
  return;
188
195
  case "subagent.event": {
189
- const child = asRecord(data.event);
196
+ const child = asRecord(data.event as Value);
190
197
  const childData = asRecord(child?.data);
191
198
  const childType = stringField(child, "type");
192
199
  const attrs = {
193
200
  "eve.subagent.name": stringField(data, "subagentName"),
194
201
  "gen_ai.tool.call.id": stringField(data, "callId"),
195
202
  };
196
- if (childType === "step.failed") {
203
+ if (
204
+ childType === "step.failed" ||
205
+ childType === "turn.failed" ||
206
+ childType === "session.failed"
207
+ ) {
197
208
  emit(
198
209
  event,
199
210
  ctx,
200
211
  "error",
201
- `Subagent step failed: ${stringField(childData, "message") ?? ""}`,
212
+ `Subagent ${childType.replace(".", " ")}: ${stringField(childData, "message") ?? ""}`,
202
213
  {
203
214
  ...attrs,
204
215
  "error.code": stringField(childData, "code"),
205
216
  "eve.error.details": jsonField(childData, "details"),
206
217
  },
207
- child as unknown as HandleMessageStreamEvent,
208
- );
209
- return;
210
- }
211
- if (childType === "turn.failed") {
212
- emit(
213
- event,
214
- ctx,
215
- "error",
216
- `Subagent turn failed: ${stringField(childData, "message") ?? ""}`,
217
218
  {
218
- ...attrs,
219
- "error.code": stringField(childData, "code"),
220
- "eve.error.details": jsonField(childData, "details"),
221
- },
222
- child as unknown as HandleMessageStreamEvent,
223
- );
224
- return;
225
- }
226
- if (childType === "session.failed") {
227
- emit(
228
- event,
229
- ctx,
230
- "error",
231
- `Subagent session failed: ${stringField(childData, "message") ?? ""}`,
232
- {
233
- ...attrs,
234
- "error.code": stringField(childData, "code"),
235
- "eve.error.details": jsonField(childData, "details"),
236
- },
237
- child as unknown as HandleMessageStreamEvent,
219
+ ...child,
220
+ data: childData,
221
+ type: childType,
222
+ } as MessageStreamEvent,
238
223
  );
239
224
  }
240
225
  return;
@@ -271,6 +256,9 @@ function logEvent(event: HandleMessageStreamEvent, ctx: HookContext): void {
271
256
  case "turn.completed":
272
257
  emit(event, ctx, "info", "Turn completed");
273
258
  return;
259
+ case "turn.cancelled":
260
+ emit(event, ctx, "warn", "Turn cancelled");
261
+ return;
274
262
  case "turn.failed":
275
263
  emit(event, ctx, "error", `Turn failed: ${stringField(data, "message") ?? ""}`, {
276
264
  "error.code": stringField(data, "code"),
@@ -305,7 +293,7 @@ export function telemetryDevHook(
305
293
  ensureInit(options, overrides);
306
294
  logEvent(event, ctx);
307
295
  } catch (error) {
308
- reportError(options.onError, error);
296
+ reportError(options.onError, error instanceof Error ? error : new Error(String(error)));
309
297
  }
310
298
  },
311
299
  },
package/src/index.ts CHANGED
@@ -5,3 +5,4 @@ export {
5
5
  telemetryDevInstrumentation,
6
6
  type TelemetryDevInstrumentationOptions,
7
7
  } from "./instrumentation.ts";
8
+ export { telemetryDevOtelIntegration, type TelemetryDevOtelIntegrationOptions } from "./otel.ts";
@@ -20,8 +20,8 @@ export interface TelemetryDevInstrumentationOptions extends TelemetryDevEveOptio
20
20
  }
21
21
 
22
22
  const envServiceName = (): string | undefined =>
23
- typeof process !== "undefined" ? process.env.OTEL_SERVICE_NAME : undefined;
24
- function reportError(onError: ((error: unknown) => void) | undefined, error: unknown): void {
23
+ globalThis.process !== undefined ? process.env.OTEL_SERVICE_NAME : undefined;
24
+ function reportError(onError: ((error: Error) => void) | undefined, error: Error): void {
25
25
  try {
26
26
  onError?.(error);
27
27
  } catch {
@@ -49,27 +49,30 @@ export function telemetryDevInstrumentation(
49
49
  overrides,
50
50
  );
51
51
  } catch (error) {
52
- reportError(sdkOptions.onError, error);
52
+ reportError(sdkOptions.onError, error instanceof Error ? error : new Error(String(error)));
53
53
  }
54
54
  },
55
55
  events: {
56
56
  "step.started"(input) {
57
- const ctx: Record<string, unknown> = { ...runtimeContext };
57
+ const ctx = { ...runtimeContext } satisfies InstrumentationRuntimeContext;
58
58
  const userId =
59
59
  input.session.auth.initiator?.principalId ?? input.session.auth.current?.principalId;
60
- if (userId) ctx["user.id"] = userId;
60
+ const runtimeContextWithUser = userId ? { ...ctx, "user.id": userId } : ctx;
61
61
 
62
62
  try {
63
63
  const userResult = stepStarted?.(input);
64
64
  if (userResult?.runtimeContext) {
65
- Object.assign(ctx, userResult.runtimeContext);
65
+ Object.assign(runtimeContextWithUser, userResult.runtimeContext);
66
66
  }
67
67
  } catch (error) {
68
- reportError(sdkOptions.onError, error);
68
+ reportError(
69
+ sdkOptions.onError,
70
+ error instanceof Error ? error : new Error(String(error)),
71
+ );
69
72
  }
70
73
 
71
- return Object.keys(ctx).length > 0
72
- ? { runtimeContext: ctx as InstrumentationRuntimeContext }
74
+ return Object.keys(runtimeContextWithUser).length > 0
75
+ ? { runtimeContext: runtimeContextWithUser }
73
76
  : undefined;
74
77
  },
75
78
  },
package/src/otel.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { TelemetrySpanProcessor, type TelemetrySpanProcessorOptions } from "@telemetry-dev/sdk";
2
+ import { otelIntegration, type OtelIntegration } from "eve/instrumentation/otel";
3
+
4
+ import { isAiScope } from "./config.ts";
5
+
6
+ export interface TelemetryDevOtelIntegrationOptions extends Omit<
7
+ TelemetrySpanProcessorOptions,
8
+ "spanExporter"
9
+ > {
10
+ recordInputs?: boolean;
11
+ recordOutputs?: boolean;
12
+ }
13
+
14
+ /**
15
+ * telemetry.dev as a destination in eve's `agent/instrumentation/` provider layout
16
+ * (`experimental.instrumentationProviders`). Export it from one file in that directory.
17
+ * eve owns the tracer provider there, so this attaches a processor instead of registering one.
18
+ */
19
+ export function telemetryDevOtelIntegration(
20
+ options: TelemetryDevOtelIntegrationOptions = {},
21
+ ): OtelIntegration {
22
+ const { recordInputs, recordOutputs, ...processorOptions } = options;
23
+ return otelIntegration({
24
+ recordInputs,
25
+ recordOutputs,
26
+ spanProcessors: [
27
+ new TelemetrySpanProcessor({
28
+ ...processorOptions,
29
+ spanFilter:
30
+ processorOptions.spanFilter ?? ((span) => isAiScope(span.instrumentationScope.name)),
31
+ }),
32
+ ],
33
+ });
34
+ }