@telemetry-dev/otel 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 +87 -0
- package/dist/index.d.mts +195 -0
- package/dist/index.mjs +553 -0
- package/package.json +65 -0
- package/src/attrs.ts +30 -0
- package/src/config.ts +29 -0
- package/src/context.ts +139 -0
- package/src/debug.ts +32 -0
- package/src/index.ts +46 -0
- package/src/metrics.ts +91 -0
- package/src/otel.ts +166 -0
- package/src/processor.ts +72 -0
- package/src/transport.ts +244 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { ExportResultCode } from "@opentelemetry/core";
|
|
2
|
+
import {
|
|
3
|
+
ProtobufLogsSerializer,
|
|
4
|
+
ProtobufMetricsSerializer,
|
|
5
|
+
ProtobufTraceSerializer,
|
|
6
|
+
} from "@opentelemetry/otlp-transformer";
|
|
7
|
+
import type { LogRecordExporter, ReadableLogRecord } from "@opentelemetry/sdk-logs";
|
|
8
|
+
import {
|
|
9
|
+
AggregationTemporality,
|
|
10
|
+
type PushMetricExporter,
|
|
11
|
+
type ResourceMetrics,
|
|
12
|
+
} from "@opentelemetry/sdk-metrics";
|
|
13
|
+
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
14
|
+
|
|
15
|
+
import { reportError } from "./debug.ts";
|
|
16
|
+
|
|
17
|
+
export interface Transport {
|
|
18
|
+
fetchImpl: typeof fetch;
|
|
19
|
+
onError?: (error: unknown) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface OtlpTarget {
|
|
23
|
+
url: string;
|
|
24
|
+
headers: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const RETRY_DELAYS_MS = [100, 500] as const;
|
|
28
|
+
|
|
29
|
+
const RETRYABLE_STATUSES = new Set([429, 502, 503, 504]);
|
|
30
|
+
|
|
31
|
+
const isRetryableStatus = (status: number) => RETRYABLE_STATUSES.has(status);
|
|
32
|
+
|
|
33
|
+
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
|
|
35
|
+
const cancelBody = async (res: Response) => {
|
|
36
|
+
try {
|
|
37
|
+
await res.body?.cancel();
|
|
38
|
+
} catch {}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const postOtlp = async ({
|
|
42
|
+
fetchImpl,
|
|
43
|
+
url,
|
|
44
|
+
headers,
|
|
45
|
+
body,
|
|
46
|
+
}: {
|
|
47
|
+
fetchImpl: typeof fetch;
|
|
48
|
+
url: string;
|
|
49
|
+
headers: Record<string, string>;
|
|
50
|
+
body: Uint8Array;
|
|
51
|
+
}) => {
|
|
52
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetchImpl(url, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers,
|
|
57
|
+
body: body as RequestInit["body"],
|
|
58
|
+
});
|
|
59
|
+
if (res.ok || !isRetryableStatus(res.status) || attempt === RETRY_DELAYS_MS.length) {
|
|
60
|
+
await cancelBody(res);
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
await cancelBody(res);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (attempt === RETRY_DELAYS_MS.length) throw error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await delay(RETRY_DELAYS_MS[attempt]!);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const GZIP_THRESHOLD_BYTES = 1024;
|
|
73
|
+
|
|
74
|
+
export async function maybeGzip(
|
|
75
|
+
body: Uint8Array,
|
|
76
|
+
): Promise<{ body: Uint8Array; contentEncoding?: "gzip" }> {
|
|
77
|
+
if (body.byteLength <= GZIP_THRESHOLD_BYTES || typeof CompressionStream === "undefined") {
|
|
78
|
+
return { body };
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const stream = new Blob([body as Uint8Array<ArrayBuffer>])
|
|
82
|
+
.stream()
|
|
83
|
+
.pipeThrough(new CompressionStream("gzip"));
|
|
84
|
+
const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
|
|
85
|
+
return { body: compressed, contentEncoding: "gzip" };
|
|
86
|
+
} catch {
|
|
87
|
+
// Fail-open: ship uncompressed rather than lose the batch.
|
|
88
|
+
return { body };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Stay under the ingest's 4 MiB raw body limit with headroom; oversized batches are halved.
|
|
93
|
+
const MAX_BODY_BYTES = 3_500_000;
|
|
94
|
+
|
|
95
|
+
interface Serializer<T> {
|
|
96
|
+
serializeRequest(items: T[]): Uint8Array | undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createOtlpBatchSender<T>(
|
|
100
|
+
serializer: Serializer<T>,
|
|
101
|
+
target: OtlpTarget,
|
|
102
|
+
transport: Transport,
|
|
103
|
+
label: string,
|
|
104
|
+
): (items: T[]) => Promise<void> {
|
|
105
|
+
const send = async (items: T[]): Promise<void> => {
|
|
106
|
+
const body = serializer.serializeRequest(items);
|
|
107
|
+
if (!body || body.byteLength === 0) return;
|
|
108
|
+
if (body.byteLength > MAX_BODY_BYTES) {
|
|
109
|
+
if (items.length > 1) {
|
|
110
|
+
const mid = Math.ceil(items.length / 2);
|
|
111
|
+
await send(items.slice(0, mid));
|
|
112
|
+
await send(items.slice(mid));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
// A single record beyond the limit can never be accepted; drop it instead of wedging the batch.
|
|
116
|
+
reportError(
|
|
117
|
+
transport.onError,
|
|
118
|
+
new Error(`telemetry.dev: ${label} record exceeds max export size, dropped`),
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
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
|
+
}
|
|
135
|
+
};
|
|
136
|
+
return send;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createTraceExporter(target: OtlpTarget, transport: Transport): SpanExporter {
|
|
140
|
+
const send = createOtlpBatchSender<ReadableSpan>(
|
|
141
|
+
ProtobufTraceSerializer,
|
|
142
|
+
target,
|
|
143
|
+
transport,
|
|
144
|
+
"trace",
|
|
145
|
+
);
|
|
146
|
+
return {
|
|
147
|
+
export(spans, resultCallback) {
|
|
148
|
+
send(spans).then(
|
|
149
|
+
() => resultCallback({ code: ExportResultCode.SUCCESS }),
|
|
150
|
+
(error: unknown) => {
|
|
151
|
+
reportError(transport.onError, error);
|
|
152
|
+
resultCallback({
|
|
153
|
+
code: ExportResultCode.FAILED,
|
|
154
|
+
error: error instanceof Error ? error : undefined,
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
forceFlush: () => Promise.resolve(),
|
|
160
|
+
shutdown: () => Promise.resolve(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function createLogExporter(target: OtlpTarget, transport: Transport): LogRecordExporter {
|
|
165
|
+
const send = createOtlpBatchSender<ReadableLogRecord>(
|
|
166
|
+
ProtobufLogsSerializer,
|
|
167
|
+
target,
|
|
168
|
+
transport,
|
|
169
|
+
"log",
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
export(logs, resultCallback) {
|
|
173
|
+
send(logs).then(
|
|
174
|
+
() => resultCallback({ code: ExportResultCode.SUCCESS }),
|
|
175
|
+
(error: unknown) => {
|
|
176
|
+
reportError(transport.onError, error);
|
|
177
|
+
resultCallback({
|
|
178
|
+
code: ExportResultCode.FAILED,
|
|
179
|
+
error: error instanceof Error ? error : undefined,
|
|
180
|
+
});
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
forceFlush: () => Promise.resolve(),
|
|
185
|
+
shutdown: () => Promise.resolve(),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createMetricExporter(target: OtlpTarget, transport: Transport): PushMetricExporter {
|
|
190
|
+
const { fetchImpl, onError } = transport;
|
|
191
|
+
return {
|
|
192
|
+
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: unknown) => {
|
|
229
|
+
reportError(onError, error);
|
|
230
|
+
resultCallback({ code: 1, error: error instanceof Error ? error : undefined });
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
selectAggregationTemporality: () => AggregationTemporality.DELTA,
|
|
234
|
+
forceFlush: () => Promise.resolve(),
|
|
235
|
+
shutdown: () => Promise.resolve(),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function otlpHeaders(apiKey: string): Record<string, string> {
|
|
240
|
+
return {
|
|
241
|
+
"content-type": "application/x-protobuf",
|
|
242
|
+
authorization: `Bearer ${apiKey}`,
|
|
243
|
+
};
|
|
244
|
+
}
|