@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.
@@ -69,6 +69,12 @@ export interface TraceContext {
69
69
  sessionId?: string;
70
70
  requestId?: string;
71
71
  callId?: string;
72
+ /** W3C `traceparent` of the span this work runs UNDER, when the caller runs the SDK
73
+ * inside its own trace. It rides with the ids because every span we emit needs it,
74
+ * not only the ones built straight from a caller's RequestContext — an agent run
75
+ * reaches its tool calls and its nested agents through this object, and those were
76
+ * left rooting traces of their own while the LLM spans joined the caller's. */
77
+ traceparent?: string;
72
78
  }
73
79
  /** Point-in-time numeric state of one queue (for metrics / observability). */
74
80
  export interface QueueSnapshot {
@@ -11,10 +11,29 @@
11
11
  * into OTLP-compatible JSON for a real OTel exporter to forward. */
12
12
  import type { HookBus } from '../../bus/hook-bus';
13
13
  import type { HookName } from '../../bus/hook-map';
14
+ /** Derive a conformant hex id from one of our readable ids. `bytes` is 16 for a trace
15
+ * id, 8 for a span id.
16
+ *
17
+ * Deterministic on purpose: the same logical trace maps to the same OTLP id on every
18
+ * export and in every process, so a trace stitched together from two exports — or
19
+ * from two services sharing a sessionId — still joins up in the backend. */
20
+ export declare function toOtlpId(input: string, bytes: 8 | 16): string;
21
+ /** One attribute in OTLP's AnyValue shape, keeping numbers numeric. Integers go out as
22
+ * `intValue` carrying a STRING, which is how OTLP/JSON encodes 64-bit integers; send
23
+ * them as plain strings instead and no backend can sum them. */
24
+ export declare function toOtlpValue(value: unknown): Record<string, unknown>;
14
25
  export type SpanKind = 'llm' | 'http' | 'media' | 'agent' | 'tool' | 'mcp' | 'other';
15
26
  export interface Span {
16
27
  traceId: string;
17
28
  spanId: string;
29
+ /** The span this one runs under. Without it every span is a sibling and a backend
30
+ * draws a flat list instead of a tree — so a run reads as "9 things happened", not
31
+ * "a turn, which called a tool, which asked a second model".
32
+ *
33
+ * Resolved in this order: the innermost container span still open on this trace
34
+ * (`agent.run` / `tool.call`), else the app's span from a supplied `traceparent`,
35
+ * else none — this span is the root. */
36
+ parentSpanId?: string;
18
37
  name: string;
19
38
  kind: SpanKind;
20
39
  startTime: number;
@@ -23,6 +42,34 @@ export interface Span {
23
42
  status: 'unset' | 'ok' | 'error';
24
43
  attributes: Record<string, unknown>;
25
44
  }
45
+ /** What kind of work an event describes. `message` is conversation content, which is not
46
+ * a span — it is the thing you want in a debug store and NOT in your metrics backend,
47
+ * which is exactly why it filters separately. */
48
+ export type TraceEventType = 'agent' | 'tool' | 'llm' | 'http' | 'mcp' | 'media' | 'message' | 'other';
49
+ /** One piece of work, carrying enough of the tree that a consumer can push it straight
50
+ * into their own tracer without reconstructing anything. */
51
+ export interface TraceEvent {
52
+ type: TraceEventType;
53
+ /** The app's trace when it supplied a `traceparent`, else ours. */
54
+ traceId: string;
55
+ spanId: string;
56
+ /** Already resolved past anything this subscriber filtered out — see `survivingParent`. */
57
+ parentSpanId?: string;
58
+ /** The conventional name (`chat gpt-5.4-nano`, `execute_tool search`). */
59
+ name: string;
60
+ startTime: number;
61
+ endTime?: number;
62
+ durationMs?: number;
63
+ status: 'unset' | 'ok' | 'error';
64
+ attributes: Record<string, unknown>;
65
+ }
66
+ /** Declarative on purpose, rather than a predicate: knowing the types up front lets a
67
+ * filtered-out event cost nothing, where a predicate would force us to build the payload
68
+ * just to let the caller throw it away. */
69
+ export interface TraceFilter {
70
+ types?: readonly TraceEventType[];
71
+ }
72
+ export type TraceHandler = (event: TraceEvent) => void;
26
73
  export interface TelemetryEvent {
27
74
  seq: number;
28
75
  time: number;
@@ -50,6 +97,13 @@ export interface TelemetryMetrics {
50
97
  avg: number;
51
98
  };
52
99
  }
100
+ /** Parse a W3C `traceparent`: `00-<32 hex trace>-<16 hex span>-<flags>`.
101
+ * Returns null for anything malformed or for the all-zero ids the spec forbids —
102
+ * a bad header must not silently reroute telemetry into a garbage trace. */
103
+ export declare function parseTraceparent(value: string | undefined): {
104
+ traceId: string;
105
+ spanId: string;
106
+ } | null;
53
107
  /** OpenTelemetry Resource — identifies the SERVICE producing this telemetry, so
54
108
  * a shared backend can separate streams from different apps and attribute cost
55
109
  * per service (`sum by service.name`). Stamped on every span/metric/log. */
@@ -80,6 +134,32 @@ export interface TelemetryAdapterOptions {
80
134
  * boundary (a shared collector, a vendor APM) and the message is replaced by a
81
135
  * fixed `[redacted]` string while name/code/status are kept for triage. */
82
136
  includeSensitiveData?: boolean;
137
+ /** Which event types to hand to `onTrace`. Omitted → everything.
138
+ *
139
+ * Filtering SPLICES the tree rather than punching holes in it: drop `http` and the
140
+ * spans under it re-parent to the nearest surviving ancestor. Dropping without that
141
+ * leaves orphans, and a backend draws an orphan as a second root — worse than not
142
+ * filtering at all. */
143
+ types?: readonly TraceEventType[];
144
+ /** Whether conversation content rides along on `message` events. Default `'none'`:
145
+ * prompts and completions are the debugging gold AND the PII, so sending them is a
146
+ * decision to make on purpose rather than inherit. `'full'` adds the Opt-In
147
+ * `gen_ai.input.messages` / `gen_ai.output.messages` attributes; `'none'` still
148
+ * reports the shape (counts and sizes), which is enough to spot a runaway prompt. */
149
+ content?: 'none' | 'full';
150
+ /** Fraction of TRACES to emit, 0..1. Default 1.
151
+ *
152
+ * Per trace, never per span: sampling spans independently shreds every tree it touches
153
+ * — a tool call with no run, a model call with no tool. The decision is a hash of the
154
+ * trace id, so it is stable across processes and two services sharing a trace agree
155
+ * without coordinating.
156
+ *
157
+ * This is HEAD sampling: the choice is made when the trace first appears, before we
158
+ * know whether it ends in an error. Keeping all errors needs tail sampling, which
159
+ * needs buffering; do that in your collector, which is built for it. */
160
+ sample?: number;
161
+ /** Convenience for the common case of a single sink — same as calling `onTrace`. */
162
+ onTrace?: TraceHandler;
83
163
  }
84
164
  export declare class TelemetryAdapter {
85
165
  readonly events: TelemetryEvent[];
@@ -88,15 +168,79 @@ export declare class TelemetryAdapter {
88
168
  /** Service identity stamped on exported telemetry. */
89
169
  readonly resource: TelemetryResource;
90
170
  private seq;
171
+ /** Discriminator for POINT spans (media, mcp connect/tool), whose natural keys are
172
+ * not unique — the same server reconnects, a run emits two images, two tool calls
173
+ * land in one millisecond. A duplicate span id inside a trace is invalid OTLP and
174
+ * the backend silently keeps only one. */
175
+ private spanSeq;
176
+ /** Per trace: the app's span from a `traceparent`, and the CONTAINER spans currently
177
+ * open on it. Together they decide what a new span hangs under — see `parentFor`.
178
+ * Both are cleared once a trace has nothing open, so a long-lived process does not
179
+ * accumulate an entry per conversation forever.
180
+ *
181
+ * A list, not a single slot: an agent nested in a tool call (C2 inside C1's tool) is a
182
+ * second run on the SAME trace, and with one slot it overwrote its own parent and then
183
+ * deleted it on close — leaving the rest of the outer run parentless. */
184
+ private appParent;
185
+ private containers;
91
186
  private latSum;
92
187
  private readonly open;
93
188
  private readonly maxEvents;
94
189
  private readonly includeSensitiveData;
95
190
  private readonly unsub;
191
+ /** Subscribers, each with its own filter. Re-parenting is computed PER SINK: two
192
+ * consumers asking for different types each get a tree that is correct for them. */
193
+ private readonly sinks;
194
+ private readonly content;
195
+ private readonly sampleRate;
196
+ /** spanId → its parent and type, for EVERY span including filtered ones — walking up
197
+ * past a dropped ancestor is the whole point, so the dropped ones must still be here.
198
+ * Bounded, because a long-lived process would otherwise remember every span it ever
199
+ * saw. */
200
+ private readonly lineage;
201
+ private readonly maxLineage;
202
+ private msgSeq;
96
203
  constructor(hooks: HookBus, opts?: TelemetryAdapterOptions);
204
+ /** Subscribe to the event stream. Returns an unsubscribe function.
205
+ *
206
+ * ```ts
207
+ * const stop = telemetry.onTrace({ types: ['agent', 'tool'] }, (e) => pipeline.push(e));
208
+ * ```
209
+ */
210
+ onTrace(handler: TraceHandler): () => void;
211
+ onTrace(filter: TraceFilter, handler: TraceHandler): () => void;
212
+ /** Record a finished span and hand it to the subscribers. Every span reaches the store
213
+ * through here, so there is one place where an event can be missed rather than five. */
214
+ private recordSpan;
215
+ private remember;
216
+ private dispatch;
217
+ /** The nearest ancestor this subscriber actually receives. Without this, filtering out
218
+ * `http` would leave its children pointing at a span that never arrives, and a backend
219
+ * renders a dangling parent as a separate root. */
220
+ private survivingParent;
221
+ /** Hashed rather than random, so the same trace samples the same way in every process
222
+ * and a trace shared by two services is kept or dropped by both. */
223
+ private isSampled;
224
+ /** Conversation content, as its own event so it can be routed somewhere different from
225
+ * the spans — a debug store, not the metrics backend. */
226
+ private emitMessage;
97
227
  /** Stop tapping the bus. */
98
228
  destroy(): void;
99
229
  private handle;
230
+ /** What a new span on this trace hangs under: the innermost container still open on
231
+ * it, else the app's span, else nothing (we are the root).
232
+ *
233
+ * A container wins over the app's span because an LLM call made during a run belongs
234
+ * to that run — attaching it straight to the app would flatten the very nesting the
235
+ * tree exists to show. A span joins the stack only after it is built, so nothing can
236
+ * become its own parent, and a run nested in a tool call lands under that tool call —
237
+ * exactly where it happened.
238
+ *
239
+ * Limit worth naming: with tools running in parallel two `tool.call` spans are open at
240
+ * once and "innermost" is merely the more recent one. Attributing a nested run to the
241
+ * right sibling needs real async context propagation, which this adapter does not
242
+ * have; sequential tools, the common case, are exact. */
243
+ private parentFor;
100
244
  private openSpan;
101
245
  private closeSpan;
102
246
  private recordLatency;
@@ -25,6 +25,17 @@ export interface RequestContext {
25
25
  * server/agent set it, else the LLM client mints in buildContext). Follows
26
26
  * the whole chain; the request half of the trace id. */
27
27
  requestId?: string;
28
+ /** W3C trace context of the span this work runs UNDER — `00-<32 hex trace>-<16 hex
29
+ * span>-<flags>`, exactly the `traceparent` header shape.
30
+ *
31
+ * Pass it and the SDK stops rooting its own trace: its spans join that trace and hang
32
+ * under that span. Without it the library cannot know it is inside an application's
33
+ * request, so the business chain and the model calls reach the backend as two
34
+ * unrelated traces.
35
+ *
36
+ * Sources: the inbound `traceparent` header, or an active span from an OTel SDK if
37
+ * the app already runs one. */
38
+ traceparent?: string;
28
39
  /** = `history.id`. Stable for the lifetime of the conversation. */
29
40
  conversationId?: string;
30
41
  /** Unique per `.complete()` / `.stream()` call. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@combycode/llm-sdk",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Unified, pluggable AI SDK for accessing the LLMs of every major provider (Anthropic, OpenAI, Google, xAI, OpenRouter) through one API. Cross-environment: Node, Bun, and the browser.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",