@telemetry-dev/sdk 0.1.3 → 0.1.5

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`,
@@ -99,6 +100,18 @@ GenAI semantic conventions; cost is computed server-side from usage unless `cost
99
100
  Duration and token-usage histograms (`gen_ai.client.operation.duration`,
100
101
  `gen_ai.client.token.usage`) are recorded automatically for generation/agent/embedding/tool spans.
101
102
 
103
+ Generation spans also emit `gen_ai.client.operation.time_to_first_chunk` when a valid
104
+ `timeToFirstChunkMs` is supplied. `handle.recordOutputChunk()` records each nonempty output chunk's
105
+ arrival; after the first chunk, intervals contribute to
106
+ `gen_ai.client.operation.time_per_output_chunk`. Both histograms use seconds and are emitted only
107
+ after the ended span passes `spanFilter`, including interrupted streams. Chunk timing uses bounded
108
+ histogram state and does not retain individual intervals. Pull-based timing includes consumer delay
109
+ between reads. An explicit timestamp uses the same monotonic millisecond clock for every chunk.
110
+
111
+ Provider integrations feature-detect chunk recording. With an older core SDK that lacks
112
+ `recordOutputChunk`, streams and existing tracing continue normally, but chunk-interval metrics are
113
+ unavailable. Upgrade the core SDK with provider integrations to enable the new metric.
114
+
102
115
  ## Serverless
103
116
 
104
117
  Use `exportMode: "immediate"` and either `await flush()` before returning or pass the platform
package/dist/index.d.mts CHANGED
@@ -172,6 +172,8 @@ interface SpanHandle {
172
172
  readonly traceparent: string | null;
173
173
  readonly isRecording: boolean;
174
174
  update(fields: SpanFields): SpanHandle;
175
+ /** Records an output chunk arrival. Pull-based streams include consumer delay between pulls. */
176
+ recordOutputChunk(timestampMs?: number): void;
175
177
  end(fields?: SpanFields & {
176
178
  endTime?: Date | number;
177
179
  }): void;
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();
@@ -363,6 +363,7 @@ const NOOP_SPAN_HANDLE = {
363
363
  traceparent: null,
364
364
  isRecording: false,
365
365
  update: () => NOOP_SPAN_HANDLE,
366
+ recordOutputChunk: () => {},
366
367
  end: () => {}
367
368
  };
368
369
  //#endregion
@@ -456,6 +457,7 @@ function createSpanHandle(core, name, options = {}) {
456
457
  if (options.name) span.updateName(options.name);
457
458
  if (options.error !== void 0) applyError(span, options.error);
458
459
  const spanContext = span.spanContext();
460
+ let previousOutputChunkTime;
459
461
  const flags = (spanContext.traceFlags & 255).toString(16).padStart(2, "0");
460
462
  const handle = {
461
463
  span,
@@ -474,6 +476,28 @@ function createSpanHandle(core, name, options = {}) {
474
476
  }
475
477
  return handle;
476
478
  },
479
+ recordOutputChunk(timestampMs) {
480
+ const now = timestampMs ?? performance.now();
481
+ if (!span.isRecording() || !Number.isFinite(now) || previousOutputChunkTime !== void 0 && now < previousOutputChunkTime) return;
482
+ if (previousOutputChunkTime !== void 0) {
483
+ const seconds = Math.max(0, now - previousOutputChunkTime) / 1e3;
484
+ const target = span;
485
+ const histogram = target[OUTPUT_CHUNK_HISTOGRAM] ??= {
486
+ count: 0,
487
+ sum: 0,
488
+ min: seconds,
489
+ max: seconds,
490
+ bucketCounts: Array.from({ length: DURATION_BUCKETS.length + 1 }, () => 0)
491
+ };
492
+ histogram.count++;
493
+ histogram.sum += seconds;
494
+ histogram.min = Math.min(histogram.min, seconds);
495
+ histogram.max = Math.max(histogram.max, seconds);
496
+ const bucket = DURATION_BUCKETS.findIndex((boundary) => seconds <= boundary);
497
+ histogram.bucketCounts[bucket < 0 ? DURATION_BUCKETS.length : bucket]++;
498
+ }
499
+ previousOutputChunkTime = now;
500
+ },
477
501
  end(fields) {
478
502
  try {
479
503
  if (fields) applyFields(span, meta, fields);
@@ -543,7 +567,9 @@ function injectW3cContext(context, carrier, options = {}) {
543
567
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
544
568
  for (const key of Object.keys(carrier)) {
545
569
  const normalized = key.toLowerCase();
546
- if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") delete carrier[key];
570
+ if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") {
571
+ if (!Reflect.deleteProperty(carrier, key)) throw new TypeError(`Cannot delete stale W3C carrier property: ${key}`);
572
+ }
547
573
  }
548
574
  propagator.inject(context, carrier, defaultTextMapSetter);
549
575
  }
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.5",
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
@@ -126,32 +126,42 @@ export function fieldsToAttributes(
126
126
 
127
127
  if (fields.systemInstructions !== undefined && cfg.captureInput) {
128
128
  const value = prepareCaptureValue("gen_ai.system_instructions", fields.systemInstructions, cfg);
129
+
129
130
  if (value !== undefined) attrs["gen_ai.system_instructions"] = value;
130
131
  }
132
+
131
133
  if (fields.input !== undefined && cfg.captureInput) {
132
134
  const key = inputKeyFor(type);
133
135
  const value = prepareCaptureValue(key, fields.input, cfg);
136
+
134
137
  if (value !== undefined) attrs[key] = value;
135
138
  }
139
+
136
140
  if (fields.output !== undefined && cfg.captureOutput) {
137
141
  const key = outputKeyFor(type);
138
142
  const value = prepareCaptureValue(key, fields.output, cfg);
143
+
139
144
  if (value !== undefined) attrs[key] = value;
140
145
  }
146
+
141
147
  if (fields.metadata) {
142
148
  for (const [key, value] of Object.entries(fields.metadata)) {
143
149
  if (RESERVED_METADATA_KEYS.has(key)) {
144
150
  diag.debug(`metadata key "${key}" is reserved; pass it via propagateAttributes instead`);
145
151
  continue;
146
152
  }
153
+
147
154
  const attr = typeof value === "string" ? value : jsonAttr(value);
155
+
148
156
  if (attr !== undefined) {
149
157
  attrs[`td.metadata.${key}`] = truncate(attr, cfg.maxAttributeLength);
150
158
  }
151
159
  }
152
160
  }
161
+
153
162
  if (fields.attributes) {
154
163
  Object.assign(attrs, omitUndefined(fields.attributes));
155
164
  }
165
+
156
166
  return attrs;
157
167
  }
package/src/capture.ts CHANGED
@@ -13,6 +13,7 @@ export interface CaptureConfig {
13
13
 
14
14
  export function truncate(value: string, maxLength: number): string {
15
15
  if (value.length <= maxLength) return value;
16
+
16
17
  // Total stays within maxLength so the provider's attributeValueLengthLimit backstop
17
18
  // (set to the same cap) never slices the marker off.
18
19
  return value.slice(0, Math.max(maxLength - TRUNCATION_MARKER.length, 0)) + TRUNCATION_MARKER;
@@ -25,15 +26,20 @@ export function prepareCaptureValue(
25
26
  cfg: CaptureConfig,
26
27
  ): string | undefined {
27
28
  let masked = value;
29
+
28
30
  if (cfg.mask) {
29
31
  try {
30
32
  masked = cfg.mask(value, { key });
31
33
  } catch (error) {
32
34
  reportError(cfg.onError, error instanceof Error ? error : new Error(String(error)));
35
+
33
36
  return undefined;
34
37
  }
35
38
  }
39
+
36
40
  const serialized = jsonAttr(masked);
41
+
37
42
  if (serialized === undefined) return undefined;
43
+
38
44
  return truncate(serialized, cfg.maxAttributeLength);
39
45
  }
package/src/client.ts CHANGED
@@ -89,6 +89,7 @@ export function init(options?: TelemetryOptions, overrides?: ClientOverrides): T
89
89
  } catch (error) {
90
90
  reportError(options?.onError, error instanceof Error ? error : new Error(String(error)));
91
91
  activeClient = NOOP_CLIENT;
92
+
92
93
  return NOOP_CLIENT;
93
94
  }
94
95
  }
@@ -104,11 +105,14 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
104
105
  }
105
106
 
106
107
  const enabled = config.enabled && Boolean(config.apiKey ?? overrides?.spanExporter);
108
+
107
109
  if (!enabled) {
108
110
  if (config.enabled && !config.apiKey) {
109
111
  diag.debug("no api key (apiKey option or TELEMETRY_DEV_API_KEY); telemetry disabled");
110
112
  }
113
+
111
114
  activeClient = NOOP_CLIENT;
115
+
112
116
  return NOOP_CLIENT;
113
117
  }
114
118
 
@@ -119,6 +123,7 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
119
123
  "deployment.environment.name": config.environment,
120
124
  ...config.resourceAttributes,
121
125
  });
126
+
122
127
  const transport: Transport = { fetchImpl: config.fetchImpl, onError: config.onError };
123
128
  const headers = config.apiKey ? otlpHeaders(config.apiKey, config.sdkName) : undefined;
124
129
 
@@ -131,6 +136,7 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
131
136
  (headers
132
137
  ? createMetricExporter({ url: `${config.baseUrl}/v1/metrics`, headers }, transport)
133
138
  : undefined);
139
+
134
140
  const metrics = metricExporter
135
141
  ? createMetricsPipeline({
136
142
  resource,
@@ -161,21 +167,26 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
161
167
  spanProcessors: [processor],
162
168
  spanLimits: { attributeValueLengthLimit: config.maxAttributeLength },
163
169
  });
170
+
164
171
  const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
165
172
 
166
173
  let registeredTrace = false;
167
174
  let registeredContext = false;
168
175
  let registeredPropagation = false;
176
+
169
177
  if (config.registerGlobal) {
170
178
  registeredTrace = trace.setGlobalTracerProvider(
171
179
  config.sessionRootOf
172
180
  ? sessionRootTracerProvider(provider, config.apiKey, config.sessionRootOf, config.onError)
173
181
  : provider,
174
182
  );
183
+
175
184
  if (als) {
176
185
  registeredContext = apiContext.setGlobalContextManager(new AlsContextManager(als));
177
186
  }
187
+
178
188
  registeredPropagation = propagation.setGlobalPropagator(new W3CTraceContextPropagator());
189
+
179
190
  if (!registeredTrace) {
180
191
  diag.warn(
181
192
  "a global TracerProvider is already registered; attach the TelemetrySpanProcessor from '@telemetry-dev/otel' to your own provider instead",
@@ -188,43 +199,56 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
188
199
  (headers
189
200
  ? createLogExporter({ url: `${config.baseUrl}/v1/logs`, headers }, transport)
190
201
  : undefined);
202
+
191
203
  // Lazy: the log pipeline only exists once log() is first called.
192
204
  let logs: { logger: Logger; forceFlush(): Promise<void>; shutdown(): Promise<void> } | undefined;
205
+
193
206
  const ensureLogger = (): Logger | undefined => {
194
207
  if (!logExporter) return undefined;
208
+
195
209
  if (!logs) {
196
210
  const logProcessor =
197
211
  config.exportMode === "immediate"
198
212
  ? new SimpleLogRecordProcessor(logExporter)
199
213
  : new BatchLogRecordProcessor(logExporter);
214
+
200
215
  const loggerProvider = new LoggerProvider({
201
216
  resource,
202
217
  logRecordLimits: { attributeValueLengthLimit: config.maxAttributeLength },
203
218
  processors: [logProcessor],
204
219
  });
220
+
205
221
  logs = {
206
222
  logger: loggerProvider.getLogger(SCOPE_NAME, SCOPE_VERSION),
207
223
  forceFlush: () => loggerProvider.forceFlush(),
208
224
  shutdown: () => loggerProvider.shutdown(),
209
225
  };
210
226
  }
227
+
211
228
  return logs.logger;
212
229
  };
213
230
 
214
231
  let torn = false;
232
+
215
233
  const collect = (op: "forceFlush" | "shutdown"): Promise<void> => {
216
234
  const parts: Promise<void>[] = [provider[op]()];
235
+
217
236
  if (logs) parts.push(logs[op]());
237
+
218
238
  if (metrics) parts.push(metrics[op]());
239
+
219
240
  return Promise.all(parts)
220
241
  .then(() => undefined)
221
242
  .catch((error: Error) => reportError(config.onError, error));
222
243
  };
244
+
223
245
  const handoff = (p: Promise<void>): Promise<void> => {
224
246
  if (config.waitUntil) {
225
247
  config.waitUntil(p);
248
+
226
249
  return Promise.resolve();
227
250
  }
251
+
228
252
  return p;
229
253
  };
230
254
 
@@ -236,14 +260,20 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
236
260
  shutdown: () => {
237
261
  if (torn) return Promise.resolve();
238
262
  torn = true;
263
+
239
264
  if (registeredTrace) trace.disable();
265
+
240
266
  if (registeredContext) apiContext.disable();
267
+
241
268
  if (registeredPropagation) propagation.disable();
242
269
  handle.core = undefined;
270
+
243
271
  return handoff(collect("shutdown"));
244
272
  },
245
273
  };
274
+
246
275
  handle.core = { config, tracer, ensureLogger };
247
276
  activeClient = handle;
277
+
248
278
  return handle;
249
279
  }
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,9 @@ export type {
6
6
  TokenUsage,
7
7
  ToolFields,
8
8
  } from "./attrs.ts";
9
+
9
10
  export { type ClientOverrides, flush, init, shutdown, type TelemetryClient } from "./client.ts";
11
+
10
12
  export type {
11
13
  BatchOptions,
12
14
  ExportMode,
@@ -15,6 +17,7 @@ export type {
15
17
  SdkLogLevel,
16
18
  TelemetryOptions,
17
19
  } from "./config.ts";
20
+
18
21
  export {
19
22
  propagateAttributes,
20
23
  type PropagatedAttributes,
@@ -22,8 +25,11 @@ export {
22
25
  TelemetrySpanProcessor,
23
26
  type TelemetrySpanProcessorOptions,
24
27
  } from "@telemetry-dev/otel";
28
+
25
29
  export { log, type LogOptions } from "./logs.ts";
30
+
26
31
  export { observe, type ObserveOptions } from "./observe.ts";
32
+
27
33
  export {
28
34
  activeContext,
29
35
  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
  }