@telemetry-dev/otel 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/src/transport.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ExportResultCode } from "@opentelemetry/core";
1
+ import { type ExportResult, ExportResultCode } from "@opentelemetry/core";
2
2
  import {
3
3
  ProtobufLogsSerializer,
4
4
  ProtobufMetricsSerializer,
@@ -12,11 +12,13 @@ import {
12
12
  } from "@opentelemetry/sdk-metrics";
13
13
  import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
14
14
 
15
+ import { DEFAULT_BATCH } from "./config.ts";
15
16
  import { reportError } from "./debug.ts";
16
17
 
17
18
  export interface Transport {
18
19
  fetchImpl: typeof fetch;
19
20
  onError?: (error: Error) => void;
21
+ exportTimeoutMillis?: number;
20
22
  }
21
23
 
22
24
  export interface OtlpTarget {
@@ -25,12 +27,132 @@ export interface OtlpTarget {
25
27
  }
26
28
 
27
29
  const RETRY_DELAYS_MS = [100, 500] as const;
30
+ const MAX_RETRY_AFTER_MS = 2_000;
31
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
28
32
 
29
33
  const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
30
34
 
31
35
  const isRetryableStatus = (status: number) => RETRYABLE_STATUSES.has(status);
32
36
 
33
- const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
37
+ const HTTP_MONTHS = [
38
+ "Jan",
39
+ "Feb",
40
+ "Mar",
41
+ "Apr",
42
+ "May",
43
+ "Jun",
44
+ "Jul",
45
+ "Aug",
46
+ "Sep",
47
+ "Oct",
48
+ "Nov",
49
+ "Dec",
50
+ ];
51
+ const HTTP_WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
52
+ const HTTP_WEEKDAYS_LONG = [
53
+ "Sunday",
54
+ "Monday",
55
+ "Tuesday",
56
+ "Wednesday",
57
+ "Thursday",
58
+ "Friday",
59
+ "Saturday",
60
+ ];
61
+
62
+ const httpDateTimestamp = (
63
+ weekday: string,
64
+ dayText: string,
65
+ monthText: string,
66
+ yearText: string,
67
+ hourText: string,
68
+ minuteText: string,
69
+ secondText: string,
70
+ weekdays: string[],
71
+ ): number | undefined => {
72
+ const day = Number(dayText);
73
+ const month = HTTP_MONTHS.indexOf(monthText);
74
+ const year = Number(yearText);
75
+ const hour = Number(hourText);
76
+ const minute = Number(minuteText);
77
+ const second = Number(secondText);
78
+ if (hour > 23 || minute > 59 || second > 59) return undefined;
79
+ const date = new Date(0);
80
+ date.setUTCFullYear(year, month, day);
81
+ date.setUTCHours(hour, minute, second, 0);
82
+ if (
83
+ date.getUTCFullYear() !== year ||
84
+ date.getUTCMonth() !== month ||
85
+ date.getUTCDate() !== day ||
86
+ weekdays[date.getUTCDay()] !== weekday
87
+ ) {
88
+ return undefined;
89
+ }
90
+ return date.getTime();
91
+ };
92
+
93
+ const parseHttpDate = (value: string): number | undefined => {
94
+ const imf =
95
+ /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(
96
+ value,
97
+ );
98
+ if (imf)
99
+ return httpDateTimestamp(
100
+ ...(imf.slice(1) as [string, string, string, string, string, string, string]),
101
+ HTTP_WEEKDAYS,
102
+ );
103
+
104
+ const rfc850 =
105
+ /^(Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/.exec(
106
+ value,
107
+ );
108
+ if (rfc850) {
109
+ const fields = rfc850.slice(1) as [string, string, string, string, string, string, string];
110
+ const currentYear = new Date().getUTCFullYear();
111
+ let year = Math.floor(currentYear / 100) * 100 + Number(fields[3]);
112
+ if (year > currentYear + 50) year -= 100;
113
+ fields[3] = String(year);
114
+ return httpDateTimestamp(...fields, HTTP_WEEKDAYS_LONG);
115
+ }
116
+
117
+ const asctime =
118
+ /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [1-9]|[12]\d|3[01]) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/.exec(
119
+ value,
120
+ );
121
+ if (!asctime) return undefined;
122
+ const [, weekday, month, day, hour, minute, second, year] = asctime;
123
+ return httpDateTimestamp(
124
+ weekday!,
125
+ day!.trim(),
126
+ month!,
127
+ year!,
128
+ hour!,
129
+ minute!,
130
+ second!,
131
+ HTTP_WEEKDAYS,
132
+ );
133
+ };
134
+
135
+ const retryAfterMs = (res: Response): number | undefined => {
136
+ const header = res.headers.get("retry-after")?.trim();
137
+ if (header === undefined) return undefined;
138
+ if (/^\d+$/.test(header)) return Number(header) * 1000;
139
+ const at = parseHttpDate(header);
140
+ return at === undefined ? undefined : Math.max(0, at - Date.now());
141
+ };
142
+
143
+ const delay = (ms: number, signal?: AbortSignal) =>
144
+ new Promise<void>((resolve, reject) => {
145
+ const onAbort = () => {
146
+ clearTimeout(timer);
147
+ reject(signal?.reason ?? new Error("telemetry.dev export aborted"));
148
+ };
149
+ const timer = setTimeout(() => {
150
+ signal?.removeEventListener("abort", onAbort);
151
+ resolve();
152
+ }, ms);
153
+ if (signal?.aborted) onAbort();
154
+ else signal?.addEventListener("abort", onAbort, { once: true });
155
+ });
34
156
 
35
157
  const cancelBody = async (res: Response) => {
36
158
  try {
@@ -43,20 +165,30 @@ export const postOtlp = async ({
43
165
  url,
44
166
  headers,
45
167
  body,
168
+ signal,
46
169
  }: {
47
170
  fetchImpl: typeof fetch;
48
171
  url: string;
49
172
  headers: Record<string, string>;
50
173
  body: Uint8Array;
174
+ signal?: AbortSignal;
51
175
  }) => {
52
176
  for (let attempt = 0; ; attempt += 1) {
177
+ let retryAfter: number | undefined;
53
178
  try {
54
179
  const res = await fetchImpl(url, {
55
180
  method: "POST",
56
181
  headers,
57
182
  body: body as RequestInit["body"],
183
+ signal,
58
184
  });
59
- if (res.ok || !isRetryableStatus(res.status) || attempt === RETRY_DELAYS_MS.length) {
185
+ retryAfter = retryAfterMs(res);
186
+ if (
187
+ res.ok ||
188
+ !isRetryableStatus(res.status) ||
189
+ attempt === RETRY_DELAYS_MS.length ||
190
+ (retryAfter !== undefined && retryAfter > MAX_RETRY_AFTER_MS)
191
+ ) {
60
192
  await cancelBody(res);
61
193
  return res;
62
194
  }
@@ -65,7 +197,7 @@ export const postOtlp = async ({
65
197
  if (attempt === RETRY_DELAYS_MS.length) throw error;
66
198
  }
67
199
 
68
- await delay(RETRY_DELAYS_MS[attempt]!);
200
+ await delay(retryAfter ?? RETRY_DELAYS_MS[attempt]!, signal);
69
201
  }
70
202
  };
71
203
 
@@ -89,6 +221,99 @@ export async function maybeGzip(
89
221
  }
90
222
  }
91
223
 
224
+ async function postSerialized(
225
+ body: Uint8Array,
226
+ target: OtlpTarget,
227
+ transport: Transport,
228
+ label: string,
229
+ post: typeof postOtlp = postOtlp,
230
+ signal?: AbortSignal,
231
+ ): Promise<void> {
232
+ const { body: finalBody, contentEncoding } = await maybeGzip(body);
233
+ const headers = contentEncoding
234
+ ? { ...target.headers, "content-encoding": contentEncoding }
235
+ : target.headers;
236
+ const res = await post({
237
+ fetchImpl: transport.fetchImpl,
238
+ url: target.url,
239
+ headers,
240
+ body: finalBody,
241
+ signal,
242
+ });
243
+ if (!res.ok) {
244
+ const retryAfter = retryAfterMs(res);
245
+ const retryAfterHeader = res.headers.get("retry-after")?.trim();
246
+ const retryDetail =
247
+ retryAfter !== undefined && retryAfter > MAX_RETRY_AFTER_MS && retryAfterHeader !== undefined
248
+ ? `; retry-after ${retryAfterHeader} exceeds ${MAX_RETRY_AFTER_MS / 1000}s retry cap`
249
+ : "";
250
+ throw new Error(`telemetry.dev ${label} ingest failed: ${res.status}${retryDetail}`);
251
+ }
252
+ }
253
+
254
+ const createExportLifecycle = (transport: Transport) => {
255
+ const inFlight = new Set<Promise<void>>();
256
+ let shutDown = false;
257
+ const configuredTimeoutMillis =
258
+ transport.exportTimeoutMillis ?? DEFAULT_BATCH.exportTimeoutMillis;
259
+ const timeoutMillis =
260
+ Number.isFinite(configuredTimeoutMillis) &&
261
+ configuredTimeoutMillis >= 0 &&
262
+ configuredTimeoutMillis <= MAX_TIMER_DELAY_MS
263
+ ? configuredTimeoutMillis
264
+ : DEFAULT_BATCH.exportTimeoutMillis;
265
+
266
+ const callback = (resultCallback: (result: ExportResult) => void, result: ExportResult) => {
267
+ try {
268
+ resultCallback(result);
269
+ } catch (error) {
270
+ reportError(transport.onError, error);
271
+ }
272
+ };
273
+
274
+ const exportBatch = (
275
+ send: (signal: AbortSignal) => Promise<void>,
276
+ resultCallback: (result: ExportResult) => void,
277
+ ) => {
278
+ if (shutDown) {
279
+ const error = new Error("telemetry.dev exporter is shut down");
280
+ callback(resultCallback, { code: ExportResultCode.FAILED, error });
281
+ return;
282
+ }
283
+ const controller = new AbortController();
284
+ let timer: ReturnType<typeof setTimeout>;
285
+ const timeout = new Promise<never>((_, reject) => {
286
+ timer = setTimeout(() => {
287
+ controller.abort();
288
+ reject(new Error(`telemetry.dev export timed out after ${timeoutMillis}ms`));
289
+ }, timeoutMillis);
290
+ });
291
+ let tracked: Promise<void>;
292
+ tracked = Promise.race([Promise.resolve().then(() => send(controller.signal)), timeout])
293
+ .then(
294
+ () => callback(resultCallback, { code: ExportResultCode.SUCCESS }),
295
+ (cause: unknown) => {
296
+ const error = cause instanceof Error ? cause : new Error(String(cause));
297
+ reportError(transport.onError, error);
298
+ callback(resultCallback, { code: ExportResultCode.FAILED, error });
299
+ },
300
+ )
301
+ .finally(() => {
302
+ clearTimeout(timer);
303
+ inFlight.delete(tracked);
304
+ });
305
+ inFlight.add(tracked);
306
+ };
307
+
308
+ const forceFlush = () => Promise.all(inFlight).then(() => undefined);
309
+ const shutdown = () => {
310
+ shutDown = true;
311
+ return forceFlush();
312
+ };
313
+
314
+ return { exportBatch, forceFlush, shutdown };
315
+ };
316
+
92
317
  // Stay under the ingest's 4 MiB raw body limit with headroom; oversized batches are halved.
93
318
  const MAX_BODY_BYTES = 3_500_000;
94
319
 
@@ -101,15 +326,15 @@ function createOtlpBatchSender<T>(
101
326
  target: OtlpTarget,
102
327
  transport: Transport,
103
328
  label: string,
104
- ): (items: T[]) => Promise<void> {
105
- const send = async (items: T[]): Promise<void> => {
329
+ ): (items: T[], signal: AbortSignal) => Promise<void> {
330
+ const send = async (items: T[], signal: AbortSignal): Promise<void> => {
106
331
  const body = serializer.serializeRequest(items);
107
332
  if (!body || body.byteLength === 0) return;
108
333
  if (body.byteLength > MAX_BODY_BYTES) {
109
334
  if (items.length > 1) {
110
335
  const mid = Math.ceil(items.length / 2);
111
- await send(items.slice(0, mid));
112
- await send(items.slice(mid));
336
+ await send(items.slice(0, mid), signal);
337
+ await send(items.slice(mid), signal);
113
338
  return;
114
339
  }
115
340
  // A single record beyond the limit can never be accepted; drop it instead of wedging the batch.
@@ -119,19 +344,7 @@ function createOtlpBatchSender<T>(
119
344
  );
120
345
  return;
121
346
  }
122
- const { body: finalBody, contentEncoding } = await maybeGzip(body);
123
- const headers = contentEncoding
124
- ? { ...target.headers, "content-encoding": contentEncoding }
125
- : target.headers;
126
- const res = await postOtlp({
127
- fetchImpl: transport.fetchImpl,
128
- url: target.url,
129
- headers,
130
- body: finalBody,
131
- });
132
- if (!res.ok) {
133
- throw new Error(`telemetry.dev ${label} ingest failed: ${res.status}`);
134
- }
347
+ await postSerialized(body, target, transport, label, postOtlp, signal);
135
348
  };
136
349
  return send;
137
350
  }
@@ -143,21 +356,13 @@ export function createTraceExporter(target: OtlpTarget, transport: Transport): S
143
356
  transport,
144
357
  "trace",
145
358
  );
359
+ const lifecycle = createExportLifecycle(transport);
146
360
  return {
147
361
  export(spans, resultCallback) {
148
- send(spans).then(
149
- () => resultCallback({ code: ExportResultCode.SUCCESS }),
150
- (error: Error) => {
151
- reportError(transport.onError, error);
152
- resultCallback({
153
- code: ExportResultCode.FAILED,
154
- error: error instanceof Error ? error : undefined,
155
- });
156
- },
157
- );
362
+ lifecycle.exportBatch((signal) => send(spans, signal), resultCallback);
158
363
  },
159
- forceFlush: () => Promise.resolve(),
160
- shutdown: () => Promise.resolve(),
364
+ forceFlush: lifecycle.forceFlush,
365
+ shutdown: lifecycle.shutdown,
161
366
  };
162
367
  }
163
368
 
@@ -168,71 +373,35 @@ export function createLogExporter(target: OtlpTarget, transport: Transport): Log
168
373
  transport,
169
374
  "log",
170
375
  );
376
+ const lifecycle = createExportLifecycle(transport);
171
377
  return {
172
378
  export(logs, resultCallback) {
173
- send(logs).then(
174
- () => resultCallback({ code: ExportResultCode.SUCCESS }),
175
- (error: Error) => {
176
- reportError(transport.onError, error);
177
- resultCallback({
178
- code: ExportResultCode.FAILED,
179
- error: error instanceof Error ? error : undefined,
180
- });
181
- },
182
- );
379
+ lifecycle.exportBatch((signal) => send(logs, signal), resultCallback);
183
380
  },
184
- forceFlush: () => Promise.resolve(),
185
- shutdown: () => Promise.resolve(),
381
+ forceFlush: lifecycle.forceFlush,
382
+ shutdown: lifecycle.shutdown,
186
383
  };
187
384
  }
188
385
 
189
386
  export function createMetricExporter(target: OtlpTarget, transport: Transport): PushMetricExporter {
190
- const { fetchImpl, onError } = transport;
387
+ const lifecycle = createExportLifecycle(transport);
191
388
  return {
192
389
  export(resourceMetrics: ResourceMetrics, resultCallback) {
193
- // Quiet intervals produce metric envelopes with zero data points; the ingest 400s empty
194
- // payloads, so skip the POST entirely.
195
- const hasData = resourceMetrics.scopeMetrics.some((scope) =>
196
- scope.metrics.some((metric) => metric.dataPoints.length > 0),
197
- );
198
- if (!hasData) {
199
- resultCallback({ code: 0 });
200
- return;
201
- }
202
- const body = ProtobufMetricsSerializer.serializeRequest(resourceMetrics);
203
- if (!body || body.byteLength === 0) {
204
- resultCallback({ code: 0 });
205
- return;
206
- }
207
- // DELTA metric batches are not idempotent; a retry after an already-ingested response would double-count.
208
- maybeGzip(body)
209
- .then(({ body: finalBody, contentEncoding }) => {
210
- const headers = contentEncoding
211
- ? { ...target.headers, "content-encoding": contentEncoding }
212
- : target.headers;
213
- return fetchImpl(target.url, {
214
- method: "POST",
215
- headers,
216
- body: finalBody as RequestInit["body"],
217
- });
218
- })
219
- .then((res) => {
220
- if (!res.ok) {
221
- const error = new Error(`telemetry.dev metric ingest failed: ${res.status}`);
222
- reportError(onError, error);
223
- resultCallback({ code: 1, error });
224
- return;
225
- }
226
- resultCallback({ code: 0 });
227
- })
228
- .catch((error: Error) => {
229
- reportError(onError, error);
230
- resultCallback({ code: 1, error: error instanceof Error ? error : undefined });
231
- });
390
+ lifecycle.exportBatch(async (signal) => {
391
+ const hasData = resourceMetrics.scopeMetrics.some((scope) =>
392
+ scope.metrics.some((metric) => metric.dataPoints.length > 0),
393
+ );
394
+ const body = hasData
395
+ ? ProtobufMetricsSerializer.serializeRequest(resourceMetrics)
396
+ : undefined;
397
+ if (body !== undefined && body.byteLength > 0) {
398
+ await postSerialized(body, target, transport, "metric", postOtlp, signal);
399
+ }
400
+ }, resultCallback);
232
401
  },
233
402
  selectAggregationTemporality: () => AggregationTemporality.DELTA,
234
- forceFlush: () => Promise.resolve(),
235
- shutdown: () => Promise.resolve(),
403
+ forceFlush: lifecycle.forceFlush,
404
+ shutdown: lifecycle.shutdown,
236
405
  };
237
406
  }
238
407