@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 +13 -7
- package/dist/index.d.mts +6 -3
- package/dist/index.mjs +49 -154
- package/package.json +4 -4
- package/src/config.ts +9 -6
- package/src/middleware.ts +53 -52
- package/src/otel.ts +44 -112
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,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?: (
|
|
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
|
|
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 {
|
|
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";
|
|
8
9
|
function resolveConfig(options = {}) {
|
|
9
|
-
const 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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const sendSpans =
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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 (
|
|
237
|
-
if (
|
|
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 (
|
|
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 &&
|
|
148
|
+
if (err && isObject(err) && "name" in err) {
|
|
255
149
|
const n = err.name;
|
|
256
|
-
if (
|
|
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 (
|
|
263
|
-
if (err &&
|
|
156
|
+
if (isString(err)) return err;
|
|
157
|
+
if (err && isObject(err) && "message" in err) {
|
|
264
158
|
const m = err.message;
|
|
265
|
-
if (
|
|
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"] &&
|
|
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
|
|
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 =
|
|
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 &&
|
|
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:
|
|
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 (
|
|
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.
|
|
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,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?: (
|
|
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?: (
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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 (
|
|
50
|
+
if (isNumber(value) || isBigInt(value)) {
|
|
43
51
|
return value.toString();
|
|
44
52
|
}
|
|
45
53
|
return null;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
|
|
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 (
|
|
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:
|
|
65
|
+
function errorTypeName<T>(err: T): string {
|
|
70
66
|
if (err instanceof Error) return err.name || "Error";
|
|
71
|
-
if (err &&
|
|
67
|
+
if (err && isObject(err) && "name" in err) {
|
|
72
68
|
const n = (err as { name?: unknown }).name;
|
|
73
|
-
if (
|
|
69
|
+
if (isString(n) && n.length > 0) return n;
|
|
74
70
|
}
|
|
75
71
|
return "Error";
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
function errorMessage(err:
|
|
74
|
+
function errorMessage<T>(err: T): string {
|
|
79
75
|
if (err instanceof Error) return err.message;
|
|
80
|
-
if (
|
|
81
|
-
if (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 (
|
|
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,
|
|
100
|
+
function samplingAttributes(modelOptions: Record<string, JsonValue> | undefined): Attributes {
|
|
105
101
|
const sampling = modelOptions ?? {};
|
|
106
102
|
const nested =
|
|
107
|
-
sampling["options"] &&
|
|
108
|
-
? (sampling["options"] as Record<string,
|
|
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,
|
|
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
|
|
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 =
|
|
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 &&
|
|
301
|
-
? (rawMetadata as Record<string,
|
|
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,
|
|
302
|
+
let restMetadata: Record<string, JsonValue> | undefined;
|
|
306
303
|
if (metadata) {
|
|
307
|
-
const rest: Record<string,
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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:
|
|
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(
|
|
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 (
|
|
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
|
-
|
|
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,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?: (
|
|
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 =
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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:
|
|
164
|
-
exportIntervalMillis:
|
|
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
|
-
|
|
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) {
|