@telemetry-dev/tanstack-ai 0.1.1 → 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,6 +12,8 @@ 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. */
@@ -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,7 +1,8 @@
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";
@@ -13,6 +14,7 @@ function resolveConfig(options = {}) {
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,73 +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
- };
89
- const isReadableSpan = (span) => "resource" in span && "instrumentationScope" in span && "events" in span;
27
+ const isReadableSpan = (span) => "resource" in span && "instrumentationScope" in span && "events" in span && (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0;
90
28
  const cache = /* @__PURE__ */ new Map();
91
29
  const buildCore = (config) => {
92
30
  const { apiKey, baseUrl, environment, serviceName } = config;
@@ -94,64 +32,29 @@ const buildCore = (config) => {
94
32
  "service.name": serviceName,
95
33
  "deployment.environment.name": environment
96
34
  });
97
- const headers = {
98
- "content-type": "application/x-protobuf",
99
- authorization: `Bearer ${apiKey}`,
100
- "x-telemetry-dev-sdk": "@telemetry-dev/tanstack-ai"
101
- };
102
- const tracer = new BasicTracerProvider({ resource }).getTracer(SCOPE_NAME, SCOPE_VERSION);
103
- const sendSpans = async (spans, transport) => {
104
- const body = ProtobufTraceSerializer.serializeRequest(spans);
105
- if (!body || body.byteLength === 0) return;
106
- const { fetchImpl, onError } = transport;
107
- const res = await postOtlp({
108
- 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({
109
42
  url: `${baseUrl}/v1/traces`,
110
- headers,
111
- 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"));
112
47
  });
113
- if (!res.ok) onError?.(/* @__PURE__ */ new Error(`telemetry.dev trace ingest failed: ${res.status}`));
114
- };
48
+ });
115
49
  const buildMetrics = (transport) => {
116
- const { fetchImpl, onError } = transport;
117
50
  const meterProvider = new MeterProvider({
118
51
  resource,
119
52
  readers: [new PeriodicExportingMetricReader({
120
- exporter: {
121
- export(resourceMetrics, resultCallback) {
122
- const body = ProtobufMetricsSerializer.serializeRequest(resourceMetrics);
123
- if (!body || body.byteLength === 0) {
124
- resultCallback({ code: 0 });
125
- return;
126
- }
127
- fetchImpl(`${baseUrl}/v1/metrics`, {
128
- method: "POST",
129
- headers,
130
- body
131
- }).then((res) => {
132
- if (!res.ok) {
133
- const error = /* @__PURE__ */ new Error(`telemetry.dev metric ingest failed: ${res.status}`);
134
- onError?.(error);
135
- resultCallback({
136
- code: 1,
137
- error
138
- });
139
- return;
140
- }
141
- resultCallback({ code: 0 });
142
- }).catch((cause) => {
143
- onError?.(cause);
144
- resultCallback({
145
- code: 1,
146
- error: cause instanceof Error ? cause : void 0
147
- });
148
- });
149
- },
150
- selectAggregationTemporality: () => AggregationTemporality.DELTA,
151
- forceFlush: () => Promise.resolve(),
152
- shutdown: () => Promise.resolve()
153
- },
154
- exportIntervalMillis: 2 ** 31 - 1
53
+ exporter: createMetricExporter({
54
+ url: `${baseUrl}/v1/metrics`,
55
+ headers
56
+ }, transport),
57
+ exportIntervalMillis: DORMANT_INTERVAL_MS
155
58
  })]
156
59
  });
157
60
  const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
@@ -174,6 +77,7 @@ const buildCore = (config) => {
174
77
  };
175
78
  };
176
79
  const coreFor = (config) => {
80
+ if (config.sampler) return buildCore(config);
177
81
  const key = `${config.apiKey}|${config.baseUrl}|${config.environment}|${config.serviceName}`;
178
82
  const cached = cache.get(key);
179
83
  if (cached) return cached;
@@ -201,7 +105,8 @@ const createEmitter = (config, overrides) => {
201
105
  metrics = void 0;
202
106
  const p = (async () => {
203
107
  try {
204
- await sendSpans(spans.filter(isReadableSpan));
108
+ const readableSpans = spans.filter(isReadableSpan);
109
+ if (readableSpans.length) await sendSpans(readableSpans);
205
110
  } catch (e) {
206
111
  onError?.(e instanceof Error ? e : new Error(String(e)));
207
112
  }
@@ -230,28 +135,11 @@ const isString = (value) => typeof value === "string";
230
135
  const isNumber = (value) => typeof value === "number";
231
136
  const isBigInt = (value) => typeof value === "bigint";
232
137
  const isObject = (value) => value !== null && typeof value === "object";
233
- function omitUndefined(attributes) {
234
- const out = {};
235
- for (const key of Object.keys(attributes)) {
236
- const value = attributes[key];
237
- if (value !== void 0) out[key] = value;
238
- }
239
- return out;
240
- }
241
138
  function readId(value) {
242
139
  if (isString(value)) return value.length > 0 ? value : null;
243
140
  if (isNumber(value) || isBigInt(value)) return value.toString();
244
141
  return null;
245
142
  }
246
- function jsonAttr(value) {
247
- if (value === void 0) return void 0;
248
- if (isString(value)) return value;
249
- try {
250
- return JSON.stringify(value);
251
- } catch {
252
- return;
253
- }
254
- }
255
143
  function firstNumber(...candidates) {
256
144
  for (const candidate of candidates) if (isNumber(candidate) && Number.isFinite(candidate)) return candidate;
257
145
  }
@@ -293,7 +181,8 @@ function samplingAttributes(modelOptions) {
293
181
  }
294
182
  /**
295
183
  * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
296
- * 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
297
186
  * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
298
187
  * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
299
188
  * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
@@ -416,7 +305,7 @@ function telemetryDev(options, overrides) {
416
305
  const rootSpan = emitter.tracer.startSpan("chat", {
417
306
  startTime: /* @__PURE__ */ new Date(),
418
307
  kind: SpanKind.INTERNAL
419
- });
308
+ }, withSessionParent(context.active(), sessionId ?? void 0, config.apiKey));
420
309
  states.set(ctx, {
421
310
  rootSpan,
422
311
  rootCtx: trace.setSpan(ROOT_CONTEXT, rootSpan),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telemetry-dev/tanstack-ai",
3
- "version": "0.1.1",
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,6 +9,8 @@ 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. */
@@ -20,6 +24,7 @@ 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
30
  onError?: (cause: unknown) => void;
@@ -38,6 +43,7 @@ export function resolveConfig(options: TelemetryDevOptions = {}): ResolvedConfig
38
43
  baseUrl,
39
44
  environment: options.environment ?? env.TELEMETRY_DEV_ENVIRONMENT ?? "production",
40
45
  serviceName: options.serviceName ?? env.OTEL_SERVICE_NAME ?? "unknown_service",
46
+ sampler: options.sampler,
41
47
  fetchImpl: options.fetch ?? globalThis.fetch,
42
48
  waitUntil: options.waitUntil,
43
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,6 +22,7 @@ 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";
@@ -40,17 +43,6 @@ const isBigInt = (value: unknown): value is bigint => typeof value === "bigint";
40
43
  const isObject = <T>(value: T): value is T & { [key: string]: JsonValue } =>
41
44
  value !== null && typeof value === "object";
42
45
 
43
- function omitUndefined(attributes: Attributes): Attributes {
44
- const out: Attributes = {};
45
- for (const key of Object.keys(attributes)) {
46
- const value = attributes[key];
47
- if (value !== undefined) {
48
- out[key] = value;
49
- }
50
- }
51
- return out;
52
- }
53
-
54
46
  function readId<T>(value: T): string | null {
55
47
  if (isString(value)) {
56
48
  return value.length > 0 ? value : null;
@@ -61,18 +53,6 @@ function readId<T>(value: T): string | null {
61
53
  return null;
62
54
  }
63
55
 
64
- // Stringify structured content (messages / tool args) for the gen_ai.* string attributes the
65
- // ingest parses back into JSON. Returns undefined so omitUndefined drops absent content.
66
- function jsonAttr<T>(value: T): string | undefined {
67
- if (value === undefined) return undefined;
68
- if (isString(value)) return value;
69
- try {
70
- return JSON.stringify(value);
71
- } catch {
72
- return undefined;
73
- }
74
- }
75
-
76
56
  function firstNumber<T>(...candidates: T[]): number | undefined {
77
57
  for (const candidate of candidates) {
78
58
  if (isNumber(candidate) && Number.isFinite(candidate)) {
@@ -173,7 +153,8 @@ interface RunState {
173
153
 
174
154
  /**
175
155
  * Build a TanStack AI chat middleware that streams `chat()` runs to telemetry.dev as
176
- * 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
177
158
  * span per agent-loop iteration, and one span per tool execution. Per-run state is keyed by the
178
159
  * middleware context in a WeakMap, so a single `telemetryDev()` instance is safe to share across
179
160
  * concurrent and overlapping `chat()` calls (e.g. registered once at module scope).
@@ -329,10 +310,12 @@ export function telemetryDev(
329
310
  restMetadata = Object.keys(rest).length > 0 ? rest : undefined;
330
311
  }
331
312
 
332
- const rootSpan = emitter.tracer.startSpan("chat", {
333
- startTime: new Date(),
334
- kind: SpanKind.INTERNAL,
335
- });
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
+ );
336
319
  states.set(ctx, {
337
320
  rootSpan,
338
321
  rootCtx: trace.setSpan(ROOT_CONTEXT, rootSpan),
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,7 +33,6 @@ 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;
@@ -46,45 +40,6 @@ export interface EmitterOverrides {
46
40
 
47
41
  type Transport = { fetchImpl: typeof fetch; onError?: (cause: unknown) => void };
48
42
 
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
- };
87
-
88
43
  interface MetricsPipeline {
89
44
  durationHistogram: Histogram;
90
45
  tokenHistogram: Histogram;
@@ -98,7 +53,10 @@ interface EmitterCore {
98
53
  }
99
54
 
100
55
  const isReadableSpan = (span: Span): span is Span & ReadableSpan =>
101
- "resource" in span && "instrumentationScope" in span && "events" in span;
56
+ "resource" in span &&
57
+ "instrumentationScope" in span &&
58
+ "events" in span &&
59
+ (span.spanContext().traceFlags & TraceFlags.SAMPLED) !== 0;
102
60
 
103
61
  const cache = new Map<string, EmitterCore>();
104
62
 
@@ -108,24 +66,21 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
108
66
  "service.name": serviceName,
109
67
  "deployment.environment.name": environment,
110
68
  });
111
- const headers = {
112
- "content-type": "application/x-protobuf",
113
- authorization: `Bearer ${apiKey}`,
114
- "x-telemetry-dev-sdk": "@telemetry-dev/tanstack-ai",
115
- };
69
+ const headers = otlpHeaders(apiKey ?? "", "@telemetry-dev/tanstack-ai");
116
70
 
117
- const provider = new BasicTracerProvider({ resource });
71
+ const provider = new BasicTracerProvider({ resource, sampler: sessionSampler(config.sampler) });
118
72
  const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
119
73
 
120
- const sendSpans = async (spans: ReadableSpan[], transport: Transport): Promise<void> => {
121
- const body = ProtobufTraceSerializer.serializeRequest(spans);
122
- if (!body || body.byteLength === 0) return;
123
- const { fetchImpl, onError } = transport;
124
- const res = await postOtlp({ fetchImpl, url: `${baseUrl}/v1/traces`, headers, body });
125
- if (!res.ok) {
126
- onError?.(new Error(`telemetry.dev trace ingest failed: ${res.status}`));
127
- }
128
- };
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
+ });
129
84
 
130
85
  // Each emitter builds its OWN metrics pipeline bound to its OWN transport: the exporter closes
131
86
  // over this call's fetch/onError, so concurrent same-identity emitters never flush metrics
@@ -133,39 +88,10 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
133
88
  // interval timer) and lazily rebuilt, so a reused globally-registered emitter gets a fresh meter
134
89
  // per generation without leaking timers.
135
90
  const buildMetrics = (transport: Transport): MetricsPipeline => {
136
- const { fetchImpl, onError } = transport;
137
- const metricsExporter: PushMetricExporter = {
138
- export(resourceMetrics: ResourceMetrics, resultCallback) {
139
- const body = ProtobufMetricsSerializer.serializeRequest(resourceMetrics);
140
- if (!body || body.byteLength === 0) {
141
- resultCallback({ code: 0 });
142
- return;
143
- }
144
- // DELTA metric batches are not idempotent; a retry after an already-ingested response would double-count.
145
- fetchImpl(`${baseUrl}/v1/metrics`, { method: "POST", headers, body })
146
- .then((res) => {
147
- if (!res.ok) {
148
- const error = new Error(`telemetry.dev metric ingest failed: ${res.status}`);
149
- onError?.(error);
150
- resultCallback({ code: 1, error });
151
- return;
152
- }
153
- resultCallback({ code: 0 });
154
- })
155
- .catch((cause: unknown) => {
156
- onError?.(cause);
157
- resultCallback({ code: 1, error: cause instanceof Error ? cause : undefined });
158
- });
159
- },
160
- selectAggregationTemporality: () => AggregationTemporality.DELTA,
161
- forceFlush: () => Promise.resolve(),
162
- shutdown: () => Promise.resolve(),
163
- };
164
-
165
91
  // A long interval keeps the periodic timer dormant; the single flush is driven by shutdown().
166
92
  const reader = new PeriodicExportingMetricReader({
167
- exporter: metricsExporter,
168
- exportIntervalMillis: 2 ** 31 - 1,
93
+ exporter: createMetricExporter({ url: `${baseUrl}/v1/metrics`, headers }, transport),
94
+ exportIntervalMillis: DORMANT_INTERVAL_MS,
169
95
  });
170
96
  const meterProvider = new MeterProvider({ resource, readers: [reader] });
171
97
  const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
@@ -187,6 +113,7 @@ const buildCore = (config: ResolvedConfig): EmitterCore => {
187
113
  };
188
114
 
189
115
  const coreFor = (config: ResolvedConfig): EmitterCore => {
116
+ if (config.sampler) return buildCore(config);
190
117
  const key = `${config.apiKey}|${config.baseUrl}|${config.environment}|${config.serviceName}`;
191
118
  const cached = cache.get(key);
192
119
  if (cached) return cached;
@@ -231,7 +158,7 @@ export const createEmitter = (config: ResolvedConfig, overrides?: EmitterOverrid
231
158
  const p = (async () => {
232
159
  try {
233
160
  const readableSpans = spans.filter(isReadableSpan);
234
- await sendSpans(readableSpans);
161
+ if (readableSpans.length) await sendSpans(readableSpans);
235
162
  } catch (e) {
236
163
  onError?.(e instanceof Error ? e : new Error(String(e)));
237
164
  }