@daloyjs/core 1.2.0 → 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/combine.js CHANGED
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * @since 0.19.0
7
7
  */
8
- import { _mergePreBodyWithEarlyRejections, EARLY_REJECTION_HOOK_MARKER } from "./middleware.js";
8
+ import { _mergePreBodyWithEarlyRejections, EARLY_REJECTION_HOOK_MARKER, } from "./middleware.js";
9
9
  /**
10
10
  * Run every supplied {@link Hooks} bundle in order, pipeline-style.
11
11
  * Equivalent to passing the bundles to `app.use(...)` one after another,
@@ -88,7 +88,9 @@ export function some(...layers) {
88
88
  return [
89
89
  async (ctx) => {
90
90
  const early = await hooks.preBody(ctx);
91
- return early instanceof Response ? early : hooks.beforeHandle(ctx);
91
+ return early instanceof Response
92
+ ? early
93
+ : hooks.beforeHandle(ctx);
92
94
  },
93
95
  ];
94
96
  }
@@ -152,7 +154,9 @@ export function some(...layers) {
152
154
  */
153
155
  export function except(when, hooks) {
154
156
  const earlyRejectionHooks = hooks[EARLY_REJECTION_HOOK_MARKER];
155
- if (!hooks.preBody && !hooks.beforeHandle && !Array.isArray(earlyRejectionHooks))
157
+ if (!hooks.preBody &&
158
+ !hooks.beforeHandle &&
159
+ !Array.isArray(earlyRejectionHooks))
156
160
  return hooks;
157
161
  const matches = compileExceptMatcher(when);
158
162
  const wrapped = { ...hooks };
@@ -166,11 +170,11 @@ export function except(when, hooks) {
166
170
  }
167
171
  if (hooks.preBody) {
168
172
  const original = hooks.preBody;
169
- wrapped.preBody = async (ctx) => ((await matches(ctx)) ? undefined : original(ctx));
173
+ wrapped.preBody = async (ctx) => (await matches(ctx)) ? undefined : original(ctx);
170
174
  }
171
175
  if (hooks.beforeHandle) {
172
176
  const original = hooks.beforeHandle;
173
- wrapped.beforeHandle = async (ctx) => ((await matches(ctx)) ? undefined : original(ctx));
177
+ wrapped.beforeHandle = async (ctx) => (await matches(ctx)) ? undefined : original(ctx);
174
178
  }
175
179
  return wrapped;
176
180
  }
@@ -206,7 +210,9 @@ function compilePathPattern(pattern) {
206
210
  return (path) => regex.test(path);
207
211
  }
208
212
  function mergeCombineHooks(layers) {
209
- const pick = (key) => layers.map((h) => h[key]).filter((f) => typeof f === "function");
213
+ const pick = (key) => layers
214
+ .map((h) => h[key])
215
+ .filter((f) => typeof f === "function");
210
216
  const merged = {};
211
217
  const onRequest = pick("onRequest");
212
218
  if (onRequest.length > 0) {
@@ -266,9 +272,9 @@ function mergeCombineHooks(layers) {
266
272
  }
267
273
  const onResponse = pick("onResponse");
268
274
  if (onResponse.length > 0) {
269
- merged.onResponse = async (res) => {
275
+ merged.onResponse = async (res, ctx) => {
270
276
  for (const fn of onResponse)
271
- await fn(res);
277
+ await fn(res, ctx);
272
278
  };
273
279
  }
274
280
  // Forward symbol-keyed security markers (CORS / CSRF / session /
package/dist/metrics.d.ts CHANGED
@@ -17,8 +17,11 @@
17
17
  * `http_requests_in_flight`) into a registry.
18
18
  *
19
19
  * Everything is built on Web-standard primitives (plus optional `process.*`
20
- * gauges guarded for non-Node runtimes), so it runs unchanged on Node, Bun,
21
- * Deno, Cloudflare Workers, and Vercel.
20
+ * gauges guarded for non-Node runtimes). The registry and renderer run on
21
+ * Node, Bun, Deno, Cloudflare Workers, and Vercel; **pull scraping
22
+ * (`GET /metrics`) is only a service-wide signal on a long-lived process**.
23
+ * On ephemeral isolates use `new App({ telemetry: true })` (OTLP push)
24
+ * instead of scraping `/metrics`.
22
25
  *
23
26
  * @module
24
27
  * @since 0.37.0
@@ -94,7 +97,14 @@ export declare class Histogram extends Metric {
94
97
  }
95
98
  /** Options for {@link MetricsRegistry}. */
96
99
  export interface MetricsRegistryOptions {
97
- /** Prefix applied to every metric name. Default `"daloy_"`. Pass `""` for none. */
100
+ /**
101
+ * Prefix applied to every metric name except the framework drop counter.
102
+ * Default `""` so process gauges (`process_resident_memory_bytes`) and HTTP
103
+ * RED series (`http_requests_total`) match the names stock Grafana panels
104
+ * already query. Pass `"daloy_"` (or any other prefix) to namespace them.
105
+ * The cardinality-drop meta-metric stays `daloy_metrics_series_dropped_total`
106
+ * when this is empty, because it is a DaloyJS-specific series.
107
+ */
98
108
  prefix?: string;
99
109
  /**
100
110
  * Hard cap on the number of distinct series **per metric**. Once reached,
@@ -172,15 +182,19 @@ export interface HttpMetricsOptions {
172
182
  registry: MetricsRegistry;
173
183
  /**
174
184
  * Resolve the low-cardinality `route` label from the request context.
175
- * Strongly recommended: return the route **template** (e.g. `/books/:id`),
176
- * not the raw path, to keep series cardinality bounded. When omitted the
177
- * request pathname is used, capped by {@link HttpMetricsOptions.maxRouteCardinality}.
185
+ * When omitted, the matched route **template** (`ctx.routePath`, e.g.
186
+ * `/books/:id`) is used. That is bounded by the route table, so a hostile
187
+ * client cannot mint series from raw paths. A custom resolver that returns
188
+ * a raw pathname should still keep cardinality bounded; the pathname
189
+ * fallback (no template on the context) is capped by
190
+ * {@link HttpMetricsOptions.maxRouteCardinality}.
178
191
  */
179
192
  route?: (ctx: BaseContext<any, any>) => string | undefined;
180
193
  /**
181
- * Maximum distinct values for the default (pathname-derived) `route` label
182
- * before further values collapse to `"<other>"`. Ignored when a custom
183
- * {@link HttpMetricsOptions.route} resolver is supplied. Default `100`.
194
+ * Maximum distinct values for the pathname fallback `route` label (used
195
+ * only when {@link HttpMetricsOptions.route} is omitted **and**
196
+ * `ctx.routePath` is missing) before further values collapse to
197
+ * `"<other>"`. Ignored when a custom resolver is supplied. Default `100`.
184
198
  */
185
199
  maxRouteCardinality?: number;
186
200
  /** Skip instrumentation for matching request paths (e.g. the scrape route). */
package/dist/metrics.js CHANGED
@@ -17,8 +17,11 @@
17
17
  * `http_requests_in_flight`) into a registry.
18
18
  *
19
19
  * Everything is built on Web-standard primitives (plus optional `process.*`
20
- * gauges guarded for non-Node runtimes), so it runs unchanged on Node, Bun,
21
- * Deno, Cloudflare Workers, and Vercel.
20
+ * gauges guarded for non-Node runtimes). The registry and renderer run on
21
+ * Node, Bun, Deno, Cloudflare Workers, and Vercel; **pull scraping
22
+ * (`GET /metrics`) is only a service-wide signal on a long-lived process**.
23
+ * On ephemeral isolates use `new App({ telemetry: true })` (OTLP push)
24
+ * instead of scraping `/metrics`.
22
25
  *
23
26
  * @module
24
27
  * @since 0.37.0
@@ -49,7 +52,10 @@ function escapeHelp(value) {
49
52
  * out of the `{...}` block and injecting forged samples.
50
53
  */
51
54
  function escapeLabelValue(value) {
52
- return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
55
+ return value
56
+ .replace(/\\/g, "\\\\")
57
+ .replace(/"/g, '\\"')
58
+ .replace(/\n/g, "\\n");
53
59
  }
54
60
  /**
55
61
  * Validate and normalize a label bag: every key must match the Prometheus
@@ -137,7 +143,10 @@ export class Counter extends Metric {
137
143
  }
138
144
  /** @internal */
139
145
  render() {
140
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} counter`];
146
+ const lines = [
147
+ `# HELP ${this.name} ${escapeHelp(this.help)}`,
148
+ `# TYPE ${this.name} counter`,
149
+ ];
141
150
  for (const { labels, value } of this.series.values()) {
142
151
  lines.push(`${this.name}${renderLabelBlock(labels)} ${value}`);
143
152
  }
@@ -183,7 +192,10 @@ export class Gauge extends Metric {
183
192
  }
184
193
  /** @internal */
185
194
  render() {
186
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} gauge`];
195
+ const lines = [
196
+ `# HELP ${this.name} ${escapeHelp(this.help)}`,
197
+ `# TYPE ${this.name} gauge`,
198
+ ];
187
199
  for (const { labels, value } of this.series.values()) {
188
200
  lines.push(`${this.name}${renderLabelBlock(labels)} ${value}`);
189
201
  }
@@ -219,7 +231,12 @@ export class Histogram extends Metric {
219
231
  if (!s) {
220
232
  if (!this.registry._admitSeries(this.series.size))
221
233
  return;
222
- s = { labels: entries, counts: new Array(this.bounds.length).fill(0), sum: 0, count: 0 };
234
+ s = {
235
+ labels: entries,
236
+ counts: new Array(this.bounds.length).fill(0),
237
+ sum: 0,
238
+ count: 0,
239
+ };
223
240
  this.series.set(key, s);
224
241
  }
225
242
  s.count += 1;
@@ -231,7 +248,10 @@ export class Histogram extends Metric {
231
248
  }
232
249
  /** @internal */
233
250
  render() {
234
- const lines = [`# HELP ${this.name} ${escapeHelp(this.help)}`, `# TYPE ${this.name} histogram`];
251
+ const lines = [
252
+ `# HELP ${this.name} ${escapeHelp(this.help)}`,
253
+ `# TYPE ${this.name} histogram`,
254
+ ];
235
255
  for (const s of this.series.values()) {
236
256
  for (let i = 0; i < this.bounds.length; i++) {
237
257
  lines.push(`${this.name}_bucket${renderLabelBlock(s.labels, ["le", String(this.bounds[i])])} ${s.counts[i]}`);
@@ -272,7 +292,7 @@ export class MetricsRegistry {
272
292
  collectors = [];
273
293
  droppedCounter;
274
294
  constructor(opts = {}) {
275
- this.prefix = opts.prefix ?? "daloy_";
295
+ this.prefix = opts.prefix ?? "";
276
296
  this.maxSeries = opts.maxSeries ?? 5000;
277
297
  if (!Number.isInteger(this.maxSeries) || this.maxSeries <= 0) {
278
298
  throw new Error("MetricsRegistry maxSeries must be a positive integer.");
@@ -345,7 +365,9 @@ export class MetricsRegistry {
345
365
  return metric;
346
366
  }
347
367
  registerDefaultMetrics() {
348
- this.droppedCounter = this.counter("metrics_series_dropped_total", "Series dropped after hitting the per-metric cardinality cap.");
368
+ this.droppedCounter = this.counter(this.prefix === ""
369
+ ? "daloy_metrics_series_dropped_total"
370
+ : "metrics_series_dropped_total", "Series dropped after hitting the per-metric cardinality cap.");
349
371
  if (typeof process === "undefined")
350
372
  return;
351
373
  const rss = this.gauge("process_resident_memory_bytes", "Resident memory size in bytes.");
@@ -369,7 +391,8 @@ export class MetricsRegistry {
369
391
  }
370
392
  /** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
371
393
  function nowMs() {
372
- return typeof performance !== "undefined" && typeof performance.now === "function"
394
+ return typeof performance !== "undefined" &&
395
+ typeof performance.now === "function"
373
396
  ? performance.now()
374
397
  : Date.now();
375
398
  }
package/dist/otlp.d.ts CHANGED
@@ -25,7 +25,10 @@
25
25
  * Everything is transport-portable (`fetch` + web-standard primitives) and
26
26
  * **fail-safe by contract**: a dead or misconfigured collector never affects
27
27
  * request serving — bounded queues, dropped-batch counters, no retry storms,
28
- * and cumulative metric temporality so totals survive failed pushes.
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.
29
32
  *
30
33
  * @module
31
34
  * @since 1.2.0
@@ -63,9 +66,16 @@ export interface OtlpExporterOptions {
63
66
  /**
64
67
  * Flush cadence in ms on runtimes with timers (`unref`'d — never keeps the
65
68
  * process alive). Defaults: 5000 (logs), 15000 (metrics). Set `0` to
66
- * disable the timer and flush manually / on shutdown only.
69
+ * disable the timer and flush manually / on shutdown / via the serverless
70
+ * adapters' per-request flush.
67
71
  */
68
72
  flushIntervalMs?: number;
73
+ /**
74
+ * Abort an in-flight export POST after this many ms so a blackholed
75
+ * collector cannot latch `flushing` forever. Default `5000`. Set `0` to
76
+ * wait indefinitely (tests that drive `fetch` themselves).
77
+ */
78
+ flushTimeoutMs?: number;
69
79
  /** Injectable transport for tests. Default `globalThis.fetch`. */
70
80
  fetch?: typeof fetch;
71
81
  }
@@ -86,7 +96,12 @@ export interface OtlpLogExporter {
86
96
  pushLine(line: string): void;
87
97
  /** Push pending records now. Never rejects; failures count as drops. */
88
98
  flush(): Promise<void>;
89
- /** Batches dropped because the queue overflowed or the collector errored. */
99
+ /**
100
+ * Mixed drop counter: overflowed log *lines* (one per `shift()` at the
101
+ * queue cap) plus failed export *POSTs* (collector 4xx/5xx, network error,
102
+ * timeout, or disallowed redirect). Not a count of failed HTTP batches
103
+ * alone — compare against queue depth under load, not "POSTs that failed".
104
+ */
90
105
  readonly droppedBatches: number;
91
106
  }
92
107
  /**
@@ -115,7 +130,12 @@ export interface OtlpMetricsExporter {
115
130
  record(name: string, attributes: Record<string, string>, value: number, options: OtlpHistogramOptions): void;
116
131
  /** Push the current state now. Never rejects; failures count as drops. */
117
132
  flush(): Promise<void>;
118
- /** Pushes dropped because the collector rejected/errored, plus series dropped at the cardinality cap. */
133
+ /**
134
+ * Mixed drop counter: failed export *POSTs* (collector 4xx/5xx, network
135
+ * error, timeout, or disallowed redirect) plus *series* refused at the
136
+ * cardinality cap. A cardinality attack inflates this without any POST
137
+ * failing.
138
+ */
119
139
  readonly droppedBatches: number;
120
140
  }
121
141
  /**
@@ -190,7 +210,13 @@ export declare function semconvHttpMetrics(sink: OtlpMetricsExporter, opts?: Sem
190
210
  * equivalent to `{}` — everything defaults on, reading the standard `OTEL_*`
191
211
  * environment variables. When no `OTEL_EXPORTER_OTLP_ENDPOINT` is present
192
212
  * (and no explicit `exporter.endpoint` is given) the option is a silent
193
- * no-op, so it is safe to leave enabled in development.
213
+ * no-op, so it is safe to leave enabled in development. A caller-supplied
214
+ * `Logger` instance is not intercepted (it owns its own sink). On isolate
215
+ * runtimes use `toFetchHandler` / `toLambdaHandler` so a per-request flush
216
+ * actually runs; Node/Bun/Deno long-lived processes flush on the interval
217
+ * and on shutdown. The collector endpoint is operator config, so export
218
+ * uses raw `fetch` (not `fetchGuard`) — the same trust class as a database
219
+ * URL.
194
220
  *
195
221
  * @since 1.2.0
196
222
  */
package/dist/otlp.js CHANGED
@@ -25,7 +25,10 @@
25
25
  * Everything is transport-portable (`fetch` + web-standard primitives) and
26
26
  * **fail-safe by contract**: a dead or misconfigured collector never affects
27
27
  * request serving — bounded queues, dropped-batch counters, no retry storms,
28
- * and cumulative metric temporality so totals survive failed pushes.
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.
29
32
  *
30
33
  * @module
31
34
  * @since 1.2.0
@@ -37,6 +40,8 @@ const LOG_FLUSH_INTERVAL_MS = 5_000;
37
40
  /** Longest log body / attribute value shipped before truncation. */
38
41
  const LOG_MAX_FIELD_LENGTH = 8_192;
39
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;
40
45
  /**
41
46
  * Cardinality guard: total metric series (counter + histogram) before new
42
47
  * series are dropped and counted. A hostile client must not be able to mint
@@ -77,11 +82,23 @@ const KNOWN_METHODS = new Set([
77
82
  ]);
78
83
  /** Portable environment lookup (Node/Bun/Deno-with-node-compat; undefined elsewhere). */
79
84
  function envVar(name) {
80
- const env = globalThis.process
81
- ?.env;
85
+ const env = globalThis.process?.env;
82
86
  return env?.[name];
83
87
  }
84
- /** Parse `key=value,key2=value2` lists (OTEL_EXPORTER_OTLP_HEADERS / OTEL_RESOURCE_ATTRIBUTES). */
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
+ */
85
102
  function parseKvList(raw) {
86
103
  const out = {};
87
104
  if (!raw)
@@ -91,7 +108,10 @@ function parseKvList(raw) {
91
108
  const idx = trimmed.indexOf("=");
92
109
  if (idx <= 0)
93
110
  continue;
94
- out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
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());
95
115
  }
96
116
  return out;
97
117
  }
@@ -109,7 +129,9 @@ function resolveTransport(signalPath, opts) {
109
129
  if (!rawEndpoint)
110
130
  return null;
111
131
  const base = rawEndpoint.replace(/\/+$/, "").replace(/:4317$/, ":4318");
112
- const headers = { "content-type": "application/json" };
132
+ const headers = {
133
+ "content-type": "application/json",
134
+ };
113
135
  Object.assign(headers, parseKvList(envVar("OTEL_EXPORTER_OTLP_HEADERS")), opts.headers ?? {});
114
136
  const resource = parseKvList(envVar("OTEL_RESOURCE_ATTRIBUTES"));
115
137
  const serviceName = envVar("OTEL_SERVICE_NAME");
@@ -131,6 +153,32 @@ function startFlushTimer(intervalMs, flush) {
131
153
  const timer = setInterval(() => void flush(), intervalMs);
132
154
  timer.unref?.();
133
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
+ }
134
182
  function toLogRecord(line) {
135
183
  let level = "info";
136
184
  let body = line;
@@ -149,7 +197,9 @@ function toLogRecord(line) {
149
197
  attributes.push({
150
198
  key,
151
199
  value: {
152
- stringValue: truncate(typeof value === "string" ? value : JSON.stringify(value) ?? "null", LOG_MAX_FIELD_LENGTH),
200
+ stringValue: truncate(typeof value === "string"
201
+ ? value
202
+ : (JSON.stringify(value) ?? "null"), LOG_MAX_FIELD_LENGTH),
153
203
  },
154
204
  });
155
205
  }
@@ -183,6 +233,7 @@ export function createOtlpLogExporter(opts = {}) {
183
233
  if (transport === null)
184
234
  return null;
185
235
  const { url, headers, resourceAttributes, fetchImpl } = transport;
236
+ const timeoutMs = opts.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
186
237
  const queue = [];
187
238
  let dropped = 0;
188
239
  let flushing = false;
@@ -201,11 +252,7 @@ export function createOtlpLogExporter(opts = {}) {
201
252
  },
202
253
  ],
203
254
  };
204
- const res = await fetchImpl(url, {
205
- method: "POST",
206
- headers,
207
- body: JSON.stringify(payload),
208
- });
255
+ const res = await fetchImpl(url, exportRequestInit(headers, JSON.stringify(payload), timeoutMs));
209
256
  if (!res.ok)
210
257
  dropped += 1;
211
258
  }
@@ -253,6 +300,7 @@ export function createOtlpMetricsExporter(opts = {}) {
253
300
  if (transport === null)
254
301
  return null;
255
302
  const { url, headers, resourceAttributes, fetchImpl } = transport;
303
+ const timeoutMs = opts.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
256
304
  const counters = new Map();
257
305
  const histograms = new Map();
258
306
  let dropped = 0;
@@ -266,7 +314,10 @@ export function createOtlpMetricsExporter(opts = {}) {
266
314
  }
267
315
  function sortedEntries(attributes) {
268
316
  return Object.entries(attributes)
269
- .map(([k, v]) => [k, truncate(v, METRICS_MAX_ATTR_LENGTH)])
317
+ .map(([k, v]) => [
318
+ k,
319
+ truncate(v, METRICS_MAX_ATTR_LENGTH),
320
+ ])
270
321
  .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
271
322
  }
272
323
  function atCapacity() {
@@ -279,6 +330,9 @@ export function createOtlpMetricsExporter(opts = {}) {
279
330
  if (flushing || !dirty)
280
331
  return;
281
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;
282
336
  try {
283
337
  const now = (BigInt(Date.now()) * 1000000n).toString();
284
338
  const sumsByName = new Map();
@@ -341,18 +395,15 @@ export function createOtlpMetricsExporter(opts = {}) {
341
395
  },
342
396
  ],
343
397
  };
344
- const res = await fetchImpl(url, {
345
- method: "POST",
346
- headers,
347
- body: JSON.stringify(payload),
348
- });
349
- if (res.ok)
350
- dirty = false;
351
- else
398
+ const res = await fetchImpl(url, exportRequestInit(headers, JSON.stringify(payload), timeoutMs));
399
+ if (!res.ok) {
352
400
  dropped += 1;
401
+ dirty = true;
402
+ }
353
403
  }
354
404
  catch {
355
- dropped += 1; // totals retained; cumulative temporality self-heals
405
+ dropped += 1;
406
+ dirty = true; // totals retained; cumulative temporality self-heals
356
407
  }
357
408
  finally {
358
409
  flushing = false;
@@ -371,7 +422,10 @@ export function createOtlpMetricsExporter(opts = {}) {
371
422
  return;
372
423
  s = {
373
424
  name,
374
- attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
425
+ attributes: entries.map(([k, v]) => ({
426
+ key: k,
427
+ value: { stringValue: v },
428
+ })),
375
429
  total: 0,
376
430
  startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
377
431
  };
@@ -393,7 +447,10 @@ export function createOtlpMetricsExporter(opts = {}) {
393
447
  name,
394
448
  unit: options.unit,
395
449
  boundaries: options.boundaries,
396
- attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
450
+ attributes: entries.map(([k, v]) => ({
451
+ key: k,
452
+ value: { stringValue: v },
453
+ })),
397
454
  count: 0,
398
455
  sum: 0,
399
456
  bucketCounts: new Array(options.boundaries.length + 1).fill(0),
@@ -417,7 +474,8 @@ export function createOtlpMetricsExporter(opts = {}) {
417
474
  }
418
475
  /** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
419
476
  function nowMs() {
420
- return typeof performance !== "undefined" && typeof performance.now === "function"
477
+ return typeof performance !== "undefined" &&
478
+ typeof performance.now === "function"
421
479
  ? performance.now()
422
480
  : Date.now();
423
481
  }
@@ -460,21 +518,25 @@ export function semconvHttpMetrics(sink, opts = {}) {
460
518
  if (started === undefined)
461
519
  return;
462
520
  SEMCONV_START_TIMES.delete(request);
463
- let pathname = "/";
464
- let scheme = "http";
465
- try {
466
- const parsed = new URL(request.url);
467
- pathname = parsed.pathname;
468
- scheme = parsed.protocol.replace(":", "");
469
- }
470
- catch {
471
- /* malformed URL — keep the fallbacks */
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;
472
532
  }
473
- if (opts.exclude !== undefined && opts.exclude(pathname))
474
- return;
533
+ const colon = url.indexOf(":");
534
+ const scheme = colon > 0 ? url.slice(0, colon) : "http";
475
535
  const rawMethod = request.method.toUpperCase();
476
536
  const attributes = {
477
- "http.request.method": KNOWN_METHODS.has(rawMethod) ? rawMethod : "_OTHER",
537
+ "http.request.method": KNOWN_METHODS.has(rawMethod)
538
+ ? rawMethod
539
+ : "_OTHER",
478
540
  "http.response.status_code": String(res.status),
479
541
  "url.scheme": scheme,
480
542
  };
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.5",
4
- "serialNumber": "urn:uuid:3d6cece6-6c87-5815-9301-8b0be8579da5",
4
+ "serialNumber": "urn:uuid:e489b0cf-2d9d-5f9f-93c0-ff3c851e6f48",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-08-20T11:51:20.093Z",
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.2.0"
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.2.0",
22
+ "bom-ref": "pkg:npm/@daloyjs/core@1.2.1",
23
23
  "name": "@daloyjs/core",
24
- "version": "1.2.0",
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.2.0",
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.2.0",
49
+ "tagId": "swidtag--daloyjs-core-1.2.1",
50
50
  "name": "@daloyjs/core",
51
- "version": "1.2.0",
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.2.0",
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.2.0",
6
- "documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.2.0-3d6cece6-6c87-5815-9301-8b0be8579da5",
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-20T11:51:20.093Z",
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.2.0",
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.2.0"
30
+ "referenceLocator": "pkg:npm/@daloyjs/core@1.2.1"
31
31
  }
32
32
  ]
33
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@daloyjs/core",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "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 \u2014 distributed via pnpm.",
5
5
  "type": "module",
6
6
  "publishConfig": {