@telemetry-dev/sdk 0.1.2 → 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
@@ -60,6 +60,12 @@ Other `init()` options: `enabled` (kill switch), `registerGlobal`, `exportMode:
60
60
  `exportTimeoutMillis` 30000), `spanFilter`, `resourceAttributes`, `logLevel` (SDK diagnostics,
61
61
  default `"warn"`), `fetch`, `waitUntil`, `onError`.
62
62
 
63
+ Root spans remain independent by default. `init({ sessionMode: "process" })` groups otherwise
64
+ uncorrelated spans and logs using a fresh opaque UUID for each enabled SDK initialization.
65
+ Explicit and propagated session IDs take precedence, and valid parent trace context is preserved.
66
+ The generated session ends when the client is shut down or replaced; a later `init()` generates a
67
+ new ID. It is not persisted across processes.
68
+
63
69
  `sampler` accepts an OpenTelemetry `Sampler` and overrides `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`.
64
70
  The default is parent-based always-on. Session roots keep the configured root policy.
65
71
  Nested spans and explicitly parented spans keep the real parent's policy.
@@ -71,7 +77,8 @@ The SDK installs the [session sampling wrapper](../otel/README.md#correlation-at
71
77
  - `observe(fn, options?)` — wrap a sync/async function: args → input, return → output, errors
72
78
  captured and rethrown; activates context for nesting.
73
79
  - `startSpan(name, options?)` — manual handle; does **not** activate context. `handle.update()`,
74
- `handle.end()`, `handle.traceparent`, `handle.span` (raw OTel span).
80
+ `handle.end()`, `handle.recordOutputChunk(timestampMs?)`, `handle.traceparent`, `handle.span`
81
+ (raw OTel span).
75
82
  - `startActiveSpan(name, options?, fn)` — callback-scoped; activates context, auto-ends.
76
83
  - `updateActiveSpan(fields)` — update the innermost active span.
77
84
  - `propagateAttributes({ userId, sessionId, metadata }, fn)` — stamps `user.id`,
@@ -93,6 +100,18 @@ GenAI semantic conventions; cost is computed server-side from usage unless `cost
93
100
  Duration and token-usage histograms (`gen_ai.client.operation.duration`,
94
101
  `gen_ai.client.token.usage`) are recorded automatically for generation/agent/embedding/tool spans.
95
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
+
96
115
  ## Serverless
97
116
 
98
117
  Use `exportMode: "immediate"` and either `await flush()` before returning or pass the platform
package/dist/index.d.mts CHANGED
@@ -20,6 +20,8 @@ interface TelemetryOptions {
20
20
  serviceName?: string;
21
21
  /** Kill switch: `false` makes the SDK a complete no-op (tests). Default true. */
22
22
  enabled?: boolean;
23
+ /** Group otherwise uncorrelated spans and logs for this SDK initialization. Default "explicit". */
24
+ sessionMode?: "explicit" | "process";
23
25
  /** Register this SDK's TracerProvider/context manager/propagator globally. Default false (isolated). */
24
26
  registerGlobal?: boolean;
25
27
  /** "batched" (default) buffers spans; "immediate" exports per span (serverless). */
@@ -170,6 +172,8 @@ interface SpanHandle {
170
172
  readonly traceparent: string | null;
171
173
  readonly isRecording: boolean;
172
174
  update(fields: SpanFields): SpanHandle;
175
+ /** Records an output chunk arrival. Pull-based streams include consumer delay between pulls. */
176
+ recordOutputChunk(timestampMs?: number): void;
173
177
  end(fields?: SpanFields & {
174
178
  endTime?: Date | number;
175
179
  }): void;
package/dist/index.mjs CHANGED
@@ -3,10 +3,11 @@ 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();
10
+ if (options.sessionMode !== void 0 && options.sessionMode !== "explicit" && options.sessionMode !== "process") throw new TypeError(`Invalid sessionMode: ${String(options.sessionMode)}`);
10
11
  const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
11
12
  return {
12
13
  apiKey: options.apiKey ?? env.TELEMETRY_DEV_API_KEY,
@@ -14,6 +15,7 @@ function resolveConfig(options = {}) {
14
15
  environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
15
16
  serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
16
17
  enabled: options.enabled ?? true,
18
+ sessionMode: options.sessionMode ?? "explicit",
17
19
  registerGlobal: options.registerGlobal ?? false,
18
20
  exportMode: options.exportMode ?? "batched",
19
21
  sampler: options.sampler,
@@ -73,6 +75,7 @@ function initInner(options, overrides) {
73
75
  activeClient = NOOP_CLIENT;
74
76
  return NOOP_CLIENT;
75
77
  }
78
+ if (config.sessionMode === "process") config.processSessionId = globalThis.crypto.randomUUID();
76
79
  const resource = resourceFromAttributes({
77
80
  "service.name": config.serviceName,
78
81
  "deployment.environment.name": config.environment,
@@ -237,7 +240,14 @@ function log(message, options) {
237
240
  onError: cfg.onError
238
241
  };
239
242
  const severity = SEVERITIES[options?.level ?? "info"];
240
- const ctx = activeContext$1();
243
+ const active = activeContext$1();
244
+ const explicitSession = options?.attributes?.["gen_ai.conversation.id"];
245
+ const propagatedSession = propagatedFromContext(active)?.["gen_ai.conversation.id"];
246
+ const sessionId = explicitSession !== void 0 ? void 0 : typeof propagatedSession === "string" ? propagatedSession : cfg.processSessionId;
247
+ const ctx = sessionId === void 0 ? active : active.setValue(PROPAGATED_KEY, {
248
+ ...propagatedFromContext(active),
249
+ "gen_ai.conversation.id": sessionId
250
+ });
241
251
  const attributes = {};
242
252
  for (const [key, value] of Object.entries(options?.attributes ?? {})) {
243
253
  if (value === void 0) continue;
@@ -353,6 +363,7 @@ const NOOP_SPAN_HANDLE = {
353
363
  traceparent: null,
354
364
  isRecording: false,
355
365
  update: () => NOOP_SPAN_HANDLE,
366
+ recordOutputChunk: () => {},
356
367
  end: () => {}
357
368
  };
358
369
  //#endregion
@@ -425,7 +436,11 @@ function metaFor(core, options) {
425
436
  function createSpanHandle(core, name, options = {}) {
426
437
  const meta = metaFor(core, options);
427
438
  const resolved = resolveParent(options.parent);
428
- const parentCtx = withSessionParent(resolved, sessionIdOf(resolved, options.attributes), core.config.apiKey);
439
+ const sessionId = sessionIdOf(resolved, options.attributes) ?? core.config.processSessionId;
440
+ const parentCtx = withSessionParent(sessionId === void 0 ? resolved : resolved.setValue(PROPAGATED_KEY, {
441
+ ...propagatedFromContext(resolved),
442
+ "gen_ai.conversation.id": sessionId
443
+ }), sessionId, core.config.apiKey);
429
444
  const attrs = fieldsToAttributes(options, meta.type, meta.capture);
430
445
  const span = core.tracer.startSpan(name, {
431
446
  kind: options.kind ?? SpanKind.INTERNAL,
@@ -442,6 +457,7 @@ function createSpanHandle(core, name, options = {}) {
442
457
  if (options.name) span.updateName(options.name);
443
458
  if (options.error !== void 0) applyError(span, options.error);
444
459
  const spanContext = span.spanContext();
460
+ let previousOutputChunkTime;
445
461
  const flags = (spanContext.traceFlags & 255).toString(16).padStart(2, "0");
446
462
  const handle = {
447
463
  span,
@@ -460,6 +476,28 @@ function createSpanHandle(core, name, options = {}) {
460
476
  }
461
477
  return handle;
462
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
+ },
463
501
  end(fields) {
464
502
  try {
465
503
  if (fields) applyFields(span, meta, fields);
@@ -529,7 +567,9 @@ function injectW3cContext(context, carrier, options = {}) {
529
567
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
530
568
  for (const key of Object.keys(carrier)) {
531
569
  const normalized = key.toLowerCase();
532
- 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
+ }
533
573
  }
534
574
  propagator.inject(context, carrier, defaultTextMapSetter);
535
575
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/sdk",
3
- "version": "0.1.2",
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/telemetry.dev.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.2"
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,19 +105,25 @@ 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
+ if (config.sessionMode === "process") config.processSessionId = globalThis.crypto.randomUUID();
120
+
115
121
  const resource = resourceFromAttributes({
116
122
  "service.name": config.serviceName,
117
123
  "deployment.environment.name": config.environment,
118
124
  ...config.resourceAttributes,
119
125
  });
126
+
120
127
  const transport: Transport = { fetchImpl: config.fetchImpl, onError: config.onError };
121
128
  const headers = config.apiKey ? otlpHeaders(config.apiKey, config.sdkName) : undefined;
122
129
 
@@ -129,6 +136,7 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
129
136
  (headers
130
137
  ? createMetricExporter({ url: `${config.baseUrl}/v1/metrics`, headers }, transport)
131
138
  : undefined);
139
+
132
140
  const metrics = metricExporter
133
141
  ? createMetricsPipeline({
134
142
  resource,
@@ -159,21 +167,26 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
159
167
  spanProcessors: [processor],
160
168
  spanLimits: { attributeValueLengthLimit: config.maxAttributeLength },
161
169
  });
170
+
162
171
  const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
163
172
 
164
173
  let registeredTrace = false;
165
174
  let registeredContext = false;
166
175
  let registeredPropagation = false;
176
+
167
177
  if (config.registerGlobal) {
168
178
  registeredTrace = trace.setGlobalTracerProvider(
169
179
  config.sessionRootOf
170
180
  ? sessionRootTracerProvider(provider, config.apiKey, config.sessionRootOf, config.onError)
171
181
  : provider,
172
182
  );
183
+
173
184
  if (als) {
174
185
  registeredContext = apiContext.setGlobalContextManager(new AlsContextManager(als));
175
186
  }
187
+
176
188
  registeredPropagation = propagation.setGlobalPropagator(new W3CTraceContextPropagator());
189
+
177
190
  if (!registeredTrace) {
178
191
  diag.warn(
179
192
  "a global TracerProvider is already registered; attach the TelemetrySpanProcessor from '@telemetry-dev/otel' to your own provider instead",
@@ -186,43 +199,56 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
186
199
  (headers
187
200
  ? createLogExporter({ url: `${config.baseUrl}/v1/logs`, headers }, transport)
188
201
  : undefined);
202
+
189
203
  // Lazy: the log pipeline only exists once log() is first called.
190
204
  let logs: { logger: Logger; forceFlush(): Promise<void>; shutdown(): Promise<void> } | undefined;
205
+
191
206
  const ensureLogger = (): Logger | undefined => {
192
207
  if (!logExporter) return undefined;
208
+
193
209
  if (!logs) {
194
210
  const logProcessor =
195
211
  config.exportMode === "immediate"
196
212
  ? new SimpleLogRecordProcessor(logExporter)
197
213
  : new BatchLogRecordProcessor(logExporter);
214
+
198
215
  const loggerProvider = new LoggerProvider({
199
216
  resource,
200
217
  logRecordLimits: { attributeValueLengthLimit: config.maxAttributeLength },
201
218
  processors: [logProcessor],
202
219
  });
220
+
203
221
  logs = {
204
222
  logger: loggerProvider.getLogger(SCOPE_NAME, SCOPE_VERSION),
205
223
  forceFlush: () => loggerProvider.forceFlush(),
206
224
  shutdown: () => loggerProvider.shutdown(),
207
225
  };
208
226
  }
227
+
209
228
  return logs.logger;
210
229
  };
211
230
 
212
231
  let torn = false;
232
+
213
233
  const collect = (op: "forceFlush" | "shutdown"): Promise<void> => {
214
234
  const parts: Promise<void>[] = [provider[op]()];
235
+
215
236
  if (logs) parts.push(logs[op]());
237
+
216
238
  if (metrics) parts.push(metrics[op]());
239
+
217
240
  return Promise.all(parts)
218
241
  .then(() => undefined)
219
242
  .catch((error: Error) => reportError(config.onError, error));
220
243
  };
244
+
221
245
  const handoff = (p: Promise<void>): Promise<void> => {
222
246
  if (config.waitUntil) {
223
247
  config.waitUntil(p);
248
+
224
249
  return Promise.resolve();
225
250
  }
251
+
226
252
  return p;
227
253
  };
228
254
 
@@ -234,14 +260,20 @@ function initInner(options?: TelemetryOptions, overrides?: ClientOverrides): Tel
234
260
  shutdown: () => {
235
261
  if (torn) return Promise.resolve();
236
262
  torn = true;
263
+
237
264
  if (registeredTrace) trace.disable();
265
+
238
266
  if (registeredContext) apiContext.disable();
267
+
239
268
  if (registeredPropagation) propagation.disable();
240
269
  handle.core = undefined;
270
+
241
271
  return handoff(collect("shutdown"));
242
272
  },
243
273
  };
274
+
244
275
  handle.core = { config, tracer, ensureLogger };
245
276
  activeClient = handle;
277
+
246
278
  return handle;
247
279
  }
package/src/config.ts CHANGED
@@ -26,6 +26,8 @@ export interface TelemetryOptions {
26
26
  serviceName?: string;
27
27
  /** Kill switch: `false` makes the SDK a complete no-op (tests). Default true. */
28
28
  enabled?: boolean;
29
+ /** Group otherwise uncorrelated spans and logs for this SDK initialization. Default "explicit". */
30
+ sessionMode?: "explicit" | "process";
29
31
  /** Register this SDK's TracerProvider/context manager/propagator globally. Default false (isolated). */
30
32
  registerGlobal?: boolean;
31
33
  /** "batched" (default) buffers spans; "immediate" exports per span (serverless). */
@@ -69,6 +71,8 @@ export interface ResolvedConfig {
69
71
  environment: string;
70
72
  serviceName: string;
71
73
  enabled: boolean;
74
+ sessionMode: "explicit" | "process";
75
+ processSessionId?: string;
72
76
  registerGlobal: boolean;
73
77
  exportMode: ExportMode;
74
78
  sampler?: Sampler;
@@ -91,16 +95,27 @@ export const DEFAULT_MAX_ATTRIBUTE_LENGTH = 65536;
91
95
 
92
96
  export function resolveConfig(options: TelemetryOptions = {}): ResolvedConfig {
93
97
  const env = resolveEnv();
98
+
99
+ if (
100
+ options.sessionMode !== undefined &&
101
+ options.sessionMode !== "explicit" &&
102
+ options.sessionMode !== "process"
103
+ ) {
104
+ throw new TypeError(`Invalid sessionMode: ${String(options.sessionMode)}`);
105
+ }
106
+
94
107
  const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
95
108
  /\/+$/,
96
109
  "",
97
110
  );
111
+
98
112
  return {
99
113
  apiKey: options.apiKey ?? env.TELEMETRY_DEV_API_KEY,
100
114
  baseUrl,
101
115
  environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
102
116
  serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
103
117
  enabled: options.enabled ?? true,
118
+ sessionMode: options.sessionMode ?? "explicit",
104
119
  registerGlobal: options.registerGlobal ?? false,
105
120
  exportMode: options.exportMode ?? "batched",
106
121
  sampler: options.sampler,
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
@@ -1,7 +1,12 @@
1
1
  import type { AttributeValue } from "@opentelemetry/api";
2
2
  import { SeverityNumber } from "@opentelemetry/api-logs";
3
3
 
4
- import { activeContext, propagatedFromContext, reportError } from "@telemetry-dev/otel";
4
+ import {
5
+ activeContext,
6
+ PROPAGATED_KEY,
7
+ propagatedFromContext,
8
+ reportError,
9
+ } from "@telemetry-dev/otel";
5
10
 
6
11
  import { prepareCaptureValue, truncate } from "./capture.ts";
7
12
  import { currentClient } from "./client.ts";
@@ -29,30 +34,58 @@ export interface LogOptions {
29
34
  */
30
35
  export function log(message: string, options?: LogOptions): void {
31
36
  const core = currentClient().core;
37
+
32
38
  if (!core) return;
39
+
33
40
  try {
34
41
  const logger = core.ensureLogger();
42
+
35
43
  if (!logger) return;
36
44
  const cfg = core.config;
45
+
37
46
  const captureCfg = {
38
47
  mask: cfg.mask,
39
48
  maxAttributeLength: cfg.maxAttributeLength,
40
49
  onError: cfg.onError,
41
50
  };
51
+
42
52
  const severity = SEVERITIES[options?.level ?? "info"];
43
- const ctx = activeContext();
53
+ const active = activeContext();
54
+ const explicitSession = options?.attributes?.["gen_ai.conversation.id"];
55
+ const propagatedSession = propagatedFromContext(active)?.["gen_ai.conversation.id"];
56
+
57
+ const sessionId =
58
+ explicitSession !== undefined
59
+ ? undefined
60
+ : typeof propagatedSession === "string"
61
+ ? propagatedSession
62
+ : cfg.processSessionId;
63
+
64
+ const ctx =
65
+ sessionId === undefined
66
+ ? active
67
+ : active.setValue(PROPAGATED_KEY, {
68
+ ...propagatedFromContext(active),
69
+ "gen_ai.conversation.id": sessionId,
70
+ });
44
71
 
45
72
  const attributes: Record<string, AttributeValue> = {};
73
+
46
74
  for (const [key, value] of Object.entries(options?.attributes ?? {})) {
47
75
  if (value === undefined) continue;
76
+
48
77
  if (typeof value === "number" || typeof value === "boolean") {
49
78
  attributes[key] = value;
50
79
  continue;
51
80
  }
81
+
52
82
  const prepared = prepareCaptureValue(key, value, captureCfg);
83
+
53
84
  if (prepared !== undefined) attributes[key] = prepared;
54
85
  }
86
+
55
87
  const propagated = propagatedFromContext(ctx);
88
+
56
89
  if (propagated) {
57
90
  for (const [key, value] of Object.entries(propagated)) {
58
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: {
@@ -174,12 +193,19 @@ export function createSpanHandle(
174
193
  ): SpanHandle {
175
194
  const meta = metaFor(core, options);
176
195
  const resolved = resolveParent(options.parent);
177
- const parentCtx = withSessionParent(
178
- resolved,
179
- sessionIdOf(resolved, options.attributes),
180
- core.config.apiKey,
181
- );
196
+ const sessionId = sessionIdOf(resolved, options.attributes) ?? core.config.processSessionId;
197
+
198
+ const sessionContext =
199
+ sessionId === undefined
200
+ ? resolved
201
+ : resolved.setValue(PROPAGATED_KEY, {
202
+ ...propagatedFromContext(resolved),
203
+ "gen_ai.conversation.id": sessionId,
204
+ });
205
+
206
+ const parentCtx = withSessionParent(sessionContext, sessionId, core.config.apiKey);
182
207
  const attrs = fieldsToAttributes(options, meta.type, meta.capture);
208
+
183
209
  const span = core.tracer.startSpan(
184
210
  name,
185
211
  {
@@ -194,14 +220,20 @@ export function createSpanHandle(
194
220
  },
195
221
  parentCtx,
196
222
  );
223
+
197
224
  SPAN_META.set(span, meta);
225
+
198
226
  // The processor stamps propagation on start; explicit fields still win.
199
227
  if (Object.keys(attrs).length > 0) span.setAttributes(attrs);
228
+
200
229
  if (options.name) span.updateName(options.name);
230
+
201
231
  if (options.error !== undefined) applyError(span, options.error);
202
232
 
203
233
  const spanContext = span.spanContext();
234
+ let previousOutputChunkTime: number | undefined;
204
235
  const flags = (spanContext.traceFlags & 0xff).toString(16).padStart(2, "0");
236
+
205
237
  const handle: SpanHandle = {
206
238
  span,
207
239
  context: trace.setSpan(parentCtx, span),
@@ -217,28 +249,69 @@ export function createSpanHandle(
217
249
  } catch (error) {
218
250
  reportError(meta.onError, error instanceof Error ? error : new Error(String(error)));
219
251
  }
252
+
220
253
  return handle;
221
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
+ },
222
290
  end(fields) {
223
291
  try {
224
292
  if (fields) applyFields(span, meta, fields);
225
293
  } catch (error) {
226
294
  reportError(meta.onError, error instanceof Error ? error : new Error(String(error)));
227
295
  }
296
+
228
297
  span.end(fields?.endTime);
229
298
  },
230
299
  };
300
+
231
301
  return handle;
232
302
  }
233
303
 
234
304
  /** Start a span WITHOUT activating context; the caller must call .end(). */
235
305
  export function startSpan(name: string, options?: StartSpanOptions): SpanHandle {
236
306
  const core = currentClient().core;
307
+
237
308
  if (!core) return NOOP_SPAN_HANDLE;
309
+
238
310
  try {
239
311
  return createSpanHandle(core, name, options);
240
312
  } catch (error) {
241
313
  reportError(core.config.onError, error instanceof Error ? error : new Error(String(error)));
314
+
242
315
  return NOOP_SPAN_HANDLE;
243
316
  }
244
317
  }
@@ -246,10 +319,12 @@ export function startSpan(name: string, options?: StartSpanOptions): SpanHandle
246
319
  export function runWithHandle<T>(handle: SpanHandle, fn: (span: SpanHandle) => T): T {
247
320
  try {
248
321
  const result = withContext(handle.context, () => fn(handle));
322
+
249
323
  if (isThenable(result)) {
250
324
  return result.then(
251
325
  (value) => {
252
326
  handle.end();
327
+
253
328
  return value;
254
329
  },
255
330
  (error: unknown) => {
@@ -258,7 +333,9 @@ export function runWithHandle<T>(handle: SpanHandle, fn: (span: SpanHandle) => T
258
333
  },
259
334
  ) as T;
260
335
  }
336
+
261
337
  handle.end();
338
+
262
339
  return result;
263
340
  } catch (error) {
264
341
  handle.end({ error });
@@ -281,16 +358,21 @@ export function startActiveSpan<T>(
281
358
  const fn = isFn ? optionsOrFn : maybeFn!;
282
359
  const options = isFn ? undefined : optionsOrFn;
283
360
  const handle = startSpan(name, options);
361
+
284
362
  if (handle === NOOP_SPAN_HANDLE) return fn(handle);
363
+
285
364
  return runWithHandle(handle, fn);
286
365
  }
287
366
 
288
367
  /** Update the innermost active span; no-op when none is recording. */
289
368
  export function updateActiveSpan(fields: SpanFields): void {
290
369
  const core = currentClient().core;
370
+
291
371
  if (!core) return;
372
+
292
373
  try {
293
374
  const span = trace.getSpan(activeContext());
375
+
294
376
  if (!span?.isRecording()) return;
295
377
  const meta = SPAN_META.get(span) ?? metaFor(core);
296
378
  applyFields(span, meta, fields);
@@ -300,6 +382,7 @@ export function updateActiveSpan(fields: SpanFields): void {
300
382
  }
301
383
 
302
384
  const TRACE_CONTEXT_PROPAGATOR = new W3CTraceContextPropagator();
385
+
303
386
  const W3C_PROPAGATOR = new CompositePropagator({
304
387
  propagators: [TRACE_CONTEXT_PROPAGATOR, new W3CBaggagePropagator()],
305
388
  });
@@ -312,6 +395,7 @@ export function extractW3cContext(
312
395
  options: { includeBaggage?: boolean } = {},
313
396
  ): Context {
314
397
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
398
+
315
399
  return propagator.extract(ROOT_CONTEXT, carrier, defaultTextMapGetter);
316
400
  }
317
401
 
@@ -322,12 +406,17 @@ export function injectW3cContext(
322
406
  options: { includeBaggage?: boolean } = {},
323
407
  ): void {
324
408
  const propagator = options.includeBaggage === true ? W3C_PROPAGATOR : TRACE_CONTEXT_PROPAGATOR;
409
+
325
410
  for (const key of Object.keys(carrier)) {
326
411
  const normalized = key.toLowerCase();
412
+
327
413
  if (normalized === "traceparent" || normalized === "tracestate" || normalized === "baggage") {
328
- delete carrier[key];
414
+ if (!Reflect.deleteProperty(carrier, key)) {
415
+ throw new TypeError(`Cannot delete stale W3C carrier property: ${key}`);
416
+ }
329
417
  }
330
418
  }
419
+
331
420
  propagator.inject(context, carrier, defaultTextMapSetter);
332
421
  }
333
422
 
@@ -335,5 +424,6 @@ export function injectW3cContext(
335
424
  export function getTraceparent(): string | null {
336
425
  const carrier = { traceparent: undefined };
337
426
  TRACE_CONTEXT_PROPAGATOR.inject(activeContext(), carrier, defaultTextMapSetter);
427
+
338
428
  return carrier.traceparent ?? null;
339
429
  }