@daloyjs/core 1.1.0 → 1.2.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/README.md +2 -1
- package/dist/app.d.ts +20 -0
- package/dist/app.js +41 -5
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/metrics.js +4 -0
- package/dist/otlp.d.ts +242 -0
- package/dist/otlp.js +527 -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/README.md
CHANGED
|
@@ -584,7 +584,7 @@ The core only ever sees `Request → Response`. Adapters live at the edge.
|
|
|
584
584
|
|
|
585
585
|
## Status
|
|
586
586
|
|
|
587
|
-
DaloyJS is at **`1.1.
|
|
587
|
+
DaloyJS is at **`1.1.1`** — the public API is frozen and follows SemVer from here: no `1.x` minor changes the API, and any deprecation gets at least one minor cycle before removal. `@daloyjs/core` (npm), `create-daloy` (npm), and [`@daloyjs/daloy`](https://jsr.io/@daloyjs/daloy) (JSR) ship together at matching versions.
|
|
588
588
|
|
|
589
589
|
The release-candidate train that led here was largely adversarial: `rc.1` through `rc.9` carried remediations from live over-the-wire engagements against realistic multi-tenant apps, and the findings clustered in one place worth naming — **composition**, not individual modules. A `responseCache()` mounted ahead of the network-identity gates silently disabled them; the same order left `rateLimit()` never counting the requests a cache hit or an idempotent replay served; a forwarded-header resolver read the one `X-Forwarded-For` slot an attacker controls; `idempotency()` replayed a stored `Set-Cookie`. Each is fixed, each has a regression test, and several are now refuse-to-boot guards so the unsafe wiring cannot ship quietly. See the [CHANGELOG](CHANGELOG.md) for the full train and [boot guards](https://daloyjs.dev/docs/security/boot-guards) for the orders the framework now refuses.
|
|
590
590
|
|
|
@@ -666,6 +666,7 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
666
666
|
- `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
|
|
667
667
|
- `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
|
|
668
668
|
- `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
|
|
669
|
+
- `new App({ telemetry: true })` native OpenTelemetry OTLP push export at `@daloyjs/core/otlp`: one flag tees the app logger to the collector as OTLP logs and records `http.server.request.duration` per the OTel HTTP semantic conventions (spec attributes incl. `http.route` from the matched route template via the new `ctx.routePath`, spec bucket boundaries), pushed as dependency-free OTLP/HTTP JSON to the endpoint in the standard `OTEL_EXPORTER_OTLP_*` env vars — zero config on collector-based platforms that inject them, silent no-op without them. Standalone `createOtlpLogExporter()` / `createOtlpMetricsExporter()` (cumulative temporality — totals survive failed pushes) / `semconvHttpMetrics()` exports; fail-safe by contract (bounded queues, series-cardinality cap, a dead collector never affects serving, tenant-routing header values never logged).
|
|
669
670
|
- `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense); `responseCache()` needs no wiring at all — it reads the resolved tenant itself and refuses to boot if mounted ahead of `tenancy()`. Runnable `examples/multitenancy-demo.ts`.
|
|
670
671
|
- `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
|
|
671
672
|
- `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
|
package/dist/app.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { WebSocketRegistry, type WebSocketHandler } from "./websocket.js";
|
|
2
2
|
import { type Logger } from "./logger.js";
|
|
3
|
+
import { type AppTelemetry, type TelemetryOptions } from "./otlp.js";
|
|
3
4
|
import type { HttpMethod, Hooks, PathString, RequestSchemas, ResponsesMap, RouteDefinition } from "./types.js";
|
|
4
5
|
import { type OpenAPIInfo, type OpenAPIOptions } from "./openapi.js";
|
|
5
6
|
import { type DocsAssetOptions, type DocsAuthLauncherOptions, type DocsContentSecurityPolicyOptions, type RedocConfiguration, type ScalarJsonValue, type ScalarReferenceConfiguration, type SwaggerUiConfiguration } from "./docs.js";
|
|
@@ -74,6 +75,18 @@ export interface AppOptions {
|
|
|
74
75
|
preset?: SecurityPreset;
|
|
75
76
|
/** Validate handler responses against declared response schemas. Default: true. */
|
|
76
77
|
validateResponses?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* OpenTelemetry OTLP push export. `true` (or an options object) exports the
|
|
80
|
+
* app logger's output as OTLP logs and records `http.server.request.duration`
|
|
81
|
+
* per the OTel HTTP semantic conventions, pushed to the collector named by
|
|
82
|
+
* the standard `OTEL_EXPORTER_OTLP_*` environment variables. A silent no-op
|
|
83
|
+
* when no endpoint is configured, so it is safe to keep enabled in
|
|
84
|
+
* development. Export failures never affect request serving. See
|
|
85
|
+
* {@link TelemetryOptions}.
|
|
86
|
+
*
|
|
87
|
+
* @since 1.2.0
|
|
88
|
+
*/
|
|
89
|
+
telemetry?: boolean | TelemetryOptions;
|
|
77
90
|
/** Hard cap on request body size in bytes. Default: 1 MiB. */
|
|
78
91
|
bodyLimitBytes?: number;
|
|
79
92
|
/** Reject requests whose Content-Type isn't in this allowlist (when a body schema is declared). */
|
|
@@ -934,6 +947,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
934
947
|
readonly options: Required<Pick<AppOptions, "validateResponses" | "bodyLimitBytes" | "requestTimeoutMs">> & AppOptions;
|
|
935
948
|
/** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
|
|
936
949
|
readonly log: Logger;
|
|
950
|
+
/**
|
|
951
|
+
* OTLP telemetry wiring created by the `telemetry` option; `undefined` when
|
|
952
|
+
* the option is off. Exposed for tests and advanced flushing.
|
|
953
|
+
*
|
|
954
|
+
* @since 1.2.0
|
|
955
|
+
*/
|
|
956
|
+
readonly telemetry?: AppTelemetry;
|
|
937
957
|
/**
|
|
938
958
|
* Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
|
|
939
959
|
*
|
package/dist/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
|
3
3
|
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
4
|
import { readBodyLimited, safeJsonParseLimited, randomId, assertInboundHeaderGuards, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey, } from "./security.js";
|
|
5
5
|
import { createLogger, noopLogger, sanitizeUrlForLog } from "./logger.js";
|
|
6
|
+
import { createAppTelemetry } from "./otlp.js";
|
|
6
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
7
8
|
import { isSchemaValidatedResponse } from "./internal-response.js";
|
|
8
9
|
import { asyncapiHtml, docsContentSecurityPolicy, redocHtml, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -374,6 +375,13 @@ export class App {
|
|
|
374
375
|
options;
|
|
375
376
|
/** Structured logger for the app. Defaults to a JSON-lines console logger; override via `options.logger`. */
|
|
376
377
|
log;
|
|
378
|
+
/**
|
|
379
|
+
* OTLP telemetry wiring created by the `telemetry` option; `undefined` when
|
|
380
|
+
* the option is off. Exposed for tests and advanced flushing.
|
|
381
|
+
*
|
|
382
|
+
* @since 1.2.0
|
|
383
|
+
*/
|
|
384
|
+
telemetry;
|
|
377
385
|
/**
|
|
378
386
|
* Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
|
|
379
387
|
*
|
|
@@ -505,12 +513,23 @@ export class App {
|
|
|
505
513
|
jsonMaxDepth: resolved.jsonMaxDepth ?? DEFAULTS.jsonMaxDepth,
|
|
506
514
|
...resolved,
|
|
507
515
|
};
|
|
516
|
+
// Telemetry is resolved before the logger so the logger's write sink can
|
|
517
|
+
// tee into the OTLP log exporter. Inert when no endpoint is configured.
|
|
518
|
+
const telemetryOpt = options.telemetry;
|
|
519
|
+
this.telemetry =
|
|
520
|
+
telemetryOpt === undefined || telemetryOpt === false
|
|
521
|
+
? undefined
|
|
522
|
+
: createAppTelemetry(telemetryOpt === true ? {} : telemetryOpt);
|
|
523
|
+
const telemetryWrite = this.telemetry?.logWrite;
|
|
508
524
|
this.log =
|
|
509
525
|
options.logger === false
|
|
510
526
|
? noopLogger
|
|
511
527
|
: options.logger && typeof options.logger.info === "function"
|
|
512
528
|
? options.logger
|
|
513
|
-
: createLogger({
|
|
529
|
+
: createLogger({
|
|
530
|
+
level: options.logger?.level ?? "info",
|
|
531
|
+
...(telemetryWrite !== undefined ? { write: telemetryWrite } : {}),
|
|
532
|
+
});
|
|
514
533
|
this.warnOnEnvMismatch();
|
|
515
534
|
this.assertDisconnectStatusCode();
|
|
516
535
|
assertBehindProxy(this.options.behindProxy);
|
|
@@ -522,6 +541,20 @@ export class App {
|
|
|
522
541
|
this.maybeInstallCrashHandlers();
|
|
523
542
|
this.maybeMountDocs();
|
|
524
543
|
this.maybeMountAsyncAPI();
|
|
544
|
+
if (this.telemetry !== undefined) {
|
|
545
|
+
if (this.telemetry.hooks !== undefined)
|
|
546
|
+
this.use(this.telemetry.hooks);
|
|
547
|
+
const telemetry = this.telemetry;
|
|
548
|
+
this.onClose(() => telemetry.flush());
|
|
549
|
+
this.log.info({
|
|
550
|
+
event: "telemetry.otlp",
|
|
551
|
+
active: telemetry.endpoint !== null,
|
|
552
|
+
// Endpoint only — OTLP headers may carry tenant credentials.
|
|
553
|
+
endpoint: telemetry.endpoint ?? undefined,
|
|
554
|
+
}, telemetry.endpoint !== null
|
|
555
|
+
? "OTLP telemetry export active"
|
|
556
|
+
: "Telemetry enabled but no OTEL_EXPORTER_OTLP_ENDPOINT configured; export disabled");
|
|
557
|
+
}
|
|
525
558
|
}
|
|
526
559
|
/**
|
|
527
560
|
* Validate {@link AppOptions.disconnectStatusCode}.
|
|
@@ -2683,6 +2716,8 @@ export class App {
|
|
|
2683
2716
|
// perimeter hooks can reject unauthenticated callers without consuming
|
|
2684
2717
|
// an attacker-controlled request stream.
|
|
2685
2718
|
ctx = createPreBodyContext(request, getUrl, match.params);
|
|
2719
|
+
// Matched route template for low-cardinality labels (`http.route`).
|
|
2720
|
+
ctx.routePath = def.path;
|
|
2686
2721
|
// Stable two-field write keeps `ctx.state`'s hidden class consistent across
|
|
2687
2722
|
// requests for the common no-decorator case. The decorations spread only
|
|
2688
2723
|
// fires when `app.decorate()` was actually called.
|
|
@@ -3502,21 +3537,21 @@ function finalizeResponse(res, ctx, hooks, stripFingerprint = true) {
|
|
|
3502
3537
|
return sentResult.then((sent) => {
|
|
3503
3538
|
if (sent instanceof Response)
|
|
3504
3539
|
final = sent;
|
|
3505
|
-
return finishFinalize(final, hooks, stripFingerprint);
|
|
3540
|
+
return finishFinalize(final, ctx, hooks, stripFingerprint);
|
|
3506
3541
|
});
|
|
3507
3542
|
}
|
|
3508
3543
|
if (sentResult instanceof Response)
|
|
3509
3544
|
final = sentResult;
|
|
3510
3545
|
}
|
|
3511
|
-
return finishFinalize(final, hooks, stripFingerprint);
|
|
3546
|
+
return finishFinalize(final, ctx, hooks, stripFingerprint);
|
|
3512
3547
|
}
|
|
3513
|
-
function finishFinalize(res, hooks, stripFingerprint) {
|
|
3548
|
+
function finishFinalize(res, ctx, hooks, stripFingerprint) {
|
|
3514
3549
|
if (stripFingerprint) {
|
|
3515
3550
|
res.headers.delete("server");
|
|
3516
3551
|
res.headers.delete("x-powered-by");
|
|
3517
3552
|
}
|
|
3518
3553
|
if (hooks.onResponse !== undefined) {
|
|
3519
|
-
const onResponseResult = hooks.onResponse(res);
|
|
3554
|
+
const onResponseResult = hooks.onResponse(res, ctx);
|
|
3520
3555
|
if (isPromiseLike(onResponseResult)) {
|
|
3521
3556
|
return onResponseResult.then(() => res);
|
|
3522
3557
|
}
|
|
@@ -3703,6 +3738,7 @@ class RequestContext {
|
|
|
3703
3738
|
body = undefined;
|
|
3704
3739
|
state;
|
|
3705
3740
|
set;
|
|
3741
|
+
routePath = undefined;
|
|
3706
3742
|
_q = undefined;
|
|
3707
3743
|
_qBuilder = undefined;
|
|
3708
3744
|
_qSet = false;
|
package/dist/index.d.ts
CHANGED
|
@@ -95,6 +95,8 @@ export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, pagination
|
|
|
95
95
|
export type { PaginationLink, PageLinkOptions, PageLinks, PaginationQueryOptions, PaginationParams, PaginationQuerySchema, } from "./pagination.js";
|
|
96
96
|
export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
97
97
|
export type { MetricLabels, MetricsRegistryOptions, HttpMetricsOptions } from "./metrics.js";
|
|
98
|
+
export { createOtlpLogExporter, createOtlpMetricsExporter, createAppTelemetry, semconvHttpMetrics, HTTP_SERVER_REQUEST_DURATION_BUCKETS, } from "./otlp.js";
|
|
99
|
+
export type { OtlpExporterOptions, OtlpLogExporter, OtlpMetricsExporter, OtlpHistogramOptions, SemconvHttpMetricsOptions, TelemetryOptions, AppTelemetry, } from "./otlp.js";
|
|
98
100
|
export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
|
|
99
101
|
export type { FileFieldSchema, FileFieldOptions, FileMagicBytesOption, FileMagicBytesSignature, MultipartObjectOptions, MultipartShape, UploadedFile, } from "./multipart.js";
|
|
100
102
|
export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,7 @@ export { idempotency, MemoryIdempotencyStore, _resetSharedIdempotencyStoresForTe
|
|
|
48
48
|
export { responseCache, MemoryResponseCacheStore, RESPONSE_CACHE_HOOK_MARKER, _resetSharedResponseCacheStoresForTests, } from "./response-cache.js";
|
|
49
49
|
export { encodeCursor, decodeCursor, buildLinkHeader, buildPageLinks, paginationQuery, MAX_CURSOR_LENGTH, } from "./pagination.js";
|
|
50
50
|
export { MetricsRegistry, Counter, Gauge, Histogram, httpMetrics, DEFAULT_DURATION_BUCKETS, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
51
|
+
export { createOtlpLogExporter, createOtlpMetricsExporter, createAppTelemetry, semconvHttpMetrics, HTTP_SERVER_REQUEST_DURATION_BUCKETS, } from "./otlp.js";
|
|
51
52
|
export { fileField, multipartObject, isFileFieldSchema, isMultipartObjectSchema, } from "./multipart.js";
|
|
52
53
|
export { otelTracing, TRACING_SPAN_KIND_SERVER, TRACING_SPAN_STATUS_UNSET, TRACING_SPAN_STATUS_OK, TRACING_SPAN_STATUS_ERROR, } from "./tracing.js";
|
|
53
54
|
export { tenancy, tenantScope, tenantFromSubdomain, tenantFromHeader, tenantFromPathPrefix, tenantFromClaim, defaultTenantNormalize, TENANCY_HOOK_MARKER, TENANCY_RESOLVED_MARKER, TENANT_UNRESOLVED, } from "./tenancy.js";
|
package/dist/metrics.js
CHANGED
|
@@ -401,6 +401,10 @@ export function httpMetrics(opts) {
|
|
|
401
401
|
const routeLabel = (ctx) => {
|
|
402
402
|
if (opts.route)
|
|
403
403
|
return opts.route(ctx) ?? "<unknown>";
|
|
404
|
+
// The router stamps the matched route template on the context; it is
|
|
405
|
+
// low-cardinality by construction, so no cap bookkeeping is needed.
|
|
406
|
+
if (typeof ctx.routePath === "string")
|
|
407
|
+
return ctx.routePath;
|
|
404
408
|
let path = "/";
|
|
405
409
|
try {
|
|
406
410
|
path = new URL(ctx.request.url).pathname;
|
package/dist/otlp.d.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
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
|
+
* and cumulative metric temporality so totals survive failed pushes.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
* @since 1.2.0
|
|
32
|
+
*/
|
|
33
|
+
import type { Hooks } from "./types.js";
|
|
34
|
+
/**
|
|
35
|
+
* Shared configuration for {@link createOtlpLogExporter} and
|
|
36
|
+
* {@link createOtlpMetricsExporter}. Every field falls back to the standard
|
|
37
|
+
* `OTEL_*` environment variables, so on platforms that inject them no
|
|
38
|
+
* explicit configuration is needed at all.
|
|
39
|
+
*
|
|
40
|
+
* @since 1.2.0
|
|
41
|
+
*/
|
|
42
|
+
export interface OtlpExporterOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Collector base URL. Defaults to `OTEL_EXPORTER_OTLP_ENDPOINT`. The
|
|
45
|
+
* conventional injected value is the gRPC form (`http://host:4317`); a
|
|
46
|
+
* trailing `:4317` is rewritten to `:4318`, the collector's OTLP/HTTP port.
|
|
47
|
+
* The signal path (`/v1/logs`, `/v1/metrics`) is appended automatically.
|
|
48
|
+
*/
|
|
49
|
+
endpoint?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Extra request headers. Defaults to `OTEL_EXPORTER_OTLP_HEADERS`
|
|
52
|
+
* (`key=value,key2=value2`). Multi-tenant collectors route on these
|
|
53
|
+
* (e.g. `tenant_id=...`), so treat the values as credentials: DaloyJS
|
|
54
|
+
* never logs them and never includes them in errors.
|
|
55
|
+
*/
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
/**
|
|
58
|
+
* OTLP resource attributes. Defaults to `OTEL_RESOURCE_ATTRIBUTES`
|
|
59
|
+
* (`key=value,...`) plus `service.name` from `OTEL_SERVICE_NAME` when not
|
|
60
|
+
* already present.
|
|
61
|
+
*/
|
|
62
|
+
resourceAttributes?: Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* Flush cadence in ms on runtimes with timers (`unref`'d — never keeps the
|
|
65
|
+
* process alive). Defaults: 5000 (logs), 15000 (metrics). Set `0` to
|
|
66
|
+
* disable the timer and flush manually / on shutdown only.
|
|
67
|
+
*/
|
|
68
|
+
flushIntervalMs?: number;
|
|
69
|
+
/** Injectable transport for tests. Default `globalThis.fetch`. */
|
|
70
|
+
fetch?: typeof fetch;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Batched OTLP/HTTP JSON **log** exporter returned by
|
|
74
|
+
* {@link createOtlpLogExporter}.
|
|
75
|
+
*
|
|
76
|
+
* @since 1.2.0
|
|
77
|
+
*/
|
|
78
|
+
export interface OtlpLogExporter {
|
|
79
|
+
/**
|
|
80
|
+
* Queue one log line (no trailing newline). JSON lines are decomposed:
|
|
81
|
+
* `msg`/`message`/`event` becomes the record body, `level` maps to the
|
|
82
|
+
* OTLP severity, and every remaining field ships as a string attribute
|
|
83
|
+
* (surfacing as structured metadata in Loki-style backends). Non-JSON
|
|
84
|
+
* lines ship verbatim as the body.
|
|
85
|
+
*/
|
|
86
|
+
pushLine(line: string): void;
|
|
87
|
+
/** Push pending records now. Never rejects; failures count as drops. */
|
|
88
|
+
flush(): Promise<void>;
|
|
89
|
+
/** Batches dropped because the queue overflowed or the collector errored. */
|
|
90
|
+
readonly droppedBatches: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Histogram configuration for {@link OtlpMetricsExporter.record}.
|
|
94
|
+
*
|
|
95
|
+
* @since 1.2.0
|
|
96
|
+
*/
|
|
97
|
+
export interface OtlpHistogramOptions {
|
|
98
|
+
/** UCUM unit, e.g. `"s"` or `"{token}"`. */
|
|
99
|
+
unit: string;
|
|
100
|
+
/** Explicit bucket upper bounds, ascending. */
|
|
101
|
+
boundaries: readonly number[];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Cumulative OTLP/HTTP JSON **metrics** exporter returned by
|
|
105
|
+
* {@link createOtlpMetricsExporter}. Counters and histograms use cumulative
|
|
106
|
+
* temporality, so a failed push is self-healing: the totals are retained and
|
|
107
|
+
* the next successful push carries them.
|
|
108
|
+
*
|
|
109
|
+
* @since 1.2.0
|
|
110
|
+
*/
|
|
111
|
+
export interface OtlpMetricsExporter {
|
|
112
|
+
/** Add `value` (default `1`, must be finite and non-negative) to the counter series `name` + `attributes`. */
|
|
113
|
+
count(name: string, attributes: Record<string, string>, value?: number): void;
|
|
114
|
+
/** Record one observation into the histogram series `name` + `attributes`. */
|
|
115
|
+
record(name: string, attributes: Record<string, string>, value: number, options: OtlpHistogramOptions): void;
|
|
116
|
+
/** Push the current state now. Never rejects; failures count as drops. */
|
|
117
|
+
flush(): Promise<void>;
|
|
118
|
+
/** Pushes dropped because the collector rejected/errored, plus series dropped at the cardinality cap. */
|
|
119
|
+
readonly droppedBatches: number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Spec-defined default bucket boundaries for `http.server.request.duration`,
|
|
123
|
+
* in seconds (OTel HTTP semantic conventions).
|
|
124
|
+
*
|
|
125
|
+
* @since 1.2.0
|
|
126
|
+
*/
|
|
127
|
+
export declare const HTTP_SERVER_REQUEST_DURATION_BUCKETS: readonly number[];
|
|
128
|
+
/**
|
|
129
|
+
* Create a batched OTLP/HTTP JSON log exporter, or `null` when no endpoint is
|
|
130
|
+
* configured (local development, tests) so callers can no-op cheaply.
|
|
131
|
+
*
|
|
132
|
+
* Fail-safe by contract: {@link OtlpLogExporter.flush} never rejects, a dead
|
|
133
|
+
* collector cannot grow the queue past its cap (oldest records are dropped
|
|
134
|
+
* and counted), and export work is decoupled from request handling.
|
|
135
|
+
*
|
|
136
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
137
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
138
|
+
* @since 1.2.0
|
|
139
|
+
*/
|
|
140
|
+
export declare function createOtlpLogExporter(opts?: OtlpExporterOptions): OtlpLogExporter | null;
|
|
141
|
+
/**
|
|
142
|
+
* Create a cumulative OTLP/HTTP JSON metrics exporter, or `null` when no
|
|
143
|
+
* endpoint is configured.
|
|
144
|
+
*
|
|
145
|
+
* Counters export as monotonic cumulative sums and histograms as cumulative
|
|
146
|
+
* explicit-bounds histograms, so state survives failed pushes (the next
|
|
147
|
+
* successful push carries the accumulated totals). Total series are capped
|
|
148
|
+
* ({@link OtlpMetricsExporter.droppedBatches} counts series refused at the
|
|
149
|
+
* cap) and attribute values are length-truncated — both cardinality guards
|
|
150
|
+
* against hostile or buggy attribute sources.
|
|
151
|
+
*
|
|
152
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
153
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
154
|
+
* @since 1.2.0
|
|
155
|
+
*/
|
|
156
|
+
export declare function createOtlpMetricsExporter(opts?: OtlpExporterOptions): OtlpMetricsExporter | null;
|
|
157
|
+
/** Options for {@link semconvHttpMetrics}. */
|
|
158
|
+
export interface SemconvHttpMetricsOptions {
|
|
159
|
+
/** Skip instrumentation for matching request paths (e.g. a metrics scrape route). */
|
|
160
|
+
exclude?: (path: string) => boolean;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A `Hooks` bundle recording `http.server.request.duration` per the OTel HTTP
|
|
164
|
+
* semantic conventions into an {@link OtlpMetricsExporter}:
|
|
165
|
+
*
|
|
166
|
+
* - unit `s`, spec bucket boundaries
|
|
167
|
+
* ({@link HTTP_SERVER_REQUEST_DURATION_BUCKETS});
|
|
168
|
+
* - attributes `http.request.method` (well-known set, else `_OTHER`),
|
|
169
|
+
* `http.response.status_code`, `url.scheme`, `http.route` (the matched
|
|
170
|
+
* route **template** via `ctx.routePath`), and `error.type` (the status
|
|
171
|
+
* code, on `5xx` only).
|
|
172
|
+
*
|
|
173
|
+
* Only requests that produce a request context are recorded. On the
|
|
174
|
+
* unmatched-404 fast path the framework skips context construction entirely
|
|
175
|
+
* (a deliberate allocation optimization), so unmatched floods record nothing
|
|
176
|
+
* — which is also the strongest possible cardinality guarantee: a hostile
|
|
177
|
+
* client can never mint metric series from raw paths.
|
|
178
|
+
*
|
|
179
|
+
* Install it **before** registering routes (group-hook ordering), or let
|
|
180
|
+
* `new App({ telemetry: true })` install it for you.
|
|
181
|
+
*
|
|
182
|
+
* @param sink - Metrics exporter the duration histogram is recorded into.
|
|
183
|
+
* @param opts - Optional path exclusion.
|
|
184
|
+
* @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
|
|
185
|
+
* @since 1.2.0
|
|
186
|
+
*/
|
|
187
|
+
export declare function semconvHttpMetrics(sink: OtlpMetricsExporter, opts?: SemconvHttpMetricsOptions): Hooks;
|
|
188
|
+
/**
|
|
189
|
+
* Configuration for the `App` `telemetry` option. Passing `true` is
|
|
190
|
+
* equivalent to `{}` — everything defaults on, reading the standard `OTEL_*`
|
|
191
|
+
* environment variables. When no `OTEL_EXPORTER_OTLP_ENDPOINT` is present
|
|
192
|
+
* (and no explicit `exporter.endpoint` is given) the option is a silent
|
|
193
|
+
* no-op, so it is safe to leave enabled in development.
|
|
194
|
+
*
|
|
195
|
+
* @since 1.2.0
|
|
196
|
+
*/
|
|
197
|
+
export interface TelemetryOptions {
|
|
198
|
+
/**
|
|
199
|
+
* Tee the app logger's output to the collector as OTLP logs. Default
|
|
200
|
+
* `true`. Applies only when the app constructs its own logger (`logger`
|
|
201
|
+
* omitted, `{ level }`, or defaulted) — a caller-supplied `Logger`
|
|
202
|
+
* instance controls its own sink and is not intercepted.
|
|
203
|
+
*/
|
|
204
|
+
logs?: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* Record `http.server.request.duration` per the OTel HTTP semantic
|
|
207
|
+
* conventions and push it to the collector. Default `true`.
|
|
208
|
+
*/
|
|
209
|
+
metrics?: boolean;
|
|
210
|
+
/** Endpoint/header/resource/interval overrides shared by both signals. */
|
|
211
|
+
exporter?: OtlpExporterOptions;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Internal wiring bundle produced by {@link createAppTelemetry} and consumed
|
|
215
|
+
* by the `App` constructor. Exposed for advanced composition and tests.
|
|
216
|
+
*
|
|
217
|
+
* @since 1.2.0
|
|
218
|
+
*/
|
|
219
|
+
export interface AppTelemetry {
|
|
220
|
+
/** Log exporter, or `null` when logs are disabled or unconfigured. */
|
|
221
|
+
logs: OtlpLogExporter | null;
|
|
222
|
+
/** Metrics exporter, or `null` when metrics are disabled or unconfigured. */
|
|
223
|
+
metrics: OtlpMetricsExporter | null;
|
|
224
|
+
/** Hooks to install when HTTP metrics are active. */
|
|
225
|
+
hooks: Hooks | undefined;
|
|
226
|
+
/** Logger `write` sink that tees to stdout and the log exporter. */
|
|
227
|
+
logWrite: ((line: string) => void) | undefined;
|
|
228
|
+
/** Resolved collector endpoint (no signal path), or `null` when inactive. */
|
|
229
|
+
endpoint: string | null;
|
|
230
|
+
/** Flush both signals. Never rejects. */
|
|
231
|
+
flush(): Promise<void>;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Build the exporters, hooks, and logger sink for the `App` `telemetry`
|
|
235
|
+
* option. Returns an inert bundle (all `null`/`undefined`) when no endpoint
|
|
236
|
+
* is configured, so `telemetry: true` costs nothing in development.
|
|
237
|
+
*
|
|
238
|
+
* @param options - The resolved {@link TelemetryOptions}.
|
|
239
|
+
* @returns The wiring bundle.
|
|
240
|
+
* @since 1.2.0
|
|
241
|
+
*/
|
|
242
|
+
export declare function createAppTelemetry(options: TelemetryOptions): AppTelemetry;
|
package/dist/otlp.js
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
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
|
+
* and cumulative metric temporality so totals survive failed pushes.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
* @since 1.2.0
|
|
32
|
+
*/
|
|
33
|
+
/** Log-queue and batching caps (drop-oldest beyond the queue cap). */
|
|
34
|
+
const LOG_MAX_BATCH = 100;
|
|
35
|
+
const LOG_MAX_QUEUE = 1_000;
|
|
36
|
+
const LOG_FLUSH_INTERVAL_MS = 5_000;
|
|
37
|
+
/** Longest log body / attribute value shipped before truncation. */
|
|
38
|
+
const LOG_MAX_FIELD_LENGTH = 8_192;
|
|
39
|
+
const METRICS_FLUSH_INTERVAL_MS = 15_000;
|
|
40
|
+
/**
|
|
41
|
+
* Cardinality guard: total metric series (counter + histogram) before new
|
|
42
|
+
* series are dropped and counted. A hostile client must not be able to mint
|
|
43
|
+
* unbounded series through attribute values.
|
|
44
|
+
*/
|
|
45
|
+
const METRICS_MAX_SERIES = 2_000;
|
|
46
|
+
/** Longest metric attribute value before truncation (cardinality + memory guard). */
|
|
47
|
+
const METRICS_MAX_ATTR_LENGTH = 256;
|
|
48
|
+
/**
|
|
49
|
+
* Spec-defined default bucket boundaries for `http.server.request.duration`,
|
|
50
|
+
* in seconds (OTel HTTP semantic conventions).
|
|
51
|
+
*
|
|
52
|
+
* @since 1.2.0
|
|
53
|
+
*/
|
|
54
|
+
export const HTTP_SERVER_REQUEST_DURATION_BUCKETS = [
|
|
55
|
+
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,
|
|
56
|
+
];
|
|
57
|
+
/** OTLP severity numbers keyed by the framework logger's level names. */
|
|
58
|
+
const SEVERITY = {
|
|
59
|
+
trace: [1, "TRACE"],
|
|
60
|
+
debug: [5, "DEBUG"],
|
|
61
|
+
info: [9, "INFO"],
|
|
62
|
+
warn: [13, "WARN"],
|
|
63
|
+
error: [17, "ERROR"],
|
|
64
|
+
fatal: [21, "FATAL"],
|
|
65
|
+
};
|
|
66
|
+
/** Known HTTP methods per the semconv `http.request.method` well-known set. */
|
|
67
|
+
const KNOWN_METHODS = new Set([
|
|
68
|
+
"GET",
|
|
69
|
+
"HEAD",
|
|
70
|
+
"POST",
|
|
71
|
+
"PUT",
|
|
72
|
+
"DELETE",
|
|
73
|
+
"CONNECT",
|
|
74
|
+
"OPTIONS",
|
|
75
|
+
"TRACE",
|
|
76
|
+
"PATCH",
|
|
77
|
+
]);
|
|
78
|
+
/** Portable environment lookup (Node/Bun/Deno-with-node-compat; undefined elsewhere). */
|
|
79
|
+
function envVar(name) {
|
|
80
|
+
const env = globalThis.process
|
|
81
|
+
?.env;
|
|
82
|
+
return env?.[name];
|
|
83
|
+
}
|
|
84
|
+
/** Parse `key=value,key2=value2` lists (OTEL_EXPORTER_OTLP_HEADERS / OTEL_RESOURCE_ATTRIBUTES). */
|
|
85
|
+
function parseKvList(raw) {
|
|
86
|
+
const out = {};
|
|
87
|
+
if (!raw)
|
|
88
|
+
return out;
|
|
89
|
+
for (const pair of raw.split(",")) {
|
|
90
|
+
const trimmed = pair.trim();
|
|
91
|
+
const idx = trimmed.indexOf("=");
|
|
92
|
+
if (idx <= 0)
|
|
93
|
+
continue;
|
|
94
|
+
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1);
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function truncate(value, max) {
|
|
99
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
100
|
+
}
|
|
101
|
+
function toAttrList(record, maxLength) {
|
|
102
|
+
return Object.entries(record).map(([key, value]) => ({
|
|
103
|
+
key,
|
|
104
|
+
value: { stringValue: truncate(value, maxLength) },
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
function resolveTransport(signalPath, opts) {
|
|
108
|
+
const rawEndpoint = opts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT");
|
|
109
|
+
if (!rawEndpoint)
|
|
110
|
+
return null;
|
|
111
|
+
const base = rawEndpoint.replace(/\/+$/, "").replace(/:4317$/, ":4318");
|
|
112
|
+
const headers = { "content-type": "application/json" };
|
|
113
|
+
Object.assign(headers, parseKvList(envVar("OTEL_EXPORTER_OTLP_HEADERS")), opts.headers ?? {});
|
|
114
|
+
const resource = parseKvList(envVar("OTEL_RESOURCE_ATTRIBUTES"));
|
|
115
|
+
const serviceName = envVar("OTEL_SERVICE_NAME");
|
|
116
|
+
if (serviceName && resource["service.name"] === undefined) {
|
|
117
|
+
resource["service.name"] = serviceName;
|
|
118
|
+
}
|
|
119
|
+
Object.assign(resource, opts.resourceAttributes ?? {});
|
|
120
|
+
return {
|
|
121
|
+
url: base + signalPath,
|
|
122
|
+
headers,
|
|
123
|
+
resourceAttributes: toAttrList(resource, LOG_MAX_FIELD_LENGTH),
|
|
124
|
+
fetchImpl: opts.fetch ?? fetch,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/** Start an `unref`'d repeating flush where the runtime supports timers. */
|
|
128
|
+
function startFlushTimer(intervalMs, flush) {
|
|
129
|
+
if (intervalMs <= 0 || typeof setInterval !== "function")
|
|
130
|
+
return;
|
|
131
|
+
const timer = setInterval(() => void flush(), intervalMs);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
}
|
|
134
|
+
function toLogRecord(line) {
|
|
135
|
+
let level = "info";
|
|
136
|
+
let body = line;
|
|
137
|
+
const attributes = [];
|
|
138
|
+
try {
|
|
139
|
+
const parsed = JSON.parse(line);
|
|
140
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
141
|
+
throw new Error();
|
|
142
|
+
if (typeof parsed.level === "string")
|
|
143
|
+
level = parsed.level;
|
|
144
|
+
const msg = parsed.msg ?? parsed.message ?? parsed.event;
|
|
145
|
+
body = typeof msg === "string" ? msg : line;
|
|
146
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
147
|
+
if (key === "level" || key === "msg" || key === "message")
|
|
148
|
+
continue;
|
|
149
|
+
attributes.push({
|
|
150
|
+
key,
|
|
151
|
+
value: {
|
|
152
|
+
stringValue: truncate(typeof value === "string" ? value : JSON.stringify(value) ?? "null", LOG_MAX_FIELD_LENGTH),
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Not a JSON object (boot banner etc.) — ship verbatim as the body.
|
|
159
|
+
}
|
|
160
|
+
const [severityNumber, severityText] = SEVERITY[level] ?? SEVERITY.info;
|
|
161
|
+
return {
|
|
162
|
+
timeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
163
|
+
severityNumber,
|
|
164
|
+
severityText,
|
|
165
|
+
body: { stringValue: truncate(body, LOG_MAX_FIELD_LENGTH) },
|
|
166
|
+
attributes,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Create a batched OTLP/HTTP JSON log exporter, or `null` when no endpoint is
|
|
171
|
+
* configured (local development, tests) so callers can no-op cheaply.
|
|
172
|
+
*
|
|
173
|
+
* Fail-safe by contract: {@link OtlpLogExporter.flush} never rejects, a dead
|
|
174
|
+
* collector cannot grow the queue past its cap (oldest records are dropped
|
|
175
|
+
* and counted), and export work is decoupled from request handling.
|
|
176
|
+
*
|
|
177
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
178
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
179
|
+
* @since 1.2.0
|
|
180
|
+
*/
|
|
181
|
+
export function createOtlpLogExporter(opts = {}) {
|
|
182
|
+
const transport = resolveTransport("/v1/logs", opts);
|
|
183
|
+
if (transport === null)
|
|
184
|
+
return null;
|
|
185
|
+
const { url, headers, resourceAttributes, fetchImpl } = transport;
|
|
186
|
+
const queue = [];
|
|
187
|
+
let dropped = 0;
|
|
188
|
+
let flushing = false;
|
|
189
|
+
async function flush() {
|
|
190
|
+
if (flushing || queue.length === 0)
|
|
191
|
+
return;
|
|
192
|
+
flushing = true;
|
|
193
|
+
try {
|
|
194
|
+
while (queue.length > 0) {
|
|
195
|
+
const batch = queue.splice(0, LOG_MAX_BATCH);
|
|
196
|
+
const payload = {
|
|
197
|
+
resourceLogs: [
|
|
198
|
+
{
|
|
199
|
+
resource: { attributes: resourceAttributes },
|
|
200
|
+
scopeLogs: [{ scope: { name: "daloyjs" }, logRecords: batch }],
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
};
|
|
204
|
+
const res = await fetchImpl(url, {
|
|
205
|
+
method: "POST",
|
|
206
|
+
headers,
|
|
207
|
+
body: JSON.stringify(payload),
|
|
208
|
+
});
|
|
209
|
+
if (!res.ok)
|
|
210
|
+
dropped += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
dropped += 1;
|
|
215
|
+
queue.length = 0; // never let a dead collector grow the queue
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
flushing = false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
startFlushTimer(opts.flushIntervalMs ?? LOG_FLUSH_INTERVAL_MS, flush);
|
|
222
|
+
return {
|
|
223
|
+
pushLine(line) {
|
|
224
|
+
if (queue.length >= LOG_MAX_QUEUE) {
|
|
225
|
+
queue.shift();
|
|
226
|
+
dropped += 1;
|
|
227
|
+
}
|
|
228
|
+
queue.push(toLogRecord(line.endsWith("\n") ? line.trimEnd() : line));
|
|
229
|
+
},
|
|
230
|
+
flush,
|
|
231
|
+
get droppedBatches() {
|
|
232
|
+
return dropped;
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Create a cumulative OTLP/HTTP JSON metrics exporter, or `null` when no
|
|
238
|
+
* endpoint is configured.
|
|
239
|
+
*
|
|
240
|
+
* Counters export as monotonic cumulative sums and histograms as cumulative
|
|
241
|
+
* explicit-bounds histograms, so state survives failed pushes (the next
|
|
242
|
+
* successful push carries the accumulated totals). Total series are capped
|
|
243
|
+
* ({@link OtlpMetricsExporter.droppedBatches} counts series refused at the
|
|
244
|
+
* cap) and attribute values are length-truncated — both cardinality guards
|
|
245
|
+
* against hostile or buggy attribute sources.
|
|
246
|
+
*
|
|
247
|
+
* @param opts - Endpoint/header/resource overrides; defaults from `OTEL_*` env vars.
|
|
248
|
+
* @returns The exporter, or `null` when no endpoint is configured.
|
|
249
|
+
* @since 1.2.0
|
|
250
|
+
*/
|
|
251
|
+
export function createOtlpMetricsExporter(opts = {}) {
|
|
252
|
+
const transport = resolveTransport("/v1/metrics", opts);
|
|
253
|
+
if (transport === null)
|
|
254
|
+
return null;
|
|
255
|
+
const { url, headers, resourceAttributes, fetchImpl } = transport;
|
|
256
|
+
const counters = new Map();
|
|
257
|
+
const histograms = new Map();
|
|
258
|
+
let dropped = 0;
|
|
259
|
+
let flushing = false;
|
|
260
|
+
let dirty = false;
|
|
261
|
+
function seriesKey(name, entries) {
|
|
262
|
+
let key = name;
|
|
263
|
+
for (const [k, v] of entries)
|
|
264
|
+
key += "|" + k + "=" + v;
|
|
265
|
+
return key;
|
|
266
|
+
}
|
|
267
|
+
function sortedEntries(attributes) {
|
|
268
|
+
return Object.entries(attributes)
|
|
269
|
+
.map(([k, v]) => [k, truncate(v, METRICS_MAX_ATTR_LENGTH)])
|
|
270
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
271
|
+
}
|
|
272
|
+
function atCapacity() {
|
|
273
|
+
if (counters.size + histograms.size < METRICS_MAX_SERIES)
|
|
274
|
+
return false;
|
|
275
|
+
dropped += 1;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
async function flush() {
|
|
279
|
+
if (flushing || !dirty)
|
|
280
|
+
return;
|
|
281
|
+
flushing = true;
|
|
282
|
+
try {
|
|
283
|
+
const now = (BigInt(Date.now()) * 1000000n).toString();
|
|
284
|
+
const sumsByName = new Map();
|
|
285
|
+
for (const s of counters.values()) {
|
|
286
|
+
const group = sumsByName.get(s.name);
|
|
287
|
+
if (group === undefined)
|
|
288
|
+
sumsByName.set(s.name, [s]);
|
|
289
|
+
else
|
|
290
|
+
group.push(s);
|
|
291
|
+
}
|
|
292
|
+
const histsByName = new Map();
|
|
293
|
+
for (const h of histograms.values()) {
|
|
294
|
+
const group = histsByName.get(h.name);
|
|
295
|
+
if (group === undefined)
|
|
296
|
+
histsByName.set(h.name, [h]);
|
|
297
|
+
else
|
|
298
|
+
group.push(h);
|
|
299
|
+
}
|
|
300
|
+
const metrics = [];
|
|
301
|
+
for (const [name, group] of sumsByName) {
|
|
302
|
+
metrics.push({
|
|
303
|
+
name,
|
|
304
|
+
unit: "1",
|
|
305
|
+
sum: {
|
|
306
|
+
aggregationTemporality: 2, // cumulative
|
|
307
|
+
isMonotonic: true,
|
|
308
|
+
dataPoints: group.map((s) => ({
|
|
309
|
+
attributes: s.attributes,
|
|
310
|
+
startTimeUnixNano: s.startTimeUnixNano,
|
|
311
|
+
timeUnixNano: now,
|
|
312
|
+
asDouble: s.total,
|
|
313
|
+
})),
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
for (const [name, group] of histsByName) {
|
|
318
|
+
metrics.push({
|
|
319
|
+
name,
|
|
320
|
+
unit: group[0].unit,
|
|
321
|
+
histogram: {
|
|
322
|
+
aggregationTemporality: 2, // cumulative
|
|
323
|
+
dataPoints: group.map((h) => ({
|
|
324
|
+
attributes: h.attributes,
|
|
325
|
+
startTimeUnixNano: h.startTimeUnixNano,
|
|
326
|
+
timeUnixNano: now,
|
|
327
|
+
// uint64 fields use the string JSON mapping in OTLP.
|
|
328
|
+
count: String(h.count),
|
|
329
|
+
sum: h.sum,
|
|
330
|
+
bucketCounts: h.bucketCounts.map(String),
|
|
331
|
+
explicitBounds: [...h.boundaries],
|
|
332
|
+
})),
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
const payload = {
|
|
337
|
+
resourceMetrics: [
|
|
338
|
+
{
|
|
339
|
+
resource: { attributes: resourceAttributes },
|
|
340
|
+
scopeMetrics: [{ scope: { name: "daloyjs" }, metrics }],
|
|
341
|
+
},
|
|
342
|
+
],
|
|
343
|
+
};
|
|
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
|
|
352
|
+
dropped += 1;
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
dropped += 1; // totals retained; cumulative temporality self-heals
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
flushing = false;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
startFlushTimer(opts.flushIntervalMs ?? METRICS_FLUSH_INTERVAL_MS, flush);
|
|
362
|
+
return {
|
|
363
|
+
count(name, attributes, value = 1) {
|
|
364
|
+
if (!Number.isFinite(value) || value < 0)
|
|
365
|
+
return;
|
|
366
|
+
const entries = sortedEntries(attributes);
|
|
367
|
+
const key = seriesKey(name, entries);
|
|
368
|
+
let s = counters.get(key);
|
|
369
|
+
if (s === undefined) {
|
|
370
|
+
if (atCapacity())
|
|
371
|
+
return;
|
|
372
|
+
s = {
|
|
373
|
+
name,
|
|
374
|
+
attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
|
|
375
|
+
total: 0,
|
|
376
|
+
startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
377
|
+
};
|
|
378
|
+
counters.set(key, s);
|
|
379
|
+
}
|
|
380
|
+
s.total += value;
|
|
381
|
+
dirty = true;
|
|
382
|
+
},
|
|
383
|
+
record(name, attributes, value, options) {
|
|
384
|
+
if (!Number.isFinite(value))
|
|
385
|
+
return;
|
|
386
|
+
const entries = sortedEntries(attributes);
|
|
387
|
+
const key = seriesKey(name, entries);
|
|
388
|
+
let h = histograms.get(key);
|
|
389
|
+
if (h === undefined) {
|
|
390
|
+
if (atCapacity())
|
|
391
|
+
return;
|
|
392
|
+
h = {
|
|
393
|
+
name,
|
|
394
|
+
unit: options.unit,
|
|
395
|
+
boundaries: options.boundaries,
|
|
396
|
+
attributes: entries.map(([k, v]) => ({ key: k, value: { stringValue: v } })),
|
|
397
|
+
count: 0,
|
|
398
|
+
sum: 0,
|
|
399
|
+
bucketCounts: new Array(options.boundaries.length + 1).fill(0),
|
|
400
|
+
startTimeUnixNano: (BigInt(Date.now()) * 1000000n).toString(),
|
|
401
|
+
};
|
|
402
|
+
histograms.set(key, h);
|
|
403
|
+
}
|
|
404
|
+
h.count += 1;
|
|
405
|
+
h.sum += value;
|
|
406
|
+
let bucket = h.boundaries.findIndex((bound) => value <= bound);
|
|
407
|
+
if (bucket === -1)
|
|
408
|
+
bucket = h.boundaries.length;
|
|
409
|
+
h.bucketCounts[bucket] += 1;
|
|
410
|
+
dirty = true;
|
|
411
|
+
},
|
|
412
|
+
flush,
|
|
413
|
+
get droppedBatches() {
|
|
414
|
+
return dropped;
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/** Monotonic clock in milliseconds, falling back to `Date.now` where needed. */
|
|
419
|
+
function nowMs() {
|
|
420
|
+
return typeof performance !== "undefined" && typeof performance.now === "function"
|
|
421
|
+
? performance.now()
|
|
422
|
+
: Date.now();
|
|
423
|
+
}
|
|
424
|
+
const SEMCONV_START_TIMES = new WeakMap();
|
|
425
|
+
/**
|
|
426
|
+
* A `Hooks` bundle recording `http.server.request.duration` per the OTel HTTP
|
|
427
|
+
* semantic conventions into an {@link OtlpMetricsExporter}:
|
|
428
|
+
*
|
|
429
|
+
* - unit `s`, spec bucket boundaries
|
|
430
|
+
* ({@link HTTP_SERVER_REQUEST_DURATION_BUCKETS});
|
|
431
|
+
* - attributes `http.request.method` (well-known set, else `_OTHER`),
|
|
432
|
+
* `http.response.status_code`, `url.scheme`, `http.route` (the matched
|
|
433
|
+
* route **template** via `ctx.routePath`), and `error.type` (the status
|
|
434
|
+
* code, on `5xx` only).
|
|
435
|
+
*
|
|
436
|
+
* Only requests that produce a request context are recorded. On the
|
|
437
|
+
* unmatched-404 fast path the framework skips context construction entirely
|
|
438
|
+
* (a deliberate allocation optimization), so unmatched floods record nothing
|
|
439
|
+
* — which is also the strongest possible cardinality guarantee: a hostile
|
|
440
|
+
* client can never mint metric series from raw paths.
|
|
441
|
+
*
|
|
442
|
+
* Install it **before** registering routes (group-hook ordering), or let
|
|
443
|
+
* `new App({ telemetry: true })` install it for you.
|
|
444
|
+
*
|
|
445
|
+
* @param sink - Metrics exporter the duration histogram is recorded into.
|
|
446
|
+
* @param opts - Optional path exclusion.
|
|
447
|
+
* @returns A `Hooks` object for `app.use(...)` or `new App({ hooks })`.
|
|
448
|
+
* @since 1.2.0
|
|
449
|
+
*/
|
|
450
|
+
export function semconvHttpMetrics(sink, opts = {}) {
|
|
451
|
+
return {
|
|
452
|
+
onRequest(req) {
|
|
453
|
+
SEMCONV_START_TIMES.set(req, nowMs());
|
|
454
|
+
},
|
|
455
|
+
onResponse(res, ctx) {
|
|
456
|
+
const request = ctx?.request;
|
|
457
|
+
if (request === undefined)
|
|
458
|
+
return;
|
|
459
|
+
const started = SEMCONV_START_TIMES.get(request);
|
|
460
|
+
if (started === undefined)
|
|
461
|
+
return;
|
|
462
|
+
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 */
|
|
472
|
+
}
|
|
473
|
+
if (opts.exclude !== undefined && opts.exclude(pathname))
|
|
474
|
+
return;
|
|
475
|
+
const rawMethod = request.method.toUpperCase();
|
|
476
|
+
const attributes = {
|
|
477
|
+
"http.request.method": KNOWN_METHODS.has(rawMethod) ? rawMethod : "_OTHER",
|
|
478
|
+
"http.response.status_code": String(res.status),
|
|
479
|
+
"url.scheme": scheme,
|
|
480
|
+
};
|
|
481
|
+
// Spec: http.route only when a route template is known. Never the raw
|
|
482
|
+
// pathname — an unmatched-path flood must not mint metric series.
|
|
483
|
+
const routePath = ctx?.routePath;
|
|
484
|
+
if (typeof routePath === "string")
|
|
485
|
+
attributes["http.route"] = routePath;
|
|
486
|
+
if (res.status >= 500)
|
|
487
|
+
attributes["error.type"] = String(res.status);
|
|
488
|
+
sink.record("http.server.request.duration", attributes, (nowMs() - started) / 1000, { unit: "s", boundaries: HTTP_SERVER_REQUEST_DURATION_BUCKETS });
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Build the exporters, hooks, and logger sink for the `App` `telemetry`
|
|
494
|
+
* option. Returns an inert bundle (all `null`/`undefined`) when no endpoint
|
|
495
|
+
* is configured, so `telemetry: true` costs nothing in development.
|
|
496
|
+
*
|
|
497
|
+
* @param options - The resolved {@link TelemetryOptions}.
|
|
498
|
+
* @returns The wiring bundle.
|
|
499
|
+
* @since 1.2.0
|
|
500
|
+
*/
|
|
501
|
+
export function createAppTelemetry(options) {
|
|
502
|
+
const exporterOpts = options.exporter ?? {};
|
|
503
|
+
const logs = options.logs === false ? null : createOtlpLogExporter(exporterOpts);
|
|
504
|
+
const metrics = options.metrics === false ? null : createOtlpMetricsExporter(exporterOpts);
|
|
505
|
+
const logWrite = logs === null
|
|
506
|
+
? undefined
|
|
507
|
+
: (line) => {
|
|
508
|
+
const proc = globalThis.process;
|
|
509
|
+
if (proc?.stdout?.write !== undefined)
|
|
510
|
+
proc.stdout.write(line + "\n");
|
|
511
|
+
// eslint-disable-next-line no-console
|
|
512
|
+
else
|
|
513
|
+
console.log(line);
|
|
514
|
+
logs.pushLine(line);
|
|
515
|
+
};
|
|
516
|
+
const endpointRaw = exporterOpts.endpoint ?? envVar("OTEL_EXPORTER_OTLP_ENDPOINT") ?? null;
|
|
517
|
+
return {
|
|
518
|
+
logs,
|
|
519
|
+
metrics,
|
|
520
|
+
hooks: metrics === null ? undefined : semconvHttpMetrics(metrics),
|
|
521
|
+
logWrite,
|
|
522
|
+
endpoint: endpointRaw,
|
|
523
|
+
async flush() {
|
|
524
|
+
await Promise.all([logs?.flush(), metrics?.flush()]);
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
}
|
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:3d6cece6-6c87-5815-9301-8b0be8579da5",
|
|
5
5
|
"version": 1,
|
|
6
6
|
"metadata": {
|
|
7
|
-
"timestamp": "2026-08-
|
|
7
|
+
"timestamp": "2026-08-20T11:51:20.093Z",
|
|
8
8
|
"tools": [
|
|
9
9
|
{
|
|
10
10
|
"vendor": "DaloyJS",
|
|
11
11
|
"name": "daloy-generate-sbom",
|
|
12
|
-
"version": "1.
|
|
12
|
+
"version": "1.2.0"
|
|
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.0",
|
|
23
23
|
"name": "@daloyjs/core",
|
|
24
|
-
"version": "1.
|
|
24
|
+
"version": "1.2.0",
|
|
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.0",
|
|
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.0",
|
|
50
50
|
"name": "@daloyjs/core",
|
|
51
|
-
"version": "1.
|
|
51
|
+
"version": "1.2.0",
|
|
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.0",
|
|
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.0",
|
|
6
|
+
"documentNamespace": "https://github.com/daloyjs/daloy/sbom/@daloyjs/core-1.2.0-3d6cece6-6c87-5815-9301-8b0be8579da5",
|
|
7
7
|
"creationInfo": {
|
|
8
|
-
"created": "2026-08-
|
|
8
|
+
"created": "2026-08-20T11:51:20.093Z",
|
|
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.0",
|
|
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.0"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -268,6 +268,15 @@ export interface BaseContext<P extends string, R extends RequestSchemas | undefi
|
|
|
268
268
|
body: InferRequest<R, P>["body"];
|
|
269
269
|
/** Mutable per-request state. Plugin-augmented context lives here. */
|
|
270
270
|
state: AppState & Record<string, unknown>;
|
|
271
|
+
/**
|
|
272
|
+
* The matched route's path **template** (e.g. `/books/:id`), set as soon as
|
|
273
|
+
* routing succeeds. `undefined` on framework-synthesised contexts (404/405
|
|
274
|
+
* error contexts, OPTIONS preflight). Use it for low-cardinality metric
|
|
275
|
+
* labels (`http.route`) instead of the raw request path.
|
|
276
|
+
*
|
|
277
|
+
* @since 1.2.0
|
|
278
|
+
*/
|
|
279
|
+
routePath?: string;
|
|
271
280
|
/** Convenience response helpers (do not bypass schema validation). */
|
|
272
281
|
set: {
|
|
273
282
|
status?: number;
|
|
@@ -299,6 +308,13 @@ export interface PreBodyContext<P extends string = string> {
|
|
|
299
308
|
body: undefined;
|
|
300
309
|
/** Mutable per-request state shared with later hooks and the handler. */
|
|
301
310
|
state: AppState & Record<string, unknown>;
|
|
311
|
+
/**
|
|
312
|
+
* The matched route's path template (routing has already succeeded when
|
|
313
|
+
* `preBody` runs). See {@link BaseContext.routePath}.
|
|
314
|
+
*
|
|
315
|
+
* @since 1.2.0
|
|
316
|
+
*/
|
|
317
|
+
routePath?: string;
|
|
302
318
|
/** Response headers/status available to a short-circuiting perimeter hook. */
|
|
303
319
|
set: {
|
|
304
320
|
status?: number;
|
|
@@ -376,8 +392,8 @@ export interface Hooks {
|
|
|
376
392
|
* the existing response. Multiple `onSend` hooks compose pipeline-style.
|
|
377
393
|
*/
|
|
378
394
|
onSend?: (res: Response, ctx: BaseContext<any, any> | undefined) => void | Response | Promise<void | Response>;
|
|
379
|
-
/** Fire-and-forget observer of the final outgoing `Response` (logging, metrics). Runs last; cannot alter the response. */
|
|
380
|
-
onResponse?: (res: Response) => void | Promise<void>;
|
|
395
|
+
/** Fire-and-forget observer of the final outgoing `Response` (logging, metrics). Runs last; cannot alter the response. `ctx` is `undefined` when the error occurred before context was built. */
|
|
396
|
+
onResponse?: (res: Response, ctx?: BaseContext<any, any> | undefined) => void | Promise<void>;
|
|
381
397
|
}
|
|
382
398
|
/**
|
|
383
399
|
* Declarative description of one HTTP endpoint. The single source of truth
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@daloyjs/core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
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": {
|
|
@@ -154,6 +154,10 @@
|
|
|
154
154
|
"types": "./dist/metrics.d.ts",
|
|
155
155
|
"import": "./dist/metrics.js"
|
|
156
156
|
},
|
|
157
|
+
"./otlp": {
|
|
158
|
+
"types": "./dist/otlp.d.ts",
|
|
159
|
+
"import": "./dist/otlp.js"
|
|
160
|
+
},
|
|
157
161
|
"./fetch-resilience": {
|
|
158
162
|
"types": "./dist/fetch-resilience.d.ts",
|
|
159
163
|
"import": "./dist/fetch-resilience.js"
|