@alisio/plugin-telemetry 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.
Files changed (53) hide show
  1. package/.agents/skills/telemetry/SKILL.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.md +188 -0
  4. package/cover.svg +54 -0
  5. package/dist/commands.d.ts +19 -0
  6. package/dist/commands.d.ts.map +1 -0
  7. package/dist/commands.js +298 -0
  8. package/dist/commands.js.map +1 -0
  9. package/dist/config.d.ts +145 -0
  10. package/dist/config.d.ts.map +1 -0
  11. package/dist/config.js +524 -0
  12. package/dist/config.js.map +1 -0
  13. package/dist/database.d.ts +16 -0
  14. package/dist/database.d.ts.map +1 -0
  15. package/dist/database.js +67 -0
  16. package/dist/database.js.map +1 -0
  17. package/dist/events.d.ts +135 -0
  18. package/dist/events.d.ts.map +1 -0
  19. package/dist/events.js +255 -0
  20. package/dist/events.js.map +1 -0
  21. package/dist/format.d.ts +36 -0
  22. package/dist/format.d.ts.map +1 -0
  23. package/dist/format.js +49 -0
  24. package/dist/format.js.map +1 -0
  25. package/dist/index.d.ts +38 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +78 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/otlp.d.ts +69 -0
  30. package/dist/otlp.d.ts.map +1 -0
  31. package/dist/otlp.js +616 -0
  32. package/dist/otlp.js.map +1 -0
  33. package/dist/redact.d.ts +36 -0
  34. package/dist/redact.d.ts.map +1 -0
  35. package/dist/redact.js +163 -0
  36. package/dist/redact.js.map +1 -0
  37. package/dist/runtime.d.ts +80 -0
  38. package/dist/runtime.d.ts.map +1 -0
  39. package/dist/runtime.js +290 -0
  40. package/dist/runtime.js.map +1 -0
  41. package/dist/store.d.ts +188 -0
  42. package/dist/store.d.ts.map +1 -0
  43. package/dist/store.js +539 -0
  44. package/dist/store.js.map +1 -0
  45. package/dist/tools.d.ts +21 -0
  46. package/dist/tools.d.ts.map +1 -0
  47. package/dist/tools.js +292 -0
  48. package/dist/tools.js.map +1 -0
  49. package/dist/version.d.ts +3 -0
  50. package/dist/version.d.ts.map +1 -0
  51. package/dist/version.js +3 -0
  52. package/dist/version.js.map +1 -0
  53. package/package.json +54 -0
package/dist/otlp.d.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { OtlpSettings, OtlpSignal } from "./config.js";
2
+ import type { Store, TelemetrySnapshot, TimeRange } from "./store.js";
3
+ export type Fetcher = typeof fetch;
4
+ type Clock = () => number;
5
+ export type Sleeper = (milliseconds: number) => Promise<void>;
6
+ export declare const RETRYABLE_STATUS: Set<number>;
7
+ export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
8
+ export interface ResourceInfo {
9
+ serviceName: string;
10
+ environment: string;
11
+ instanceId: string;
12
+ }
13
+ export interface OtlpAttribute {
14
+ key: string;
15
+ value: Record<string, unknown>;
16
+ }
17
+ export interface ExportResult {
18
+ signal: OtlpSignal;
19
+ batchKey: string;
20
+ status: "sent" | "retry" | "dead" | "skipped";
21
+ attempts: number;
22
+ httpStatus: number | null;
23
+ error: string | null;
24
+ }
25
+ export interface ExporterOptions {
26
+ store: Store;
27
+ settings: OtlpSettings;
28
+ fetcher: Fetcher;
29
+ now: Clock;
30
+ sleep: Sleeper;
31
+ /** Read from the token environment at flush time and never stored. */
32
+ token: string | null;
33
+ buildSnapshot: (range: TimeRange) => TelemetrySnapshot;
34
+ /** Window looked at when deriving payloads. Defaults to 24 hours. */
35
+ windowMs?: number;
36
+ maxResponseBytes?: number;
37
+ maxBatchPerFlush?: number;
38
+ }
39
+ /** Build the OTLP endpoint for a signal under the configured base endpoint. */
40
+ export declare function otlpUrl(endpoint: string, signal: OtlpSignal): string;
41
+ export declare function providerFromModel(model: string | null): {
42
+ provider: string;
43
+ host: string;
44
+ } | null;
45
+ /** Deterministic sampling by run id so a run is never half-exported. */
46
+ export declare function shouldSample(runId: string, ratio: number): boolean;
47
+ type GeneratorOptions = {
48
+ resource: ResourceInfo;
49
+ settings: OtlpSettings;
50
+ now: Clock;
51
+ };
52
+ export declare function buildTracesPayload(snapshot: TelemetrySnapshot, options: GeneratorOptions): unknown;
53
+ export declare function buildLogsPayload(snapshot: TelemetrySnapshot, options: GeneratorOptions): unknown;
54
+ export declare function buildMetricsPayload(snapshot: TelemetrySnapshot, options: GeneratorOptions): unknown;
55
+ /** Parse a `Retry-After` header (seconds or HTTP-date) into a bounded delay. */
56
+ export declare function parseRetryAfter(value: string | null, nowMs: number, maxMs?: number): number;
57
+ /** Read a response body through a hard byte cap and cancel the remainder. */
58
+ export declare function readCappedText(response: Response, maxBytes?: number): Promise<{
59
+ text: string;
60
+ truncated: boolean;
61
+ }>;
62
+ export interface Exporter {
63
+ /** Derive pending batches from the store, then attempt to send them. */
64
+ flush(): Promise<ExportResult[]>;
65
+ }
66
+ /** Build the exporter. The OTLP token is passed per call and is never retained. */
67
+ export declare function createExporter(options: ExporterOptions): Exporter;
68
+ export {};
69
+ //# sourceMappingURL=otlp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"otlp.d.ts","sourceRoot":"","sources":["../src/otlp.ts"],"names":[],"mappings":"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"}
package/dist/otlp.js ADDED
@@ -0,0 +1,616 @@
1
+ /**
2
+ * OpenTelemetry-aligned OTLP/HTTP export.
3
+ *
4
+ * EXPERIMENTAL BY NATURE. Every `gen_ai.*` semantic convention is currently
5
+ * Development/experimental (the GenAI conventions even moved out of the main
6
+ * semconv repository). Using the standard names here is deliberate, and so is
7
+ * refusing to claim stability: these attribute names may change. Where no
8
+ * standard exists (cache-hit ratio, tool effect, token totals, retry state) this
9
+ * module uses clearly namespaced `alisio.telemetry.*` attributes instead of
10
+ * inventing new `gen_ai.*` names.
11
+ *
12
+ * DURABILITY. Payloads are persisted as batches in SQLite before any network
13
+ * call. A batch key is the content hash of the payload, so re-export is
14
+ * idempotent: the same data never creates a second batch and never sends twice.
15
+ * A successful send marks the batch `sent`; a retryable failure leaves it
16
+ * `pending` for a later flush; a non-retryable failure marks it `dead` so it is
17
+ * visible in status rather than silently lost.
18
+ *
19
+ * RETRY POLICY. HTTP 200 (including a partial-success body) is success and is
20
+ * never retried. Only 429, 502, 503 and 504 are retried, with bounded attempts
21
+ * and floor-ed exponential backoff that honors `Retry-After`. Redirects are
22
+ * refused. Response bodies are read through a hard cap.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { gzipSync } from "node:zlib";
26
+ import { VERSION } from "./version.js";
27
+ export const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
28
+ export const DEFAULT_MAX_RESPONSE_BYTES = 256 * 1024;
29
+ const BASE_BACKOFF_MS = 500;
30
+ const MAX_BACKOFF_MS = 30_000;
31
+ /** Build the OTLP endpoint for a signal under the configured base endpoint. */
32
+ export function otlpUrl(endpoint, signal) {
33
+ return `${endpoint.replace(/\/+$/, "")}/v1/${signal}`;
34
+ }
35
+ function hashHex(input, length) {
36
+ return createHash("sha256").update(input).digest("hex").slice(0, length);
37
+ }
38
+ function traceIdFor(runId) {
39
+ return hashHex(`trace:${runId}`, 32);
40
+ }
41
+ function spanIdFor(key) {
42
+ return hashHex(`span:${key}`, 16);
43
+ }
44
+ function isoToNanos(iso, fallbackMs) {
45
+ const parsed = Date.parse(iso);
46
+ const millis = Number.isFinite(parsed) ? parsed : fallbackMs;
47
+ return (BigInt(Math.trunc(millis)) * 1000000n).toString();
48
+ }
49
+ function attribute(key, value) {
50
+ if (typeof value === "string")
51
+ return { key, value: { stringValue: value } };
52
+ if (typeof value === "boolean")
53
+ return { key, value: { boolValue: value } };
54
+ if (typeof value === "number" && Number.isFinite(value)) {
55
+ if (Number.isInteger(value))
56
+ return { key, value: { intValue: value } };
57
+ return { key, value: { doubleValue: value } };
58
+ }
59
+ return null;
60
+ }
61
+ function attributes(entries) {
62
+ const out = [];
63
+ for (const [key, value] of entries) {
64
+ const built = attribute(key, value);
65
+ if (built !== null)
66
+ out.push(built);
67
+ }
68
+ return out;
69
+ }
70
+ /** Best-effort provider/host derived from a `provider/model` reference. */
71
+ const PROVIDER_HOSTS = {
72
+ openai: "api.openai.com",
73
+ anthropic: "api.anthropic.com",
74
+ deepseek: "api.deepseek.com",
75
+ openrouter: "openrouter.ai",
76
+ google: "generativelanguage.googleapis.com",
77
+ gemini: "generativelanguage.googleapis.com",
78
+ mistral: "api.mistral.ai",
79
+ groq: "api.groq.com",
80
+ xai: "api.x.ai",
81
+ ollama: "localhost",
82
+ };
83
+ export function providerFromModel(model) {
84
+ if (model === null)
85
+ return null;
86
+ const slash = model.indexOf("/");
87
+ if (slash <= 0)
88
+ return null;
89
+ const provider = model.slice(0, slash).toLowerCase();
90
+ const host = PROVIDER_HOSTS[provider];
91
+ return host === undefined ? null : { provider, host };
92
+ }
93
+ /** Deterministic sampling by run id so a run is never half-exported. */
94
+ export function shouldSample(runId, ratio) {
95
+ if (!(ratio > 0))
96
+ return false;
97
+ if (ratio >= 1)
98
+ return true;
99
+ const bucket = Number.parseInt(hashHex(`sample:${runId}`, 8), 16);
100
+ return bucket / 0xffffffff < ratio;
101
+ }
102
+ function resourceAttributes(resource) {
103
+ return attributes([
104
+ ["service.name", resource.serviceName],
105
+ ["service.version", VERSION],
106
+ ["telemetry.sdk.name", "alisio-plugin-telemetry"],
107
+ ["telemetry.sdk.language", "nodejs"],
108
+ ["telemetry.sdk.version", VERSION],
109
+ ["alisio.telemetry.schema_version", 1],
110
+ ...(resource.environment
111
+ ? [["deployment.environment", resource.environment]]
112
+ : []),
113
+ ...(resource.instanceId
114
+ ? [["alisio.telemetry.instance_id", resource.instanceId]]
115
+ : []),
116
+ ]);
117
+ }
118
+ function runAttributes(run, settings) {
119
+ const provider = providerFromModel(run.model);
120
+ const cacheHitRatio = run.inputTokens > 0 ? run.cachedInputTokens / run.inputTokens : null;
121
+ const total = run.totalTokens ?? run.inputTokens + run.outputTokens;
122
+ return attributes([
123
+ ["gen_ai.operation.name", "invoke_agent"],
124
+ ["gen_ai.conversation.id", run.sessionId],
125
+ ...(provider
126
+ ? [["gen_ai.provider.name", provider.provider]]
127
+ : []),
128
+ ...(run.model
129
+ ? [
130
+ ["gen_ai.request.model", run.model],
131
+ ["gen_ai.response.model", run.model],
132
+ ]
133
+ : []),
134
+ ["gen_ai.usage.input_tokens", run.inputTokens],
135
+ ["gen_ai.usage.output_tokens", run.outputTokens],
136
+ ["gen_ai.usage.cached_input_tokens", run.cachedInputTokens],
137
+ ["alisio.telemetry.tokens.total", total],
138
+ ["alisio.telemetry.tool.calls", run.toolCalls],
139
+ ["alisio.telemetry.tool.errors", run.toolErrors],
140
+ ["alisio.telemetry.truncated", run.truncated],
141
+ ...(cacheHitRatio === null
142
+ ? []
143
+ : [["alisio.telemetry.cache_hit_ratio", cacheHitRatio]]),
144
+ ...(run.status === "error"
145
+ ? [["error.type", "agent_run_error"]]
146
+ : []),
147
+ ["alisio.telemetry.redaction", settings.enabled ? "enabled" : "local-only"],
148
+ ]);
149
+ }
150
+ function statusFor(ok) {
151
+ return ok ? { code: 1 } : { code: 2, message: "error" };
152
+ }
153
+ export function buildTracesPayload(snapshot, options) {
154
+ const spans = [];
155
+ const sampledRuns = snapshot.runs.filter((run) => shouldSample(run.runId, options.settings.samplingRatio));
156
+ const runSpanIds = new Map();
157
+ for (const run of sampledRuns)
158
+ runSpanIds.set(run.runId, spanIdFor(`${run.runId}:invoke_agent`));
159
+ for (const run of sampledRuns) {
160
+ const spanId = runSpanIds.get(run.runId);
161
+ const start = isoToNanos(run.startedAt, options.now());
162
+ const end = isoToNanos(run.endedAt ?? run.startedAt, options.now());
163
+ spans.push({
164
+ traceId: traceIdFor(run.runId),
165
+ spanId,
166
+ name: `invoke_agent ${run.model ?? options.resource.serviceName}`,
167
+ kind: 1,
168
+ startTimeUnixNano: start,
169
+ endTimeUnixNano: end,
170
+ status: statusFor(run.status !== "error"),
171
+ attributes: runAttributes(run, options.settings),
172
+ });
173
+ }
174
+ for (const turn of snapshot.turns) {
175
+ const parent = runSpanIds.get(turn.runId);
176
+ if (parent === undefined)
177
+ continue;
178
+ const provider = providerFromModel(turn.model);
179
+ const start = isoToNanos(turn.createdAt, options.now());
180
+ const durationMs = turn.durationMs ?? 0;
181
+ const end = (BigInt(start) +
182
+ BigInt(Math.max(0, Math.trunc(durationMs))) * 1000000n).toString();
183
+ spans.push({
184
+ traceId: traceIdFor(turn.runId),
185
+ spanId: spanIdFor(`${turn.runId}:turn:${turn.turn}`),
186
+ parentSpanId: parent,
187
+ name: `chat ${turn.model ?? "unknown"}`,
188
+ kind: 1,
189
+ startTimeUnixNano: start,
190
+ endTimeUnixNano: end,
191
+ status: statusFor(true),
192
+ attributes: attributes([
193
+ ["gen_ai.operation.name", "chat"],
194
+ ["gen_ai.conversation.id", turn.sessionId],
195
+ ...(provider
196
+ ? [["gen_ai.provider.name", provider.provider]]
197
+ : []),
198
+ ...(provider
199
+ ? [
200
+ ["server.address", provider.host],
201
+ ["server.port", 443],
202
+ ]
203
+ : []),
204
+ ...(turn.model
205
+ ? [
206
+ ["gen_ai.request.model", turn.model],
207
+ ["gen_ai.response.model", turn.model],
208
+ ]
209
+ : []),
210
+ ["gen_ai.usage.input_tokens", turn.inputTokens],
211
+ ["gen_ai.usage.output_tokens", turn.outputTokens],
212
+ ["gen_ai.usage.cached_input_tokens", turn.cachedInputTokens],
213
+ ["alisio.telemetry.turn", turn.turn],
214
+ ...(turn.durationMs === null
215
+ ? []
216
+ : [["alisio.telemetry.turn.duration_ms", turn.durationMs]]),
217
+ ]),
218
+ });
219
+ }
220
+ for (const tool of snapshot.tools) {
221
+ const parent = runSpanIds.get(tool.runId);
222
+ if (parent === undefined)
223
+ continue;
224
+ const startedAt = tool.startedAt ?? tool.finishedAt ?? new Date(options.now()).toISOString();
225
+ const start = isoToNanos(startedAt, options.now());
226
+ const end = isoToNanos(tool.finishedAt ?? startedAt, options.now());
227
+ spans.push({
228
+ traceId: traceIdFor(tool.runId),
229
+ spanId: spanIdFor(`${tool.runId}:tool:${tool.callId}`),
230
+ parentSpanId: parent,
231
+ name: `execute_tool ${tool.name}`,
232
+ kind: 1,
233
+ startTimeUnixNano: start,
234
+ endTimeUnixNano: end,
235
+ status: statusFor(!tool.isError),
236
+ attributes: attributes([
237
+ ["gen_ai.operation.name", "execute_tool"],
238
+ ["gen_ai.tool.name", tool.name],
239
+ ["gen_ai.tool.call.id", tool.callId],
240
+ ["gen_ai.conversation.id", tool.sessionId],
241
+ ...(tool.effect
242
+ ? [["alisio.telemetry.tool.effect", tool.effect]]
243
+ : []),
244
+ ...(tool.durationMs === null
245
+ ? []
246
+ : [["alisio.telemetry.tool.duration_ms", tool.durationMs]]),
247
+ ...(tool.isError ? [["error.type", "tool_error"]] : []),
248
+ ]),
249
+ });
250
+ }
251
+ return {
252
+ resourceSpans: [
253
+ {
254
+ resource: { attributes: resourceAttributes(options.resource) },
255
+ scopeSpans: [
256
+ {
257
+ scope: { name: "alisio-plugin-telemetry", version: VERSION },
258
+ spans,
259
+ },
260
+ ],
261
+ },
262
+ ],
263
+ };
264
+ }
265
+ export function buildLogsPayload(snapshot, options) {
266
+ const sampled = snapshot.runs.filter((run) => shouldSample(run.runId, options.settings.samplingRatio));
267
+ const runIds = new Set(sampled.map((run) => run.runId));
268
+ const records = [];
269
+ for (const run of sampled) {
270
+ const at = run.endedAt ?? run.startedAt;
271
+ records.push({
272
+ timeUnixNano: isoToNanos(at, options.now()),
273
+ observedTimeUnixNano: isoToNanos(at, options.now()),
274
+ severityNumber: run.status === "error" ? 17 : 9,
275
+ severityText: run.status === "error" ? "ERROR" : "INFO",
276
+ body: { stringValue: `run ${run.runId} ${run.status}` },
277
+ attributes: attributes([
278
+ ["gen_ai.operation.name", "invoke_agent"],
279
+ ["gen_ai.conversation.id", run.sessionId],
280
+ ...(run.model ? [["gen_ai.request.model", run.model]] : []),
281
+ ["alisio.telemetry.tokens.total", run.totalTokens ?? run.inputTokens + run.outputTokens],
282
+ ...(run.status === "error"
283
+ ? [["error.type", "agent_run_error"]]
284
+ : []),
285
+ ]),
286
+ });
287
+ }
288
+ for (const tool of snapshot.tools) {
289
+ if (!tool.isError || !runIds.has(tool.runId))
290
+ continue;
291
+ const at = tool.finishedAt ?? tool.startedAt ?? new Date(options.now()).toISOString();
292
+ records.push({
293
+ timeUnixNano: isoToNanos(at, options.now()),
294
+ observedTimeUnixNano: isoToNanos(at, options.now()),
295
+ severityNumber: 13,
296
+ severityText: "WARN",
297
+ body: { stringValue: `tool ${tool.name} failed` },
298
+ attributes: attributes([
299
+ ["gen_ai.operation.name", "execute_tool"],
300
+ ["gen_ai.tool.name", tool.name],
301
+ ["gen_ai.tool.call.id", tool.callId],
302
+ ["gen_ai.conversation.id", tool.sessionId],
303
+ ["error.type", "tool_error"],
304
+ ]),
305
+ });
306
+ }
307
+ return {
308
+ resourceLogs: [
309
+ {
310
+ resource: { attributes: resourceAttributes(options.resource) },
311
+ scopeLogs: [
312
+ {
313
+ scope: { name: "alisio-plugin-telemetry", version: VERSION },
314
+ logRecords: records,
315
+ },
316
+ ],
317
+ },
318
+ ],
319
+ };
320
+ }
321
+ export function buildMetricsPayload(snapshot, options) {
322
+ const sampled = snapshot.runs.filter((run) => shouldSample(run.runId, options.settings.samplingRatio));
323
+ const end = options.now();
324
+ const start = end;
325
+ let inputTokens = 0;
326
+ let outputTokens = 0;
327
+ let cachedTokens = 0;
328
+ let toolCalls = 0;
329
+ let toolErrors = 0;
330
+ for (const run of sampled) {
331
+ inputTokens += run.inputTokens;
332
+ outputTokens += run.outputTokens;
333
+ cachedTokens += run.cachedInputTokens;
334
+ toolCalls += run.toolCalls;
335
+ toolErrors += run.toolErrors;
336
+ }
337
+ const point = (attributesList, value) => ({
338
+ startTimeUnixNano: String(BigInt(Math.trunc(start)) * 1000000n),
339
+ timeUnixNano: String(BigInt(Math.trunc(end)) * 1000000n),
340
+ asInt: String(value),
341
+ attributes: attributesList,
342
+ });
343
+ return {
344
+ resourceMetrics: [
345
+ {
346
+ resource: { attributes: resourceAttributes(options.resource) },
347
+ scopeMetrics: [
348
+ {
349
+ scope: { name: "alisio-plugin-telemetry", version: VERSION },
350
+ metrics: [
351
+ {
352
+ name: "alisio.telemetry.tokens",
353
+ description: "Tokens observed in the export window (custom; no stable semconv exists).",
354
+ unit: "{token}",
355
+ sum: {
356
+ aggregationTemporality: 2,
357
+ isMonotonic: true,
358
+ dataPoints: [
359
+ point(attributes([["alisio.telemetry.token.type", "input"]]), inputTokens),
360
+ point(attributes([["alisio.telemetry.token.type", "output"]]), outputTokens),
361
+ point(attributes([["alisio.telemetry.token.type", "cached_input"]]), cachedTokens),
362
+ ],
363
+ },
364
+ },
365
+ {
366
+ name: "alisio.telemetry.tool.calls",
367
+ description: "Tool calls observed in the export window (custom).",
368
+ unit: "{call}",
369
+ sum: {
370
+ aggregationTemporality: 2,
371
+ isMonotonic: true,
372
+ dataPoints: [
373
+ point(attributes([["alisio.telemetry.tool.outcome", "ok"]]), Math.max(0, toolCalls - toolErrors)),
374
+ point(attributes([["alisio.telemetry.tool.outcome", "error"]]), toolErrors),
375
+ ],
376
+ },
377
+ },
378
+ ],
379
+ },
380
+ ],
381
+ },
382
+ ],
383
+ };
384
+ }
385
+ /** Parse a `Retry-After` header (seconds or HTTP-date) into a bounded delay. */
386
+ export function parseRetryAfter(value, nowMs, maxMs = MAX_BACKOFF_MS) {
387
+ if (value === null)
388
+ return 0;
389
+ const trimmed = value.trim();
390
+ if (trimmed === "")
391
+ return 0;
392
+ if (/^\d+$/.test(trimmed))
393
+ return Math.min(Number(trimmed) * 1000, maxMs);
394
+ const parsed = Date.parse(trimmed);
395
+ if (!Number.isFinite(parsed))
396
+ return 0;
397
+ return Math.min(Math.max(0, parsed - nowMs), maxMs);
398
+ }
399
+ /** Read a response body through a hard byte cap and cancel the remainder. */
400
+ export async function readCappedText(response, maxBytes = DEFAULT_MAX_RESPONSE_BYTES) {
401
+ const body = response.body;
402
+ if (body === null) {
403
+ try {
404
+ const text = await response.text();
405
+ return { text: text.slice(0, maxBytes), truncated: text.length > maxBytes };
406
+ }
407
+ catch {
408
+ return { text: "", truncated: false };
409
+ }
410
+ }
411
+ const reader = body.getReader();
412
+ const chunks = [];
413
+ let total = 0;
414
+ let truncated = false;
415
+ try {
416
+ for (;;) {
417
+ const { done, value } = await reader.read();
418
+ if (done)
419
+ break;
420
+ if (value === undefined)
421
+ continue;
422
+ if (total + value.byteLength > maxBytes) {
423
+ chunks.push(value.subarray(0, Math.max(0, maxBytes - total)));
424
+ truncated = true;
425
+ break;
426
+ }
427
+ chunks.push(value);
428
+ total += value.byteLength;
429
+ }
430
+ }
431
+ catch {
432
+ // A body read failure is not fatal; return what we have.
433
+ }
434
+ finally {
435
+ try {
436
+ await reader.cancel();
437
+ }
438
+ catch {
439
+ // Ignore cancellation errors.
440
+ }
441
+ }
442
+ const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
443
+ return { text: buffer.toString("utf8"), truncated };
444
+ }
445
+ function payloadFor(signal, snapshot, options) {
446
+ if (signal === "traces")
447
+ return buildTracesPayload(snapshot, options);
448
+ if (signal === "logs")
449
+ return buildLogsPayload(snapshot, options);
450
+ return buildMetricsPayload(snapshot, options);
451
+ }
452
+ function isEmptyPayload(signal, payload) {
453
+ const object = payload;
454
+ if (signal === "traces") {
455
+ const spans = object.resourceSpans?.[0]?.scopeSpans;
456
+ return !spans || spans.every((scope) => Array.isArray(scope.spans) && scope.spans.length === 0);
457
+ }
458
+ if (signal === "logs") {
459
+ const logs = object.resourceLogs?.[0]?.scopeLogs;
460
+ return (!logs ||
461
+ logs.every((scope) => Array.isArray(scope.logRecords) && scope.logRecords.length === 0));
462
+ }
463
+ return false;
464
+ }
465
+ /** Build the exporter. The OTLP token is passed per call and is never retained. */
466
+ export function createExporter(options) {
467
+ const { store, settings, fetcher, now, sleep, token } = options;
468
+ const windowMs = options.windowMs ?? 24 * 60 * 60 * 1000;
469
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
470
+ const maxBatchPerFlush = options.maxBatchPerFlush ?? 50;
471
+ const range = () => {
472
+ const to = new Date(now()).toISOString();
473
+ const from = new Date(now() - windowMs).toISOString();
474
+ return { from, to };
475
+ };
476
+ const enqueue = () => {
477
+ if (settings.endpoint === null)
478
+ return;
479
+ const snapshot = options.buildSnapshot(range());
480
+ const generatorOptions = {
481
+ resource: {
482
+ serviceName: settings.serviceName,
483
+ environment: settings.environment,
484
+ instanceId: settings.instanceId,
485
+ },
486
+ settings,
487
+ now,
488
+ };
489
+ const signals = ["traces", "logs", "metrics"].filter((signal) => settings.signals[signal]);
490
+ const createdAt = new Date(now()).toISOString();
491
+ for (const signal of signals) {
492
+ const payload = payloadFor(signal, snapshot, generatorOptions);
493
+ if (isEmptyPayload(signal, payload))
494
+ continue;
495
+ const serialized = JSON.stringify(payload);
496
+ const key = `${signal}:${hashHex(serialized, 32)}`;
497
+ store.enqueueBatch(key, signal, serialized, createdAt);
498
+ }
499
+ };
500
+ const send = async (signal, payload) => {
501
+ if (settings.endpoint === null)
502
+ return { status: "skipped", httpStatus: null, error: null, attempts: 0 };
503
+ const url = otlpUrl(settings.endpoint, signal);
504
+ const headers = {
505
+ "content-type": "application/json",
506
+ ...settings.headers,
507
+ };
508
+ if (token !== null && token.trim() !== "")
509
+ headers.authorization = `Bearer ${token}`;
510
+ let body = payload;
511
+ if (settings.gzip) {
512
+ headers["content-encoding"] = "gzip";
513
+ body = gzipSync(Buffer.from(payload));
514
+ }
515
+ let lastError = null;
516
+ let attemptsMade = 0;
517
+ for (let attempt = 1; attempt <= settings.maxAttempts; attempt += 1) {
518
+ attemptsMade = attempt;
519
+ let response;
520
+ try {
521
+ response = await fetcher(url, {
522
+ method: "POST",
523
+ headers,
524
+ body: body,
525
+ redirect: "error",
526
+ signal: AbortSignal.timeout(settings.timeoutMs),
527
+ });
528
+ }
529
+ catch (error) {
530
+ lastError = error instanceof Error ? error.message : String(error);
531
+ if (attempt < settings.maxAttempts) {
532
+ await sleep(Math.min(BASE_BACKOFF_MS * 2 ** (attempt - 1), MAX_BACKOFF_MS));
533
+ continue;
534
+ }
535
+ return { status: "retry", httpStatus: null, error: lastError, attempts: attemptsMade };
536
+ }
537
+ await readCappedText(response, maxResponseBytes);
538
+ if (response.status === 200)
539
+ return { status: "sent", httpStatus: 200, error: null, attempts: attemptsMade };
540
+ if (RETRYABLE_STATUS.has(response.status)) {
541
+ lastError = `HTTP ${response.status}`;
542
+ const delay = parseRetryAfter(response.headers.get("retry-after"), now());
543
+ const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (attempt - 1), MAX_BACKOFF_MS);
544
+ if (attempt < settings.maxAttempts) {
545
+ await sleep(Math.max(delay, backoff));
546
+ continue;
547
+ }
548
+ return {
549
+ status: "retry",
550
+ httpStatus: response.status,
551
+ error: lastError,
552
+ attempts: attemptsMade,
553
+ };
554
+ }
555
+ return {
556
+ status: "dead",
557
+ httpStatus: response.status,
558
+ error: `HTTP ${response.status}`,
559
+ attempts: attemptsMade,
560
+ };
561
+ }
562
+ return { status: "retry", httpStatus: null, error: lastError, attempts: attemptsMade };
563
+ };
564
+ return {
565
+ async flush() {
566
+ const results = [];
567
+ if (!settings.enabled || settings.endpoint === null)
568
+ return results;
569
+ try {
570
+ enqueue();
571
+ }
572
+ catch {
573
+ return results;
574
+ }
575
+ let batches;
576
+ try {
577
+ batches = store.pendingBatches(maxBatchPerFlush);
578
+ }
579
+ catch {
580
+ return results;
581
+ }
582
+ for (const batch of batches) {
583
+ const signal = batch.signal;
584
+ let outcome;
585
+ try {
586
+ outcome = await send(signal, batch.payload);
587
+ }
588
+ catch (error) {
589
+ outcome = {
590
+ status: "retry",
591
+ httpStatus: null,
592
+ error: error instanceof Error ? error.message : String(error),
593
+ attempts: 1,
594
+ };
595
+ }
596
+ const at = new Date(now()).toISOString();
597
+ if (outcome.status === "sent")
598
+ store.markBatchSent(batch.id, at);
599
+ else if (outcome.status === "dead")
600
+ store.markBatchDead(batch.id, outcome.error ?? "unknown", at);
601
+ else if (outcome.status === "retry")
602
+ store.markBatchRetry(batch.id, outcome.error ?? "unknown", at);
603
+ results.push({
604
+ signal,
605
+ batchKey: batch.batchKey,
606
+ status: outcome.status,
607
+ attempts: outcome.attempts,
608
+ httpStatus: outcome.httpStatus,
609
+ error: outcome.error,
610
+ });
611
+ }
612
+ return results;
613
+ },
614
+ };
615
+ }
616
+ //# sourceMappingURL=otlp.js.map