@telemetry-dev/sdk 0.1.3 → 0.1.6

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
@@ -77,7 +77,8 @@ The SDK installs the [session sampling wrapper](../otel/README.md#correlation-at
77
77
  - `observe(fn, options?)` — wrap a sync/async function: args → input, return → output, errors
78
78
  captured and rethrown; activates context for nesting.
79
79
  - `startSpan(name, options?)` — manual handle; does **not** activate context. `handle.update()`,
80
- `handle.end()`, `handle.traceparent`, `handle.span` (raw OTel span).
80
+ `handle.end()`, `handle.recordOutputChunk(timestampMs?)`, `handle.traceparent`, `handle.span`
81
+ (raw OTel span).
81
82
  - `startActiveSpan(name, options?, fn)` — callback-scoped; activates context, auto-ends.
82
83
  - `updateActiveSpan(fields)` — update the innermost active span.
83
84
  - `propagateAttributes({ userId, sessionId, metadata }, fn)` — stamps `user.id`,
@@ -89,16 +90,35 @@ The SDK installs the [session sampling wrapper](../otel/README.md#correlation-at
89
90
  context.
90
91
  - `extractW3cContext(carrier, options?)` / `injectW3cContext(context, carrier, options?)` — extract
91
92
  or inject W3C trace context. Baggage is omitted unless `includeBaggage` is true.
93
+ - `captureEnabled("input" | "output")` — report whether the active initialized client permits that
94
+ capture direction. Returns `false` when no client is initialized.
95
+ - `boundedCapture(value, options?)` — copy a JSON-compatible value within bounded work and retained
96
+ size. Defaults to 48 KiB, depth 32, and 1,000 traversal items. Returns `{ value, truncated }`;
97
+ `value` can be partial or `undefined` when a limit, cycle, getter, or skip rule prevents capture.
92
98
  - `flush()` / `shutdown()` — export pending data; required before serverless freeze/exit.
93
99
 
94
100
  Span types: `span` (default) | `generation` | `tool` | `agent` | `embedding` — mapped to
95
101
  `gen_ai.operation.name` `function` / `chat` / `execute_tool` / `invoke_agent` / `embeddings`.
96
102
  Generation fields (`model`, `provider`, `usage`, `costUsd`, sampling params, …) map to the OTel
97
- GenAI semantic conventions; cost is computed server-side from usage unless `costUsd` is set.
103
+ GenAI semantic conventions. An explicit `costUsd` takes precedence; otherwise, the server estimates
104
+ standard token cost when reported usage and matching model pricing are available. Missing pricing leaves cost
105
+ unavailable, not zero, and does not prevent tracing or token accounting.
98
106
 
99
107
  Duration and token-usage histograms (`gen_ai.client.operation.duration`,
100
108
  `gen_ai.client.token.usage`) are recorded automatically for generation/agent/embedding/tool spans.
101
109
 
110
+ Generation spans also emit `gen_ai.client.operation.time_to_first_chunk` when a valid
111
+ `timeToFirstChunkMs` is supplied. `handle.recordOutputChunk()` records each nonempty output chunk's
112
+ arrival; after the first chunk, intervals contribute to
113
+ `gen_ai.client.operation.time_per_output_chunk`. Both histograms use seconds and are emitted only
114
+ after the ended span passes `spanFilter`, including interrupted streams. Chunk timing uses bounded
115
+ histogram state and does not retain individual intervals. Pull-based timing includes consumer delay
116
+ between reads. An explicit timestamp uses the same monotonic millisecond clock for every chunk.
117
+
118
+ Provider integrations feature-detect chunk recording. With an older core SDK that lacks
119
+ `recordOutputChunk`, streams and existing tracing continue normally, but chunk-interval metrics are
120
+ unavailable. Upgrade the core SDK with provider integrations to enable the new metric.
121
+
102
122
  ## Serverless
103
123
 
104
124
  Use `exportMode: "immediate"` and either `await flush()` before returning or pass the platform
package/dist/index.d.mts CHANGED
@@ -59,6 +59,24 @@ interface TelemetryOptions {
59
59
  sdkName?: string;
60
60
  }
61
61
  //#endregion
62
+ //#region src/capture.d.ts
63
+ interface BoundedCaptureOptions {
64
+ maxBytes?: number;
65
+ maxDepth?: number;
66
+ maxItems?: number;
67
+ skip?: (key: string, value: unknown, parent: unknown, path: readonly string[]) => boolean;
68
+ }
69
+ interface BoundedCaptureResult {
70
+ value: unknown;
71
+ truncated: boolean;
72
+ }
73
+ interface BoundedCaptureDetails extends BoundedCaptureResult {
74
+ bytes: number;
75
+ items: number;
76
+ }
77
+ declare function boundedCaptureDetails(input: unknown, options?: BoundedCaptureOptions): BoundedCaptureDetails;
78
+ declare function boundedCapture(input: unknown, options?: BoundedCaptureOptions): BoundedCaptureResult;
79
+ //#endregion
62
80
  //#region src/attrs.d.ts
63
81
  type SpanType = "span" | "generation" | "tool" | "agent" | "embedding";
64
82
  interface TokenUsage {
@@ -68,6 +86,15 @@ interface TokenUsage {
68
86
  cacheReadInputTokens?: number;
69
87
  cacheCreationInputTokens?: number;
70
88
  reasoningOutputTokens?: number;
89
+ textInputTokens?: number;
90
+ textOutputTokens?: number;
91
+ textCacheReadInputTokens?: number;
92
+ imageInputTokens?: number;
93
+ imageOutputTokens?: number;
94
+ imageCacheReadInputTokens?: number;
95
+ audioInputTokens?: number;
96
+ audioOutputTokens?: number;
97
+ audioCacheReadInputTokens?: number;
71
98
  }
72
99
  interface GenerationFields {
73
100
  model?: string;
@@ -126,6 +153,7 @@ interface TelemetryClient {
126
153
  /** Flush + teardown + unregister globals; the client becomes a no-op afterwards. */
127
154
  shutdown(): Promise<void>;
128
155
  }
156
+ declare function captureEnabled(kind: "input" | "output"): boolean;
129
157
  declare function flush(): Promise<void>;
130
158
  declare function shutdown(): Promise<void>;
131
159
  declare function init(options?: TelemetryOptions, overrides?: ClientOverrides): TelemetryClient;
@@ -172,6 +200,8 @@ interface SpanHandle {
172
200
  readonly traceparent: string | null;
173
201
  readonly isRecording: boolean;
174
202
  update(fields: SpanFields): SpanHandle;
203
+ /** Records an output chunk arrival. Pull-based streams include consumer delay between pulls. */
204
+ recordOutputChunk(timestampMs?: number): void;
175
205
  end(fields?: SpanFields & {
176
206
  endTime?: Date | number;
177
207
  }): void;
@@ -205,4 +235,4 @@ interface ObserveOptions extends StartSpanOptions {
205
235
  */
206
236
  declare function observe<TThis, TArgs extends unknown[], TReturn>(fn: (this: TThis, ...args: TArgs) => TReturn, options?: ObserveOptions): (...args: TArgs) => TReturn;
207
237
  //#endregion
208
- export { type AgentFields, type BatchOptions, type ClientOverrides, type ExportMode, type GenerationFields, type LogLevel, type LogOptions, type MaskFn, type ObserveOptions, type ParentRef, type PropagatedAttributes, SCOPE_NAME, type SdkLogLevel, type SpanFields, type SpanHandle, type SpanType, type StartSpanOptions, type TelemetryClient, type TelemetryOptions, TelemetrySpanProcessor, type TelemetrySpanProcessorOptions, type TokenUsage, type ToolFields, activeContext, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
238
+ export { type AgentFields, type BatchOptions, type BoundedCaptureDetails, type BoundedCaptureOptions, type BoundedCaptureResult, type ClientOverrides, type ExportMode, type GenerationFields, type LogLevel, type LogOptions, type MaskFn, type ObserveOptions, type ParentRef, type PropagatedAttributes, SCOPE_NAME, type SdkLogLevel, type SpanFields, type SpanHandle, type SpanType, type StartSpanOptions, type TelemetryClient, type TelemetryOptions, TelemetrySpanProcessor, type TelemetrySpanProcessorOptions, type TokenUsage, type ToolFields, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { CompositePropagator, W3CBaggagePropagator, W3CTraceContextPropagator, p
3
3
  import { resourceFromAttributes } from "@opentelemetry/resources";
4
4
  import { BatchLogRecordProcessor, LoggerProvider, SimpleLogRecordProcessor } from "@opentelemetry/sdk-logs";
5
5
  import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
6
- import { AlsContextManager, BATCHED_METRIC_INTERVAL_MS, DEFAULT_BASE_URL, DEFAULT_BATCH, DORMANT_INTERVAL_MS, PROPAGATED_KEY, SCOPE_NAME, SCOPE_NAME as SCOPE_NAME$1, SCOPE_VERSION, StampingSpanProcessor, TelemetrySpanProcessor, activeContext, activeContext as activeContext$1, als, createLogExporter, createMetricExporter, createMetricsPipeline, createTraceExporter, diag, jsonAttr, omitUndefined, otlpHeaders, propagateAttributes, propagatedFromContext, reportError, resolveEnv, sessionIdOf, sessionRootTracerProvider, sessionSampler, setLogLevel, withContext, withContext as withContext$1, withSessionParent } from "@telemetry-dev/otel";
6
+ import { AlsContextManager, BATCHED_METRIC_INTERVAL_MS, DEFAULT_BASE_URL, DEFAULT_BATCH, DORMANT_INTERVAL_MS, DURATION_BUCKETS, OUTPUT_CHUNK_HISTOGRAM, PROPAGATED_KEY, SCOPE_NAME, SCOPE_NAME as SCOPE_NAME$1, SCOPE_VERSION, StampingSpanProcessor, TelemetrySpanProcessor, activeContext, activeContext as activeContext$1, als, createLogExporter, createMetricExporter, createMetricsPipeline, createTraceExporter, diag, jsonAttr, omitUndefined, otlpHeaders, propagateAttributes, propagatedFromContext, reportError, resolveEnv, sessionIdOf, sessionRootTracerProvider, sessionSampler, setLogLevel, withContext, withContext as withContext$1, withSessionParent } from "@telemetry-dev/otel";
7
7
  import { SeverityNumber } from "@opentelemetry/api-logs";
8
8
  function resolveConfig(options = {}) {
9
9
  const env = resolveEnv();
@@ -48,6 +48,10 @@ let activeClient;
48
48
  function currentClient() {
49
49
  return activeClient ?? NOOP_CLIENT;
50
50
  }
51
+ function captureEnabled(kind) {
52
+ const config = currentClient().core?.config;
53
+ return kind === "input" ? config?.captureInput ?? false : config?.captureOutput ?? false;
54
+ }
51
55
  function flush() {
52
56
  return currentClient().flush();
53
57
  }
@@ -186,6 +190,137 @@ function initInner(options, overrides) {
186
190
  //#endregion
187
191
  //#region src/capture.ts
188
192
  const TRUNCATION_MARKER = "...[truncated]";
193
+ const jsonStringByteLength = (value, limit) => {
194
+ let bytes = 2;
195
+ for (let index = 0; index < value.length && bytes <= limit; index += 1) {
196
+ const code = value.charCodeAt(index);
197
+ if (code === 34 || code === 92 || code === 8 || code === 9 || code === 10 || code === 12 || code === 13) bytes += 2;
198
+ else if (code < 32) bytes += 6;
199
+ else if (code < 128) bytes += 1;
200
+ else if (code < 2048) bytes += 2;
201
+ else if (code >= 55296 && code <= 56319) {
202
+ const next = value.charCodeAt(index + 1);
203
+ if (next >= 56320 && next <= 57343) {
204
+ bytes += 4;
205
+ index += 1;
206
+ } else bytes += 6;
207
+ } else if (code >= 56320 && code <= 57343) bytes += 6;
208
+ else bytes += 3;
209
+ }
210
+ return bytes;
211
+ };
212
+ const captureLimit = (name, value, fallback) => {
213
+ if (value === void 0) return fallback;
214
+ if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${name} must be a non-negative safe integer`);
215
+ return value;
216
+ };
217
+ function boundedCaptureDetails(input, options = {}) {
218
+ const maxBytes = captureLimit("maxBytes", options.maxBytes, 48 * 1024);
219
+ const maxDepth = captureLimit("maxDepth", options.maxDepth, 32);
220
+ const maxItems = captureLimit("maxItems", options.maxItems, 1e3);
221
+ const ancestors = /* @__PURE__ */ new WeakSet();
222
+ let bytes = 0;
223
+ let items = 0;
224
+ let truncated = false;
225
+ const visit = (value, depth, path) => {
226
+ if (items >= maxItems) {
227
+ truncated = true;
228
+ return;
229
+ }
230
+ items += 1;
231
+ if (value === null || typeof value === "boolean" || typeof value === "number" || typeof value === "string") {
232
+ const length = typeof value === "string" ? jsonStringByteLength(value, maxBytes - bytes) : JSON.stringify(value)?.length ?? 0;
233
+ if (bytes + length > maxBytes) {
234
+ truncated = true;
235
+ return;
236
+ }
237
+ bytes += length;
238
+ return value;
239
+ }
240
+ if (typeof value !== "object") return void 0;
241
+ if (depth >= maxDepth || ancestors.has(value) || bytes + 2 > maxBytes) {
242
+ truncated = true;
243
+ return;
244
+ }
245
+ ancestors.add(value);
246
+ bytes += 2;
247
+ try {
248
+ if (Array.isArray(value)) {
249
+ const result = [];
250
+ for (const item of value) {
251
+ if (items >= maxItems) {
252
+ truncated = true;
253
+ break;
254
+ }
255
+ const previousBytes = bytes;
256
+ if (result.length > 0) bytes += 1;
257
+ const converted = visit(item, depth + 1, path);
258
+ if (converted === void 0) bytes = previousBytes;
259
+ else result.push(converted);
260
+ }
261
+ return result;
262
+ }
263
+ const result = {};
264
+ let emitted = false;
265
+ for (const key in value) {
266
+ if (items >= maxItems) {
267
+ truncated = true;
268
+ break;
269
+ }
270
+ if (!Object.hasOwn(value, key)) {
271
+ items += 1;
272
+ continue;
273
+ }
274
+ const keyBytes = jsonStringByteLength(key, maxBytes - bytes) + 1 + (emitted ? 1 : 0);
275
+ if (bytes + keyBytes > maxBytes) {
276
+ items += 1;
277
+ truncated = true;
278
+ continue;
279
+ }
280
+ let item;
281
+ try {
282
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
283
+ item = descriptor?.get ? descriptor.get.call(value) : descriptor?.value;
284
+ if (options.skip?.(key, item, value, path)) {
285
+ items += 1;
286
+ continue;
287
+ }
288
+ } catch {
289
+ items += 1;
290
+ truncated = true;
291
+ continue;
292
+ }
293
+ const previousBytes = bytes;
294
+ bytes += keyBytes;
295
+ const converted = visit(item, depth + 1, [...path, key]);
296
+ if (converted === void 0) bytes = previousBytes;
297
+ else {
298
+ result[key] = converted;
299
+ emitted = true;
300
+ }
301
+ }
302
+ return result;
303
+ } catch {
304
+ truncated = true;
305
+ return;
306
+ } finally {
307
+ ancestors.delete(value);
308
+ }
309
+ };
310
+ return {
311
+ value: visit(input, 0, []),
312
+ truncated,
313
+ bytes,
314
+ items
315
+ };
316
+ }
317
+ function boundedCapture(input, options = {}) {
318
+ const { value, truncated } = boundedCaptureDetails(input, options);
319
+ return {
320
+ value,
321
+ truncated
322
+ };
323
+ }
189
324
  function truncate(value, maxLength) {
190
325
  if (value.length <= maxLength) return value;
191
326
  return value.slice(0, Math.max(maxLength - 14, 0)) + TRUNCATION_MARKER;
@@ -308,6 +443,15 @@ function fieldsToAttributes(fields, type, cfg) {
308
443
  "gen_ai.usage.cache_read.input_tokens": fields.usage?.cacheReadInputTokens,
309
444
  "gen_ai.usage.cache_creation.input_tokens": fields.usage?.cacheCreationInputTokens,
310
445
  "gen_ai.usage.reasoning.output_tokens": fields.usage?.reasoningOutputTokens,
446
+ "gen_ai.usage.text.input_tokens": fields.usage?.textInputTokens,
447
+ "gen_ai.usage.text.output_tokens": fields.usage?.textOutputTokens,
448
+ "gen_ai.usage.text.cache_read.input_tokens": fields.usage?.textCacheReadInputTokens,
449
+ "gen_ai.usage.image.input_tokens": fields.usage?.imageInputTokens,
450
+ "gen_ai.usage.image.output_tokens": fields.usage?.imageOutputTokens,
451
+ "gen_ai.usage.image.cache_read.input_tokens": fields.usage?.imageCacheReadInputTokens,
452
+ "gen_ai.usage.audio.input_tokens": fields.usage?.audioInputTokens,
453
+ "gen_ai.usage.audio.output_tokens": fields.usage?.audioOutputTokens,
454
+ "gen_ai.usage.audio.cache_read.input_tokens": fields.usage?.audioCacheReadInputTokens,
311
455
  "gen_ai.usage.cost": fields.costUsd,
312
456
  "gen_ai.response.finish_reasons": fields.finishReason ? [fields.finishReason] : void 0,
313
457
  "gen_ai.output.type": fields.outputType,
@@ -363,6 +507,7 @@ const NOOP_SPAN_HANDLE = {
363
507
  traceparent: null,
364
508
  isRecording: false,
365
509
  update: () => NOOP_SPAN_HANDLE,
510
+ recordOutputChunk: () => {},
366
511
  end: () => {}
367
512
  };
368
513
  //#endregion
@@ -456,6 +601,7 @@ function createSpanHandle(core, name, options = {}) {
456
601
  if (options.name) span.updateName(options.name);
457
602
  if (options.error !== void 0) applyError(span, options.error);
458
603
  const spanContext = span.spanContext();
604
+ let previousOutputChunkTime;
459
605
  const flags = (spanContext.traceFlags & 255).toString(16).padStart(2, "0");
460
606
  const handle = {
461
607
  span,
@@ -474,6 +620,28 @@ function createSpanHandle(core, name, options = {}) {
474
620
  }
475
621
  return handle;
476
622
  },
623
+ recordOutputChunk(timestampMs) {
624
+ const now = timestampMs ?? performance.now();
625
+ if (!span.isRecording() || !Number.isFinite(now) || previousOutputChunkTime !== void 0 && now < previousOutputChunkTime) return;
626
+ if (previousOutputChunkTime !== void 0) {
627
+ const seconds = Math.max(0, now - previousOutputChunkTime) / 1e3;
628
+ const target = span;
629
+ const histogram = target[OUTPUT_CHUNK_HISTOGRAM] ??= {
630
+ count: 0,
631
+ sum: 0,
632
+ min: seconds,
633
+ max: seconds,
634
+ bucketCounts: Array.from({ length: DURATION_BUCKETS.length + 1 }, () => 0)
635
+ };
636
+ histogram.count++;
637
+ histogram.sum += seconds;
638
+ histogram.min = Math.min(histogram.min, seconds);
639
+ histogram.max = Math.max(histogram.max, seconds);
640
+ const bucket = DURATION_BUCKETS.findIndex((boundary) => seconds <= boundary);
641
+ histogram.bucketCounts[bucket < 0 ? DURATION_BUCKETS.length : bucket]++;
642
+ }
643
+ previousOutputChunkTime = now;
644
+ },
477
645
  end(fields) {
478
646
  try {
479
647
  if (fields) applyFields(span, meta, fields);
@@ -543,7 +711,9 @@ function injectW3cContext(context, carrier, options = {}) {
543
711
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
544
712
  for (const key of Object.keys(carrier)) {
545
713
  const normalized = key.toLowerCase();
546
- if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") delete carrier[key];
714
+ if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") {
715
+ if (!Reflect.deleteProperty(carrier, key)) throw new TypeError(`Cannot delete stale W3C carrier property: ${key}`);
716
+ }
547
717
  }
548
718
  propagator.inject(context, carrier, defaultTextMapSetter);
549
719
  }
@@ -599,4 +769,4 @@ function observe(fn, options) {
599
769
  return wrapped;
600
770
  }
601
771
  //#endregion
602
- export { SCOPE_NAME, TelemetrySpanProcessor, activeContext, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
772
+ export { SCOPE_NAME, TelemetrySpanProcessor, activeContext, boundedCapture, boundedCaptureDetails, captureEnabled, extractW3cContext, flush, getTraceparent, init, injectW3cContext, log, observe, propagateAttributes, shutdown, startActiveSpan, startSpan, updateActiveSpan, withContext };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/sdk",
3
- "version": "0.1.3",
3
+ "version": "0.1.6",
4
4
  "description": "Pure TypeScript SDK for telemetry.dev: LLM traces, logs, and GenAI metrics on OpenTelemetry, for Node and edge runtimes.",
5
5
  "keywords": [
6
6
  "genai",
@@ -16,7 +16,7 @@
16
16
  "license": "MIT",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/telemetry-dev/sdks.git",
19
+ "url": "git+https://github.com/telemetry-dev/telemetry.git",
20
20
  "directory": "packages/sdk"
21
21
  },
22
22
  "files": [
@@ -42,7 +42,7 @@
42
42
  "@opentelemetry/sdk-logs": "^0.218.0",
43
43
  "@opentelemetry/sdk-metrics": "^2.7.1",
44
44
  "@opentelemetry/sdk-trace-base": "^2.7.1",
45
- "@telemetry-dev/otel": "^0.1.3"
45
+ "@telemetry-dev/otel": "^0.1.5"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@opentelemetry/api": "^1.9.1",
package/src/attrs.ts CHANGED
@@ -32,6 +32,15 @@ export interface TokenUsage {
32
32
  cacheReadInputTokens?: number;
33
33
  cacheCreationInputTokens?: number;
34
34
  reasoningOutputTokens?: number;
35
+ textInputTokens?: number;
36
+ textOutputTokens?: number;
37
+ textCacheReadInputTokens?: number;
38
+ imageInputTokens?: number;
39
+ imageOutputTokens?: number;
40
+ imageCacheReadInputTokens?: number;
41
+ audioInputTokens?: number;
42
+ audioOutputTokens?: number;
43
+ audioCacheReadInputTokens?: number;
35
44
  }
36
45
 
37
46
  export interface GenerationFields {
@@ -103,6 +112,15 @@ export function fieldsToAttributes(
103
112
  "gen_ai.usage.cache_read.input_tokens": fields.usage?.cacheReadInputTokens,
104
113
  "gen_ai.usage.cache_creation.input_tokens": fields.usage?.cacheCreationInputTokens,
105
114
  "gen_ai.usage.reasoning.output_tokens": fields.usage?.reasoningOutputTokens,
115
+ "gen_ai.usage.text.input_tokens": fields.usage?.textInputTokens,
116
+ "gen_ai.usage.text.output_tokens": fields.usage?.textOutputTokens,
117
+ "gen_ai.usage.text.cache_read.input_tokens": fields.usage?.textCacheReadInputTokens,
118
+ "gen_ai.usage.image.input_tokens": fields.usage?.imageInputTokens,
119
+ "gen_ai.usage.image.output_tokens": fields.usage?.imageOutputTokens,
120
+ "gen_ai.usage.image.cache_read.input_tokens": fields.usage?.imageCacheReadInputTokens,
121
+ "gen_ai.usage.audio.input_tokens": fields.usage?.audioInputTokens,
122
+ "gen_ai.usage.audio.output_tokens": fields.usage?.audioOutputTokens,
123
+ "gen_ai.usage.audio.cache_read.input_tokens": fields.usage?.audioCacheReadInputTokens,
106
124
  "gen_ai.usage.cost": fields.costUsd,
107
125
  "gen_ai.response.finish_reasons": fields.finishReason ? [fields.finishReason] : undefined,
108
126
  "gen_ai.output.type": fields.outputType,
@@ -126,32 +144,42 @@ export function fieldsToAttributes(
126
144
 
127
145
  if (fields.systemInstructions !== undefined && cfg.captureInput) {
128
146
  const value = prepareCaptureValue("gen_ai.system_instructions", fields.systemInstructions, cfg);
147
+
129
148
  if (value !== undefined) attrs["gen_ai.system_instructions"] = value;
130
149
  }
150
+
131
151
  if (fields.input !== undefined && cfg.captureInput) {
132
152
  const key = inputKeyFor(type);
133
153
  const value = prepareCaptureValue(key, fields.input, cfg);
154
+
134
155
  if (value !== undefined) attrs[key] = value;
135
156
  }
157
+
136
158
  if (fields.output !== undefined && cfg.captureOutput) {
137
159
  const key = outputKeyFor(type);
138
160
  const value = prepareCaptureValue(key, fields.output, cfg);
161
+
139
162
  if (value !== undefined) attrs[key] = value;
140
163
  }
164
+
141
165
  if (fields.metadata) {
142
166
  for (const [key, value] of Object.entries(fields.metadata)) {
143
167
  if (RESERVED_METADATA_KEYS.has(key)) {
144
168
  diag.debug(`metadata key "${key}" is reserved; pass it via propagateAttributes instead`);
145
169
  continue;
146
170
  }
171
+
147
172
  const attr = typeof value === "string" ? value : jsonAttr(value);
173
+
148
174
  if (attr !== undefined) {
149
175
  attrs[`td.metadata.${key}`] = truncate(attr, cfg.maxAttributeLength);
150
176
  }
151
177
  }
152
178
  }
179
+
153
180
  if (fields.attributes) {
154
181
  Object.assign(attrs, omitUndefined(fields.attributes));
155
182
  }
183
+
156
184
  return attrs;
157
185
  }
package/src/capture.ts CHANGED
@@ -11,8 +11,238 @@ export interface CaptureConfig {
11
11
  onError?: (cause: Error) => void;
12
12
  }
13
13
 
14
+ export interface BoundedCaptureOptions {
15
+ maxBytes?: number;
16
+ maxDepth?: number;
17
+ maxItems?: number;
18
+ skip?: (key: string, value: unknown, parent: unknown, path: readonly string[]) => boolean;
19
+ }
20
+
21
+ export interface BoundedCaptureResult {
22
+ value: unknown;
23
+ truncated: boolean;
24
+ }
25
+
26
+ export interface BoundedCaptureDetails extends BoundedCaptureResult {
27
+ bytes: number;
28
+ items: number;
29
+ }
30
+
31
+ interface CapturedObject {
32
+ [key: string]: CapturedValue;
33
+ }
34
+
35
+ type CapturedValue = null | boolean | number | string | CapturedValue[] | CapturedObject;
36
+
37
+ const jsonStringByteLength = (value: string, limit: number) => {
38
+ let bytes = 2;
39
+
40
+ for (let index = 0; index < value.length && bytes <= limit; index += 1) {
41
+ const code = value.charCodeAt(index);
42
+
43
+ if (
44
+ code === 0x22 ||
45
+ code === 0x5c ||
46
+ code === 0x08 ||
47
+ code === 0x09 ||
48
+ code === 0x0a ||
49
+ code === 0x0c ||
50
+ code === 0x0d
51
+ ) {
52
+ bytes += 2;
53
+ } else if (code < 0x20) {
54
+ bytes += 6;
55
+ } else if (code < 0x80) {
56
+ bytes += 1;
57
+ } else if (code < 0x800) {
58
+ bytes += 2;
59
+ } else if (code >= 0xd800 && code <= 0xdbff) {
60
+ const next = value.charCodeAt(index + 1);
61
+
62
+ if (next >= 0xdc00 && next <= 0xdfff) {
63
+ bytes += 4;
64
+ index += 1;
65
+ } else {
66
+ bytes += 6;
67
+ }
68
+ } else if (code >= 0xdc00 && code <= 0xdfff) {
69
+ bytes += 6;
70
+ } else {
71
+ bytes += 3;
72
+ }
73
+ }
74
+
75
+ return bytes;
76
+ };
77
+
78
+ const captureLimit = (name: string, value: number | undefined, fallback: number) => {
79
+ if (value === undefined) return fallback;
80
+
81
+ if (!Number.isSafeInteger(value) || value < 0) {
82
+ throw new RangeError(`${name} must be a non-negative safe integer`);
83
+ }
84
+
85
+ return value;
86
+ };
87
+
88
+ export function boundedCaptureDetails(
89
+ input: unknown,
90
+ options: BoundedCaptureOptions = {},
91
+ ): BoundedCaptureDetails {
92
+ const maxBytes = captureLimit("maxBytes", options.maxBytes, 48 * 1024);
93
+ const maxDepth = captureLimit("maxDepth", options.maxDepth, 32);
94
+ const maxItems = captureLimit("maxItems", options.maxItems, 1_000);
95
+ const ancestors = new WeakSet<object>();
96
+ let bytes = 0;
97
+ let items = 0;
98
+ let truncated = false;
99
+
100
+ const visit = (
101
+ value: unknown,
102
+ depth: number,
103
+ path: readonly string[],
104
+ ): CapturedValue | undefined => {
105
+ if (items >= maxItems) {
106
+ truncated = true;
107
+
108
+ return undefined;
109
+ }
110
+
111
+ items += 1;
112
+
113
+ if (
114
+ value === null ||
115
+ typeof value === "boolean" ||
116
+ typeof value === "number" ||
117
+ typeof value === "string"
118
+ ) {
119
+ const length =
120
+ typeof value === "string"
121
+ ? jsonStringByteLength(value, maxBytes - bytes)
122
+ : (JSON.stringify(value)?.length ?? 0);
123
+
124
+ if (bytes + length > maxBytes) {
125
+ truncated = true;
126
+
127
+ return undefined;
128
+ }
129
+
130
+ bytes += length;
131
+
132
+ return value;
133
+ }
134
+
135
+ if (typeof value !== "object") return undefined;
136
+
137
+ if (depth >= maxDepth || ancestors.has(value) || bytes + 2 > maxBytes) {
138
+ truncated = true;
139
+
140
+ return undefined;
141
+ }
142
+
143
+ ancestors.add(value);
144
+ bytes += 2;
145
+
146
+ try {
147
+ if (Array.isArray(value)) {
148
+ const result: CapturedValue[] = [];
149
+
150
+ for (const item of value) {
151
+ if (items >= maxItems) {
152
+ truncated = true;
153
+ break;
154
+ }
155
+
156
+ const previousBytes = bytes;
157
+
158
+ if (result.length > 0) bytes += 1;
159
+ const converted = visit(item, depth + 1, path);
160
+
161
+ if (converted === undefined) {
162
+ bytes = previousBytes;
163
+ } else {
164
+ result.push(converted);
165
+ }
166
+ }
167
+
168
+ return result;
169
+ }
170
+
171
+ const result: CapturedObject = {};
172
+ let emitted = false;
173
+
174
+ for (const key in value) {
175
+ if (items >= maxItems) {
176
+ truncated = true;
177
+ break;
178
+ }
179
+
180
+ if (!Object.hasOwn(value, key)) {
181
+ items += 1;
182
+ continue;
183
+ }
184
+
185
+ const keyBytes = jsonStringByteLength(key, maxBytes - bytes) + 1 + (emitted ? 1 : 0);
186
+
187
+ if (bytes + keyBytes > maxBytes) {
188
+ items += 1;
189
+ truncated = true;
190
+ continue;
191
+ }
192
+
193
+ let item: unknown;
194
+
195
+ try {
196
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
197
+ item = descriptor?.get ? descriptor.get.call(value) : descriptor?.value;
198
+
199
+ if (options.skip?.(key, item, value, path)) {
200
+ items += 1;
201
+ continue;
202
+ }
203
+ } catch {
204
+ items += 1;
205
+ truncated = true;
206
+ continue;
207
+ }
208
+
209
+ const previousBytes = bytes;
210
+ bytes += keyBytes;
211
+ const converted = visit(item, depth + 1, [...path, key]);
212
+
213
+ if (converted === undefined) {
214
+ bytes = previousBytes;
215
+ } else {
216
+ result[key] = converted;
217
+ emitted = true;
218
+ }
219
+ }
220
+
221
+ return result;
222
+ } catch {
223
+ truncated = true;
224
+
225
+ return undefined;
226
+ } finally {
227
+ ancestors.delete(value);
228
+ }
229
+ };
230
+
231
+ return { value: visit(input, 0, []), truncated, bytes, items };
232
+ }
233
+
234
+ export function boundedCapture(
235
+ input: unknown,
236
+ options: BoundedCaptureOptions = {},
237
+ ): BoundedCaptureResult {
238
+ const { value, truncated } = boundedCaptureDetails(input, options);
239
+
240
+ return { value, truncated };
241
+ }
242
+
14
243
  export function truncate(value: string, maxLength: number): string {
15
244
  if (value.length <= maxLength) return value;
245
+
16
246
  // Total stays within maxLength so the provider's attributeValueLengthLimit backstop
17
247
  // (set to the same cap) never slices the marker off.
18
248
  return value.slice(0, Math.max(maxLength - TRUNCATION_MARKER.length, 0)) + TRUNCATION_MARKER;
@@ -25,15 +255,20 @@ export function prepareCaptureValue(
25
255
  cfg: CaptureConfig,
26
256
  ): string | undefined {
27
257
  let masked = value;
258
+
28
259
  if (cfg.mask) {
29
260
  try {
30
261
  masked = cfg.mask(value, { key });
31
262
  } catch (error) {
32
263
  reportError(cfg.onError, error instanceof Error ? error : new Error(String(error)));
264
+
33
265
  return undefined;
34
266
  }
35
267
  }
268
+
36
269
  const serialized = jsonAttr(masked);
270
+
37
271
  if (serialized === undefined) return undefined;
272
+
38
273
  return truncate(serialized, cfg.maxAttributeLength);
39
274
  }
package/src/client.ts CHANGED
@@ -75,6 +75,12 @@ export function currentClient(): ClientHandle {
75
75
  return activeClient ?? NOOP_CLIENT;
76
76
  }
77
77
 
78
+ export function captureEnabled(kind: "input" | "output"): boolean {
79
+ const config = currentClient().core?.config;
80
+
81
+ return kind === "input" ? (config?.captureInput ?? false) : (config?.captureOutput ?? false);
82
+ }
83
+
78
84
  export function flush(): Promise<void> {
79
85
  return currentClient().flush();
80
86
  }
@@ -89,6 +95,7 @@ export function init(options?: TelemetryOptions, overrides?: ClientOverrides): T
89
95
  } catch (error) {
90
96
  reportError(options?.onError, error instanceof Error ? error : new Error(String(error)));
91
97
  activeClient = NOOP_CLIENT;
98
+
92
99
  return NOOP_CLIENT;
93
100
  }
94
101
  }
@@ -104,11 +111,14 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
104
111
  }
105
112
 
106
113
  const enabled = config.enabled && Boolean(config.apiKey ?? overrides?.spanExporter);
114
+
107
115
  if (!enabled) {
108
116
  if (config.enabled && !config.apiKey) {
109
117
  diag.debug("no api key (apiKey option or TELEMETRY_DEV_API_KEY); telemetry disabled");
110
118
  }
119
+
111
120
  activeClient = NOOP_CLIENT;
121
+
112
122
  return NOOP_CLIENT;
113
123
  }
114
124
 
@@ -119,6 +129,7 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
119
129
  "deployment.environment.name": config.environment,
120
130
  ...config.resourceAttributes,
121
131
  });
132
+
122
133
  const transport: Transport = { fetchImpl: config.fetchImpl, onError: config.onError };
123
134
  const headers = config.apiKey ? otlpHeaders(config.apiKey, config.sdkName) : undefined;
124
135
 
@@ -131,6 +142,7 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
131
142
  (headers
132
143
  ? createMetricExporter({ url: `${config.baseUrl}/v1/metrics`, headers }, transport)
133
144
  : undefined);
145
+
134
146
  const metrics = metricExporter
135
147
  ? createMetricsPipeline({
136
148
  resource,
@@ -161,21 +173,26 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
161
173
  spanProcessors: [processor],
162
174
  spanLimits: { attributeValueLengthLimit: config.maxAttributeLength },
163
175
  });
176
+
164
177
  const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
165
178
 
166
179
  let registeredTrace = false;
167
180
  let registeredContext = false;
168
181
  let registeredPropagation = false;
182
+
169
183
  if (config.registerGlobal) {
170
184
  registeredTrace = trace.setGlobalTracerProvider(
171
185
  config.sessionRootOf
172
186
  ? sessionRootTracerProvider(provider, config.apiKey, config.sessionRootOf, config.onError)
173
187
  : provider,
174
188
  );
189
+
175
190
  if (als) {
176
191
  registeredContext = apiContext.setGlobalContextManager(new AlsContextManager(als));
177
192
  }
193
+
178
194
  registeredPropagation = propagation.setGlobalPropagator(new W3CTraceContextPropagator());
195
+
179
196
  if (!registeredTrace) {
180
197
  diag.warn(
181
198
  "a global TracerProvider is already registered; attach the TelemetrySpanProcessor from '@telemetry-dev/otel' to your own provider instead",
@@ -188,43 +205,56 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
188
205
  (headers
189
206
  ? createLogExporter({ url: `${config.baseUrl}/v1/logs`, headers }, transport)
190
207
  : undefined);
208
+
191
209
  // Lazy: the log pipeline only exists once log() is first called.
192
210
  let logs: { logger: Logger; forceFlush(): Promise<void>; shutdown(): Promise<void> } | undefined;
211
+
193
212
  const ensureLogger = (): Logger | undefined => {
194
213
  if (!logExporter) return undefined;
214
+
195
215
  if (!logs) {
196
216
  const logProcessor =
197
217
  config.exportMode === "immediate"
198
218
  ? new SimpleLogRecordProcessor(logExporter)
199
219
  : new BatchLogRecordProcessor(logExporter);
220
+
200
221
  const loggerProvider = new LoggerProvider({
201
222
  resource,
202
223
  logRecordLimits: { attributeValueLengthLimit: config.maxAttributeLength },
203
224
  processors: [logProcessor],
204
225
  });
226
+
205
227
  logs = {
206
228
  logger: loggerProvider.getLogger(SCOPE_NAME, SCOPE_VERSION),
207
229
  forceFlush: () => loggerProvider.forceFlush(),
208
230
  shutdown: () => loggerProvider.shutdown(),
209
231
  };
210
232
  }
233
+
211
234
  return logs.logger;
212
235
  };
213
236
 
214
237
  let torn = false;
238
+
215
239
  const collect = (op: "forceFlush" | "shutdown"): Promise<void> => {
216
240
  const parts: Promise<void>[] = [provider[op]()];
241
+
217
242
  if (logs) parts.push(logs[op]());
243
+
218
244
  if (metrics) parts.push(metrics[op]());
245
+
219
246
  return Promise.all(parts)
220
247
  .then(() => undefined)
221
248
  .catch((error: Error) => reportError(config.onError, error));
222
249
  };
250
+
223
251
  const handoff = (p: Promise<void>): Promise<void> => {
224
252
  if (config.waitUntil) {
225
253
  config.waitUntil(p);
254
+
226
255
  return Promise.resolve();
227
256
  }
257
+
228
258
  return p;
229
259
  };
230
260
 
@@ -236,14 +266,20 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
236
266
  shutdown: () => {
237
267
  if (torn) return Promise.resolve();
238
268
  torn = true;
269
+
239
270
  if (registeredTrace) trace.disable();
271
+
240
272
  if (registeredContext) apiContext.disable();
273
+
241
274
  if (registeredPropagation) propagation.disable();
242
275
  handle.core = undefined;
276
+
243
277
  return handoff(collect("shutdown"));
244
278
  },
245
279
  };
280
+
246
281
  handle.core = { config, tracer, ensureLogger };
247
282
  activeClient = handle;
283
+
248
284
  return handle;
249
285
  }
package/src/config.ts CHANGED
@@ -95,6 +95,7 @@ export const DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536;
95
95
 
96
96
  export function resolveConfig(options: TelemetryOptions = {}): ResolvedConfig {
97
97
  const env = resolveEnv();
98
+
98
99
  if (
99
100
  options.sessionMode !== undefined &&
100
101
  options.sessionMode !== "explicit" &&
@@ -102,10 +103,12 @@ export function resolveConfig(options: TelemetryOptions = {}): ResolvedConfig {
102
103
  ) {
103
104
  throw new TypeError(`Invalid sessionMode: ${String(options.sessionMode)}`);
104
105
  }
106
+
105
107
  const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
106
108
  /\/+$/,
107
109
  "",
108
110
  );
111
+
109
112
  return {
110
113
  apiKey: options.apiKey ?? env.TELEMETRY_DEV_API_KEY,
111
114
  baseUrl,
package/src/index.ts CHANGED
@@ -6,7 +6,16 @@ export type {
6
6
  TokenUsage,
7
7
  ToolFields,
8
8
  } from "./attrs.ts";
9
- export { type ClientOverrides, flush, init, shutdown, type TelemetryClient } from "./client.ts";
9
+
10
+ export {
11
+ captureEnabled,
12
+ type ClientOverrides,
13
+ flush,
14
+ init,
15
+ shutdown,
16
+ type TelemetryClient,
17
+ } from "./client.ts";
18
+
10
19
  export type {
11
20
  BatchOptions,
12
21
  ExportMode,
@@ -15,6 +24,15 @@ export type {
15
24
  SdkLogLevel,
16
25
  TelemetryOptions,
17
26
  } from "./config.ts";
27
+
28
+ export {
29
+ boundedCapture,
30
+ boundedCaptureDetails,
31
+ type BoundedCaptureDetails,
32
+ type BoundedCaptureOptions,
33
+ type BoundedCaptureResult,
34
+ } from "./capture.ts";
35
+
18
36
  export {
19
37
  propagateAttributes,
20
38
  type PropagatedAttributes,
@@ -22,8 +40,11 @@ export {
22
40
  TelemetrySpanProcessor,
23
41
  type TelemetrySpanProcessorOptions,
24
42
  } from "@telemetry-dev/otel";
43
+
25
44
  export { log, type LogOptions } from "./logs.ts";
45
+
26
46
  export { observe, type ObserveOptions } from "./observe.ts";
47
+
27
48
  export {
28
49
  activeContext,
29
50
  extractW3cContext,
package/src/logs.ts CHANGED
@@ -34,26 +34,33 @@ export interface LogOptions {
34
34
  */
35
35
  export function log(message: string, options?: LogOptions): void {
36
36
  const core = currentClient().core;
37
+
37
38
  if (!core) return;
39
+
38
40
  try {
39
41
  const logger = core.ensureLogger();
42
+
40
43
  if (!logger) return;
41
44
  const cfg = core.config;
45
+
42
46
  const captureCfg = {
43
47
  mask: cfg.mask,
44
48
  maxAttributeLength: cfg.maxAttributeLength,
45
49
  onError: cfg.onError,
46
50
  };
51
+
47
52
  const severity = SEVERITIES[options?.level ?? "info"];
48
53
  const active = activeContext();
49
54
  const explicitSession = options?.attributes?.["gen_ai.conversation.id"];
50
55
  const propagatedSession = propagatedFromContext(active)?.["gen_ai.conversation.id"];
56
+
51
57
  const sessionId =
52
58
  explicitSession !== undefined
53
59
  ? undefined
54
60
  : typeof propagatedSession === "string"
55
61
  ? propagatedSession
56
62
  : cfg.processSessionId;
63
+
57
64
  const ctx =
58
65
  sessionId === undefined
59
66
  ? active
@@ -63,16 +70,22 @@ export function log(message: string, options?: LogOptions): void {
63
70
  });
64
71
 
65
72
  const attributes: Record<string, AttributeValue> = {};
73
+
66
74
  for (const [key, value] of Object.entries(options?.attributes ?? {})) {
67
75
  if (value === undefined) continue;
76
+
68
77
  if (typeof value === "number" || typeof value === "boolean") {
69
78
  attributes[key] = value;
70
79
  continue;
71
80
  }
81
+
72
82
  const prepared = prepareCaptureValue(key, value, captureCfg);
83
+
73
84
  if (prepared !== undefined) attributes[key] = prepared;
74
85
  }
86
+
75
87
  const propagated = propagatedFromContext(ctx);
88
+
76
89
  if (propagated) {
77
90
  for (const [key, value] of Object.entries(propagated)) {
78
91
  if (value !== undefined) {
package/src/noop.ts CHANGED
@@ -13,5 +13,6 @@ export const NOOP_SPAN_HANDLE: SpanHandle = {
13
13
  traceparent: null,
14
14
  isRecording: false,
15
15
  update: () => NOOP_SPAN_HANDLE,
16
+ recordOutputChunk: () => {},
16
17
  end: () => {},
17
18
  };
package/src/observe.ts CHANGED
@@ -19,11 +19,14 @@ export function observe<TThis, TArgs extends unknown[], TReturn>(
19
19
  ): (...args: TArgs) => TReturn {
20
20
  const wrapped = function (this: TThis, ...args: TArgs): TReturn {
21
21
  const core = currentClient().core;
22
+
22
23
  if (!core) return fn.apply(this, args);
23
24
 
24
25
  let handle: SpanHandle;
26
+
25
27
  try {
26
28
  const name = options?.name ?? (fn.name || "anonymous");
29
+
27
30
  const input =
28
31
  options?.input !== undefined
29
32
  ? options.input
@@ -32,18 +35,22 @@ export function observe<TThis, TArgs extends unknown[], TReturn>(
32
35
  : args.length === 1
33
36
  ? args[0]
34
37
  : args;
38
+
35
39
  handle = createSpanHandle(core, name, { ...options, input });
36
40
  } catch (error) {
37
41
  reportError(core.config.onError, error instanceof Error ? error : new Error(String(error)));
42
+
38
43
  return fn.apply(this, args);
39
44
  }
40
45
 
41
46
  try {
42
47
  const result = withContext(handle.context, () => fn.apply(this, args));
48
+
43
49
  if (isThenable(result)) {
44
50
  return result.then(
45
51
  (value) => {
46
52
  handle.end({ output: value });
53
+
47
54
  return value;
48
55
  },
49
56
  (error: Error) => {
@@ -52,13 +59,17 @@ export function observe<TThis, TArgs extends unknown[], TReturn>(
52
59
  },
53
60
  ) as TReturn;
54
61
  }
62
+
55
63
  handle.end({ output: result });
64
+
56
65
  return result;
57
66
  } catch (error) {
58
67
  handle.end({ error: error instanceof Error ? error : new Error(String(error)) });
59
68
  throw error;
60
69
  }
61
70
  };
71
+
62
72
  Object.defineProperty(wrapped, "name", { value: fn.name || "anonymous", configurable: true });
73
+
63
74
  return wrapped;
64
75
  }
package/src/span.ts CHANGED
@@ -19,7 +19,10 @@ import {
19
19
 
20
20
  import {
21
21
  activeContext,
22
+ DURATION_BUCKETS,
22
23
  omitUndefined,
24
+ OUTPUT_CHUNK_HISTOGRAM,
25
+ type OutputChunkHistogram,
23
26
  PROPAGATED_KEY,
24
27
  propagatedFromContext,
25
28
  reportError,
@@ -67,6 +70,8 @@ export interface SpanHandle {
67
70
  readonly traceparent: string | null;
68
71
  readonly isRecording: boolean;
69
72
  update(fields: SpanFields): SpanHandle;
73
+ /** Records an output chunk arrival. Pull-based streams include consumer delay between pulls. */
74
+ recordOutputChunk(timestampMs?: number): void;
70
75
  end(fields?: SpanFields & { endTime?: Date | number }): void;
71
76
  }
72
77
 
@@ -104,25 +109,35 @@ function isSpanHandle(value: Exclude<ParentRef, string>): value is SpanHandle {
104
109
  function withAmbientPropagated(ctx: Context): Context {
105
110
  if (propagatedFromContext(ctx)) return ctx;
106
111
  const ambient = propagatedFromContext(activeContext());
112
+
107
113
  return ambient ? ctx.setValue(PROPAGATED_KEY, ambient) : ctx;
108
114
  }
109
115
 
110
116
  function resolveParent(parent: ParentRef | undefined): Context {
111
117
  const base = activeContext();
118
+
112
119
  if (parent === undefined) return base;
120
+
113
121
  if (typeof parent === "string") {
114
122
  const parsed = parseTraceParent(parent);
123
+
115
124
  if (!parsed) return base;
125
+
116
126
  return trace.setSpanContext(base, { ...parsed, isRemote: true });
117
127
  }
128
+
118
129
  const ref = parent as Exclude<ParentRef, string>;
130
+
119
131
  if (isSpanHandle(ref)) return withAmbientPropagated(ref.context);
132
+
120
133
  if (isContext(ref)) return withAmbientPropagated(ref);
134
+
121
135
  return trace.setSpanContext(base, ref);
122
136
  }
123
137
 
124
138
  function applyError(span: Span, error: unknown): void {
125
139
  const err = error instanceof Error ? error : undefined;
140
+
126
141
  // Prefer the specific subclass name (Python parity: type(error).__name__) — many SDK
127
142
  // error classes (e.g. openai's BadRequestError) never override the inherited "Error" name.
128
143
  const errorType =
@@ -131,6 +146,7 @@ function applyError(span: Span, error: unknown): void {
131
146
  : err.name !== "Error" && err.name.length > 0
132
147
  ? err.name
133
148
  : (err.constructor?.name ?? "Error") || "Error";
149
+
134
150
  const message = err?.message ?? String(error);
135
151
  span.setStatus({ code: SpanStatusCode.ERROR });
136
152
  span.setAttribute("error.type", errorType);
@@ -148,12 +164,15 @@ function applyError(span: Span, error: unknown): void {
148
164
  function applyFields(span: Span, meta: SpanMeta, fields: SpanFields): void {
149
165
  if (fields.name) span.updateName(fields.name);
150
166
  const attrs = fieldsToAttributes(fields, meta.type, meta.capture);
167
+
151
168
  if (Object.keys(attrs).length > 0) span.setAttributes(attrs);
169
+
152
170
  if (fields.error !== undefined) applyError(span, fields.error);
153
171
  }
154
172
 
155
173
  function metaFor(core: ClientCore, options?: StartSpanOptions): SpanMeta {
156
174
  const cfg = core.config;
175
+
157
176
  return {
158
177
  type: options?.type ?? "span",
159
178
  capture: {
@@ -175,6 +194,7 @@ export function createSpanHandle(
175
194
  const meta = metaFor(core, options);
176
195
  const resolved = resolveParent(options.parent);
177
196
  const sessionId = sessionIdOf(resolved, options.attributes) ?? core.config.processSessionId;
197
+
178
198
  const sessionContext =
179
199
  sessionId === undefined
180
200
  ? resolved
@@ -182,8 +202,10 @@ export function createSpanHandle(
182
202
  ...propagatedFromContext(resolved),
183
203
  "gen_ai.conversation.id": sessionId,
184
204
  });
205
+
185
206
  const parentCtx = withSessionParent(sessionContext, sessionId, core.config.apiKey);
186
207
  const attrs = fieldsToAttributes(options, meta.type, meta.capture);
208
+
187
209
  const span = core.tracer.startSpan(
188
210
  name,
189
211
  {
@@ -198,14 +220,20 @@ export function createSpanHandle(
198
220
  },
199
221
  parentCtx,
200
222
  );
223
+
201
224
  SPAN_META.set(span, meta);
225
+
202
226
  // The processor stamps propagation on start; explicit fields still win.
203
227
  if (Object.keys(attrs).length > 0) span.setAttributes(attrs);
228
+
204
229
  if (options.name) span.updateName(options.name);
230
+
205
231
  if (options.error !== undefined) applyError(span, options.error);
206
232
 
207
233
  const spanContext = span.spanContext();
234
+ let previousOutputChunkTime: number | undefined;
208
235
  const flags = (spanContext.traceFlags & 0xff).toString(16).padStart(2, "0");
236
+
209
237
  const handle: SpanHandle = {
210
238
  span,
211
239
  context: trace.setSpan(parentCtx, span),
@@ -221,28 +249,69 @@ export function createSpanHandle(
221
249
  } catch (error) {
222
250
  reportError(meta.onError, error instanceof Error ? error : new Error(String(error)));
223
251
  }
252
+
224
253
  return handle;
225
254
  },
255
+ recordOutputChunk(timestampMs) {
256
+ const now = timestampMs ?? performance.now();
257
+
258
+ if (
259
+ !span.isRecording() ||
260
+ !Number.isFinite(now) ||
261
+ (previousOutputChunkTime !== undefined && now < previousOutputChunkTime)
262
+ )
263
+ return;
264
+
265
+ if (previousOutputChunkTime !== undefined) {
266
+ const seconds = Math.max(0, now - previousOutputChunkTime) / 1000;
267
+
268
+ const target = span as Span & {
269
+ [OUTPUT_CHUNK_HISTOGRAM]?: OutputChunkHistogram;
270
+ };
271
+
272
+ const histogram = (target[OUTPUT_CHUNK_HISTOGRAM] ??= {
273
+ count: 0,
274
+ sum: 0,
275
+ min: seconds,
276
+ max: seconds,
277
+ bucketCounts: Array.from({ length: DURATION_BUCKETS.length + 1 }, () => 0),
278
+ });
279
+
280
+ histogram.count++;
281
+ histogram.sum += seconds;
282
+ histogram.min = Math.min(histogram.min, seconds);
283
+ histogram.max = Math.max(histogram.max, seconds);
284
+ const bucket = DURATION_BUCKETS.findIndex((boundary) => seconds <= boundary);
285
+ histogram.bucketCounts[bucket < 0 ? DURATION_BUCKETS.length : bucket]++;
286
+ }
287
+
288
+ previousOutputChunkTime = now;
289
+ },
226
290
  end(fields) {
227
291
  try {
228
292
  if (fields) applyFields(span, meta, fields);
229
293
  } catch (error) {
230
294
  reportError(meta.onError, error instanceof Error ? error : new Error(String(error)));
231
295
  }
296
+
232
297
  span.end(fields?.endTime);
233
298
  },
234
299
  };
300
+
235
301
  return handle;
236
302
  }
237
303
 
238
304
  /** Start a span WITHOUT activating context; the caller must call .end(). */
239
305
  export function startSpan(name: string, options?: StartSpanOptions): SpanHandle {
240
306
  const core = currentClient().core;
307
+
241
308
  if (!core) return NOOP_SPAN_HANDLE;
309
+
242
310
  try {
243
311
  return createSpanHandle(core, name, options);
244
312
  } catch (error) {
245
313
  reportError(core.config.onError, error instanceof Error ? error : new Error(String(error)));
314
+
246
315
  return NOOP_SPAN_HANDLE;
247
316
  }
248
317
  }
@@ -250,10 +319,12 @@ export function startSpan(name: string, options?: StartSpanOptions): SpanHandle
250
319
  export function runWithHandle<T>(handle: SpanHandle, fn: (span: SpanHandle) => T): T {
251
320
  try {
252
321
  const result = withContext(handle.context, () => fn(handle));
322
+
253
323
  if (isThenable(result)) {
254
324
  return result.then(
255
325
  (value) => {
256
326
  handle.end();
327
+
257
328
  return value;
258
329
  },
259
330
  (error: unknown) => {
@@ -262,7 +333,9 @@ export function runWithHandle<T>(handle: SpanHandle, fn: (span: SpanHandle) => T
262
333
  },
263
334
  ) as T;
264
335
  }
336
+
265
337
  handle.end();
338
+
266
339
  return result;
267
340
  } catch (error) {
268
341
  handle.end({ error });
@@ -285,16 +358,21 @@ export function startActiveSpan<T>(
285
358
  const fn = isFn ? optionsOrFn : maybeFn!;
286
359
  const options = isFn ? undefined : optionsOrFn;
287
360
  const handle = startSpan(name, options);
361
+
288
362
  if (handle === NOOP_SPAN_HANDLE) return fn(handle);
363
+
289
364
  return runWithHandle(handle, fn);
290
365
  }
291
366
 
292
367
  /** Update the innermost active span; no-op when none is recording. */
293
368
  export function updateActiveSpan(fields: SpanFields): void {
294
369
  const core = currentClient().core;
370
+
295
371
  if (!core) return;
372
+
296
373
  try {
297
374
  const span = trace.getSpan(activeContext());
375
+
298
376
  if (!span?.isRecording()) return;
299
377
  const meta = SPAN_META.get(span) ?? metaFor(core);
300
378
  applyFields(span, meta, fields);
@@ -304,6 +382,7 @@ export function updateActiveSpan(fields: SpanFields): void {
304
382
  }
305
383
 
306
384
  const TRACE_CONTEXT_PROPAGATOR = new W3CTraceContextPropagator();
385
+
307
386
  const W3C_PROPAGATOR = new CompositePropagator({
308
387
  propagators: [TRACE_CONTEXT_PROPAGATOR, new W3CBaggagePropagator()],
309
388
  });
@@ -316,6 +395,7 @@ export function extractW3cContext(
316
395
  options: { includeBaggage?: boolean } = {},
317
396
  ): Context {
318
397
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
398
+
319
399
  return propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter);
320
400
  }
321
401
 
@@ -326,12 +406,17 @@ export function injectW3cContext(
326
406
  options: { includeBaggage?: boolean } = {},
327
407
  ): void {
328
408
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
409
+
329
410
  for (const key of Object.keys(carrier)) {
330
411
  const normalized = key.toLowerCase();
412
+
331
413
  if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") {
332
- delete carrier[key];
414
+ if (!Reflect.deleteProperty(carrier, key)) {
415
+ throw new TypeError(`Cannot delete stale W3C carrier property: ${key}`);
416
+ }
333
417
  }
334
418
  }
419
+
335
420
  propagator.inject(context, carrier, defaultTextMapSetter);
336
421
  }
337
422
 
@@ -339,5 +424,6 @@ export function injectW3cContext(
339
424
  export function getTraceparent(): string | null {
340
425
  const carrier = { traceparent: undefined };
341
426
  TRACE_CONTEXT_PROPAGATOR.inject(activeContext(), carrier, defaultTextMapSetter);
427
+
342
428
  return carrier.traceparent ?? null;
343
429
  }