@telemetry-dev/otel 0.1.0 → 0.1.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/otel",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Bring-your-own OpenTelemetry base layer for telemetry.dev: span processor and OTLP/protobuf trace exporter.",
5
5
  "keywords": [
6
6
  "genai",
package/src/attrs.ts CHANGED
@@ -19,7 +19,15 @@ export function omitUndefined(attributes: Attributes): Attributes {
19
19
 
20
20
  // Stringify structured content (messages / tool args) for the gen_ai.* string attributes the
21
21
  // ingest parses back into JSON. Returns undefined so omitUndefined drops absent content.
22
- export function jsonAttr(value: unknown): string | undefined {
22
+ export type JsonValue =
23
+ | string
24
+ | number
25
+ | boolean
26
+ | null
27
+ | JsonValue[]
28
+ | { [key: string]: JsonValue };
29
+
30
+ export function jsonAttr<T>(value: T): string | undefined {
23
31
  if (value === undefined) return undefined;
24
32
  if (typeof value === "string") return value;
25
33
  try {
package/src/config.ts CHANGED
@@ -23,7 +23,6 @@ export const DEFAULT_BATCH: Required<BatchOptions> = {
23
23
  };
24
24
 
25
25
  export function resolveEnv(): Record<string, string | undefined> {
26
- if (typeof process !== "undefined" && process.env) return process.env;
27
- const emptyEnv: Record<string, string | undefined> = {};
28
- return emptyEnv;
26
+ if (globalThis.process !== undefined && process.env) return process.env;
27
+ return {};
29
28
  }
package/src/context.ts CHANGED
@@ -53,13 +53,13 @@ export function withContext<T>(ctx: Context, fn: () => T): T {
53
53
 
54
54
  export const PROPAGATED_KEY = createContextKey("telemetry.dev propagated attributes");
55
55
 
56
- export interface PropagatedAttributes {
56
+ export interface PropagatedAttributes<MetadataValue = unknown> {
57
57
  /** Stamped as user.id on every span and log record in scope. */
58
58
  userId?: string;
59
59
  /** Stamped as gen_ai.conversation.id on every span and log record in scope. */
60
60
  sessionId?: string;
61
61
  /** Stamped as td.metadata.<key> on every span and log record in scope. */
62
- metadata?: Record<string, unknown>;
62
+ metadata?: Record<string, MetadataValue>;
63
63
  }
64
64
 
65
65
  const RESERVED_METADATA_KEYS = new Set(["userId", "sessionId", "user_id", "session_id"]);
package/src/debug.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { LogLevel, SdkLogLevel } from "./config.ts";
2
2
 
3
- const ORDER: Record<SdkLogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
3
+ const ORDER = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 } satisfies Record<
4
+ SdkLogLevel,
5
+ number
6
+ >;
4
7
 
5
8
  let currentLevel: SdkLogLevel = "warn";
6
9
 
@@ -22,11 +25,11 @@ export const diag = {
22
25
  };
23
26
 
24
27
  /** Fail-open guard: SDK internals report through onError + diagnostics, never into user code. */
25
- export function reportError(onError: ((error: unknown) => void) | undefined, error: unknown): void {
28
+ export function reportError(onError: ((error: Error) => void) | undefined, cause: unknown): void {
26
29
  try {
27
- onError?.(error);
30
+ onError?.(cause instanceof Error ? cause : new Error(String(cause)));
28
31
  } catch {
29
32
  // onError itself must never propagate
30
33
  }
31
- diag.error(error);
34
+ diag.error(cause);
32
35
  }
package/src/index.ts CHANGED
@@ -34,6 +34,15 @@ export {
34
34
  type TelemetrySpanProcessorOptions,
35
35
  } from "./otel.ts";
36
36
  export { StampingSpanProcessor, type StampingProcessorOptions } from "./processor.ts";
37
+ export {
38
+ sessionIdOf,
39
+ type SessionRootOf,
40
+ sessionRootTracerProvider,
41
+ sessionSampler,
42
+ sessionSpanContext,
43
+ sha256,
44
+ withSessionParent,
45
+ } from "./session.ts";
37
46
  export {
38
47
  createLogExporter,
39
48
  createMetricExporter,
package/src/metrics.ts CHANGED
@@ -31,8 +31,8 @@ export interface MetricsPipeline {
31
31
  shutdown(): Promise<void>;
32
32
  }
33
33
 
34
- function stringAttr(value: unknown): string | undefined {
35
- return typeof value === "string" ? value : undefined;
34
+ function stringAttr(value: Attributes[string]): string | undefined {
35
+ return value?.constructor === String ? `${value}` : undefined;
36
36
  }
37
37
 
38
38
  export function createMetricsPipeline({
@@ -59,7 +59,7 @@ export function createMetricsPipeline({
59
59
 
60
60
  const record = (span: ReadableSpan): void => {
61
61
  const operation = span.attributes["gen_ai.operation.name"];
62
- if (typeof operation !== "string" || !DURATION_OPERATIONS.has(operation)) return;
62
+ if (operation?.constructor !== String || !DURATION_OPERATIONS.has(`${operation}`)) return;
63
63
  const attrs: Attributes = omitUndefined({
64
64
  "gen_ai.operation.name": operation,
65
65
  "gen_ai.provider.name": stringAttr(span.attributes["gen_ai.provider.name"]),
@@ -74,11 +74,11 @@ export function createMetricsPipeline({
74
74
  );
75
75
  if (!TOKEN_OPERATIONS.has(operation)) return;
76
76
  const inputTokens = span.attributes["gen_ai.usage.input_tokens"];
77
- if (typeof inputTokens === "number") {
77
+ if (inputTokens?.constructor === Number) {
78
78
  tokenHistogram.record(inputTokens, { ...attrs, "gen_ai.token.type": "input" });
79
79
  }
80
80
  const outputTokens = span.attributes["gen_ai.usage.output_tokens"];
81
- if (typeof outputTokens === "number") {
81
+ if (outputTokens?.constructor === Number) {
82
82
  tokenHistogram.record(outputTokens, { ...attrs, "gen_ai.token.type": "output" });
83
83
  }
84
84
  };
package/src/otel.ts CHANGED
@@ -47,7 +47,7 @@ export interface TelemetrySpanProcessorOptions {
47
47
  /** deployment.environment.name on the metrics resource. Falls back to `TELEMETRY_DEV_ENVIRONMENT`. */
48
48
  environment?: string;
49
49
  fetch?: typeof fetch;
50
- onError?: (error: unknown) => void;
50
+ onError?: (error: Error) => void;
51
51
  /** Advanced/test seam: replaces the OTLP fetch exporter. */
52
52
  spanExporter?: SpanExporter;
53
53
  }
@@ -71,6 +71,7 @@ export class TelemetrySpanProcessor implements SpanProcessor {
71
71
  const transport: Transport = {
72
72
  fetchImpl: options.fetch ?? globalThis.fetch,
73
73
  onError: options.onError,
74
+ exportTimeoutMillis: options.batch?.exportTimeoutMillis,
74
75
  };
75
76
 
76
77
  const exporter =
@@ -142,7 +143,8 @@ export function createTelemetrySpanExporter(
142
143
  apiKey?: string;
143
144
  baseUrl?: string;
144
145
  fetch?: typeof fetch;
145
- onError?: (error: unknown) => void;
146
+ onError?: (error: Error) => void;
147
+ exportTimeoutMillis?: number;
146
148
  } = {},
147
149
  ): SpanExporter {
148
150
  const env = resolveEnv();
@@ -161,6 +163,10 @@ export function createTelemetrySpanExporter(
161
163
  }
162
164
  return createTraceExporter(
163
165
  { url: `${baseUrl}/v1/traces`, headers: otlpHeaders(apiKey) },
164
- { fetchImpl: options.fetch ?? globalThis.fetch, onError: options.onError },
166
+ {
167
+ fetchImpl: options.fetch ?? globalThis.fetch,
168
+ onError: options.onError,
169
+ exportTimeoutMillis: options.exportTimeoutMillis,
170
+ },
165
171
  );
166
172
  }
package/src/processor.ts CHANGED
@@ -18,7 +18,7 @@ export interface StampingProcessorOptions {
18
18
  batch: Required<BatchOptions>;
19
19
  spanFilter?: (span: ReadableSpan) => boolean;
20
20
  recordMetrics?: (span: ReadableSpan) => void;
21
- onError?: (error: unknown) => void;
21
+ onError?: (error: Error) => void;
22
22
  }
23
23
 
24
24
  /**
package/src/session.ts ADDED
@@ -0,0 +1,260 @@
1
+ import {
2
+ type Attributes,
3
+ type Context,
4
+ context as apiContext,
5
+ diag,
6
+ isSpanContextValid,
7
+ type Span,
8
+ type SpanContext,
9
+ type SpanOptions,
10
+ trace,
11
+ TraceFlags,
12
+ type Tracer,
13
+ type TracerProvider,
14
+ } from "@opentelemetry/api";
15
+ import { getNumberFromEnv, getStringFromEnv } from "@opentelemetry/core";
16
+ import {
17
+ AlwaysOffSampler,
18
+ AlwaysOnSampler,
19
+ ParentBasedSampler,
20
+ type Sampler,
21
+ TraceIdRatioBasedSampler,
22
+ } from "@opentelemetry/sdk-trace-base";
23
+
24
+ import { propagatedFromContext } from "./context.ts";
25
+ import { reportError } from "./debug.ts";
26
+
27
+ const SESSION_PARENTS = new WeakMap<Span, Context>();
28
+
29
+ /**
30
+ * Install on the provider used with withSessionParent or sessionRootTracerProvider.
31
+ * Pass the provider's configured sampler explicitly; OTel cannot read it back from a provider.
32
+ * Without an argument, uses OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG defaults.
33
+ */
34
+ export function sessionSampler(inner: Sampler = samplerFromEnv()): Sampler {
35
+ return {
36
+ shouldSample(ctx, traceId, name, kind, attributes, links) {
37
+ const parent = trace.getSpan(ctx);
38
+ const original = parent && SESSION_PARENTS.get(parent);
39
+ if (original) {
40
+ const span = trace.getSpan(original);
41
+ ctx = span ? trace.setSpan(ctx, span) : trace.deleteSpan(ctx);
42
+ }
43
+ return inner.shouldSample(ctx, traceId, name, kind, attributes, links);
44
+ },
45
+ toString: () => `SessionSampler{${inner.toString()}}`,
46
+ };
47
+ }
48
+
49
+ function samplerFromEnv(): Sampler {
50
+ const name = getStringFromEnv("OTEL_TRACES_SAMPLER") ?? "parentbased_always_on";
51
+ let root: Sampler;
52
+ switch (name) {
53
+ case "always_off":
54
+ case "parentbased_always_off":
55
+ root = new AlwaysOffSampler();
56
+ break;
57
+ case "traceidratio":
58
+ case "parentbased_traceidratio": {
59
+ const ratio = getNumberFromEnv("OTEL_TRACES_SAMPLER_ARG");
60
+ const valid = ratio !== undefined && Number.isFinite(ratio) && ratio >= 0 && ratio <= 1;
61
+ if (!valid) diag.error("Invalid OTEL_TRACES_SAMPLER_ARG; using 1.");
62
+ root = new TraceIdRatioBasedSampler(valid ? ratio : 1);
63
+ break;
64
+ }
65
+ case "always_on":
66
+ case "parentbased_always_on":
67
+ root = new AlwaysOnSampler();
68
+ break;
69
+ default:
70
+ diag.error(`Invalid OTEL_TRACES_SAMPLER "${name}"; using parentbased_always_on.`);
71
+ return new ParentBasedSampler({ root: new AlwaysOnSampler() });
72
+ }
73
+ return name.startsWith("parentbased_") ? new ParentBasedSampler({ root }) : root;
74
+ }
75
+ /**
76
+ * Deterministic remote parent for a session: every root span of the session joins one trace
77
+ * (`traceId = SHA-256(apiKey ‖ 0x00 ‖ sessionId)[0:16]`, parent `spanId = digest[16:24]`).
78
+ * The API key is part of the input so two projects with the same session id never share a trace
79
+ * id. No span is ever emitted for the parent. Its flags are not a sampling decision:
80
+ * use withSessionParent and install sessionSampler on the provider. Python mirrors these IDs.
81
+ */
82
+ export function sessionSpanContext(apiKey: string | undefined, sessionId: string): SpanContext {
83
+ const digest = sha256(new TextEncoder().encode(`${apiKey ?? ""}\0${sessionId}`));
84
+ return {
85
+ traceId: hex(digest.subarray(0, 16)),
86
+ spanId: hex(digest.subarray(16, 24)),
87
+ traceFlags: TraceFlags.NONE,
88
+ isRemote: true,
89
+ };
90
+ }
91
+
92
+ /** Returns the session id when a span must start a new turn in the session trace. */
93
+ export type SessionRootOf = (name: string, attributes: Attributes) => string | undefined;
94
+
95
+ /**
96
+ * Wraps a provider so that spans `sessionRootOf` recognizes are parented under the session
97
+ * parent even when a parent is active. Frameworks that run each turn inside their own
98
+ * engine span (eve's "workflow" scope) otherwise give every turn its own trace.
99
+ * The inner provider MUST use sessionSampler(configuredSampler) to retain sampling policy.
100
+ */
101
+ export function sessionRootTracerProvider(
102
+ inner: TracerProvider,
103
+ apiKey: string | undefined,
104
+ sessionRootOf: SessionRootOf,
105
+ onError?: (error: Error) => void,
106
+ ): TracerProvider {
107
+ const reparent = (name: string, options: SpanOptions | undefined, ctx: Context): Context => {
108
+ if (!apiKey) return ctx;
109
+ try {
110
+ const sessionId = sessionRootOf(name, options?.attributes ?? {});
111
+ return sessionId
112
+ ? setSessionParent(options?.root ? trace.deleteSpan(ctx) : ctx, sessionId, apiKey)
113
+ : ctx;
114
+ } catch (error) {
115
+ reportError(onError, error);
116
+ return ctx;
117
+ }
118
+ };
119
+ return {
120
+ getTracer(name, version, options): Tracer {
121
+ const tracer = inner.getTracer(name, version, options);
122
+ return {
123
+ startSpan: (spanName, spanOptions, ctx = apiContext.active()) => {
124
+ const parent = reparent(spanName, spanOptions, ctx);
125
+ return tracer.startSpan(
126
+ spanName,
127
+ parent !== ctx && spanOptions?.root ? { ...spanOptions, root: false } : spanOptions,
128
+ parent,
129
+ );
130
+ },
131
+ startActiveSpan<F extends (span: Span) => unknown>(
132
+ spanName: string,
133
+ ...args: unknown[]
134
+ ): ReturnType<F> {
135
+ const fn = args.at(-1) as F;
136
+ const spanOptions = args.length > 1 ? (args[0] as SpanOptions) : undefined;
137
+ const ctx = args.length > 2 ? (args[1] as Context) : apiContext.active();
138
+ const parent = reparent(spanName, spanOptions, ctx);
139
+ return tracer.startActiveSpan(
140
+ spanName,
141
+ parent !== ctx && spanOptions?.root
142
+ ? { ...spanOptions, root: false }
143
+ : (spanOptions ?? {}),
144
+ parent,
145
+ fn,
146
+ );
147
+ },
148
+ };
149
+ },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Parent a would-be root under the session; nested spans keep their real parent.
155
+ * Requires sessionSampler(configuredSampler) on the provider that starts the span.
156
+ */
157
+ export function withSessionParent(
158
+ ctx: Context,
159
+ sessionId: string | undefined,
160
+ apiKey: string | undefined,
161
+ ): Context {
162
+ if (!apiKey || !sessionId) return ctx;
163
+ const current = trace.getSpanContext(ctx);
164
+ if (current && isSpanContextValid(current)) return ctx;
165
+ return setSessionParent(ctx, sessionId, apiKey);
166
+ }
167
+
168
+ function setSessionParent(ctx: Context, sessionId: string, apiKey: string): Context {
169
+ const parent = trace.wrapSpanContext({
170
+ ...sessionSpanContext(apiKey, sessionId),
171
+ traceState: trace.getSpanContext(ctx)?.traceState,
172
+ });
173
+ SESSION_PARENTS.set(parent, ctx);
174
+ return trace.setSpan(ctx, parent);
175
+ }
176
+
177
+ export function sessionIdOf(ctx: Context, attributes?: Attributes): string | undefined {
178
+ const explicit = attributes?.["gen_ai.conversation.id"];
179
+ if (typeof explicit === "string") return explicit;
180
+ const propagated = propagatedFromContext(ctx)?.["gen_ai.conversation.id"];
181
+ return typeof propagated === "string" ? propagated : undefined;
182
+ }
183
+
184
+ function hex(bytes: Uint8Array): string {
185
+ let s = "";
186
+ for (const b of bytes) s += b.toString(16).padStart(2, "0");
187
+ return s;
188
+ }
189
+
190
+ const K = new Uint32Array([
191
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
192
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
193
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
194
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
195
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
196
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
197
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
198
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
199
+ ]);
200
+
201
+ const rotr = (x: number, n: number) => (x >>> n) | (x << (32 - n));
202
+
203
+ /** FIPS 180-4 SHA-256 in plain JS: the package must stay synchronous and runtime-neutral. */
204
+ export function sha256(bytes: Uint8Array): Uint8Array {
205
+ const h = new Uint32Array([
206
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
207
+ ]);
208
+ const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64);
209
+ padded.set(bytes);
210
+ padded[bytes.length] = 0x80;
211
+ const view = new DataView(padded.buffer);
212
+ const bitLen = bytes.length * 8;
213
+ view.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000));
214
+ view.setUint32(padded.length - 4, bitLen >>> 0);
215
+ const w = new Uint32Array(64);
216
+ for (let off = 0; off < padded.length; off += 64) {
217
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4);
218
+ for (let i = 16; i < 64; i++) {
219
+ const x = w[i - 15]!;
220
+ const y = w[i - 2]!;
221
+ const s0 = rotr(x, 7) ^ rotr(x, 18) ^ (x >>> 3);
222
+ const s1 = rotr(y, 17) ^ rotr(y, 19) ^ (y >>> 10);
223
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) >>> 0;
224
+ }
225
+ let a = h[0]!;
226
+ let b = h[1]!;
227
+ let c = h[2]!;
228
+ let d = h[3]!;
229
+ let e = h[4]!;
230
+ let f = h[5]!;
231
+ let g = h[6]!;
232
+ let hh = h[7]!;
233
+ for (let i = 0; i < 64; i++) {
234
+ const t1 =
235
+ (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i]! + w[i]!) >>>
236
+ 0;
237
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
238
+ hh = g;
239
+ g = f;
240
+ f = e;
241
+ e = (d + t1) >>> 0;
242
+ d = c;
243
+ c = b;
244
+ b = a;
245
+ a = (t1 + t2) >>> 0;
246
+ }
247
+ h[0] = (h[0]! + a) >>> 0;
248
+ h[1] = (h[1]! + b) >>> 0;
249
+ h[2] = (h[2]! + c) >>> 0;
250
+ h[3] = (h[3]! + d) >>> 0;
251
+ h[4] = (h[4]! + e) >>> 0;
252
+ h[5] = (h[5]! + f) >>> 0;
253
+ h[6] = (h[6]! + g) >>> 0;
254
+ h[7] = (h[7]! + hh) >>> 0;
255
+ }
256
+ const out = new Uint8Array(32);
257
+ const outView = new DataView(out.buffer);
258
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i]!);
259
+ return out;
260
+ }