@telemetry-dev/tanstack-ai 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/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # @telemetry-dev/tanstack-ai
2
2
 
3
3
  TanStack AI telemetry integration for [telemetry.dev](https://telemetry.dev). A `chat()` middleware
4
- that streams every run to the telemetry.dev ingest API. Each `chat()` call is its own OTel trace: a
5
- root span (operation `chat`, or `invoke_agent` once tools are used), a `chat` span per agent-loop
6
- iteration, and an `execute_tool` span per tool call — spans are typed by `gen_ai.operation.name`.
7
- Conversations are carried by `gen_ai.conversation.id` (lifted from `metadata.sessionId`, falling
8
- back to the chat's `threadId`), not by sharing one trace.
4
+ that streams every run to the telemetry.dev ingest API. Each `chat()` call produces a root span
5
+ (operation `chat`, or `invoke_agent` once tools are used), a `chat` span per agent-loop iteration,
6
+ and an `execute_tool` span per tool call — spans are typed by `gen_ai.operation.name`. All calls of
7
+ one conversation (`metadata.sessionId`, falling back to the chat's `threadId`) share one trace, with
8
+ the root of each call as a sibling in start order; the id is also stamped as
9
+ `gen_ai.conversation.id` on every span.
9
10
 
10
11
  ## Install
11
12
 
@@ -27,6 +28,12 @@ Requires `@tanstack/ai >= 0.28.0 < 1`.
27
28
  All four are also settable via `telemetryDev({ apiKey, baseUrl, environment, serviceName })`, which
28
29
  takes precedence over the environment.
29
30
 
31
+ Trace sampling uses `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG`, which also control session roots.
32
+ The default is parent-based always-on.
33
+ Pass an OTel `Sampler` as `telemetryDev({ sampler })` to override the environment.
34
+ Real active parents keep their sampling decisions. The integration does not send dropped or record-only spans.
35
+ The [session sampling contract](../otel/README.md#correlation-attributes) lists environment modes and argument defaults.
36
+
30
37
  ## Usage
31
38
 
32
39
  ```ts
@@ -44,8 +51,7 @@ const stream = chat({
44
51
 
45
52
  `metadata.userId` is recorded as the `user.id` span attribute and `metadata.sessionId` as
46
53
  `gen_ai.conversation.id` (when absent, the chat's `threadId` is used); any remaining metadata keys
47
- ride along as `td.metadata.<key>` attributes. Each call is its own trace `sessionId` only
48
- correlates calls through `gen_ai.conversation.id`, it never merges them into a single trace.
54
+ ride along as `td.metadata.<key>` attributes. Calls that share a conversation id share one trace.
49
55
 
50
56
  A single `telemetryDev()` instance is **concurrency-safe**: per-run state is keyed by the chat's
51
57
  middleware context, so you can create one at module scope and share it across overlapping `chat()`
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Attributes } from "@opentelemetry/api";
2
- import { ReadableSpan } from "@opentelemetry/sdk-trace-base";
2
+ import { ReadableSpan, Sampler } from "@opentelemetry/sdk-trace-base";
3
3
  import { ChatMiddleware } from "@tanstack/ai";
4
4
 
5
5
  //#region src/config.d.ts
@@ -12,12 +12,14 @@ interface TelemetryDevOptions {
12
12
  environment?: string;
13
13
  /** Service name attached to every trace. Falls back to `OTEL_SERVICE_NAME`, then `unknown_service`. */
14
14
  serviceName?: string;
15
+ /** OTel sampling policy. Defaults to OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG. */
16
+ sampler?: Sampler;
15
17
  /** Injected fetch implementation. Defaults to `globalThis.fetch`. */
16
18
  fetch?: typeof fetch;
17
19
  /** Serverless extender (e.g. Cloudflare `ctx.waitUntil`). When provided, `onFinish` does not await the POST. */
18
20
  waitUntil?: (p: Promise<unknown>) => void;
19
21
  /** Receives any error raised while emitting telemetry; the integration never throws into the SDK. */
20
- onError?: (error: unknown) => void;
22
+ onError?: (cause: unknown) => void;
21
23
  }
22
24
  //#endregion
23
25
  //#region src/otel.d.ts
@@ -30,7 +32,8 @@ interface EmitterOverrides {
30
32
  //#region src/middleware.d.ts
31
33
  /**
32
34
  * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
33
- * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call, one CLIENT
35
+ * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call (the roots
36
+ * of one session share a trace, see withSessionParent), one CLIENT
34
37
  * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
35
38
  * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
36
39
  * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
package/dist/index.mjs CHANGED
@@ -1,18 +1,20 @@
1
- import { ROOT_CONTEXT, SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
2
- import { ProtobufMetricsSerializer, ProtobufTraceSerializer } from "@opentelemetry/otlp-transformer";
1
+ import { ROOT_CONTEXT, SpanKind, SpanStatusCode, TraceFlags, context, trace } from "@opentelemetry/api";
2
+ import { DORMANT_INTERVAL_MS, DURATION_BUCKETS, TOKEN_BUCKETS, createMetricExporter, createTraceExporter, jsonAttr, omitUndefined, otlpHeaders, sessionSampler, withSessionParent } from "@telemetry-dev/otel";
3
+ import { ExportResultCode } from "@opentelemetry/core";
3
4
  import { resourceFromAttributes } from "@opentelemetry/resources";
4
- import { AggregationTemporality, MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
5
+ import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
5
6
  import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base";
6
7
  //#region src/config.ts
7
8
  const DEFAULT_BASE_URL = "https://ingest.telemetry.dev";
8
9
  function resolveConfig(options = {}) {
9
- const env = typeof process !== "undefined" && process.env ? process.env : {};
10
+ const env = globalThis.process?.env ?? {};
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,
13
14
  baseUrl,
14
15
  environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
15
16
  serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
17
+ sampler: options.sampler,
16
18
  fetchImpl: options.fetch ?? globalThis.fetch,
17
19
  waitUntil: options.waitUntil,
18
20
  onError: options.onError
@@ -20,72 +22,9 @@ function resolveConfig(options = {}) {
20
22
  }
21
23
  //#endregion
22
24
  //#region src/otel.ts
23
- const DURATION_BUCKETS = [
24
- .01,
25
- .02,
26
- .04,
27
- .08,
28
- .16,
29
- .32,
30
- .64,
31
- 1.28,
32
- 2.56,
33
- 5.12,
34
- 10.24,
35
- 20.48,
36
- 40.96,
37
- 81.92
38
- ];
39
- const TOKEN_BUCKETS = [
40
- 1,
41
- 4,
42
- 16,
43
- 64,
44
- 256,
45
- 1024,
46
- 4096,
47
- 16384,
48
- 65536,
49
- 262144,
50
- 1048576,
51
- 4194304,
52
- 16777216,
53
- 67108864
54
- ];
55
25
  const SCOPE_NAME = "@telemetry-dev/tanstack-ai";
56
26
  const SCOPE_VERSION = "0.0.0";
57
- const RETRY_DELAYS_MS = [100, 500];
58
- const RETRYABLE_STATUSES = new Set([
59
- 429,
60
- 502,
61
- 503,
62
- 504
63
- ]);
64
- const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
65
- const cancelBody = async (res) => {
66
- try {
67
- await res.body?.cancel();
68
- } catch {}
69
- };
70
- const postOtlp = async ({ fetchImpl, url, headers, body }) => {
71
- for (let attempt = 0;; attempt += 1) {
72
- try {
73
- const res = await fetchImpl(url, {
74
- method: "POST",
75
- headers,
76
- body
77
- });
78
- if (res.ok || !RETRYABLE_STATUSES.has(res.status) || attempt === RETRY_DELAYS_MS.length) {
79
- await cancelBody(res);
80
- return res;
81
- }
82
- await cancelBody(res);
83
- } catch (error) {
84
- if (attempt === RETRY_DELAYS_MS.length) throw error;
85
- }
86
- await delay(RETRY_DELAYS_MS[attempt]);
87
- }
88
- };
27
+ const isReadableSpan = (span) => "resource" in span && "instrumentationScope" in span && "events" in span && (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0;
89
28
  const cache = /* @__PURE__ */ new Map();
90
29
  const buildCore = (config) => {
91
30
  const { apiKey, baseUrl, environment, serviceName } = config;
@@ -93,63 +32,29 @@ const buildCore = (config) => {
93
32
  "service.name": serviceName,
94
33
  "deployment.environment.name": environment
95
34
  });
96
- const headers = {
97
- "content-type": "application/x-protobuf",
98
- authorization: `Bearer ${apiKey}`
99
- };
100
- const tracer = new BasicTracerProvider({ resource }).getTracer(SCOPE_NAME, SCOPE_VERSION);
101
- const sendSpans = async (spans, transport) => {
102
- const body = ProtobufTraceSerializer.serializeRequest(spans);
103
- if (!body || body.byteLength === 0) return;
104
- const { fetchImpl, onError } = transport;
105
- const res = await postOtlp({
106
- fetchImpl,
35
+ const headers = otlpHeaders(apiKey ?? "", "@telemetry-dev/tanstack-ai");
36
+ const tracer = new BasicTracerProvider({
37
+ resource,
38
+ sampler: sessionSampler(config.sampler)
39
+ }).getTracer(SCOPE_NAME, SCOPE_VERSION);
40
+ const sendSpans = (spans, transport) => new Promise((resolve, reject) => {
41
+ createTraceExporter({
107
42
  url: `${baseUrl}/v1/traces`,
108
- headers,
109
- body
43
+ headers
44
+ }, { fetchImpl: transport.fetchImpl }).export(spans, (result) => {
45
+ if (result.code === ExportResultCode.SUCCESS) resolve();
46
+ else reject(result.error ?? /* @__PURE__ */ new Error("telemetry.dev trace export failed"));
110
47
  });
111
- if (!res.ok) onError?.(/* @__PURE__ */ new Error(`telemetry.dev trace ingest failed: ${res.status}`));
112
- };
48
+ });
113
49
  const buildMetrics = (transport) => {
114
- const { fetchImpl, onError } = transport;
115
50
  const meterProvider = new MeterProvider({
116
51
  resource,
117
52
  readers: [new PeriodicExportingMetricReader({
118
- exporter: {
119
- export(resourceMetrics, resultCallback) {
120
- const body = ProtobufMetricsSerializer.serializeRequest(resourceMetrics);
121
- if (!body || body.byteLength === 0) {
122
- resultCallback({ code: 0 });
123
- return;
124
- }
125
- fetchImpl(`${baseUrl}/v1/metrics`, {
126
- method: "POST",
127
- headers,
128
- body
129
- }).then((res) => {
130
- if (!res.ok) {
131
- const error = /* @__PURE__ */ new Error(`telemetry.dev metric ingest failed: ${res.status}`);
132
- onError?.(error);
133
- resultCallback({
134
- code: 1,
135
- error
136
- });
137
- return;
138
- }
139
- resultCallback({ code: 0 });
140
- }).catch((error) => {
141
- onError?.(error);
142
- resultCallback({
143
- code: 1,
144
- error: error instanceof Error ? error : void 0
145
- });
146
- });
147
- },
148
- selectAggregationTemporality: () => AggregationTemporality.DELTA,
149
- forceFlush: () => Promise.resolve(),
150
- shutdown: () => Promise.resolve()
151
- },
152
- exportIntervalMillis: 2 ** 31 - 1
53
+ exporter: createMetricExporter({
54
+ url: `${baseUrl}/v1/metrics`,
55
+ headers
56
+ }, transport),
57
+ exportIntervalMillis: DORMANT_INTERVAL_MS
153
58
  })]
154
59
  });
155
60
  const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
@@ -172,6 +77,7 @@ const buildCore = (config) => {
172
77
  };
173
78
  };
174
79
  const coreFor = (config) => {
80
+ if (config.sampler) return buildCore(config);
175
81
  const key = `${config.apiKey}|${config.baseUrl}|${config.environment}|${config.serviceName}`;
176
82
  const cached = cache.get(key);
177
83
  if (cached) return cached;
@@ -199,14 +105,15 @@ const createEmitter = (config, overrides) => {
199
105
  metrics = void 0;
200
106
  const p = (async () => {
201
107
  try {
202
- await sendSpans(spans);
108
+ const readableSpans = spans.filter(isReadableSpan);
109
+ if (readableSpans.length) await sendSpans(readableSpans);
203
110
  } catch (e) {
204
- onError?.(e);
111
+ onError?.(e instanceof Error ? e : new Error(String(e)));
205
112
  }
206
113
  try {
207
114
  if (pipeline) await pipeline.shutdown();
208
115
  } catch (e) {
209
- onError?.(e);
116
+ onError?.(e instanceof Error ? e : new Error(String(e)));
210
117
  }
211
118
  })();
212
119
  if (config.waitUntil) {
@@ -224,45 +131,32 @@ const createEmitter = (config, overrides) => {
224
131
  };
225
132
  //#endregion
226
133
  //#region src/middleware.ts
227
- function omitUndefined(attributes) {
228
- const out = {};
229
- for (const key of Object.keys(attributes)) {
230
- const value = attributes[key];
231
- if (value !== void 0) out[key] = value;
232
- }
233
- return out;
234
- }
134
+ const isString = (value) => typeof value === "string";
135
+ const isNumber = (value) => typeof value === "number";
136
+ const isBigInt = (value) => typeof value === "bigint";
137
+ const isObject = (value) => value !== null && typeof value === "object";
235
138
  function readId(value) {
236
- if (typeof value === "string") return value.length > 0 ? value : null;
237
- if (typeof value === "number" || typeof value === "bigint") return value.toString();
139
+ if (isString(value)) return value.length > 0 ? value : null;
140
+ if (isNumber(value) || isBigInt(value)) return value.toString();
238
141
  return null;
239
142
  }
240
- function jsonAttr(value) {
241
- if (value === void 0) return void 0;
242
- if (typeof value === "string") return value;
243
- try {
244
- return JSON.stringify(value);
245
- } catch {
246
- return;
247
- }
248
- }
249
143
  function firstNumber(...candidates) {
250
- for (const candidate of candidates) if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
144
+ for (const candidate of candidates) if (isNumber(candidate) && Number.isFinite(candidate)) return candidate;
251
145
  }
252
146
  function errorTypeName(err) {
253
147
  if (err instanceof Error) return err.name || "Error";
254
- if (err && typeof err === "object" && "name" in err) {
148
+ if (err && isObject(err) && "name" in err) {
255
149
  const n = err.name;
256
- if (typeof n === "string" && n.length > 0) return n;
150
+ if (isString(n) && n.length > 0) return n;
257
151
  }
258
152
  return "Error";
259
153
  }
260
154
  function errorMessage(err) {
261
155
  if (err instanceof Error) return err.message;
262
- if (typeof err === "string") return err;
263
- if (err && typeof err === "object" && "message" in err) {
156
+ if (isString(err)) return err;
157
+ if (err && isObject(err) && "message" in err) {
264
158
  const m = err.message;
265
- if (typeof m === "string") return m;
159
+ if (isString(m)) return m;
266
160
  }
267
161
  return String(err);
268
162
  }
@@ -278,7 +172,7 @@ const MAX_TOKENS_KEYS = [
278
172
  ];
279
173
  function samplingAttributes(modelOptions) {
280
174
  const sampling = modelOptions ?? {};
281
- const nested = sampling["options"] && typeof sampling["options"] === "object" ? sampling["options"] : void 0;
175
+ const nested = sampling["options"] && isObject(sampling["options"]) ? sampling["options"] : void 0;
282
176
  return omitUndefined({
283
177
  "gen_ai.request.temperature": firstNumber(sampling["temperature"], nested?.["temperature"]),
284
178
  "gen_ai.request.top_p": firstNumber(sampling["top_p"], sampling["topP"], nested?.["top_p"]),
@@ -287,7 +181,8 @@ function samplingAttributes(modelOptions) {
287
181
  }
288
182
  /**
289
183
  * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
290
- * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call, one CLIENT
184
+ * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call (the roots
185
+ * of one session share a trace, see withSessionParent), one CLIENT
291
186
  * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
292
187
  * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
293
188
  * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
@@ -337,7 +232,7 @@ function telemetryDev(options, overrides) {
337
232
  ...state.rootSampling
338
233
  }));
339
234
  if (state.restMetadata) for (const [key, value] of Object.entries(state.restMetadata)) {
340
- const attr = typeof value === "string" ? value : jsonAttr(value);
235
+ const attr = isString(value) ? value : jsonAttr(value);
341
236
  if (attr !== void 0) state.rootSpan.setAttribute(`td.metadata.${key}`, attr);
342
237
  }
343
238
  };
@@ -398,7 +293,7 @@ function telemetryDev(options, overrides) {
398
293
  onStart(ctx) {
399
294
  try {
400
295
  const rawMetadata = ctx.options?.["metadata"];
401
- const metadata = rawMetadata && typeof rawMetadata === "object" ? rawMetadata : void 0;
296
+ const metadata = rawMetadata && isObject(rawMetadata) ? rawMetadata : void 0;
402
297
  const userId = readId(metadata?.["userId"]);
403
298
  const sessionId = readId(metadata?.["sessionId"]) ?? ctx.threadId;
404
299
  let restMetadata;
@@ -410,7 +305,7 @@ function telemetryDev(options, overrides) {
410
305
  const rootSpan = emitter.tracer.startSpan("chat", {
411
306
  startTime: /* @__PURE__ */ new Date(),
412
307
  kind: SpanKind.INTERNAL
413
- });
308
+ }, withSessionParent(context.active(), sessionId ?? void 0, config.apiKey));
414
309
  states.set(ctx, {
415
310
  rootSpan,
416
311
  rootCtx: trace.setSpan(ROOT_CONTEXT, rootSpan),
@@ -444,7 +339,7 @@ function telemetryDev(options, overrides) {
444
339
  const inputMessages = [];
445
340
  for (const prompt of chatConfig.systemPrompts) inputMessages.push({
446
341
  role: "system",
447
- content: typeof prompt === "string" ? prompt : prompt.content
342
+ content: isString(prompt) ? prompt : prompt.content
448
343
  });
449
344
  for (const message of chatConfig.messages) inputMessages.push({
450
345
  role: message.role,
@@ -493,7 +388,7 @@ function telemetryDev(options, overrides) {
493
388
  if (chunk.type === "CUSTOM") {
494
389
  if (iteration.structured && chunk.name === "structured-output.complete") {
495
390
  const raw = chunk.value?.raw;
496
- if (typeof raw === "string") iteration.outputText = raw;
391
+ if (isString(raw)) iteration.outputText = raw;
497
392
  }
498
393
  return;
499
394
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/tanstack-ai",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "TanStack AI telemetry integration for telemetry.dev: chat() middleware emitting OpenTelemetry GenAI spans.",
5
5
  "keywords": [
6
6
  "genai",
@@ -36,13 +36,13 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "@opentelemetry/api": "^1.9.1",
39
- "@opentelemetry/otlp-transformer": "^0.218.0",
39
+ "@opentelemetry/core": "^2.7.1",
40
40
  "@opentelemetry/resources": "^2.7.1",
41
41
  "@opentelemetry/sdk-metrics": "^2.7.1",
42
- "@opentelemetry/sdk-trace-base": "^2.7.1"
42
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
43
+ "@telemetry-dev/otel": "^0.1.2"
43
44
  },
44
45
  "devDependencies": {
45
- "@opentelemetry/core": "^2.7.1",
46
46
  "@tanstack/ai": "0.28.0",
47
47
  "@types/node": "^25.5.0",
48
48
  "@typescript/native-preview": "7.0.0-dev.20260328.1",
package/src/config.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { Sampler } from "@opentelemetry/sdk-trace-base";
2
+
1
3
  export interface TelemetryDevOptions {
2
4
  /** telemetry.dev ingest key (`td_live_…`). Falls back to `TELEMETRY_DEV_API_KEY`. When absent the integration is a no-op. */
3
5
  apiKey?: string;
@@ -7,12 +9,14 @@ export interface TelemetryDevOptions {
7
9
  environment?: string;
8
10
  /** Service name attached to every trace. Falls back to `OTEL_SERVICE_NAME`, then `unknown_service`. */
9
11
  serviceName?: string;
12
+ /** OTel sampling policy. Defaults to OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG. */
13
+ sampler?: Sampler;
10
14
  /** Injected fetch implementation. Defaults to `globalThis.fetch`. */
11
15
  fetch?: typeof fetch;
12
16
  /** Serverless extender (e.g. Cloudflare `ctx.waitUntil`). When provided, `onFinish` does not await the POST. */
13
17
  waitUntil?: (p: Promise<unknown>) => void;
14
18
  /** Receives any error raised while emitting telemetry; the integration never throws into the SDK. */
15
- onError?: (error: unknown) => void;
19
+ onError?: (cause: unknown) => void;
16
20
  }
17
21
 
18
22
  export interface ResolvedConfig {
@@ -20,18 +24,16 @@ export interface ResolvedConfig {
20
24
  baseUrl: string;
21
25
  environment: string;
22
26
  serviceName: string;
27
+ sampler?: Sampler;
23
28
  fetchImpl: typeof fetch;
24
29
  waitUntil?: (p: Promise<unknown>) => void;
25
- onError?: (error: unknown) => void;
30
+ onError?: (cause: unknown) => void;
26
31
  }
27
32
 
28
33
  const DEFAULT_BASE_URL = "https://ingest.telemetry.dev";
29
34
 
30
35
  export function resolveConfig(options: TelemetryDevOptions = {}): ResolvedConfig {
31
- const env =
32
- typeof process !== "undefined" && process.env
33
- ? process.env
34
- : ({} as Record<string, string | undefined>);
36
+ const env = globalThis.process?.env ?? {};
35
37
  const baseUrl = (options.baseUrl ?? env.TELEMETRY_DEV_BASE_URL ?? DEFAULT_BASE_URL).replace(
36
38
  /\/+$/,
37
39
  "",
@@ -41,6 +43,7 @@ export function resolveConfig(options: TelemetryDevOptions = {}): ResolvedConfig
41
43
  baseUrl,
42
44
  environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
43
45
  serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
46
+ sampler: options.sampler,
44
47
  fetchImpl: options.fetch ?? globalThis.fetch,
45
48
  waitUntil: options.waitUntil,
46
49
  onError: options.onError,
package/src/middleware.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  type Attributes,
3
3
  type Context,
4
+ context as otelContext,
4
5
  ROOT_CONTEXT,
5
6
  type Span,
6
7
  SpanKind,
7
8
  SpanStatusCode,
8
9
  trace,
9
10
  } from "@opentelemetry/api";
11
+ import { withSessionParent } from "@telemetry-dev/otel";
10
12
  import type {
11
13
  AbortInfo,
12
14
  AfterToolCallInfo,
@@ -20,67 +22,61 @@ import type {
20
22
  ToolPhaseCompleteInfo,
21
23
  UsageInfo,
22
24
  } from "@tanstack/ai";
25
+ import { jsonAttr, omitUndefined } from "@telemetry-dev/otel";
23
26
 
24
27
  import { resolveConfig, type TelemetryDevOptions } from "./config.ts";
25
28
  import { createEmitter, type EmitterOverrides } from "./otel.ts";
26
29
 
27
- function omitUndefined(attributes: Attributes): Attributes {
28
- const out: Attributes = {};
29
- for (const key of Object.keys(attributes)) {
30
- const value = attributes[key];
31
- if (value !== undefined) {
32
- out[key] = value;
33
- }
34
- }
35
- return out;
36
- }
37
-
38
- function readId(value: unknown): string | null {
39
- if (typeof value === "string") {
30
+ type JsonValue =
31
+ | string
32
+ | number
33
+ | bigint
34
+ | boolean
35
+ | null
36
+ | undefined
37
+ | JsonValue[]
38
+ | { [key: string]: JsonValue };
39
+
40
+ const isString = (value: unknown): value is string => typeof value === "string";
41
+ const isNumber = (value: unknown): value is number => typeof value === "number";
42
+ const isBigInt = (value: unknown): value is bigint => typeof value === "bigint";
43
+ const isObject = <T>(value: T): value is T & { [key: string]: JsonValue } =>
44
+ value !== null && typeof value === "object";
45
+
46
+ function readId<T>(value: T): string | null {
47
+ if (isString(value)) {
40
48
  return value.length > 0 ? value : null;
41
49
  }
42
- if (typeof value === "number" || typeof value === "bigint") {
50
+ if (isNumber(value) || isBigInt(value)) {
43
51
  return value.toString();
44
52
  }
45
53
  return null;
46
54
  }
47
55
 
48
- // Stringify structured content (messages / tool args) for the gen_ai.* string attributes the
49
- // ingest parses back into JSON. Returns undefined so omitUndefined drops absent content.
50
- function jsonAttr(value: unknown): string | undefined {
51
- if (value === undefined) return undefined;
52
- if (typeof value === "string") return value;
53
- try {
54
- return JSON.stringify(value);
55
- } catch {
56
- return undefined;
57
- }
58
- }
59
-
60
- function firstNumber(...candidates: unknown[]): number | undefined {
56
+ function firstNumber<T>(...candidates: T[]): number | undefined {
61
57
  for (const candidate of candidates) {
62
- if (typeof candidate === "number" && Number.isFinite(candidate)) {
58
+ if (isNumber(candidate) && Number.isFinite(candidate)) {
63
59
  return candidate;
64
60
  }
65
61
  }
66
62
  return undefined;
67
63
  }
68
64
 
69
- function errorTypeName(err: unknown): string {
65
+ function errorTypeName<T>(err: T): string {
70
66
  if (err instanceof Error) return err.name || "Error";
71
- if (err && typeof err === "object" && "name" in err) {
67
+ if (err && isObject(err) && "name" in err) {
72
68
  const n = (err as { name?: unknown }).name;
73
- if (typeof n === "string" && n.length > 0) return n;
69
+ if (isString(n) && n.length > 0) return n;
74
70
  }
75
71
  return "Error";
76
72
  }
77
73
 
78
- function errorMessage(err: unknown): string {
74
+ function errorMessage<T>(err: T): string {
79
75
  if (err instanceof Error) return err.message;
80
- if (typeof err === "string") return err;
81
- if (err && typeof err === "object" && "message" in err) {
76
+ if (isString(err)) return err;
77
+ if (err && isObject(err) && "message" in err) {
82
78
  const m = (err as { message?: unknown }).message;
83
- if (typeof m === "string") return m;
79
+ if (isString(m)) return m;
84
80
  }
85
81
  return String(err);
86
82
  }
@@ -101,11 +97,11 @@ const MAX_TOKENS_KEYS = [
101
97
 
102
98
  // Sampling options live in opaque provider-native `modelOptions`; pick the first numeric value
103
99
  // among the known spellings (including Ollama's nested `options`) for the gen_ai.request.* attrs.
104
- function samplingAttributes(modelOptions: Record<string, unknown> | undefined): Attributes {
100
+ function samplingAttributes(modelOptions: Record<string, JsonValue> | undefined): Attributes {
105
101
  const sampling = modelOptions ?? {};
106
102
  const nested =
107
- sampling["options"] && typeof sampling["options"] === "object"
108
- ? (sampling["options"] as Record<string, unknown>)
103
+ sampling["options"] && isObject(sampling["options"])
104
+ ? (sampling["options"] as Record<string, JsonValue>)
109
105
  : undefined;
110
106
  return omitUndefined({
111
107
  "gen_ai.request.temperature": firstNumber(sampling["temperature"], nested?.["temperature"]),
@@ -136,7 +132,7 @@ interface RunState {
136
132
  responseModel: string | null;
137
133
  userId: string | null;
138
134
  sessionId: string;
139
- restMetadata: Record<string, unknown> | undefined;
135
+ restMetadata: Record<string, JsonValue> | undefined;
140
136
  rootInput: string | undefined;
141
137
  rootSampling: Attributes;
142
138
  rootCaptured: boolean;
@@ -157,7 +153,8 @@ interface RunState {
157
153
 
158
154
  /**
159
155
  * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
160
- * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call, one CLIENT
156
+ * OpenTelemetry GenAI (`gen_ai.*`) spans + metrics: one root span per `chat()` call (the roots
157
+ * of one session share a trace, see withSessionParent), one CLIENT
161
158
  * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
162
159
  * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
163
160
  * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
@@ -229,7 +226,7 @@ export function telemetryDev(
229
226
  );
230
227
  if (state.restMetadata) {
231
228
  for (const [key, value] of Object.entries(state.restMetadata)) {
232
- const attr = typeof value === "string" ? value : jsonAttr(value);
229
+ const attr = isString(value) ? value : jsonAttr(value);
233
230
  if (attr !== undefined) {
234
231
  state.rootSpan.setAttribute(`td.metadata.${key}`, attr);
235
232
  }
@@ -297,14 +294,14 @@ export function telemetryDev(
297
294
  try {
298
295
  const rawMetadata = ctx.options?.["metadata"];
299
296
  const metadata =
300
- rawMetadata && typeof rawMetadata === "object"
301
- ? (rawMetadata as Record<string, unknown>)
297
+ rawMetadata && isObject(rawMetadata)
298
+ ? (rawMetadata as Record<string, JsonValue>)
302
299
  : undefined;
303
300
  const userId = readId(metadata?.["userId"]);
304
301
  const sessionId = readId(metadata?.["sessionId"]) ?? ctx.threadId;
305
- let restMetadata: Record<string, unknown> | undefined;
302
+ let restMetadata: Record<string, JsonValue> | undefined;
306
303
  if (metadata) {
307
- const rest: Record<string, unknown> = {};
304
+ const rest: Record<string, JsonValue> = {};
308
305
  for (const [key, value] of Object.entries(metadata)) {
309
306
  if (key !== "userId" && key !== "sessionId") {
310
307
  rest[key] = value;
@@ -313,10 +310,12 @@ export function telemetryDev(
313
310
  restMetadata = Object.keys(rest).length > 0 ? rest : undefined;
314
311
  }
315
312
 
316
- const rootSpan = emitter.tracer.startSpan("chat", {
317
- startTime: new Date(),
318
- kind: SpanKind.INTERNAL,
319
- });
313
+ // Session-parented: see withSessionParent.
314
+ const rootSpan = emitter.tracer.startSpan(
315
+ "chat",
316
+ { startTime: new Date(), kind: SpanKind.INTERNAL },
317
+ withSessionParent(otelContext.active(), sessionId ?? undefined, config.apiKey),
318
+ );
320
319
  states.set(ctx, {
321
320
  rootSpan,
322
321
  rootCtx: trace.setSpan(ROOT_CONTEXT, rootSpan),
@@ -360,14 +359,16 @@ export function telemetryDev(
360
359
  for (const prompt of chatConfig.systemPrompts) {
361
360
  inputMessages.push({
362
361
  role: "system",
363
- content: typeof prompt === "string" ? prompt : prompt.content,
362
+ content: isString(prompt) ? prompt : prompt.content,
364
363
  });
365
364
  }
366
365
  for (const message of chatConfig.messages) {
367
366
  inputMessages.push({ role: message.role, content: message.content });
368
367
  }
369
368
  const inputJson = jsonAttr(inputMessages);
370
- const sampling = samplingAttributes(chatConfig.modelOptions ?? ctx.modelOptions);
369
+ const sampling = samplingAttributes(
370
+ (chatConfig.modelOptions ?? ctx.modelOptions) as Record<string, JsonValue> | undefined,
371
+ );
371
372
  if (!state.rootCaptured) {
372
373
  state.rootCaptured = true;
373
374
  state.rootInput = inputJson;
@@ -418,7 +419,7 @@ export function telemetryDev(
418
419
  // still holds the agent loop's text, so this is the structured span's only output.
419
420
  if (iteration.structured && chunk.name === "structured-output.complete") {
420
421
  const raw = (chunk.value as { raw?: unknown } | null | undefined)?.raw;
421
- if (typeof raw === "string") iteration.outputText = raw;
422
+ if (isString(raw)) iteration.outputText = raw;
422
423
  }
423
424
  return undefined;
424
425
  }
package/src/otel.ts CHANGED
@@ -1,35 +1,30 @@
1
- import { type Attributes, type Histogram, type Span, type Tracer } from "@opentelemetry/api";
2
1
  import {
3
- ProtobufMetricsSerializer,
4
- ProtobufTraceSerializer,
5
- } from "@opentelemetry/otlp-transformer";
2
+ type Attributes,
3
+ type Histogram,
4
+ type Span,
5
+ TraceFlags,
6
+ type Tracer,
7
+ } from "@opentelemetry/api";
8
+ import { ExportResultCode } from "@opentelemetry/core";
6
9
  import { resourceFromAttributes } from "@opentelemetry/resources";
7
- import {
8
- AggregationTemporality,
9
- MeterProvider,
10
- PeriodicExportingMetricReader,
11
- type PushMetricExporter,
12
- type ResourceMetrics,
13
- } from "@opentelemetry/sdk-metrics";
10
+ import { MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
14
11
  import { BasicTracerProvider, type ReadableSpan } from "@opentelemetry/sdk-trace-base";
12
+ import {
13
+ createMetricExporter,
14
+ createTraceExporter,
15
+ DORMANT_INTERVAL_MS,
16
+ DURATION_BUCKETS,
17
+ otlpHeaders,
18
+ sessionSampler,
19
+ TOKEN_BUCKETS,
20
+ } from "@telemetry-dev/otel";
15
21
 
16
22
  import type { ResolvedConfig } from "./config.ts";
17
23
 
18
- // Histogram bucket boundaries from the OTel GenAI semantic-convention recommendations for
19
- // gen_ai.client.operation.duration (seconds) and gen_ai.client.token.usage ({token}).
20
- const DURATION_BUCKETS = [
21
- 0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28, 2.56, 5.12, 10.24, 20.48, 40.96, 81.92,
22
- ];
23
- const TOKEN_BUCKETS = [
24
- 1, 4, 16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, 67108864,
25
- ];
26
-
27
24
  const SCOPE_NAME = "@telemetry-dev/tanstack-ai";
28
25
  const SCOPE_VERSION = "0.0.0";
29
26
 
30
- // Spans created by the SDK tracer are concrete `Span`s that, once `.end()`ed, also satisfy
31
- // ReadableSpan (the serializer's input). The integration only holds the api `Span` type, so the
32
- // emitter surface accepts `Span[]` and casts at the single serialize site.
27
+ // The sampler can return non-recording API spans.
33
28
  export interface Emitter {
34
29
  tracer: Tracer;
35
30
  recordDuration(seconds: number, attributes: Attributes): void;
@@ -38,52 +33,12 @@ export interface Emitter {
38
33
  }
39
34
 
40
35
  export interface EmitterOverrides {
41
- // Test seam: capture serialized spans / recorded metric points instead of POSTing them.
42
36
  sendSpans?: (spans: ReadableSpan[]) => Promise<void>;
43
37
  recordDuration?: (seconds: number, attributes: Attributes) => void;
44
38
  recordTokens?: (tokenType: "input" | "output", count: number, attributes: Attributes) => void;
45
39
  }
46
40
 
47
- type Transport = { fetchImpl: typeof fetch; onError?: (error: unknown) => void };
48
-
49
- const RETRY_DELAYS_MS = [100, 500] as const;
50
-
51
- const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
52
-
53
- const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
54
-
55
- const cancelBody = async (res: Response) => {
56
- try {
57
- await res.body?.cancel();
58
- } catch {}
59
- };
60
-
61
- const postOtlp = async ({
62
- fetchImpl,
63
- url,
64
- headers,
65
- body,
66
- }: {
67
- fetchImpl: typeof fetch;
68
- url: string;
69
- headers: Record<string, string>;
70
- body: Uint8Array;
71
- }) => {
72
- for (let attempt = 0; ; attempt += 1) {
73
- try {
74
- const res = await fetchImpl(url, { method: "POST", headers, body });
75
- if (res.ok || !RETRYABLE_STATUSES.has(res.status) || attempt === RETRY_DELAYS_MS.length) {
76
- await cancelBody(res);
77
- return res;
78
- }
79
- await cancelBody(res);
80
- } catch (error) {
81
- if (attempt === RETRY_DELAYS_MS.length) throw error;
82
- }
83
-
84
- await delay(RETRY_DELAYS_MS[attempt]!);
85
- }
86
- };
41
+ type Transport = { fetchImpl: typeof fetch; onError?: (cause: unknown) => void };
87
42
 
88
43
  interface MetricsPipeline {
89
44
  durationHistogram: Histogram;
@@ -97,6 +52,12 @@ interface EmitterCore {
97
52
  buildMetrics: (transport: Transport) => MetricsPipeline;
98
53
  }
99
54
 
55
+ const isReadableSpan = (span: Span): span is Span & ReadableSpan =>
56
+ "resource" in span &&
57
+ "instrumentationScope" in span &&
58
+ "events" in span &&
59
+ (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0;
60
+
100
61
  const cache = new Map<string, EmitterCore>();
101
62
 
102
63
  const buildCore = (config: ResolvedConfig): EmitterCore => {
@@ -105,23 +66,21 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
105
66
  "service.name": serviceName,
106
67
  "deployment.environment.name": environment,
107
68
  });
108
- const headers = {
109
- "content-type": "application/x-protobuf",
110
- authorization: `Bearer ${apiKey}`,
111
- };
69
+ const headers = otlpHeaders(apiKey ?? "", "@telemetry-dev/tanstack-ai");
112
70
 
113
- const provider = new BasicTracerProvider({ resource });
71
+ const provider = new BasicTracerProvider({ resource, sampler: sessionSampler(config.sampler) });
114
72
  const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
115
73
 
116
- const sendSpans = async (spans: ReadableSpan[], transport: Transport): Promise<void> => {
117
- const body = ProtobufTraceSerializer.serializeRequest(spans);
118
- if (!body || body.byteLength === 0) return;
119
- const { fetchImpl, onError } = transport;
120
- const res = await postOtlp({ fetchImpl, url: `${baseUrl}/v1/traces`, headers, body });
121
- if (!res.ok) {
122
- onError?.(new Error(`telemetry.dev trace ingest failed: ${res.status}`));
123
- }
124
- };
74
+ const sendSpans = (spans: ReadableSpan[], transport: Transport): Promise<void> =>
75
+ new Promise((resolve, reject) => {
76
+ createTraceExporter(
77
+ { url: `${baseUrl}/v1/traces`, headers },
78
+ { fetchImpl: transport.fetchImpl },
79
+ ).export(spans, (result) => {
80
+ if (result.code === ExportResultCode.SUCCESS) resolve();
81
+ else reject(result.error ?? new Error("telemetry.dev trace export failed"));
82
+ });
83
+ });
125
84
 
126
85
  // Each emitter builds its OWN metrics pipeline bound to its OWN transport: the exporter closes
127
86
  // over this call's fetch/onError, so concurrent same-identity emitters never flush metrics
@@ -129,39 +88,10 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
129
88
  // interval timer) and lazily rebuilt, so a reused globally-registered emitter gets a fresh meter
130
89
  // per generation without leaking timers.
131
90
  const buildMetrics = (transport: Transport): MetricsPipeline => {
132
- const { fetchImpl, onError } = transport;
133
- const metricsExporter: PushMetricExporter = {
134
- export(resourceMetrics: ResourceMetrics, resultCallback) {
135
- const body = ProtobufMetricsSerializer.serializeRequest(resourceMetrics);
136
- if (!body || body.byteLength === 0) {
137
- resultCallback({ code: 0 });
138
- return;
139
- }
140
- // DELTA metric batches are not idempotent; a retry after an already-ingested response would double-count.
141
- fetchImpl(`${baseUrl}/v1/metrics`, { method: "POST", headers, body })
142
- .then((res) => {
143
- if (!res.ok) {
144
- const error = new Error(`telemetry.dev metric ingest failed: ${res.status}`);
145
- onError?.(error);
146
- resultCallback({ code: 1, error });
147
- return;
148
- }
149
- resultCallback({ code: 0 });
150
- })
151
- .catch((error: unknown) => {
152
- onError?.(error);
153
- resultCallback({ code: 1, error: error instanceof Error ? error : undefined });
154
- });
155
- },
156
- selectAggregationTemporality: () => AggregationTemporality.DELTA,
157
- forceFlush: () => Promise.resolve(),
158
- shutdown: () => Promise.resolve(),
159
- };
160
-
161
91
  // A long interval keeps the periodic timer dormant; the single flush is driven by shutdown().
162
92
  const reader = new PeriodicExportingMetricReader({
163
- exporter: metricsExporter,
164
- exportIntervalMillis: 2 ** 31 - 1,
93
+ exporter: createMetricExporter({ url: `${baseUrl}/v1/metrics`, headers }, transport),
94
+ exportIntervalMillis: DORMANT_INTERVAL_MS,
165
95
  });
166
96
  const meterProvider = new MeterProvider({ resource, readers: [reader] });
167
97
  const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
@@ -183,6 +113,7 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
183
113
  };
184
114
 
185
115
  const coreFor = (config: ResolvedConfig): EmitterCore => {
116
+ if (config.sampler) return buildCore(config);
186
117
  const key = `${config.apiKey}|${config.baseUrl}|${config.environment}|${config.serviceName}`;
187
118
  const cached = cache.get(key);
188
119
  if (cached) return cached;
@@ -226,14 +157,15 @@ export const createEmitter = (config: ResolvedConfig, overrides?: EmitterOverrid
226
157
  metrics = undefined;
227
158
  const p = (async () => {
228
159
  try {
229
- await sendSpans(spans as unknown as ReadableSpan[]);
160
+ const readableSpans = spans.filter(isReadableSpan);
161
+ if (readableSpans.length) await sendSpans(readableSpans);
230
162
  } catch (e) {
231
- onError?.(e);
163
+ onError?.(e instanceof Error ? e : new Error(String(e)));
232
164
  }
233
165
  try {
234
166
  if (pipeline) await pipeline.shutdown();
235
167
  } catch (e) {
236
- onError?.(e);
168
+ onError?.(e instanceof Error ? e : new Error(String(e)));
237
169
  }
238
170
  })();
239
171
  if (config.waitUntil) {