@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 +13 -7
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +29 -140
- package/package.json +4 -4
- package/src/config.ts +6 -0
- package/src/middleware.ts +11 -28
- package/src/otel.ts +38 -111
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
};
|
|
102
|
-
const
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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/
|
|
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
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
//
|
|
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 &&
|
|
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 =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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:
|
|
168
|
-
exportIntervalMillis:
|
|
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
|
}
|