@daloyjs/core 1.1.1 → 1.2.1
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 +2 -1
- package/dist/adapters/cloudflare.d.ts +8 -1
- package/dist/adapters/cloudflare.js +15 -1
- package/dist/adapters/lambda.js +15 -5
- package/dist/adapters/vercel.d.ts +5 -0
- package/dist/adapters/vercel.js +27 -2
- package/dist/app.d.ts +34 -8
- package/dist/app.js +257 -72
- package/dist/combine.js +14 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/metrics.d.ts +23 -9
- package/dist/metrics.js +37 -10
- package/dist/otlp.d.ts +268 -0
- package/dist/otlp.js +589 -0
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/types.d.ts +18 -2
- package/package.json +5 -1
package/dist/otlp.js
ADDED
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry OTLP push export — logs, metrics, and semantic-convention
|
|
3
|
+
* HTTP server instrumentation, with zero runtime dependencies.
|
|
4
|
+
*
|
|
5
|
+
* Many container platforms run an in-cluster OTel collector and expect
|
|
6
|
+
* workloads to **push** telemetry: they inject the standard
|
|
7
|
+
* `OTEL_EXPORTER_OTLP_ENDPOINT` / `OTEL_EXPORTER_OTLP_HEADERS` /
|
|
8
|
+
* `OTEL_RESOURCE_ATTRIBUTES` / `OTEL_SERVICE_NAME` variables into every
|
|
9
|
+
* container and scrape nothing — not stdout, not a `/metrics` route. The
|
|
10
|
+
* OTel Node SDK covers that with monkey-patching auto-instrumentation, but
|
|
11
|
+
* its ESM loader hooks are fragile on modern Node and unavailable on edge
|
|
12
|
+
* runtimes. This module is the framework-native alternative:
|
|
13
|
+
*
|
|
14
|
+
* - {@link createOtlpLogExporter} — batched OTLP/HTTP JSON log export;
|
|
15
|
+
* tee your logger's `write` sink into it.
|
|
16
|
+
* - {@link createOtlpMetricsExporter} — cumulative counters + histograms
|
|
17
|
+
* pushed as OTLP/HTTP JSON.
|
|
18
|
+
* - {@link semconvHttpMetrics} — a `Hooks` bundle emitting
|
|
19
|
+
* `http.server.request.duration` exactly per the OTel HTTP semantic
|
|
20
|
+
* conventions (names, attributes, bucket boundaries), so standard Grafana
|
|
21
|
+
* dashboards work unchanged.
|
|
22
|
+
* - `new App({ telemetry: true })` wires all of the above automatically
|
|
23
|
+
* (see {@link TelemetryOptions}).
|
|
24
|
+
*
|
|
25
|
+
* Everything is transport-portable (`fetch` + web-standard primitives) and
|
|
26
|
+
* **fail-safe by contract**: a dead or misconfigured collector never affects
|
|
27
|
+
* request serving — bounded queues, dropped-batch counters, no retry storms,
|
|
28
|
+
* a timeout on every export POST, and cumulative metric temporality so
|
|
29
|
+
* totals survive failed pushes. Long-lived runtimes flush on an `unref`'d
|
|
30
|
+
* interval plus graceful shutdown; isolate runtimes (Workers, Vercel, Lambda)
|
|
31
|
+
* flush per request via the adapters' `waitUntil` / handler-await path.
|
|
32
|
+
*
|
|
33
|
+
* @module
|
|
34
|
+
* @since 1.2.0
|
|
35
|
+
*/
|
|
36
|
+
/** Log-queue and batching caps (drop-oldest beyond the queue cap). */
|
|
37
|
+
const LOG_MAX_BATCH = 100;
|
|
38
|
+
const LOG_MAX_QUEUE = 1_000;
|
|
39
|
+
const LOG_FLUSH_INTERVAL_MS = 5_000;
|
|
40
|
+
/** Longest log body / attribute value shipped before truncation. */
|
|
41
|
+
const LOG_MAX_FIELD_LENGTH = 8_192;
|
|
42
|
+
const METRICS_FLUSH_INTERVAL_MS = 15_000;
|
|
43
|
+
/** Default abort timeout for an in-flight OTLP POST. */
|
|
44
|
+
const DEFAULT_FLUSH_TIMEOUT_MS = 5_000;
|
|
45
|
+
/**
|
|
46
|
+
* Cardinality guard: total metric series (counter + histogram) before new
|
|
47
|
+
* series are dropped and counted. A hostile client must not be able to mint
|
|
48
|
+
* unbounded series through attribute values.
|
|
49
|
+
*/
|
|
50
|
+
const METRICS_MAX_SERIES = 2_000;
|
|
51
|
+
/** Longest metric attribute value before truncation (cardinality + memory guard). */
|
|
52
|
+
const METRICS_MAX_ATTR_LENGTH = 256;
|
|
53
|
+
/**
|
|
54
|
+
* Spec-defined default bucket boundaries for `http.server.request.duration`,
|
|
55
|
+
* in seconds (OTel HTTP semantic conventions).
|
|
56
|
+
*
|
|
57
|
+
* @since 1.2.0
|
|
58
|
+
*/
|
|
59
|
+
export const HTTP_SERVER_REQUEST_DURATION_BUCKETS = [
|
|
60
|
+
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
61
|
+
];
|
|
62
|
+
/** OTLP severity numbers keyed by the framework logger's level names. */
|
|
63
|
+
const SEVERITY = {
|
|
64
|
+
trace: [1, "TRACE"],
|
|
65
|
+
debug: [5, "DEBUG"],
|
|
66
|
+
info: [9, "INFO"],
|
|
67
|
+
warn: [13, "WARN"],
|
|
68
|
+
error: [17, "ERROR"],
|
|
69
|
+
fatal: [21, "FATAL"],
|
|
70
|
+
};
|
|
71
|
+
/** Known HTTP methods per the semconv `http.request.method` well-known set. */
|
|
72
|
+
const KNOWN_METHODS = new Set([
|
|
73
|
+
"GET",
|
|
74
|
+
"HEAD",
|
|
75
|
+
"POST",
|
|
76
|
+
"PUT",
|
|
77
|
+
"DELETE",
|
|
78
|
+
"CONNECT",
|
|
79
|
+
"OPTIONS",
|
|
80
|
+
"TRACE",
|
|
81
|
+
"PATCH",
|
|
82
|
+
]);
|
|
83
|
+
/** Portable environment lookup (Node/Bun/Deno-with-node-compat; undefined elsewhere). */
|
|
84
|
+
function envVar(name) {
|
|
85
|
+
const env = globalThis.process?.env;
|
|
86
|
+
return env?.[name];
|
|
87
|
+
}
|
|
88
|
+
/** Decode one OTEL env-list component; invalid percent-encoding is kept raw. */
|
|
89
|
+
function decodeOtelComponent(value) {
|
|
90
|
+
try {
|
|
91
|
+
return decodeURIComponent(value);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse `key=value,key2=value2` lists (`OTEL_EXPORTER_OTLP_HEADERS` /
|
|
99
|
+
* `OTEL_RESOURCE_ATTRIBUTES`). Spec values are percent-encoded; commas in a
|
|
100
|
+
* value must be `%2C`.
|
|
101
|
+
*/
|
|
102
|
+
function parseKvList(raw) {
|
|
103
|
+
const out = {};
|
|
104
|
+
if (!raw)
|
|
105
|
+
return out;
|
|
106
|
+
for (const pair of raw.split(",")) {
|
|
107
|
+
const trimmed = pair.trim();
|
|
108
|
+
const idx = trimmed.indexOf("=");
|
|
109
|
+
if (idx <= 0)
|
|
110
|
+
continue;
|
|
111
|
+
const key = decodeOtelComponent(trimmed.slice(0, idx).trim());
|
|
112
|
+
if (key.length === 0)
|
|
113
|
+
continue;
|
|
114
|
+
out[key] = decodeOtelComponent(trimmed.slice(idx + 1).trim());
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
function truncate(value, max) {
|
|
119
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
120
|
+
}
|
|
121
|
+
function toAttrList(record, maxLength) {
|
|
122
|
+
return Object.entries(record).map(([key, value]) => ({
|
|
123
|
+
key,
|
|
124
|
+
value: { stringValue: truncate(value, maxLength) },
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function resolveTransport(signalPath, opts) {
|
|
128
|
+
const rawEndpoint = opts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
129
|
+
if (!rawEndpoint)
|
|
130
|
+
return null;
|
|
131
|
+
const base = rawEndpoint.replace(/\/+$/, "").replace(/:4317$/, ":4318");
|
|
132
|
+
const headers = {
|
|
133
|
+
"content-type": "application/json",
|
|
134
|
+
};
|
|
135
|
+
Object.assign(headers, parseKvList(envVar("OTEL_EXPORTER_OTLP_HEADERS")), opts.headers ?? {});
|
|
136
|
+
const resource = parseKvList(envVar("OTEL_RESOURCE_ATTRIBUTES"));
|
|
137
|
+
const serviceName = envVar("OTEL_SERVICE_NAME");
|
|
138
|
+
if (serviceName && resource["service.name"] === undefined) {
|
|
139
|
+
resource["service.name"] = serviceName;
|
|
140
|
+
}
|
|
141
|
+
Object.assign(resource, opts.resourceAttributes ?? {});
|
|
142
|
+
return {
|
|
143
|
+
url: base + signalPath,
|
|
144
|
+
headers,
|
|
145
|
+
resourceAttributes: toAttrList(resource, LOG_MAX_FIELD_LENGTH),
|
|
146
|
+
fetchImpl: opts.fetch ?? fetch,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** Start an `unref`'d repeating flush where the runtime supports timers. */
|
|
150
|
+
function startFlushTimer(intervalMs, flush) {
|
|
151
|
+
if (intervalMs <= 0 || typeof setInterval !== "function")
|
|
152
|
+
return;
|
|
153
|
+
const timer = setInterval(() => void flush(), intervalMs);
|
|
154
|
+
timer.unref?.();
|
|
155
|
+
}
|
|
156
|
+
/** AbortSignal that fires after `timeoutMs`, or `undefined` when timeout is disabled. */
|
|
157
|
+
function flushSignal(timeoutMs) {
|
|
158
|
+
if (timeoutMs <= 0)
|
|
159
|
+
return undefined;
|
|
160
|
+
if (typeof AbortSignal !== "undefined" &&
|
|
161
|
+
typeof AbortSignal.timeout === "function") {
|
|
162
|
+
return AbortSignal.timeout(timeoutMs);
|
|
163
|
+
}
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
166
|
+
timer.unref?.();
|
|
167
|
+
return controller.signal;
|
|
168
|
+
}
|
|
169
|
+
/** Shared POST init: never follow redirects (tenant headers would leak). */
|
|
170
|
+
function exportRequestInit(headers, body, timeoutMs) {
|
|
171
|
+
const init = {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers,
|
|
174
|
+
body,
|
|
175
|
+
redirect: "error",
|
|
176
|
+
};
|
|
177
|
+
const signal = flushSignal(timeoutMs);
|
|
178
|
+
if (signal !== undefined)
|
|
179
|
+
init.signal = signal;
|
|
180
|
+
return init;
|
|
181
|
+
}
|
|
182
|
+
function toLogRecord(line) {
|
|
183
|
+
let level = "info";
|
|
184
|
+
let body = line;
|
|
185
|
+
const attributes = [];
|
|
186
|
+
try {
|
|
187
|
+
const parsed = JSON.parse(line);
|
|
188
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
189
|
+
throw new Error();
|
|
190
|
+
if (typeof parsed.level === "string")
|
|
191
|
+
level = parsed.level;
|
|
192
|
+
const msg = parsed.msg ?? parsed.message ?? parsed.event;
|
|
193
|
+
body = typeof msg === "string" ? msg : line;
|
|
194
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
195
|
+
if (key === "level" || key === "msg" || key === "message")
|
|
196
|
+
continue;
|
|
197
|
+
attributes.push({
|
|
198
|
+
key,
|
|
199
|
+
value: {
|
|
200
|
+
stringValue: truncate(typeof value === "string"
|
|
201
|
+
? value
|
|
202
|
+
: (JSON.stringify(value) ?? "null"), LOG_MAX_FIELD_LENGTH),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// Not a JSON object (boot banner etc.) — ship verbatim as the body.
|
|
209
|
+
}
|
|
210
|
+
const [severityNumber, severityText] = SEVERITY[level] ?? SEVERITY.info;
|
|
211
|
+
return {
|
|
212
|
+
timeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
213
|
+
severityNumber,
|
|
214
|
+
severityText,
|
|
215
|
+
body: { stringValue: truncate(body, LOG_MAX_FIELD_LENGTH) },
|
|
216
|
+
attributes,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Create a batched OTLP/HTTP JSON log exporter, or `null` when no endpoint is
|
|
221
|
+
* configured (local development, tests) so callers can no-op cheaply.
|
|
222
|
+
*
|
|
223
|
+
* Fail-safe by contract: {@link OtlpLogExporter.flush} never rejects, a dead
|
|
224
|
+
* collector cannot grow the queue past its cap (oldest records are dropped
|
|
225
|
+
* and counted), and export work is decoupled from request handling.
|
|
226
|
+
*
|
|
227
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
228
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
229
|
+
* @since 1.2.0
|
|
230
|
+
*/
|
|
231
|
+
export function createOtlpLogExporter(opts = {}) {
|
|
232
|
+
const transport = resolveTransport("/v1/logs", opts);
|
|
233
|
+
if (transport === null)
|
|
234
|
+
return null;
|
|
235
|
+
const { url, headers, resourceAttributes, fetchImpl } = transport;
|
|
236
|
+
const timeoutMs = opts.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
237
|
+
const queue = [];
|
|
238
|
+
let dropped = 0;
|
|
239
|
+
let flushing = false;
|
|
240
|
+
async function flush() {
|
|
241
|
+
if (flushing || queue.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
flushing = true;
|
|
244
|
+
try {
|
|
245
|
+
while (queue.length > 0) {
|
|
246
|
+
const batch = queue.splice(0, LOG_MAX_BATCH);
|
|
247
|
+
const payload = {
|
|
248
|
+
resourceLogs: [
|
|
249
|
+
{
|
|
250
|
+
resource: { attributes: resourceAttributes },
|
|
251
|
+
scopeLogs: [{ scope: { name: "daloyjs" }, logRecords: batch }],
|
|
252
|
+
},
|
|
253
|
+
],
|
|
254
|
+
};
|
|
255
|
+
const res = await fetchImpl(url, exportRequestInit(headers, JSON.stringify(payload), timeoutMs));
|
|
256
|
+
if (!res.ok)
|
|
257
|
+
dropped += 1;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
dropped += 1;
|
|
262
|
+
queue.length = 0; // never let a dead collector grow the queue
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
flushing = false;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
startFlushTimer(opts.flushIntervalMs ?? LOG_FLUSH_INTERVAL_MS, flush);
|
|
269
|
+
return {
|
|
270
|
+
pushLine(line) {
|
|
271
|
+
if (queue.length >= LOG_MAX_QUEUE) {
|
|
272
|
+
queue.shift();
|
|
273
|
+
dropped += 1;
|
|
274
|
+
}
|
|
275
|
+
queue.push(toLogRecord(line.endsWith("\n") ? line.trimEnd() : line));
|
|
276
|
+
},
|
|
277
|
+
flush,
|
|
278
|
+
get droppedBatches() {
|
|
279
|
+
return dropped;
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Create a cumulative OTLP/HTTP JSON metrics exporter, or `null` when no
|
|
285
|
+
* endpoint is configured.
|
|
286
|
+
*
|
|
287
|
+
* Counters export as monotonic cumulative sums and histograms as cumulative
|
|
288
|
+
* explicit-bounds histograms, so state survives failed pushes (the next
|
|
289
|
+
* successful push carries the accumulated totals). Total series are capped
|
|
290
|
+
* ({@link OtlpMetricsExporter.droppedBatches} counts series refused at the
|
|
291
|
+
* cap) and attribute values are length-truncated — both cardinality guards
|
|
292
|
+
* against hostile or buggy attribute sources.
|
|
293
|
+
*
|
|
294
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
295
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
296
|
+
* @since 1.2.0
|
|
297
|
+
*/
|
|
298
|
+
export function createOtlpMetricsExporter(opts = {}) {
|
|
299
|
+
const transport = resolveTransport("/v1/metrics", opts);
|
|
300
|
+
if (transport === null)
|
|
301
|
+
return null;
|
|
302
|
+
const { url, headers, resourceAttributes, fetchImpl } = transport;
|
|
303
|
+
const timeoutMs = opts.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
|
|
304
|
+
const counters = new Map();
|
|
305
|
+
const histograms = new Map();
|
|
306
|
+
let dropped = 0;
|
|
307
|
+
let flushing = false;
|
|
308
|
+
let dirty = false;
|
|
309
|
+
function seriesKey(name, entries) {
|
|
310
|
+
let key = name;
|
|
311
|
+
for (const [k, v] of entries)
|
|
312
|
+
key += "|" + k + "=" + v;
|
|
313
|
+
return key;
|
|
314
|
+
}
|
|
315
|
+
function sortedEntries(attributes) {
|
|
316
|
+
return Object.entries(attributes)
|
|
317
|
+
.map(([k, v]) => [
|
|
318
|
+
k,
|
|
319
|
+
truncate(v, METRICS_MAX_ATTR_LENGTH),
|
|
320
|
+
])
|
|
321
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
322
|
+
}
|
|
323
|
+
function atCapacity() {
|
|
324
|
+
if (counters.size + histograms.size < METRICS_MAX_SERIES)
|
|
325
|
+
return false;
|
|
326
|
+
dropped += 1;
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
async function flush() {
|
|
330
|
+
if (flushing || !dirty)
|
|
331
|
+
return;
|
|
332
|
+
flushing = true;
|
|
333
|
+
// Clear before the POST so a concurrent count/record keeps the flag true
|
|
334
|
+
// and the next flush carries the in-flight increment.
|
|
335
|
+
dirty = false;
|
|
336
|
+
try {
|
|
337
|
+
const now = (BigInt(Date.now()) * 1000000n).toString();
|
|
338
|
+
const sumsByName = new Map();
|
|
339
|
+
for (const s of counters.values()) {
|
|
340
|
+
const group = sumsByName.get(s.name);
|
|
341
|
+
if (group === undefined)
|
|
342
|
+
sumsByName.set(s.name, [s]);
|
|
343
|
+
else
|
|
344
|
+
group.push(s);
|
|
345
|
+
}
|
|
346
|
+
const histsByName = new Map();
|
|
347
|
+
for (const h of histograms.values()) {
|
|
348
|
+
const group = histsByName.get(h.name);
|
|
349
|
+
if (group === undefined)
|
|
350
|
+
histsByName.set(h.name, [h]);
|
|
351
|
+
else
|
|
352
|
+
group.push(h);
|
|
353
|
+
}
|
|
354
|
+
const metrics = [];
|
|
355
|
+
for (const [name, group] of sumsByName) {
|
|
356
|
+
metrics.push({
|
|
357
|
+
name,
|
|
358
|
+
unit: "1",
|
|
359
|
+
sum: {
|
|
360
|
+
aggregationTemporality: 2, // cumulative
|
|
361
|
+
isMonotonic: true,
|
|
362
|
+
dataPoints: group.map((s) => ({
|
|
363
|
+
attributes: s.attributes,
|
|
364
|
+
startTimeUnixNano: s.startTimeUnixNano,
|
|
365
|
+
timeUnixNano: now,
|
|
366
|
+
asDouble: s.total,
|
|
367
|
+
})),
|
|
368
|
+
},
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
for (const [name, group] of histsByName) {
|
|
372
|
+
metrics.push({
|
|
373
|
+
name,
|
|
374
|
+
unit: group[0].unit,
|
|
375
|
+
histogram: {
|
|
376
|
+
aggregationTemporality: 2, // cumulative
|
|
377
|
+
dataPoints: group.map((h) => ({
|
|
378
|
+
attributes: h.attributes,
|
|
379
|
+
startTimeUnixNano: h.startTimeUnixNano,
|
|
380
|
+
timeUnixNano: now,
|
|
381
|
+
// uint64 fields use the string JSON mapping in OTLP.
|
|
382
|
+
count: String(h.count),
|
|
383
|
+
sum: h.sum,
|
|
384
|
+
bucketCounts: h.bucketCounts.map(String),
|
|
385
|
+
explicitBounds: [...h.boundaries],
|
|
386
|
+
})),
|
|
387
|
+
},
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
const payload = {
|
|
391
|
+
resourceMetrics: [
|
|
392
|
+
{
|
|
393
|
+
resource: { attributes: resourceAttributes },
|
|
394
|
+
scopeMetrics: [{ scope: { name: "daloyjs" }, metrics }],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
};
|
|
398
|
+
const res = await fetchImpl(url, exportRequestInit(headers, JSON.stringify(payload), timeoutMs));
|
|
399
|
+
if (!res.ok) {
|
|
400
|
+
dropped += 1;
|
|
401
|
+
dirty = true;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
dropped += 1;
|
|
406
|
+
dirty = true; // totals retained; cumulative temporality self-heals
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
flushing = false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
startFlushTimer(opts.flushIntervalMs ?? METRICS_FLUSH_INTERVAL_MS, flush);
|
|
413
|
+
return {
|
|
414
|
+
count(name, attributes, value = 1) {
|
|
415
|
+
if (!Number.isFinite(value) || value < 0)
|
|
416
|
+
return;
|
|
417
|
+
const entries = sortedEntries(attributes);
|
|
418
|
+
const key = seriesKey(name, entries);
|
|
419
|
+
let s = counters.get(key);
|
|
420
|
+
if (s === undefined) {
|
|
421
|
+
if (atCapacity())
|
|
422
|
+
return;
|
|
423
|
+
s = {
|
|
424
|
+
name,
|
|
425
|
+
attributes: entries.map(([k, v]) => ({
|
|
426
|
+
key: k,
|
|
427
|
+
value: { stringValue: v },
|
|
428
|
+
})),
|
|
429
|
+
total: 0,
|
|
430
|
+
startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
431
|
+
};
|
|
432
|
+
counters.set(key, s);
|
|
433
|
+
}
|
|
434
|
+
s.total += value;
|
|
435
|
+
dirty = true;
|
|
436
|
+
},
|
|
437
|
+
record(name, attributes, value, options) {
|
|
438
|
+
if (!Number.isFinite(value))
|
|
439
|
+
return;
|
|
440
|
+
const entries = sortedEntries(attributes);
|
|
441
|
+
const key = seriesKey(name, entries);
|
|
442
|
+
let h = histograms.get(key);
|
|
443
|
+
if (h === undefined) {
|
|
444
|
+
if (atCapacity())
|
|
445
|
+
return;
|
|
446
|
+
h = {
|
|
447
|
+
name,
|
|
448
|
+
unit: options.unit,
|
|
449
|
+
boundaries: options.boundaries,
|
|
450
|
+
attributes: entries.map(([k, v]) => ({
|
|
451
|
+
key: k,
|
|
452
|
+
value: { stringValue: v },
|
|
453
|
+
})),
|
|
454
|
+
count: 0,
|
|
455
|
+
sum: 0,
|
|
456
|
+
bucketCounts: new Array(options.boundaries.length + 1).fill(0),
|
|
457
|
+
startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
458
|
+
};
|
|
459
|
+
histograms.set(key, h);
|
|
460
|
+
}
|
|
461
|
+
h.count += 1;
|
|
462
|
+
h.sum += value;
|
|
463
|
+
let bucket = h.boundaries.findIndex((bound) => value <= bound);
|
|
464
|
+
if (bucket === -1)
|
|
465
|
+
bucket = h.boundaries.length;
|
|
466
|
+
h.bucketCounts[bucket] += 1;
|
|
467
|
+
dirty = true;
|
|
468
|
+
},
|
|
469
|
+
flush,
|
|
470
|
+
get droppedBatches() {
|
|
471
|
+
return dropped;
|
|
472
|
+
},
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
|
|
476
|
+
function nowMs() {
|
|
477
|
+
return typeof performance !== "undefined" &&
|
|
478
|
+
typeof performance.now === "function"
|
|
479
|
+
? performance.now()
|
|
480
|
+
: Date.now();
|
|
481
|
+
}
|
|
482
|
+
const SEMCONV_START_TIMES = new WeakMap();
|
|
483
|
+
/**
|
|
484
|
+
* A `Hooks` bundle recording `http.server.request.duration` per the OTel HTTP
|
|
485
|
+
* semantic conventions into an {@link OtlpMetricsExporter}:
|
|
486
|
+
*
|
|
487
|
+
* - unit `s`, spec bucket boundaries
|
|
488
|
+
* ({@link HTTP_SERVER_REQUEST_DURATION_BUCKETS});
|
|
489
|
+
* - attributes `http.request.method` (well-known set, else `_OTHER`),
|
|
490
|
+
* `http.response.status_code`, `url.scheme`, `http.route` (the matched
|
|
491
|
+
* route **template** via `ctx.routePath`), and `error.type` (the status
|
|
492
|
+
* code, on `5xx` only).
|
|
493
|
+
*
|
|
494
|
+
* Only requests that produce a request context are recorded. On the
|
|
495
|
+
* unmatched-404 fast path the framework skips context construction entirely
|
|
496
|
+
* (a deliberate allocation optimization), so unmatched floods record nothing
|
|
497
|
+
* — which is also the strongest possible cardinality guarantee: a hostile
|
|
498
|
+
* client can never mint metric series from raw paths.
|
|
499
|
+
*
|
|
500
|
+
* Install it **before** registering routes (group-hook ordering), or let
|
|
501
|
+
* `new App({ telemetry: true })` install it for you.
|
|
502
|
+
*
|
|
503
|
+
* @param sink - Metrics exporter the duration histogram is recorded into.
|
|
504
|
+
* @param opts - Optional path exclusion.
|
|
505
|
+
* @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
|
|
506
|
+
* @since 1.2.0
|
|
507
|
+
*/
|
|
508
|
+
export function semconvHttpMetrics(sink, opts = {}) {
|
|
509
|
+
return {
|
|
510
|
+
onRequest(req) {
|
|
511
|
+
SEMCONV_START_TIMES.set(req, nowMs());
|
|
512
|
+
},
|
|
513
|
+
onResponse(res, ctx) {
|
|
514
|
+
const request = ctx?.request;
|
|
515
|
+
if (request === undefined)
|
|
516
|
+
return;
|
|
517
|
+
const started = SEMCONV_START_TIMES.get(request);
|
|
518
|
+
if (started === undefined)
|
|
519
|
+
return;
|
|
520
|
+
SEMCONV_START_TIMES.delete(request);
|
|
521
|
+
const url = request.url;
|
|
522
|
+
if (opts.exclude !== undefined) {
|
|
523
|
+
let pathname = "/";
|
|
524
|
+
try {
|
|
525
|
+
pathname = new URL(url).pathname;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
/* malformed URL — keep the fallback */
|
|
529
|
+
}
|
|
530
|
+
if (opts.exclude(pathname))
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const colon = url.indexOf(":");
|
|
534
|
+
const scheme = colon > 0 ? url.slice(0, colon) : "http";
|
|
535
|
+
const rawMethod = request.method.toUpperCase();
|
|
536
|
+
const attributes = {
|
|
537
|
+
"http.request.method": KNOWN_METHODS.has(rawMethod)
|
|
538
|
+
? rawMethod
|
|
539
|
+
: "_OTHER",
|
|
540
|
+
"http.response.status_code": String(res.status),
|
|
541
|
+
"url.scheme": scheme,
|
|
542
|
+
};
|
|
543
|
+
// Spec: http.route only when a route template is known. Never the raw
|
|
544
|
+
// pathname — an unmatched-path flood must not mint metric series.
|
|
545
|
+
const routePath = ctx?.routePath;
|
|
546
|
+
if (typeof routePath === "string")
|
|
547
|
+
attributes["http.route"] = routePath;
|
|
548
|
+
if (res.status >= 500)
|
|
549
|
+
attributes["error.type"] = String(res.status);
|
|
550
|
+
sink.record("http.server.request.duration", attributes, (nowMs() - started) / 1000, { unit: "s", boundaries: HTTP_SERVER_REQUEST_DURATION_BUCKETS });
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Build the exporters, hooks, and logger sink for the `App` `telemetry`
|
|
556
|
+
* option. Returns an inert bundle (all `null`/`undefined`) when no endpoint
|
|
557
|
+
* is configured, so `telemetry: true` costs nothing in development.
|
|
558
|
+
*
|
|
559
|
+
* @param options - The resolved {@link TelemetryOptions}.
|
|
560
|
+
* @returns The wiring bundle.
|
|
561
|
+
* @since 1.2.0
|
|
562
|
+
*/
|
|
563
|
+
export function createAppTelemetry(options) {
|
|
564
|
+
const exporterOpts = options.exporter ?? {};
|
|
565
|
+
const logs = options.logs === false ? null : createOtlpLogExporter(exporterOpts);
|
|
566
|
+
const metrics = options.metrics === false ? null : createOtlpMetricsExporter(exporterOpts);
|
|
567
|
+
const logWrite = logs === null
|
|
568
|
+
? undefined
|
|
569
|
+
: (line) => {
|
|
570
|
+
const proc = globalThis.process;
|
|
571
|
+
if (proc?.stdout?.write !== undefined)
|
|
572
|
+
proc.stdout.write(line + "\n");
|
|
573
|
+
// eslint-disable-next-line no-console
|
|
574
|
+
else
|
|
575
|
+
console.log(line);
|
|
576
|
+
logs.pushLine(line);
|
|
577
|
+
};
|
|
578
|
+
const endpointRaw = exporterOpts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT") ?? null;
|
|
579
|
+
return {
|
|
580
|
+
logs,
|
|
581
|
+
metrics,
|
|
582
|
+
hooks: metrics === null ? undefined : semconvHttpMetrics(metrics),
|
|
583
|
+
logWrite,
|
|
584
|
+
endpoint: endpointRaw,
|
|
585
|
+
async flush() {
|
|
586
|
+
await Promise.all([logs?.flush(), metrics?.flush()]);
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
package/dist/sbom.cdx.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"bomFormat": "CycloneDX",
|
|
3
3
|
"specVersion": "1.5",
|
|
4
|
-
"serialNumber": "urn:uuid:
|
|
4
|
+
"serialNumber": "urn:uuid:e489b0cf-2d9d-5f9f-93c0-ff3c851e6f48",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-08-
|
|
7
|
+
"timestamp": "2026-08-20T14:37:11.548Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.2.1"
|
|
13
13
|
}
|
|
14
14
|
],
|
|
15
15
|
"authors": [
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
],
|
|
20
20
|
"component": {
|
|
21
21
|
"type": "library",
|
|
22
|
-
"bom-ref": "pkg:npm/@daloyjs/core@1.
|
|
22
|
+
"bom-ref": "pkg:npm/@daloyjs/core@1.2.1",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.
|
|
24
|
+
"version": "1.2.1",
|
|
25
25
|
"description": "DaloyJS is a runtime-portable, contract-first TypeScript web framework with built-in OpenAPI (Hey API), typed client generation, large-scale maintainability, and security-first defaults. Hono-grade portability, Elysia-grade DX, FastAPI-grade docs, Fastify-grade ops — distributed via pnpm.",
|
|
26
|
-
"purl": "pkg:npm/@daloyjs/core@1.
|
|
26
|
+
"purl": "pkg:npm/@daloyjs/core@1.2.1",
|
|
27
27
|
"licenses": [
|
|
28
28
|
{
|
|
29
29
|
"license": {
|
|
@@ -46,9 +46,9 @@
|
|
|
46
46
|
}
|
|
47
47
|
],
|
|
48
48
|
"swid": {
|
|
49
|
-
"tagId": "swidtag--daloyjs-core-1.
|
|
49
|
+
"tagId": "swidtag--daloyjs-core-1.2.1",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.
|
|
51
|
+
"version": "1.2.1",
|
|
52
52
|
"tagVersion": 0,
|
|
53
53
|
"patch": false
|
|
54
54
|
}
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"components": [],
|
|
58
58
|
"dependencies": [
|
|
59
59
|
{
|
|
60
|
-
"ref": "pkg:npm/@daloyjs/core@1.
|
|
60
|
+
"ref": "pkg:npm/@daloyjs/core@1.2.1",
|
|
61
61
|
"dependsOn": []
|
|
62
62
|
}
|
|
63
63
|
]
|
package/dist/sbom.spdx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"spdxVersion": "SPDX-2.3",
|
|
3
3
|
"dataLicense": "CC0-1.0",
|
|
4
4
|
"SPDXID": "SPDXRef-DOCUMENT",
|
|
5
|
-
"name": "@daloyjs/core-1.
|
|
6
|
-
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.
|
|
5
|
+
"name": "@daloyjs/core-1.2.1",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.2.1-e489b0cf-2d9d-5f9f-93c0-ff3c851e6f48",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-08-
|
|
8
|
+
"created": "2026-08-20T14:37:11.548Z",
|
|
9
9
|
"creators": [
|
|
10
10
|
"Tool: daloy-generate-sbom",
|
|
11
11
|
"Organization: DaloyJS"
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
{
|
|
17
17
|
"SPDXID": "SPDXRef-Package--daloyjs-core",
|
|
18
18
|
"name": "@daloyjs/core",
|
|
19
|
-
"versionInfo": "1.
|
|
19
|
+
"versionInfo": "1.2.1",
|
|
20
20
|
"downloadLocation": "https://github.com/daloyjs/daloy",
|
|
21
21
|
"filesAnalyzed": false,
|
|
22
22
|
"licenseConcluded": "MIT",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
{
|
|
28
28
|
"referenceCategory": "PACKAGE-MANAGER",
|
|
29
29
|
"referenceType": "purl",
|
|
30
|
-
"referenceLocator": "pkg:npm/@daloyjs/core@1.
|
|
30
|
+
"referenceLocator": "pkg:npm/@daloyjs/core@1.2.1"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|