@combycode/llm-sdk 2.1.0 → 2.2.0

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.
@@ -165,6 +165,86 @@ var REDACTED = "***REDACTED***";
165
165
  var SENSITIVE_QUERY_PARAMS = /* @__PURE__ */ new Set(["key", "api_key", "access_token", "token"]);
166
166
  var SENSITIVE_HEADERS = /* @__PURE__ */ new Set(["authorization", "x-goog-api-key", "x-api-key", "api-key"]);
167
167
  var MAX_ERROR_RAW_CHARS = 512;
168
+ var OTLP_SPAN_KIND = { internal: 1, client: 3 };
169
+ var OTLP_KIND_BY_SPAN = {
170
+ llm: OTLP_SPAN_KIND.client,
171
+ http: OTLP_SPAN_KIND.client,
172
+ mcp: OTLP_SPAN_KIND.client,
173
+ media: OTLP_SPAN_KIND.client,
174
+ agent: OTLP_SPAN_KIND.internal,
175
+ tool: OTLP_SPAN_KIND.internal,
176
+ other: OTLP_SPAN_KIND.internal
177
+ };
178
+ function fnv1a32(input, seed) {
179
+ let h = seed >>> 0;
180
+ for (let i = 0; i < input.length; i++) {
181
+ h ^= input.charCodeAt(i);
182
+ h = Math.imul(h, 16777619) >>> 0;
183
+ }
184
+ return h >>> 0;
185
+ }
186
+ var isHex = (value, chars) => value.length === chars && /^[0-9a-f]+$/.test(value);
187
+ function toOtlpId(input, bytes) {
188
+ let out = "";
189
+ for (let i = 0; i < bytes / 4; i++) {
190
+ out += fnv1a32(input, 2166136261 + i * 2654435769 >>> 0).toString(16).padStart(8, "0");
191
+ }
192
+ return /^0+$/.test(out) ? `${out.slice(0, -1)}1` : out;
193
+ }
194
+ function toOtlpValue(value) {
195
+ if (typeof value === "boolean") return { boolValue: value };
196
+ if (typeof value === "number" && Number.isFinite(value)) {
197
+ return Number.isInteger(value) ? { intValue: String(value) } : { doubleValue: value };
198
+ }
199
+ if (typeof value === "string") return { stringValue: value };
200
+ if (value === null || value === void 0) return { stringValue: "" };
201
+ return { stringValue: typeof value === "object" ? JSON.stringify(value) : String(value) };
202
+ }
203
+ var SPAN_NAME_SUBJECT = {
204
+ chat: "gen_ai.request.model",
205
+ invoke_agent: "gen_ai.agent.name",
206
+ execute_tool: "gen_ai.tool.name"
207
+ };
208
+ function otlpSpanName(span) {
209
+ const op = span.attributes["gen_ai.operation.name"];
210
+ if (typeof op !== "string") return span.name;
211
+ const subject = SPAN_NAME_SUBJECT[op] ? span.attributes[SPAN_NAME_SUBJECT[op]] : void 0;
212
+ return typeof subject === "string" && subject ? `${op} ${subject}` : op;
213
+ }
214
+ function toMessageList(payload, defaultRole) {
215
+ if (payload == null) return [];
216
+ if (typeof payload === "string") {
217
+ return payload ? [{ role: defaultRole, content: payload }] : [];
218
+ }
219
+ if (Array.isArray(payload)) {
220
+ const parts2 = payload;
221
+ if (parts2.length > 0 && parts2[0] && "role" in parts2[0]) {
222
+ return parts2.map((m) => ({ role: String(m.role ?? defaultRole), content: contentToText(m.content) })).filter((m) => m.content);
223
+ }
224
+ const text2 = contentToText(parts2);
225
+ return text2 ? [{ role: defaultRole, content: text2 }] : [];
226
+ }
227
+ const text = contentToText(payload);
228
+ return text ? [{ role: defaultRole, content: text }] : [];
229
+ }
230
+ function contentToText(content) {
231
+ if (typeof content === "string") return content;
232
+ if (!Array.isArray(content)) return "";
233
+ return content.map((part) => {
234
+ const p = part;
235
+ return typeof p?.text === "string" ? p.text : "";
236
+ }).filter(Boolean).join("");
237
+ }
238
+ var EVENT_TYPE_BY_KIND = {
239
+ agent: "agent",
240
+ tool: "tool",
241
+ llm: "llm",
242
+ http: "http",
243
+ mcp: "mcp",
244
+ media: "media",
245
+ other: "other"
246
+ };
247
+ var SAMPLE_SEED = 2654435769;
168
248
  var CATEGORY = {
169
249
  // Network
170
250
  onEnqueue: "network",
@@ -233,10 +313,29 @@ function traceIdsOf(ctx) {
233
313
  const t = c?.trace ?? c?.ctx ?? c;
234
314
  return {
235
315
  sessionId: t?.sessionId,
236
- requestId: t?.requestId
316
+ requestId: t?.requestId,
317
+ /** W3C parent context, when the app is already inside a trace of its own. */
318
+ traceparent: t?.traceparent,
319
+ // `gen_ai.conversation.id` in the semantic conventions — the thread a turn
320
+ // belongs to, which is what lets a backend group turns into one conversation.
321
+ // AgentLoop sets it from the history id; a bare client call has none.
322
+ conversationId: t?.conversationId
237
323
  };
238
324
  }
239
- var traceKey = (ids) => ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
325
+ function parseTraceparent(value) {
326
+ if (!value) return null;
327
+ const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(value.trim().toLowerCase());
328
+ if (!m) return null;
329
+ const [, traceId, spanId] = m;
330
+ if (/^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
331
+ return { traceId, spanId };
332
+ }
333
+ var CONTAINER_SPANS = /* @__PURE__ */ new Set(["agent.run", "tool.call"]);
334
+ var traceKey = (ids) => {
335
+ const parent = parseTraceparent(ids.traceparent);
336
+ if (parent) return parent.traceId;
337
+ return ids.requestId ? `${ids.sessionId ?? "?"}:${ids.requestId}` : void 0;
338
+ };
240
339
  var TelemetryAdapter = class {
241
340
  events = [];
242
341
  spans = [];
@@ -257,17 +356,138 @@ var TelemetryAdapter = class {
257
356
  /** Service identity stamped on exported telemetry. */
258
357
  resource;
259
358
  seq = 0;
359
+ /** Discriminator for POINT spans (media, mcp connect/tool), whose natural keys are
360
+ * not unique — the same server reconnects, a run emits two images, two tool calls
361
+ * land in one millisecond. A duplicate span id inside a trace is invalid OTLP and
362
+ * the backend silently keeps only one. */
363
+ spanSeq = 0;
364
+ /** Per trace: the app's span from a `traceparent`, and the CONTAINER spans currently
365
+ * open on it. Together they decide what a new span hangs under — see `parentFor`.
366
+ * Both are cleared once a trace has nothing open, so a long-lived process does not
367
+ * accumulate an entry per conversation forever.
368
+ *
369
+ * A list, not a single slot: an agent nested in a tool call (C2 inside C1's tool) is a
370
+ * second run on the SAME trace, and with one slot it overwrote its own parent and then
371
+ * deleted it on close — leaving the rest of the outer run parentless. */
372
+ appParent = /* @__PURE__ */ new Map();
373
+ containers = /* @__PURE__ */ new Map();
260
374
  latSum = 0;
261
375
  open = /* @__PURE__ */ new Map();
262
376
  maxEvents;
263
377
  includeSensitiveData;
264
378
  unsub;
379
+ /** Subscribers, each with its own filter. Re-parenting is computed PER SINK: two
380
+ * consumers asking for different types each get a tree that is correct for them. */
381
+ sinks = [];
382
+ content;
383
+ sampleRate;
384
+ /** spanId → its parent and type, for EVERY span including filtered ones — walking up
385
+ * past a dropped ancestor is the whole point, so the dropped ones must still be here.
386
+ * Bounded, because a long-lived process would otherwise remember every span it ever
387
+ * saw. */
388
+ lineage = /* @__PURE__ */ new Map();
389
+ maxLineage;
390
+ msgSeq = 0;
265
391
  constructor(hooks, opts = {}) {
266
392
  this.maxEvents = opts.maxEvents ?? 2e3;
267
393
  this.includeSensitiveData = opts.includeSensitiveData ?? true;
268
394
  this.resource = opts.resource ?? { serviceName: "unknown_service" };
395
+ this.content = opts.content ?? "none";
396
+ this.sampleRate = opts.sample ?? 1;
397
+ this.maxLineage = this.maxEvents * 2;
398
+ if (opts.onTrace) this.onTrace({ types: opts.types }, opts.onTrace);
269
399
  this.unsub = hooks.onAny((name, ctx) => this.handle(name, ctx));
270
400
  }
401
+ onTrace(filterOrHandler, maybeHandler) {
402
+ const handler = typeof filterOrHandler === "function" ? filterOrHandler : maybeHandler;
403
+ if (!handler) throw new Error("onTrace requires a handler");
404
+ const filter = typeof filterOrHandler === "function" ? {} : filterOrHandler;
405
+ const sink = { types: filter.types ? new Set(filter.types) : void 0, handler };
406
+ this.sinks.push(sink);
407
+ return () => {
408
+ const at = this.sinks.indexOf(sink);
409
+ if (at !== -1) this.sinks.splice(at, 1);
410
+ };
411
+ }
412
+ /** Record a finished span and hand it to the subscribers. Every span reaches the store
413
+ * through here, so there is one place where an event can be missed rather than five. */
414
+ recordSpan(span) {
415
+ this.spans.push(span);
416
+ const type = EVENT_TYPE_BY_KIND[span.kind];
417
+ if (!this.lineage.has(span.spanId)) this.remember(span.spanId, span.parentSpanId, type);
418
+ this.dispatch({
419
+ type,
420
+ traceId: span.traceId,
421
+ spanId: span.spanId,
422
+ parentSpanId: span.parentSpanId,
423
+ name: otlpSpanName(span),
424
+ startTime: span.startTime,
425
+ endTime: span.endTime,
426
+ durationMs: span.durationMs,
427
+ status: span.status,
428
+ attributes: span.attributes
429
+ });
430
+ }
431
+ remember(spanId, parentSpanId, type) {
432
+ this.lineage.set(spanId, { parentSpanId, type });
433
+ if (this.lineage.size > this.maxLineage) {
434
+ const oldest = this.lineage.keys().next().value;
435
+ if (oldest !== void 0) this.lineage.delete(oldest);
436
+ }
437
+ }
438
+ dispatch(event) {
439
+ if (this.sinks.length === 0) return;
440
+ if (!this.isSampled(event.traceId)) return;
441
+ for (const sink of this.sinks) {
442
+ if (sink.types && !sink.types.has(event.type)) continue;
443
+ const parentSpanId = sink.types ? this.survivingParent(event.parentSpanId, sink.types) : event.parentSpanId;
444
+ sink.handler(parentSpanId === event.parentSpanId ? event : { ...event, parentSpanId });
445
+ }
446
+ }
447
+ /** The nearest ancestor this subscriber actually receives. Without this, filtering out
448
+ * `http` would leave its children pointing at a span that never arrives, and a backend
449
+ * renders a dangling parent as a separate root. */
450
+ survivingParent(parentSpanId, types) {
451
+ let id = parentSpanId;
452
+ while (id) {
453
+ const node = this.lineage.get(id);
454
+ if (!node) return void 0;
455
+ if (types.has(node.type)) return id;
456
+ id = node.parentSpanId;
457
+ }
458
+ return void 0;
459
+ }
460
+ /** Hashed rather than random, so the same trace samples the same way in every process
461
+ * and a trace shared by two services is kept or dropped by both. */
462
+ isSampled(traceId) {
463
+ if (this.sampleRate >= 1) return true;
464
+ if (this.sampleRate <= 0) return false;
465
+ return fnv1a32(traceId, SAMPLE_SEED) / 4294967296 < this.sampleRate;
466
+ }
467
+ /** Conversation content, as its own event so it can be routed somewhere different from
468
+ * the spans — a debug store, not the metrics backend. */
469
+ emitMessage(span, direction, payload) {
470
+ if (this.sinks.length === 0) return;
471
+ const messages = toMessageList(payload, direction === "input" ? "user" : "assistant");
472
+ if (messages.length === 0) return;
473
+ const chars = messages.reduce((n, m) => n + m.content.length, 0);
474
+ this.dispatch({
475
+ type: "message",
476
+ traceId: span.traceId,
477
+ spanId: `${span.spanId}:msg${this.msgSeq++}`,
478
+ parentSpanId: span.spanId,
479
+ name: `message.${direction}`,
480
+ startTime: Date.now(),
481
+ status: "unset",
482
+ attributes: clean({
483
+ "message.direction": direction,
484
+ "message.count": messages.length,
485
+ "message.chars": chars,
486
+ // Opt-In in the spec, and off by default here for the same reason.
487
+ [`gen_ai.${direction}.messages`]: this.content === "full" ? messages : void 0
488
+ })
489
+ });
490
+ }
271
491
  /** Stop tapping the bus. */
272
492
  destroy() {
273
493
  this.unsub();
@@ -275,6 +495,8 @@ var TelemetryAdapter = class {
275
495
  handle(name, ctx) {
276
496
  const ids = traceIdsOf(ctx);
277
497
  const traceId = traceKey(ids);
498
+ const parent = parseTraceparent(ids.traceparent);
499
+ if (parent && traceId) this.appParent.set(traceId, parent.spanId);
278
500
  this.events.push({
279
501
  seq: this.seq++,
280
502
  time: Date.now(),
@@ -297,20 +519,27 @@ var TelemetryAdapter = class {
297
519
  this.metrics.outputTokens += usage.outputTokens ?? 0;
298
520
  }
299
521
  if (traceId) {
522
+ const responseModel = c.response?.model;
300
523
  const attrs = {
301
- "gen_ai.provider": c.provider,
302
- "gen_ai.model": c.model,
524
+ "gen_ai.provider.name": c.provider,
525
+ "gen_ai.operation.name": "chat",
526
+ "gen_ai.request.model": c.model,
527
+ // The model that actually answered, which can differ from the one asked
528
+ // for (an alias resolving to a dated snapshot, a router picking a peer).
529
+ "gen_ai.response.model": responseModel,
530
+ "gen_ai.conversation.id": ids.conversationId,
303
531
  "gen_ai.usage.input_tokens": usage?.inputTokens,
304
532
  "gen_ai.usage.output_tokens": usage?.outputTokens
305
533
  };
306
534
  const key = `llm:${traceId}`;
535
+ let llmSpan;
307
536
  if (this.open.has(key)) {
308
- this.closeSpan(key, "ok", attrs);
537
+ llmSpan = this.closeSpan(key, "ok", attrs);
309
538
  } else {
310
539
  const http = [...this.spans].reverse().find((s) => s.traceId === traceId && s.kind === "http");
311
540
  const start = http?.startTime ?? Date.now();
312
541
  const end = Date.now();
313
- this.spans.push({
542
+ llmSpan = {
314
543
  traceId,
315
544
  spanId: key,
316
545
  name: "llm.request",
@@ -320,7 +549,12 @@ var TelemetryAdapter = class {
320
549
  durationMs: end - start,
321
550
  status: "ok",
322
551
  attributes: clean(attrs)
323
- });
552
+ };
553
+ this.recordSpan(llmSpan);
554
+ }
555
+ if (llmSpan) {
556
+ const response = c.response;
557
+ this.emitMessage(llmSpan, "output", response?.content ?? response?.text);
324
558
  }
325
559
  }
326
560
  break;
@@ -366,9 +600,11 @@ var TelemetryAdapter = class {
366
600
  this.metrics.mediaGenerated += c.count ?? 1;
367
601
  if (traceId) {
368
602
  const now = Date.now();
369
- this.spans.push({
603
+ this.recordSpan({
370
604
  traceId,
371
- spanId: `media:${traceId}`,
605
+ // One run can generate several images; `media:${traceId}` would give them
606
+ // all the same span id, which is invalid within a trace.
607
+ spanId: `media:${traceId}:${this.spanSeq++}`,
372
608
  name: "media.generate",
373
609
  kind: "media",
374
610
  startTime: now,
@@ -383,10 +619,21 @@ var TelemetryAdapter = class {
383
619
  case "onRunStart": {
384
620
  const runId = c.runId;
385
621
  if (runId) {
386
- this.openSpan(`agent:${runId}`, runId, "agent.run", "agent", {
387
- "agent.id": c.agentId,
388
- "agent.model": c.model
622
+ const runSpan = this.openSpan(`agent:${runId}`, traceId ?? runId, "agent.run", "agent", {
623
+ // The host's own attributes go FIRST so ours win on a key collision: a stray
624
+ // `gen_ai.*` key in a caller's bag must not be able to rewrite the identity
625
+ // of the span.
626
+ ...c.attributes,
627
+ "gen_ai.operation.name": "invoke_agent",
628
+ // Named when the agent was given a label; the exported span is then
629
+ // `invoke_agent {label}` rather than the bare operation.
630
+ "gen_ai.agent.name": c.label,
631
+ "gen_ai.agent.id": c.agentId,
632
+ "gen_ai.request.model": c.model,
633
+ // Ours, not a convention attribute — the GenAI spec has no term for it.
634
+ "agent.source": c.source
389
635
  });
636
+ this.emitMessage(runSpan, "input", c.userMessage);
390
637
  }
391
638
  break;
392
639
  }
@@ -413,9 +660,11 @@ var TelemetryAdapter = class {
413
660
  case "onToolCallStart": {
414
661
  const callId = c.callId;
415
662
  if (callId) {
416
- this.openSpan(`tool:${callId}`, callId, "tool.call", "tool", {
417
- "tool.name": c.toolName,
418
- "agent.id": c.agentId
663
+ this.openSpan(`tool:${callId}`, traceId ?? callId, "tool.call", "tool", {
664
+ "gen_ai.operation.name": "execute_tool",
665
+ "gen_ai.tool.name": c.toolName,
666
+ "gen_ai.tool.call.id": callId,
667
+ "gen_ai.agent.id": c.agentId
419
668
  });
420
669
  }
421
670
  break;
@@ -424,7 +673,7 @@ var TelemetryAdapter = class {
424
673
  const callId = c.callId;
425
674
  if (callId) {
426
675
  this.closeSpan(`tool:${callId}`, "ok", {
427
- "tool.name": c.toolName,
676
+ "gen_ai.tool.name": c.toolName,
428
677
  "tool.latency_ms": c.latencyMs
429
678
  });
430
679
  }
@@ -434,7 +683,7 @@ var TelemetryAdapter = class {
434
683
  const callId = c.callId;
435
684
  if (callId) {
436
685
  this.closeSpan(`tool:${callId}`, "error", {
437
- "tool.name": c.toolName,
686
+ "gen_ai.tool.name": c.toolName,
438
687
  "tool.error": c.error?.message
439
688
  });
440
689
  }
@@ -445,9 +694,17 @@ var TelemetryAdapter = class {
445
694
  const server = c.server;
446
695
  if (server) {
447
696
  const now = Date.now();
448
- this.spans.push({
449
- traceId: server,
450
- spanId: `mcp:connect:${server}`,
697
+ this.recordSpan({
698
+ // A connect usually happens at startup, outside any run, so there is often
699
+ // no trace to join — but keying the trace by server name merged every
700
+ // reconnect over the process lifetime into one trace. Falls back to a span
701
+ // of its own instead.
702
+ traceId: traceId ?? `mcp:connect:${server}:${this.spanSeq}`,
703
+ // `${server}` alone repeats on every reconnect, and a duplicate span id
704
+ // within a trace is invalid OTLP — the backend keeps one and drops the
705
+ // rest. The counter is monotonic where a timestamp is not: two connects
706
+ // inside the same millisecond would still collide.
707
+ spanId: `mcp:connect:${server}:${this.spanSeq++}`,
451
708
  name: "mcp.connect",
452
709
  kind: "mcp",
453
710
  startTime: now,
@@ -469,9 +726,14 @@ var TelemetryAdapter = class {
469
726
  if (server && tool) {
470
727
  const now = Date.now();
471
728
  const lat = c.latencyMs;
472
- this.spans.push({
473
- traceId: server,
474
- spanId: `mcp:tool:${server}:${tool}:${now}`,
729
+ this.recordSpan({
730
+ // An MCP tool call happens INSIDE a run, so it belongs to that run's trace.
731
+ // Keying it by server put every call to one server in a single eternal
732
+ // trace, and none of them with the agent that made the call.
733
+ traceId: traceId ?? `mcp:${server}`,
734
+ // A timestamp is not a unique key: two tool calls in the same millisecond
735
+ // share it. The counter is.
736
+ spanId: `mcp:tool:${server}:${tool}:${this.spanSeq++}`,
475
737
  name: "mcp.tool_call",
476
738
  kind: "mcp",
477
739
  startTime: now - (lat ?? 0),
@@ -489,10 +751,33 @@ var TelemetryAdapter = class {
489
751
  }
490
752
  }
491
753
  }
754
+ /** What a new span on this trace hangs under: the innermost container still open on
755
+ * it, else the app's span, else nothing (we are the root).
756
+ *
757
+ * A container wins over the app's span because an LLM call made during a run belongs
758
+ * to that run — attaching it straight to the app would flatten the very nesting the
759
+ * tree exists to show. A span joins the stack only after it is built, so nothing can
760
+ * become its own parent, and a run nested in a tool call lands under that tool call —
761
+ * exactly where it happened.
762
+ *
763
+ * Limit worth naming: with tools running in parallel two `tool.call` spans are open at
764
+ * once and "innermost" is merely the more recent one. Attributing a nested run to the
765
+ * right sibling needs real async context propagation, which this adapter does not
766
+ * have; sequential tools, the common case, are exact. */
767
+ parentFor(traceId) {
768
+ const stack = this.containers.get(traceId);
769
+ return stack?.[stack.length - 1] ?? this.appParent.get(traceId);
770
+ }
492
771
  openSpan(key, traceId, spanName, kind, attributes) {
772
+ const parentSpanId = this.parentFor(traceId);
493
773
  const span = {
494
774
  traceId,
495
- spanId: key,
775
+ ...parentSpanId ? { parentSpanId } : {},
776
+ // The KEY pairs open with close (`llm:${traceId}`); the SPAN ID must be unique.
777
+ // Those were the same string until a run stopped fragmenting into one trace per
778
+ // call — at which point every LLM call in a run produced the identical key, and
779
+ // the collision merged them into one span at the collector.
780
+ spanId: `${key}#${this.spanSeq++}`,
496
781
  name: spanName,
497
782
  kind,
498
783
  startTime: Date.now(),
@@ -500,17 +785,33 @@ var TelemetryAdapter = class {
500
785
  attributes
501
786
  };
502
787
  this.open.set(key, span);
788
+ this.remember(span.spanId, parentSpanId, EVENT_TYPE_BY_KIND[kind]);
789
+ if (CONTAINER_SPANS.has(spanName)) {
790
+ const stack = this.containers.get(traceId);
791
+ if (stack) stack.push(span.spanId);
792
+ else this.containers.set(traceId, [span.spanId]);
793
+ }
503
794
  return span;
504
795
  }
505
796
  closeSpan(key, status, attributes) {
506
797
  const span = this.open.get(key);
507
- if (!span) return;
798
+ if (!span) return void 0;
799
+ const stack = this.containers.get(span.traceId);
800
+ if (stack) {
801
+ const at = stack.lastIndexOf(span.spanId);
802
+ if (at !== -1) stack.splice(at, 1);
803
+ if (stack.length === 0) {
804
+ this.containers.delete(span.traceId);
805
+ this.appParent.delete(span.traceId);
806
+ }
807
+ }
508
808
  span.endTime = Date.now();
509
809
  span.durationMs = span.endTime - span.startTime;
510
810
  span.status = status;
511
811
  Object.assign(span.attributes, clean(attributes));
512
812
  this.open.delete(key);
513
- this.spans.push(span);
813
+ this.recordSpan(span);
814
+ return span;
514
815
  }
515
816
  recordLatency(ms) {
516
817
  if (typeof ms !== "number") return;
@@ -560,14 +861,27 @@ var TelemetryAdapter = class {
560
861
  {
561
862
  scope: { name: "combycode.telemetry" },
562
863
  spans: this.spans.map((s) => ({
563
- traceId: s.traceId,
564
- spanId: s.spanId,
565
- name: s.name,
864
+ // An app-supplied trace id is ALREADY a real 32-hex id — hashing it
865
+ // would produce a different trace and defeat the whole point of
866
+ // accepting a parent.
867
+ traceId: isHex(s.traceId, 32) ? s.traceId : toOtlpId(s.traceId, 16),
868
+ // Scoped by trace: two conversations can each hold a span keyed
869
+ // `llm:…`, and colliding their ids would merge unrelated traces.
870
+ spanId: toOtlpId(`${s.traceId}|${s.spanId}`, 8),
871
+ // The app's own span id arrives as hex and passes through; one of ours
872
+ // is hashed exactly as it was when we emitted it, so the link matches.
873
+ ...s.parentSpanId ? {
874
+ parentSpanId: isHex(s.parentSpanId, 16) ? s.parentSpanId : toOtlpId(`${s.traceId}|${s.parentSpanId}`, 8)
875
+ } : {},
876
+ name: otlpSpanName(s),
566
877
  startTimeUnixNano: Math.round(s.startTime * 1e6),
567
878
  endTimeUnixNano: Math.round((s.endTime ?? s.startTime) * 1e6),
568
- kind: s.kind,
879
+ kind: OTLP_KIND_BY_SPAN[s.kind] ?? OTLP_SPAN_KIND.internal,
569
880
  status: { code: s.status === "error" ? 2 : s.status === "ok" ? 1 : 0 },
570
- attributes: Object.entries(s.attributes).map(([key, value]) => ({ key, value: { stringValue: String(value) } }))
881
+ attributes: Object.entries(s.attributes).map(([key, value]) => ({
882
+ key,
883
+ value: toOtlpValue(value)
884
+ }))
571
885
  }))
572
886
  }
573
887
  ]
@@ -25220,7 +25534,7 @@ function extractSystem(messages) {
25220
25534
  const rest = [];
25221
25535
  for (const m of messages) {
25222
25536
  if (m.role === "system") {
25223
- const text = typeof m.content === "string" ? m.content : contentToText(m.content);
25537
+ const text = typeof m.content === "string" ? m.content : contentToText2(m.content);
25224
25538
  if (text) systemTexts.push(text);
25225
25539
  } else {
25226
25540
  rest.push(m);
@@ -25231,7 +25545,7 @@ function extractSystem(messages) {
25231
25545
  messages: rest
25232
25546
  };
25233
25547
  }
25234
- function contentToText(content) {
25548
+ function contentToText2(content) {
25235
25549
  return content.filter((p) => p.type === "text").map((p) => p.text).join("\n");
25236
25550
  }
25237
25551
  function parseStructured(text) {
@@ -25513,7 +25827,15 @@ var LLMClient = class {
25513
25827
  signal: options.signal,
25514
25828
  provider: this.provider,
25515
25829
  model: this.model,
25516
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25830
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25831
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25832
+ // own while the LLM span they belong to had joined the caller's.
25833
+ trace: {
25834
+ sessionId: ctx.sessionId,
25835
+ requestId: ctx.requestId,
25836
+ callId: ctx.callId,
25837
+ traceparent: ctx.traceparent
25838
+ }
25517
25839
  };
25518
25840
  response = await this.fetchFn(httpReq, {
25519
25841
  queueName: this.queueName,
@@ -25641,7 +25963,15 @@ var LLMClient = class {
25641
25963
  stream: true,
25642
25964
  provider: this.provider,
25643
25965
  model: this.model,
25644
- trace: { sessionId: ctx.sessionId, requestId: ctx.requestId, callId: ctx.callId }
25966
+ // Every trace field, not a hand-picked three: `traceparent` rides with the ids,
25967
+ // and picking fields here is what left the HTTP spans rooting a trace of their
25968
+ // own while the LLM span they belong to had joined the caller's.
25969
+ trace: {
25970
+ sessionId: ctx.sessionId,
25971
+ requestId: ctx.requestId,
25972
+ callId: ctx.callId,
25973
+ traceparent: ctx.traceparent
25974
+ }
25645
25975
  };
25646
25976
  const start = performance.now();
25647
25977
  let text = "";
@@ -31548,6 +31878,12 @@ async function handleToolError(e, tc, hooks, runId, agentId, step, metrics, repo
31548
31878
  // src/agent/loop.ts
31549
31879
  var AgentLoop = class _AgentLoop {
31550
31880
  id;
31881
+ /** Human name, surfaced as `gen_ai.agent.name` — see AgentLoopConfig.label. */
31882
+ label;
31883
+ /** Which part of the host system this agent belongs to. */
31884
+ source;
31885
+ /** Extra attributes stamped on this agent's spans. */
31886
+ attributes;
31551
31887
  client;
31552
31888
  hooks;
31553
31889
  _system;
@@ -31621,6 +31957,9 @@ var AgentLoop = class _AgentLoop {
31621
31957
  this._history = new ConversationHistory();
31622
31958
  }
31623
31959
  this.id = this._history.id;
31960
+ this.label = config.label;
31961
+ this.source = config.source;
31962
+ this.attributes = config.attributes;
31624
31963
  writeAgentLoopSystem(this._history.registry, this._system, "agent-loop");
31625
31964
  writeAgentLoopContext(this._history.registry, this._context, "agent-loop");
31626
31965
  this.syncLazyProtocol();
@@ -31725,7 +32064,7 @@ var AgentLoop = class _AgentLoop {
31725
32064
  }
31726
32065
  // ─── complete (non-streaming) ───────────────────────────────────────────
31727
32066
  async complete(input, options = {}) {
31728
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32067
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31729
32068
  const steps = [];
31730
32069
  const totalUsage = emptyUsage();
31731
32070
  let totalLlmTimeMs = 0;
@@ -31777,7 +32116,23 @@ var AgentLoop = class _AgentLoop {
31777
32116
  thinking: options.thinking ?? this._thinking,
31778
32117
  cache: options.cache ?? this._cache,
31779
32118
  tools: this.toolDefinitions(options),
31780
- ctx: { ...options.ctx, conversationId: this._history.id },
32119
+ ctx: {
32120
+ // The RUN's trace, handed down to every LLM call it makes.
32121
+ //
32122
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32123
+ // while each `client.complete()` fell through to mint-if-absent and
32124
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32125
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32126
+ // measured against a real collector: a single turn with one tool call
32127
+ // produced six. Correlation is the whole point of a trace id, so this is
32128
+ // the one thing it must not get wrong.
32129
+ ...runTrace,
32130
+ conversationId: this._history.id,
32131
+ // A caller's explicit ctx wins over all of the above: an app that already
32132
+ // owns a request id or a conversation id has better information than we do,
32133
+ // and silently overwriting it is how its telemetry stops joining up.
32134
+ ...options.ctx
32135
+ },
31781
32136
  signal: options.signal ?? this._abortController?.signal
31782
32137
  });
31783
32138
  const stepLatency = performance.now() - stepStart;
@@ -31945,7 +32300,7 @@ var AgentLoop = class _AgentLoop {
31945
32300
  }
31946
32301
  // ─── stream ─────────────────────────────────────────────────────────────
31947
32302
  async *stream(input, options = {}) {
31948
- const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input);
32303
+ const { runId, startedAt, startPerf, userMessageText, runTrace } = await this.beginRun(input, options.ctx);
31949
32304
  const steps = [];
31950
32305
  const totalUsage = emptyUsage();
31951
32306
  let totalLlmTimeMs = 0;
@@ -32001,7 +32356,23 @@ var AgentLoop = class _AgentLoop {
32001
32356
  thinking: options.thinking ?? this._thinking,
32002
32357
  cache: options.cache ?? this._cache,
32003
32358
  tools: this.toolDefinitions(options),
32004
- ctx: { ...options.ctx, conversationId: this._history.id },
32359
+ ctx: {
32360
+ // The RUN's trace, handed down to every LLM call it makes.
32361
+ //
32362
+ // Without this the agent kept `runTrace` to itself: its own spans used it
32363
+ // while each `client.complete()` fell through to mint-if-absent and
32364
+ // invented a fresh `requestId`. Since the trace id is `sessionId:requestId`,
32365
+ // one conversation arrived at the backend as SEVERAL unrelated traces —
32366
+ // measured against a real collector: a single turn with one tool call
32367
+ // produced six. Correlation is the whole point of a trace id, so this is
32368
+ // the one thing it must not get wrong.
32369
+ ...runTrace,
32370
+ conversationId: this._history.id,
32371
+ // A caller's explicit ctx wins over all of the above: an app that already
32372
+ // owns a request id or a conversation id has better information than we do,
32373
+ // and silently overwriting it is how its telemetry stops joining up.
32374
+ ...options.ctx
32375
+ },
32005
32376
  signal: options.signal ?? this._abortController?.signal
32006
32377
  })) {
32007
32378
  const toYield = accumulateStreamEvent(event, state);
@@ -32213,7 +32584,7 @@ var AgentLoop = class _AgentLoop {
32213
32584
  arguments: tc.arguments,
32214
32585
  callId: tc.id,
32215
32586
  step,
32216
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32587
+ trace: { ...runTrace, callId: tc.id }
32217
32588
  });
32218
32589
  if (!decision.pass) {
32219
32590
  return this.buildDeniedResult(tc, decision.reason, reports);
@@ -32233,7 +32604,7 @@ var AgentLoop = class _AgentLoop {
32233
32604
  }
32234
32605
  }
32235
32606
  try {
32236
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32607
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32237
32608
  const result = await executeWithTimeout(lookup.tool, tc, baseCtx, this._toolTimeout);
32238
32609
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, lookup.tool, baseCtx);
32239
32610
  } catch (e) {
@@ -32291,7 +32662,7 @@ var AgentLoop = class _AgentLoop {
32291
32662
  arguments: tc.arguments,
32292
32663
  reason,
32293
32664
  step,
32294
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id }
32665
+ trace: { ...runTrace, callId: tc.id }
32295
32666
  };
32296
32667
  const pending = {
32297
32668
  callId: tc.id,
@@ -32324,7 +32695,7 @@ var AgentLoop = class _AgentLoop {
32324
32695
  return this.buildOverriddenResult(tc, decision.overrideResult, reports);
32325
32696
  }
32326
32697
  try {
32327
- const baseCtx = { step, callId: tc.id, metrics, trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId, callId: tc.id } };
32698
+ const baseCtx = { step, callId: tc.id, metrics, trace: { ...runTrace, callId: tc.id } };
32328
32699
  const result = await executeWithTimeout(tool, tc, baseCtx, this._toolTimeout);
32329
32700
  return await this.buildSuccessResult(tc, result, runId, step, metrics, reports, toolStart, runTrace, tool, baseCtx);
32330
32701
  } catch (e) {
@@ -32420,7 +32791,7 @@ var AgentLoop = class _AgentLoop {
32420
32791
  return own.length > 0 ? own : void 0;
32421
32792
  }
32422
32793
  // ─── Run helpers ────────────────────────────────────────────────────────
32423
- async beginRun(input) {
32794
+ async beginRun(input, callerCtx) {
32424
32795
  if (this._running) throw new Error("AgentLoop is already running");
32425
32796
  this._running = true;
32426
32797
  this._stopRequested = false;
@@ -32437,10 +32808,23 @@ var AgentLoop = class _AgentLoop {
32437
32808
  const startedAt = Date.now();
32438
32809
  const startPerf = performance.now();
32439
32810
  const userMessageText = typeof input === "string" ? input : Array.isArray(input) && input.length > 0 && "role" in input[0] ? contentText(input[input.length - 1].content) : contentText(input);
32440
- const runTrace = { sessionId: this.id, requestId: runId };
32811
+ const runTrace = {
32812
+ sessionId: callerCtx?.sessionId ?? this.id,
32813
+ requestId: callerCtx?.requestId ?? runId,
32814
+ // The caller's span travels WITH the ids, for the same reason they do: `agent.run`,
32815
+ // every `tool.call`, and any agent nested inside a tool reach the telemetry through
32816
+ // `runTrace` and nothing else. While this field was missing from it only the LLM
32817
+ // calls joined the app's trace — they are built from the caller's ctx directly —
32818
+ // and the run that made them sat in a second, unrelated one. Measured against a
32819
+ // live backend; the unit tests fed the hooks directly and never saw it.
32820
+ ...callerCtx?.traceparent ? { traceparent: callerCtx.traceparent } : {}
32821
+ };
32441
32822
  await this.hooks.emit("onRunStart", {
32442
32823
  runId,
32443
32824
  agentId: this.id,
32825
+ label: this.label,
32826
+ source: this.source,
32827
+ attributes: this.attributes,
32444
32828
  userMessage: input,
32445
32829
  model: this.client.model,
32446
32830
  system: this._history.system,
@@ -32495,7 +32879,7 @@ var AgentLoop = class _AgentLoop {
32495
32879
  if (g.kind !== "input") continue;
32496
32880
  const decision = await g.check({
32497
32881
  kind: "input",
32498
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32882
+ trace: { ...runTrace },
32499
32883
  step,
32500
32884
  messages,
32501
32885
  system
@@ -32523,7 +32907,7 @@ var AgentLoop = class _AgentLoop {
32523
32907
  if (g.kind !== "output") continue;
32524
32908
  const decision = await g.check({
32525
32909
  kind: "output",
32526
- trace: { sessionId: runTrace.sessionId, requestId: runTrace.requestId },
32910
+ trace: { ...runTrace },
32527
32911
  step,
32528
32912
  response
32529
32913
  });
@@ -34688,6 +35072,7 @@ function createEngine(config = {}) {
34688
35072
  const fetchStreamBound = (req, options) => network.fetchStream(req, options);
34689
35073
  const connectBound = (req) => network.connect(req);
34690
35074
  const cost = new CostCollector({ hooks, catalog });
35075
+ const telemetry = config.telemetry ? new TelemetryAdapter(hooks, config.telemetry) : null;
34691
35076
  const handle = {
34692
35077
  sessionId,
34693
35078
  hooks,
@@ -34700,9 +35085,11 @@ function createEngine(config = {}) {
34700
35085
  connect: connectBound,
34701
35086
  catalog,
34702
35087
  cost,
35088
+ telemetry,
34703
35089
  apiKeys: config.apiKeys ?? {},
34704
35090
  destroy() {
34705
35091
  cost.destroy();
35092
+ telemetry?.destroy();
34706
35093
  network.destroy();
34707
35094
  }
34708
35095
  };