@telemetry-dev/tanstack-ai 0.1.0
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/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.d.mts +40 -0
- package/dist/index.mjs +681 -0
- package/package.json +62 -0
- package/src/config.ts +48 -0
- package/src/index.ts +2 -0
- package/src/middleware.ts +659 -0
- package/src/otel.ts +247 -0
package/src/otel.ts
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { type Attributes, type Histogram, type Span, type Tracer } from "@opentelemetry/api";
|
|
2
|
+
import {
|
|
3
|
+
ProtobufMetricsSerializer,
|
|
4
|
+
ProtobufTraceSerializer,
|
|
5
|
+
} from "@opentelemetry/otlp-transformer";
|
|
6
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
|
+
import {
|
|
8
|
+
AggregationTemporality,
|
|
9
|
+
MeterProvider,
|
|
10
|
+
PeriodicExportingMetricReader,
|
|
11
|
+
type PushMetricExporter,
|
|
12
|
+
type ResourceMetrics,
|
|
13
|
+
} from "@opentelemetry/sdk-metrics";
|
|
14
|
+
import { BasicTracerProvider, type ReadableSpan } from "@opentelemetry/sdk-trace-base";
|
|
15
|
+
|
|
16
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
17
|
+
|
|
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
|
+
const SCOPE_NAME = "@telemetry-dev/tanstack-ai";
|
|
28
|
+
const SCOPE_VERSION = "0.0.0";
|
|
29
|
+
|
|
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.
|
|
33
|
+
export interface Emitter {
|
|
34
|
+
tracer: Tracer;
|
|
35
|
+
recordDuration(seconds: number, attributes: Attributes): void;
|
|
36
|
+
recordTokens(tokenType: "input" | "output", count: number, attributes: Attributes): void;
|
|
37
|
+
flush(spans: Span[]): Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EmitterOverrides {
|
|
41
|
+
// Test seam: capture serialized spans / recorded metric points instead of POSTing them.
|
|
42
|
+
sendSpans?: (spans: ReadableSpan[]) => Promise<void>;
|
|
43
|
+
recordDuration?: (seconds: number, attributes: Attributes) => void;
|
|
44
|
+
recordTokens?: (tokenType: "input" | "output", count: number, attributes: Attributes) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Transport = { fetchImpl: typeof fetch; onError?: (error: unknown) => void };
|
|
48
|
+
|
|
49
|
+
const RETRY_DELAYS_MS = [100, 500] as const;
|
|
50
|
+
|
|
51
|
+
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
52
|
+
|
|
53
|
+
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
54
|
+
|
|
55
|
+
const cancelBody = async (res: Response) => {
|
|
56
|
+
try {
|
|
57
|
+
await res.body?.cancel();
|
|
58
|
+
} catch {}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const postOtlp = async ({
|
|
62
|
+
fetchImpl,
|
|
63
|
+
url,
|
|
64
|
+
headers,
|
|
65
|
+
body,
|
|
66
|
+
}: {
|
|
67
|
+
fetchImpl: typeof fetch;
|
|
68
|
+
url: string;
|
|
69
|
+
headers: Record<string, string>;
|
|
70
|
+
body: Uint8Array;
|
|
71
|
+
}) => {
|
|
72
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetchImpl(url, { method: "POST", headers, body });
|
|
75
|
+
if (res.ok || !RETRYABLE_STATUSES.has(res.status) || attempt === RETRY_DELAYS_MS.length) {
|
|
76
|
+
await cancelBody(res);
|
|
77
|
+
return res;
|
|
78
|
+
}
|
|
79
|
+
await cancelBody(res);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (attempt === RETRY_DELAYS_MS.length) throw error;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await delay(RETRY_DELAYS_MS[attempt]!);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
interface MetricsPipeline {
|
|
89
|
+
durationHistogram: Histogram;
|
|
90
|
+
tokenHistogram: Histogram;
|
|
91
|
+
shutdown: () => Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface EmitterCore {
|
|
95
|
+
tracer: Tracer;
|
|
96
|
+
sendSpans: (spans: ReadableSpan[], transport: Transport) => Promise<void>;
|
|
97
|
+
buildMetrics: (transport: Transport) => MetricsPipeline;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const cache = new Map<string, EmitterCore>();
|
|
101
|
+
|
|
102
|
+
const buildCore = (config: ResolvedConfig): EmitterCore => {
|
|
103
|
+
const { apiKey, baseUrl, environment, serviceName } = config;
|
|
104
|
+
const resource = resourceFromAttributes({
|
|
105
|
+
"service.name": serviceName,
|
|
106
|
+
"deployment.environment.name": environment,
|
|
107
|
+
});
|
|
108
|
+
const headers = {
|
|
109
|
+
"content-type": "application/x-protobuf",
|
|
110
|
+
authorization: `Bearer ${apiKey}`,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const provider = new BasicTracerProvider({ resource });
|
|
114
|
+
const tracer = provider.getTracer(SCOPE_NAME, SCOPE_VERSION);
|
|
115
|
+
|
|
116
|
+
const sendSpans = async (spans: ReadableSpan[], transport: Transport): Promise<void> => {
|
|
117
|
+
const body = ProtobufTraceSerializer.serializeRequest(spans);
|
|
118
|
+
if (!body || body.byteLength === 0) return;
|
|
119
|
+
const { fetchImpl, onError } = transport;
|
|
120
|
+
const res = await postOtlp({ fetchImpl, url: `${baseUrl}/v1/traces`, headers, body });
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
onError?.(new Error(`telemetry.dev trace ingest failed: ${res.status}`));
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Each emitter builds its OWN metrics pipeline bound to its OWN transport: the exporter closes
|
|
127
|
+
// over this call's fetch/onError, so concurrent same-identity emitters never flush metrics
|
|
128
|
+
// through one another's context. The pipeline is torn down on flush (which clears the reader's
|
|
129
|
+
// interval timer) and lazily rebuilt, so a reused globally-registered emitter gets a fresh meter
|
|
130
|
+
// per generation without leaking timers.
|
|
131
|
+
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
|
+
// A long interval keeps the periodic timer dormant; the single flush is driven by shutdown().
|
|
162
|
+
const reader = new PeriodicExportingMetricReader({
|
|
163
|
+
exporter: metricsExporter,
|
|
164
|
+
exportIntervalMillis: 2 ** 31 - 1,
|
|
165
|
+
});
|
|
166
|
+
const meterProvider = new MeterProvider({ resource, readers: [reader] });
|
|
167
|
+
const meter = meterProvider.getMeter(SCOPE_NAME, SCOPE_VERSION);
|
|
168
|
+
|
|
169
|
+
const durationHistogram = meter.createHistogram("gen_ai.client.operation.duration", {
|
|
170
|
+
unit: "s",
|
|
171
|
+
advice: { explicitBucketBoundaries: DURATION_BUCKETS },
|
|
172
|
+
});
|
|
173
|
+
const tokenHistogram = meter.createHistogram("gen_ai.client.token.usage", {
|
|
174
|
+
unit: "{token}",
|
|
175
|
+
advice: { explicitBucketBoundaries: TOKEN_BUCKETS },
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// shutdown() flushes the collected deltas through the exporter AND clears the interval timer.
|
|
179
|
+
return { durationHistogram, tokenHistogram, shutdown: () => meterProvider.shutdown() };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { tracer, sendSpans, buildMetrics };
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const coreFor = (config: ResolvedConfig): EmitterCore => {
|
|
186
|
+
const key = `${config.apiKey}|${config.baseUrl}|${config.environment}|${config.serviceName}`;
|
|
187
|
+
const cached = cache.get(key);
|
|
188
|
+
if (cached) return cached;
|
|
189
|
+
const core = buildCore(config);
|
|
190
|
+
cache.set(key, core);
|
|
191
|
+
return core;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const createEmitter = (config: ResolvedConfig, overrides?: EmitterOverrides): Emitter => {
|
|
195
|
+
const core = coreFor(config);
|
|
196
|
+
const transport: Transport = { fetchImpl: config.fetchImpl, onError: config.onError };
|
|
197
|
+
const onError = config.onError;
|
|
198
|
+
|
|
199
|
+
// Spans are request-scoped: each emitter sends through its OWN transport, so concurrent
|
|
200
|
+
// same-identity emitters never cross-talk.
|
|
201
|
+
const sendSpans =
|
|
202
|
+
overrides?.sendSpans ?? ((spans: ReadableSpan[]) => core.sendSpans(spans, transport));
|
|
203
|
+
|
|
204
|
+
// Metrics are also per-emitter and bound to this call's transport. The pipeline is built lazily on
|
|
205
|
+
// first record and detached on flush, so a reused emitter rebuilds a fresh one each generation.
|
|
206
|
+
let metrics: MetricsPipeline | undefined;
|
|
207
|
+
const ensureMetrics = (): MetricsPipeline => (metrics ??= core.buildMetrics(transport));
|
|
208
|
+
|
|
209
|
+
const recordDuration =
|
|
210
|
+
overrides?.recordDuration ??
|
|
211
|
+
((seconds: number, attributes: Attributes) =>
|
|
212
|
+
ensureMetrics().durationHistogram.record(seconds, attributes));
|
|
213
|
+
const recordTokens =
|
|
214
|
+
overrides?.recordTokens ??
|
|
215
|
+
((tokenType: "input" | "output", count: number, attributes: Attributes) =>
|
|
216
|
+
ensureMetrics().tokenHistogram.record(count, {
|
|
217
|
+
...attributes,
|
|
218
|
+
"gen_ai.token.type": tokenType,
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
// Default: await the flush so serverless runtimes don't tear down before spans/metrics leave.
|
|
222
|
+
// When a waitUntil extender is supplied, hand off the combined promise and return immediately.
|
|
223
|
+
const flush = (spans: Span[]): Promise<void> => {
|
|
224
|
+
// Detach this generation's metrics pipeline synchronously so the next generation builds its own.
|
|
225
|
+
const pipeline = metrics;
|
|
226
|
+
metrics = undefined;
|
|
227
|
+
const p = (async () => {
|
|
228
|
+
try {
|
|
229
|
+
await sendSpans(spans as unknown as ReadableSpan[]);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
onError?.(e);
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
if (pipeline) await pipeline.shutdown();
|
|
235
|
+
} catch (e) {
|
|
236
|
+
onError?.(e);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
if (config.waitUntil) {
|
|
240
|
+
config.waitUntil(p);
|
|
241
|
+
return Promise.resolve();
|
|
242
|
+
}
|
|
243
|
+
return p;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return { tracer: core.tracer, recordDuration, recordTokens, flush };
|
|
247
|
+
};
|