@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/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
+ }
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:9f8a7323-070b-54ee-8121-9f94a41c10eb",
4
+ "serialNumber": "urn:uuid:e489b0cf-2d9d-5f9f-93c0-ff3c851e6f48",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-08-09T15:45:42.359Z",
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.1.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.1.1",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.2.1",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.1.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.1.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.1.1",
49
+ "tagId": "swidtag--daloyjs-core-1.2.1",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.1.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.1.1",
60
+ "ref": "pkg:npm/@daloyjs/core@1.2.1",
61
61
  "dependsOn": []
62
62
  }
63
63
  ]
@@ -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.1.1",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.1.1-9f8a7323-070b-54ee-8121-9f94a41c10eb",
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-09T15:45:42.359Z",
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.1.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.1.1"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.2.1"
31
31
  }
32
32
  ]
33
33
  }