@dunx/http 3.1.3 → 3.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 CHANGED
@@ -1,8 +1,9 @@
1
1
  # @dunx/http
2
2
 
3
- `Bun.serve` adapter for [dunx](https://github.com/petarzarkov/dunx). Class-based
4
- controllers **and WebSocket gateways**, standard decorators, and no JavaScript
5
- router - Bun's native `routes` does path params and per-method dispatch in Zig.
3
+ `Bun.serve` adapter for [dunx](https://github.com/petarzarkov/dunx): class-based
4
+ controllers, **WebSocket gateways**, and standard decorators. There is no
5
+ JavaScript router - Bun's native `routes` does path params and per-method
6
+ dispatch in Zig.
6
7
 
7
8
  `Bun.serve` takes `routes` and `websocket` in one call, so both live here: one
8
9
  `listen()`, one server, one port. No `express`, no `ws`, no `socket.io`.
@@ -62,6 +63,8 @@ The guide is canonical for every row; this table is the index.
62
63
  | Middleware and guards | One extension point, `@UseGuards`, `@Roles`, `@Public` | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
63
64
  | WebSocket gateways | `@Gateway`, handlers, `PubSub`, multi-node relay | [WebSockets](../../docs/guide/09-websockets.md) |
64
65
  | Request logging | One structured entry per request, on by default | [Logging](../../docs/guide/13-logging.md) |
66
+ | Trace context | W3C `traceparent` adopted and propagated, on by default | [Logging](../../docs/guide/13-logging.md) |
67
+ | Metrics | Per-route counts and timings, off by default | [Metrics](../../docs/guide/22-metrics.md) |
65
68
  | Health and draining | `/health/live`, `/health/ready`, readiness during a rollout | [Health checks](../../docs/guide/20-health-checks.md) |
66
69
  | Throttling | `@Throttle`, `@SkipThrottle`, memory and Redis counters | [Middleware and guards](../../docs/guide/08-middleware-and-guards.md) |
67
70
  | Static files | `Bun.file` behind a mount, with a cache policy | [Deployment](../../docs/guide/19-deployment.md) |
@@ -89,7 +92,14 @@ from, and it may change in any release.
89
92
  - Handlers may return a `Response`, any JSON-serialisable value, or `undefined`
90
93
  for a 204.
91
94
  - Schemas, parsers and the status resolve at boot into the same closure the
92
- middleware chain folds into, so a request reads no metadata and does no lookup.
95
+ middleware chain folds into. A request reads no metadata and does no lookup.
96
+ - Every request adopts W3C Trace Context, so `traceId`, `spanId`, `parentSpanId`
97
+ and `traceFlags` reach every line it writes and `traceresponse` goes out on the
98
+ response. `requestLogging: { trace: false }` removes both;
99
+ `{ traceResponse: false }` keeps the trace and drops the header, which is ~500
100
+ ns. W3C Trace Context is the only correlation id; there is no second one.
101
+ - `metrics: true` adds per-route counts and a nanosecond histogram at +35.2 ns a
102
+ request, folded into the `.then` request logging already allocates.
93
103
 
94
104
  ## License
95
105
 
@@ -105,4 +105,73 @@ var HttpStatusCode = Object.freeze({
105
105
  GATEWAY_TIMEOUT: 504
106
106
  });
107
107
 
108
- export { __privateGet, __privateAdd, __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode };
108
+ // src/server/trace-context.ts
109
+ var TRACEPARENT_HEADER = "traceparent";
110
+ var TRACESTATE_HEADER = "tracestate";
111
+ var TRACERESPONSE_HEADER = "traceresponse";
112
+ var HEX_32 = /^[0-9a-f]{32}$/;
113
+ var HEX_16 = /^[0-9a-f]{16}$/;
114
+ var HEX_2 = /^[0-9a-f]{2}$/;
115
+ var ZERO_TRACE = "0".repeat(32);
116
+ var ZERO_SPAN = "0".repeat(16);
117
+ var SAMPLED = 1;
118
+ var DEFAULT_FLAGS = "01";
119
+ var TRACE = Symbol.for("dunx.http.trace");
120
+ var EXPOSE = Symbol.for("dunx.http.trace.expose");
121
+ var mint = (bytes) => crypto.getRandomValues(new Uint8Array(bytes)).toHex();
122
+
123
+ class TraceContext {
124
+ static adopt(req, expose = true) {
125
+ const inbound = TraceContext.#parse(req.headers.get(TRACEPARENT_HEADER));
126
+ const state = req.headers.get(TRACESTATE_HEADER);
127
+ const trace = inbound === undefined ? { traceId: mint(16), spanId: mint(8), flags: DEFAULT_FLAGS } : {
128
+ traceId: inbound.traceId,
129
+ spanId: mint(8),
130
+ parentSpanId: inbound.spanId,
131
+ flags: inbound.flags,
132
+ ...state === null ? {} : { state }
133
+ };
134
+ req[TRACE] = trace;
135
+ if (expose)
136
+ req[EXPOSE] = true;
137
+ return trace;
138
+ }
139
+ static of(req) {
140
+ return req[TRACE];
141
+ }
142
+ static header(trace) {
143
+ return `00-${trace.traceId}-${trace.spanId}-${trace.flags}`;
144
+ }
145
+ static stamp(response, req) {
146
+ const traced = req;
147
+ const trace = traced[TRACE];
148
+ if (trace !== undefined && traced[EXPOSE] === true) {
149
+ response.headers.set(TRACERESPONSE_HEADER, TraceContext.header(trace));
150
+ }
151
+ return response;
152
+ }
153
+ static sampled(trace) {
154
+ return (Number.parseInt(trace.flags, 16) & SAMPLED) === SAMPLED;
155
+ }
156
+ static #parse(header) {
157
+ if (header === null)
158
+ return;
159
+ const parts = header.split("-");
160
+ if (parts.length < 4)
161
+ return;
162
+ const [version, traceId, spanId, flags] = parts;
163
+ if (!HEX_2.test(version) || version === "ff")
164
+ return;
165
+ if (version === "00" && parts.length !== 4)
166
+ return;
167
+ if (!HEX_32.test(traceId) || traceId === ZERO_TRACE)
168
+ return;
169
+ if (!HEX_16.test(spanId) || spanId === ZERO_SPAN)
170
+ return;
171
+ if (!HEX_2.test(flags))
172
+ return;
173
+ return { traceId, spanId, flags };
174
+ }
175
+ }
176
+
177
+ export { __privateGet, __privateAdd, __decoratorStart, __decoratorMetadata, __runInitializers, __decorateElement, HttpStatusCode, TRACEPARENT_HEADER, TRACESTATE_HEADER, TRACERESPONSE_HEADER, TraceContext };
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  import {
3
3
  HttpStatusCode
4
- } from "./chunk-sz4pvqxy.js";
4
+ } from "./chunk-9x3evk19.js";
5
5
 
6
6
  // src/client/errors.ts
7
7
  import { AppError } from "@dunx/core";
@@ -1,13 +1,14 @@
1
1
  // @bun
2
2
  import {
3
3
  HttpStatusCode,
4
+ TraceContext,
4
5
  __decorateElement,
5
6
  __decoratorMetadata,
6
7
  __decoratorStart,
7
8
  __privateAdd,
8
9
  __privateGet,
9
10
  __runInitializers
10
- } from "./chunk-sz4pvqxy.js";
11
+ } from "./chunk-9x3evk19.js";
11
12
 
12
13
  // src/route/marker.ts
13
14
  var ROUTE = Symbol.for("dunx.route");
@@ -570,26 +571,6 @@ class WsRelay {
570
571
  }
571
572
  }
572
573
 
573
- // src/server/request-id.ts
574
- var REQUEST_ID_HEADER = "x-request-id";
575
- var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
576
- var traceId = (inbound) => inbound !== null && inbound.length === 36 && UUID.test(inbound) ? inbound : crypto.randomUUID();
577
- var ID = Symbol.for("dunx.http.requestId");
578
-
579
- class RequestIds {
580
- static assign(req) {
581
- const id = traceId(req.headers.get(REQUEST_ID_HEADER));
582
- req[ID] = id;
583
- return id;
584
- }
585
- static stamp(response, req) {
586
- const id = req[ID];
587
- if (id !== undefined)
588
- response.headers.set(REQUEST_ID_HEADER, id);
589
- return response;
590
- }
591
- }
592
-
593
574
  // src/server/context.ts
594
575
  var EMPTY = new Map;
595
576
  var buildContext = (route) => {
@@ -843,7 +824,7 @@ var buildFallback = (middleware = [], onError = defaultErrorMapper, cors, notFou
843
824
  try {
844
825
  return await compose(middleware, unmatchedContext(req, notFound === "public"), miss)(req);
845
826
  } catch (error) {
846
- return RequestIds.stamp(onError(error, req), req);
827
+ return TraceContext.stamp(onError(error, req), req);
847
828
  }
848
829
  };
849
830
  return cors ? withCors(cors, run) : run;
@@ -900,7 +881,7 @@ var buildRoutes = (discovered, middleware = [], onError = defaultErrorMapper, co
900
881
  try {
901
882
  return await chained(req);
902
883
  } catch (error) {
903
- return RequestIds.stamp(onError(error, req), req);
884
+ return TraceContext.stamp(onError(error, req), req);
904
885
  }
905
886
  };
906
887
  const byMethod = routes[route.path] ??= {};
@@ -1313,4 +1294,4 @@ __runInitializers(_init, 1, HiddenHealthController);
1313
1294
  __decoratorMetadata(_init, HiddenHealthController);
1314
1295
  let _HiddenHealthController = HiddenHealthController;
1315
1296
 
1316
- export { defaultStatusFor, Controller, Get, Post, Put, Patch, Delete, metaKey, meta, ROLES, PUBLIC, HIDDEN, UNMATCHED, Roles, Public, ApiHidden, UseGuards, guardsOf, metaOf, mergeMeta, HttpError, ValidationError, ErrorFilter, isErrorFilter, toErrorMapper, errorMapper, defaultErrorMapper, joinPath, discoverRoutes, encode, decode, HandlerKind, markHandler, markGateway, isGateway, composeSocket, observe, buildRuntime, buildGateways, buildWebSocket, normalizePath, discoverGateway, discoverGateways, DEFAULT_RELAY_CHANNEL, defaultRelayError, encodeRelay, decodeRelay, WsRelay, RawBody, REQUEST_ID_HEADER, RequestIds, buildContext, withCors, preflight, compose, assertNoCollisions, assertNoGatewayCollisions, withUpgradeRoutes, buildFallback, buildRoutes, StaticOptions, normalizePrefix, negotiate, CompressionEncoding, isCompressibleType, CompressionOptions, defaultRelayUrl, RedisRelay, HEALTH_REPORT_SCHEMA, HealthOptions, HealthRegistry, HealthController, HiddenHealthController };
1297
+ export { defaultStatusFor, Controller, Get, Post, Put, Patch, Delete, metaKey, meta, ROLES, PUBLIC, HIDDEN, UNMATCHED, Roles, Public, ApiHidden, UseGuards, guardsOf, metaOf, mergeMeta, HttpError, ValidationError, ErrorFilter, isErrorFilter, toErrorMapper, errorMapper, defaultErrorMapper, joinPath, discoverRoutes, encode, decode, HandlerKind, markHandler, markGateway, isGateway, composeSocket, observe, buildRuntime, buildGateways, buildWebSocket, normalizePath, discoverGateway, discoverGateways, DEFAULT_RELAY_CHANNEL, defaultRelayError, encodeRelay, decodeRelay, WsRelay, RawBody, buildContext, withCors, preflight, compose, assertNoCollisions, assertNoGatewayCollisions, withUpgradeRoutes, buildFallback, buildRoutes, StaticOptions, normalizePrefix, negotiate, CompressionEncoding, isCompressibleType, CompressionOptions, defaultRelayUrl, RedisRelay, HEALTH_REPORT_SCHEMA, HealthOptions, HealthRegistry, HealthController, HiddenHealthController };
@@ -16,21 +16,17 @@ export interface HttpClientOptionsInit {
16
16
  /** Sent on every request, under anything a call sets itself. */
17
17
  readonly headers?: Readonly<Record<string, string>>;
18
18
  readonly retry?: RetryOptions<unknown>;
19
- /**
20
- * Forward the inbound request id to the upstream, so one trace spans both
21
- * services. `true` uses `x-request-id`; a string names the header. Read from
22
- * `RequestContext`, so it only carries when there is a request in scope.
23
- *
24
- * @default true
25
- */
26
- readonly propagateRequestId?: boolean | string;
27
19
  /**
28
20
  * Forward W3C Trace Context upstream as `traceparent`, so the callee's spans
29
- * join this request's trace.
21
+ * join this request's trace and one trace spans both services.
30
22
  *
31
23
  * Read from `RequestContext`, so it only carries when a trace is in scope -
32
- * which means `requestLogging: { trace: true }` on the inbound side. With that
33
- * off there is nothing to send and this costs one property read.
24
+ * which the inbound side puts there unless `requestLogging: { trace: false }`
25
+ * removed it. With that off there is nothing to send and this costs one
26
+ * property read.
27
+ *
28
+ * The caller's `traceFlags` are sent as they arrived, so a trace an upstream
29
+ * sampler declined is not re-sampled at this hop.
34
30
  *
35
31
  * @default true
36
32
  */
@@ -67,7 +63,6 @@ export interface HttpClientOptionsInit {
67
63
  /** How many redirects to follow before rejecting. */
68
64
  readonly maxRedirects?: number;
69
65
  }
70
- export declare const DEFAULT_REQUEST_ID_HEADER = "x-request-id";
71
66
  /**
72
67
  * The resolved options, as a class so it is both the injection token and the type
73
68
  * a factory annotates - the same trick `RedisOptions` and `ConfigService` use.
@@ -77,7 +72,6 @@ export declare class HttpClientOptions {
77
72
  readonly timeoutMs: number;
78
73
  readonly headers: Readonly<Record<string, string>>;
79
74
  readonly retry: RetryOptions<unknown>;
80
- readonly requestIdHeader: string | undefined;
81
75
  readonly propagateTrace: boolean;
82
76
  readonly name: string | undefined;
83
77
  readonly fetchOptions: Readonly<Record<string, unknown>>;
@@ -42,8 +42,8 @@ export interface RequestConfig<TRequest = unknown, TResponse = unknown> {
42
42
  }
43
43
  type BaseOptions<TRequest, TResponse> = Omit<RequestConfig<TRequest, TResponse>, 'method' | 'url' | 'payload'>;
44
44
  /**
45
- * A `fetch` client with a per-request timeout, retry with backoff, request-id
46
- * propagation and one log line per call. `fetch` and nothing else, so there is no
45
+ * A `fetch` client with a per-request timeout, retry with backoff, W3C Trace
46
+ * Context propagation and one log line per call. `fetch` and nothing else, so there is no
47
47
  * client dependency; what it adds is the parts every caller otherwise
48
48
  * reimplements - the timeout, the retry policy, `Retry-After`, url building, and
49
49
  * a failure that says which call failed.
package/dist/client.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * `@dunx/http` does not load any of this.
7
7
  */
8
8
  export { FetchError, FetchTransportError } from './client/errors.js';
9
- export { DEFAULT_REQUEST_ID_HEADER, HttpClientOptions, type HttpClientOptionsInit, } from './client/options.js';
9
+ export { HttpClientOptions, type HttpClientOptionsInit, } from './client/options.js';
10
10
  export type { BackoffOptions, RetryOptions } from './client/retry.js';
11
11
  export { httpClient, HttpModule, type ClientTarget } from './client/module.js';
12
12
  export { HttpService, type HeaderFactory, type RequestConfig, type RequestMethod, } from './client/service.js';
package/dist/client.js CHANGED
@@ -1,25 +1,22 @@
1
1
  // @bun
2
- import {
3
- TRACEPARENT_HEADER,
4
- TraceContext
5
- } from "./chunk-25g22350.js";
6
2
  import {
7
3
  FetchError,
8
4
  FetchTransportError,
9
5
  executeWithRetry,
10
6
  isJsonBody,
11
7
  safeStringify
12
- } from "./chunk-7xrtwbx3.js";
13
- import"./chunk-sz4pvqxy.js";
8
+ } from "./chunk-e8a9c6j2.js";
9
+ import {
10
+ TRACEPARENT_HEADER,
11
+ TRACESTATE_HEADER,
12
+ TraceContext
13
+ } from "./chunk-9x3evk19.js";
14
14
  // src/client/options.ts
15
- var DEFAULT_REQUEST_ID_HEADER = "x-request-id";
16
-
17
15
  class HttpClientOptions {
18
16
  baseUrl;
19
17
  timeoutMs;
20
18
  headers;
21
19
  retry;
22
- requestIdHeader;
23
20
  propagateTrace;
24
21
  name;
25
22
  fetchOptions;
@@ -30,8 +27,6 @@ class HttpClientOptions {
30
27
  this.retry = init.retry ?? {};
31
28
  this.name = init.name;
32
29
  this.propagateTrace = init.propagateTrace ?? true;
33
- const propagate = init.propagateRequestId ?? true;
34
- this.requestIdHeader = propagate === false ? undefined : propagate === true ? DEFAULT_REQUEST_ID_HEADER : propagate;
35
30
  this.fetchOptions = Object.fromEntries([
36
31
  ["proxy", init.proxy],
37
32
  ["tls", init.tls],
@@ -213,19 +208,18 @@ class HttpService extends UrlHelper {
213
208
  return { body: serialised, serialised, json: true };
214
209
  }
215
210
  async send(config, url, body, serialised, accept = "application/json") {
216
- const requestId = this.options.requestIdHeader === undefined ? undefined : this.requestContext.getContext().requestId;
217
211
  const trace = this.options.propagateTrace ? this.requestContext.getContext() : undefined;
218
212
  const headers = {
219
213
  accept,
220
214
  ...serialised === "" ? {} : { "content-type": "application/json" },
221
215
  ...this.options.headers,
222
- ...requestId === undefined || this.options.requestIdHeader === undefined ? {} : { [this.options.requestIdHeader]: requestId },
223
216
  ...typeof trace?.traceId === "string" && typeof trace.spanId === "string" ? {
224
217
  [TRACEPARENT_HEADER]: TraceContext.header({
225
218
  traceId: trace.traceId,
226
219
  spanId: trace.spanId,
227
- flags: "01"
228
- })
220
+ flags: typeof trace.traceFlags === "string" ? trace.traceFlags : "01"
221
+ }),
222
+ ...typeof trace.traceState === "string" ? { [TRACESTATE_HEADER]: trace.traceState } : {}
229
223
  } : {},
230
224
  ...config.headerFactory?.({
231
225
  timestamp: Math.floor(Date.now() / 1000),
@@ -344,7 +338,6 @@ class HttpModule {
344
338
  }
345
339
  }
346
340
  export {
347
- DEFAULT_REQUEST_ID_HEADER,
348
341
  FetchError,
349
342
  FetchTransportError,
350
343
  HttpClientOptions,
package/dist/index.d.ts CHANGED
@@ -8,9 +8,9 @@ export type { CorsOptions, CorsOrigin } from './server/cors.js';
8
8
  export { defaultErrorMapper, ErrorFilter, errorMapper, HttpError, ValidationError, type ErrorHandler, type ErrorMapper, type HttpErrorOptions, type InputSource, type ValidationIssue, } from './server/errors.js';
9
9
  export { HttpFactory, type HttpApp, type HttpOptions, } from './server/factory.js';
10
10
  export { DefaultHttpOptions, HttpOptionsProvider, } from './server/options-provider.js';
11
- export { REQUEST_ID_HEADER } from './server/request-id.js';
12
- export { TRACEPARENT_HEADER, TRACESTATE_HEADER, TraceContext, type Trace, } from './server/trace-context.js';
11
+ export { TRACEPARENT_HEADER, TRACERESPONSE_HEADER, TRACESTATE_HEADER, TraceContext, type Trace, } from './server/trace-context.js';
13
12
  export { RequestLoggingMiddleware, type RequestLoggingOptions, } from './server/request-logging.js';
13
+ export { MetricsMiddleware, RequestMetrics, UNMATCHED_ROUTE, type HttpStatsReport, type RouteStats, } from './server/metrics.js';
14
14
  export type { Middleware, Next, RouteHandler } from './server/middleware.js';
15
15
  export type { AppSettings } from './server/settings.js';
16
16
  export { StaticFiles } from './static/files.js';
package/dist/index.js CHANGED
@@ -1,9 +1,4 @@
1
1
  // @bun
2
- import {
3
- TRACEPARENT_HEADER,
4
- TRACESTATE_HEADER,
5
- TraceContext
6
- } from "./chunk-25g22350.js";
7
2
  import {
8
3
  ApiHidden,
9
4
  CompressionEncoding,
@@ -26,11 +21,9 @@ import {
26
21
  Post,
27
22
  Public,
28
23
  Put,
29
- REQUEST_ID_HEADER,
30
24
  ROLES,
31
25
  RawBody,
32
26
  RedisRelay,
33
- RequestIds,
34
27
  Roles,
35
28
  StaticOptions,
36
29
  UNMATCHED,
@@ -61,14 +54,18 @@ import {
61
54
  observe,
62
55
  toErrorMapper,
63
56
  withUpgradeRoutes
64
- } from "./chunk-53cs6qek.js";
57
+ } from "./chunk-y85wcdhw.js";
65
58
  import {
66
59
  HttpStatusCode,
60
+ TRACEPARENT_HEADER,
61
+ TRACERESPONSE_HEADER,
62
+ TRACESTATE_HEADER,
63
+ TraceContext,
67
64
  __decorateElement,
68
65
  __decoratorMetadata,
69
66
  __decoratorStart,
70
67
  __runInitializers
71
- } from "./chunk-sz4pvqxy.js";
68
+ } from "./chunk-9x3evk19.js";
72
69
  // src/server/client-address.ts
73
70
  import { AppError } from "@dunx/core";
74
71
  var trustedHops = (setting) => {
@@ -112,6 +109,98 @@ import {
112
109
  RequestContext as RequestContext3
113
110
  } from "@dunx/core";
114
111
 
112
+ // src/server/metrics.ts
113
+ import { Durations } from "@dunx/core";
114
+ var UNMATCHED_ROUTE = "(unmatched)";
115
+ var seriesFor = (route, method) => ({
116
+ route,
117
+ method,
118
+ count: 0,
119
+ byStatus: {},
120
+ duration: new Durations,
121
+ slowestNs: 0,
122
+ slowestTraceId: undefined
123
+ });
124
+
125
+ class RequestMetrics {
126
+ #series = new Map;
127
+ #unmatched = new Map;
128
+ #since = new Date;
129
+ #server;
130
+ observe(ctx, status, durationNs, traceId) {
131
+ let series = this.#series.get(ctx);
132
+ if (series === undefined) {
133
+ if (ctx.get(UNMATCHED) === true) {
134
+ series = this.#unmatched.get(ctx.method);
135
+ if (series === undefined) {
136
+ series = seriesFor(UNMATCHED_ROUTE, ctx.method);
137
+ this.#unmatched.set(ctx.method, series);
138
+ }
139
+ } else {
140
+ series = seriesFor(ctx.path, ctx.method);
141
+ this.#series.set(ctx, series);
142
+ }
143
+ }
144
+ series.count += 1;
145
+ const key = String(status);
146
+ series.byStatus[key] = (series.byStatus[key] ?? 0) + 1;
147
+ series.duration.record(durationNs);
148
+ if (durationNs > series.slowestNs) {
149
+ series.slowestNs = durationNs;
150
+ series.slowestTraceId = traceId;
151
+ }
152
+ }
153
+ snapshot() {
154
+ const routes = [];
155
+ for (const series of [
156
+ ...this.#series.values(),
157
+ ...this.#unmatched.values()
158
+ ]) {
159
+ routes.push({
160
+ route: series.route,
161
+ method: series.method,
162
+ count: series.count,
163
+ byStatus: { ...series.byStatus },
164
+ duration: series.duration.snapshot(),
165
+ ...series.slowestTraceId === undefined ? {} : { slowestTraceId: series.slowestTraceId }
166
+ });
167
+ }
168
+ return {
169
+ routes,
170
+ inFlight: this.#server?.pendingRequests ?? 0,
171
+ pendingWebSockets: this.#server?.pendingWebSockets ?? 0,
172
+ since: this.#since.toISOString()
173
+ };
174
+ }
175
+ reset() {
176
+ this.#series.clear();
177
+ this.#unmatched.clear();
178
+ this.#since = new Date;
179
+ }
180
+ attach(server) {
181
+ this.#server = server;
182
+ }
183
+ }
184
+ var usesMetricsMiddleware = (options) => options.metrics === true && options.requestLogging === false;
185
+
186
+ class MetricsMiddleware {
187
+ metrics;
188
+ constructor(metrics) {
189
+ this.metrics = metrics;
190
+ }
191
+ handle(_req, ctx, next) {
192
+ const started = Bun.nanoseconds();
193
+ return next().then((response) => {
194
+ this.metrics.observe(ctx, response.status, Bun.nanoseconds() - started);
195
+ return response;
196
+ }, (error) => {
197
+ this.metrics.observe(ctx, error instanceof HttpError ? error.status : HttpStatusCode.INTERNAL_SERVER_ERROR, Bun.nanoseconds() - started);
198
+ throw error;
199
+ });
200
+ }
201
+ }
202
+ Object.defineProperty(MetricsMiddleware, Symbol.for("dunx.deps"), { value: () => [RequestMetrics] });
203
+
115
204
  // src/ws/logging.ts
116
205
  import { Logger, LogLevel, RequestContext } from "@dunx/core";
117
206
  var LIFECYCLE_LABEL = {
@@ -378,7 +467,9 @@ class RequestLoggingMiddleware {
378
467
  #correlateIgnored;
379
468
  #correlate;
380
469
  #trace;
381
- constructor(logger, context, options = {}) {
470
+ #traceResponse;
471
+ #metrics;
472
+ constructor(logger, context, options = {}, metrics) {
382
473
  this.logger = logger;
383
474
  this.context = context;
384
475
  this.#limit = options.maxBodyLength ?? 2048;
@@ -388,7 +479,9 @@ class RequestLoggingMiddleware {
388
479
  this.#ignorePrefix = options.ignorePrefix ?? [];
389
480
  this.#correlateIgnored = options.correlateIgnored ?? false;
390
481
  this.#correlate = options.correlate ?? true;
391
- this.#trace = options.trace ?? false;
482
+ this.#trace = options.trace ?? true;
483
+ this.#traceResponse = options.traceResponse ?? true;
484
+ this.#metrics = metrics;
392
485
  }
393
486
  #ignored(path) {
394
487
  if (this.#ignore.size > 0 && this.#ignore.has(path))
@@ -403,28 +496,32 @@ class RequestLoggingMiddleware {
403
496
  const mark = from === -1 ? -1 : url.indexOf("?", from);
404
497
  const path = from === -1 ? "/" : mark === -1 ? url.slice(from) : url.slice(from, mark);
405
498
  if (this.#ignored(path)) {
499
+ if (this.#metrics !== undefined) {
500
+ return this.#ignoredWithMetrics(req, ctx, path, next);
501
+ }
406
502
  return this.#correlateIgnored ? this.#correlated(req, ctx, path, next) : next();
407
503
  }
408
504
  const started = Bun.nanoseconds();
409
- const requestId = RequestIds.assign(req);
410
505
  const scope = {
411
- requestId,
412
506
  method: ctx.method,
413
507
  event: path,
414
508
  flow: "http",
415
509
  context: `${ctx.controller}.${ctx.handler}`
416
510
  };
417
511
  if (this.#trace) {
418
- const trace = TraceContext.adopt(req, requestId);
512
+ const trace = TraceContext.adopt(req, this.#traceResponse);
419
513
  scope.traceId = trace.traceId;
420
514
  scope.spanId = trace.spanId;
515
+ scope.traceFlags = trace.flags;
421
516
  if (trace.parentSpanId !== undefined) {
422
517
  scope.parentSpanId = trace.parentSpanId;
423
518
  }
519
+ if (trace.state !== undefined)
520
+ scope.traceState = trace.state;
424
521
  }
425
- return this.#correlate ? this.context.runWithContext(scope, () => this.#begin(req, ctx, url, mark, path, requestId, started, next, undefined)) : this.#begin(req, ctx, url, mark, path, requestId, started, next, scope);
522
+ return this.#correlate ? this.context.runWithContext(scope, () => this.#begin(req, ctx, url, mark, path, started, next, undefined)) : this.#begin(req, ctx, url, mark, path, started, next, scope);
426
523
  }
427
- #begin(req, ctx, url, mark, path, requestId, started, next, scope) {
524
+ #begin(req, ctx, url, mark, path, started, next, scope) {
428
525
  const request = {};
429
526
  if (mark !== -1) {
430
527
  request["query"] = Object.fromEntries(new URLSearchParams(url.slice(mark + 1)));
@@ -432,13 +529,13 @@ class RequestLoggingMiddleware {
432
529
  const body = this.#body(req, ctx);
433
530
  if (body === undefined) {
434
531
  request["userAgent"] = req.headers.get("user-agent");
435
- return this.#dispatch(req, path, requestId, started, request, next, scope);
532
+ return this.#dispatch(req, ctx, path, started, request, next, scope);
436
533
  }
437
534
  return body.then((value) => {
438
535
  if (value !== undefined)
439
536
  request["body"] = value;
440
537
  request["userAgent"] = req.headers.get("user-agent");
441
- return this.#dispatch(req, path, requestId, started, request, next, scope);
538
+ return this.#dispatch(req, ctx, path, started, request, next, scope);
442
539
  });
443
540
  }
444
541
  #shared(req, request) {
@@ -453,36 +550,56 @@ class RequestLoggingMiddleware {
453
550
  if (value !== undefined)
454
551
  request["body"] = value;
455
552
  }
456
- #correlated(req, ctx, path, next) {
457
- const requestId = RequestIds.assign(req);
458
- const stamp = (response) => {
459
- response.headers.set(REQUEST_ID_HEADER, requestId);
460
- return response;
553
+ #ignoredWithMetrics(req, ctx, path, next) {
554
+ const started = Bun.nanoseconds();
555
+ const failed = (error) => {
556
+ this.#observe(req, ctx, error instanceof HttpError ? error.status : HttpStatusCode.INTERNAL_SERVER_ERROR, started);
557
+ throw error;
461
558
  };
559
+ let settled;
560
+ try {
561
+ settled = this.#correlateIgnored ? this.#correlated(req, ctx, path, next) : next();
562
+ } catch (error) {
563
+ return failed(error);
564
+ }
565
+ return settled.then((response) => {
566
+ this.#observe(req, ctx, response.status, started);
567
+ return response;
568
+ }, failed);
569
+ }
570
+ #correlated(req, ctx, path, next) {
571
+ const trace = this.#trace ? TraceContext.adopt(req, this.#traceResponse) : undefined;
572
+ const stamp = (response) => trace === undefined ? response : TraceContext.stamp(response, req);
462
573
  if (!this.#correlate)
463
574
  return next().then(stamp);
464
575
  return this.context.runWithContext({
465
- requestId,
576
+ ...trace === undefined ? {} : {
577
+ traceId: trace.traceId,
578
+ spanId: trace.spanId,
579
+ traceFlags: trace.flags,
580
+ ...trace.parentSpanId === undefined ? {} : { parentSpanId: trace.parentSpanId },
581
+ ...trace.state === undefined ? {} : { traceState: trace.state }
582
+ },
466
583
  method: ctx.method,
467
584
  event: path,
468
585
  flow: "http",
469
586
  context: `${ctx.controller}.${ctx.handler}`
470
587
  }, () => next().then(stamp));
471
588
  }
472
- #dispatch(req, path, requestId, started, request, next, scope) {
589
+ #dispatch(req, ctx, path, started, request, next, scope) {
473
590
  let settled;
474
591
  try {
475
592
  settled = next();
476
593
  } catch (error) {
477
- this.#failed(req, path, started, request, error, scope);
594
+ this.#failed(req, ctx, path, started, request, error, scope);
478
595
  throw error;
479
596
  }
480
- return settled.then((response) => this.#succeeded(req, path, requestId, started, request, response, scope), (error) => {
481
- this.#failed(req, path, started, request, error, scope);
597
+ return settled.then((response) => this.#succeeded(req, ctx, path, started, request, response, scope), (error) => {
598
+ this.#failed(req, ctx, path, started, request, error, scope);
482
599
  throw error;
483
600
  });
484
601
  }
485
- #failed(req, path, started, request, error, scope) {
602
+ #failed(req, ctx, path, started, request, error, scope) {
486
603
  this.#shared(req, request);
487
604
  const status = error instanceof HttpError ? error.status : HttpStatusCode.INTERNAL_SERVER_ERROR;
488
605
  const entry = {
@@ -492,6 +609,7 @@ class RequestLoggingMiddleware {
492
609
  statusCode: status,
493
610
  elapsedMs: elapsedMs2(started)
494
611
  };
612
+ this.#observe(req, ctx, status, started);
495
613
  const line = `${req.method} ${path} ${status}`;
496
614
  if (status < HttpStatusCode.INTERNAL_SERVER_ERROR) {
497
615
  this.logger.warn(line, entry);
@@ -499,8 +617,9 @@ class RequestLoggingMiddleware {
499
617
  this.logger.error(line, entry);
500
618
  }
501
619
  }
502
- #succeeded(req, path, requestId, started, request, response, scope) {
620
+ #succeeded(req, ctx, path, started, request, response, scope) {
503
621
  this.#shared(req, request);
622
+ this.#observe(req, ctx, response.status, started);
504
623
  const body = this.#responseFields(response);
505
624
  if (body === undefined) {
506
625
  this.logger.info(`${req.method} ${path} ${response.status}`, {
@@ -509,8 +628,7 @@ class RequestLoggingMiddleware {
509
628
  statusCode: response.status,
510
629
  elapsedMs: elapsedMs2(started)
511
630
  });
512
- response.headers.set(REQUEST_ID_HEADER, requestId);
513
- return response;
631
+ return TraceContext.stamp(response, req);
514
632
  }
515
633
  return body.then((value) => {
516
634
  this.logger.info(`${req.method} ${path} ${response.status}`, {
@@ -520,10 +638,14 @@ class RequestLoggingMiddleware {
520
638
  ...value === undefined ? {} : { responseBody: value },
521
639
  elapsedMs: elapsedMs2(started)
522
640
  });
523
- response.headers.set(REQUEST_ID_HEADER, requestId);
524
- return response;
641
+ return TraceContext.stamp(response, req);
525
642
  });
526
643
  }
644
+ #observe(req, ctx, status, started) {
645
+ if (this.#metrics === undefined)
646
+ return;
647
+ this.#metrics.observe(ctx, status, Bun.nanoseconds() - started, TraceContext.of(req)?.traceId);
648
+ }
527
649
  #body(req, ctx) {
528
650
  if (!this.#requestBody)
529
651
  return;
@@ -547,7 +669,7 @@ class RequestLoggingMiddleware {
547
669
  return response.clone().text().then((text) => parse(text, this.#limit));
548
670
  }
549
671
  }
550
- Object.defineProperty(RequestLoggingMiddleware, Symbol.for("dunx.deps"), { value: () => [Logger2, RequestContext2, { unresolved: "options: RequestLoggingOptions = {}", optional: true }] });
672
+ Object.defineProperty(RequestLoggingMiddleware, Symbol.for("dunx.deps"), { value: () => [Logger2, RequestContext2, { unresolved: "options: RequestLoggingOptions = {}", optional: true }, { unresolved: "metrics?: RequestMetrics", typeOnly: "RequestMetrics" }] });
551
673
 
552
674
  // src/server/settings.ts
553
675
  var defaultSettings = () => ({ "trust proxy": false });
@@ -584,6 +706,7 @@ class HttpApplication {
584
706
  this.#discovered = discovered;
585
707
  this.#middleware = [
586
708
  ...options.requestLogging === false ? [] : [RequestLoggingMiddleware],
709
+ ...usesMetricsMiddleware(options) ? [MetricsMiddleware] : [],
587
710
  ...options.middleware ?? []
588
711
  ];
589
712
  this.#onError = options.onError === undefined ? errorMapper(app.get(Logger3)) : toErrorMapper(options.onError, (token) => app.get(token, root));
@@ -666,6 +789,7 @@ class HttpApplication {
666
789
  server: this.#server,
667
790
  trustProxy: this.#settings["trust proxy"]
668
791
  });
792
+ this.#app.get(RequestMetrics).attach(this.#server);
669
793
  const pubsub = this.#app.get(PubSub);
670
794
  pubsub.attach(this.#server);
671
795
  if (this.#relay) {
@@ -776,6 +900,9 @@ class HttpOptionsProvider {
776
900
  get socketLogging() {
777
901
  return true;
778
902
  }
903
+ get metrics() {
904
+ return false;
905
+ }
779
906
  get onError() {
780
907
  return;
781
908
  }
@@ -806,6 +933,7 @@ function resolveHttpOptions(settings, given) {
806
933
  cors: settings.cors,
807
934
  requestLogging: settings.requestLogging,
808
935
  socketLogging: settings.socketLogging,
936
+ metrics: settings.metrics,
809
937
  onError: settings.onError,
810
938
  websocket: settings.websocket,
811
939
  relay: settings.relay,
@@ -828,15 +956,24 @@ var pick = (given, fallback) => {
828
956
  class HttpFactory {
829
957
  static async create(root, options = {}) {
830
958
  const logging = provide(RequestLoggingMiddleware, {
831
- useFactory: (logger, context, settings) => new RequestLoggingMiddleware(logger, context, pick(options.requestLogging, settings.requestLogging)),
832
- inject: [Logger4, RequestContext3, HttpOptionsProvider]
959
+ useFactory: (logger, context, settings, metrics) => new RequestLoggingMiddleware(logger, context, pick(options.requestLogging, settings.requestLogging), options.metrics ?? settings.metrics ? metrics : undefined),
960
+ inject: [
961
+ Logger4,
962
+ RequestContext3,
963
+ HttpOptionsProvider,
964
+ RequestMetrics
965
+ ]
966
+ });
967
+ const metricsMiddleware = provide(MetricsMiddleware, {
968
+ useFactory: (metrics) => new MetricsMiddleware(metrics),
969
+ inject: [RequestMetrics]
833
970
  });
834
971
  const socketLogging = provide(SocketLoggingMiddleware, {
835
972
  useFactory: (logger, context, settings) => new SocketLoggingMiddleware(logger, context, pick(options.socketLogging, settings.socketLogging)),
836
973
  inject: [Logger4, RequestContext3, HttpOptionsProvider]
837
974
  });
838
- const services = [PubSub, ClientAddress];
839
- const providers = [...services, logging, socketLogging];
975
+ const services = [PubSub, ClientAddress, RequestMetrics];
976
+ const providers = [...services, logging, metricsMiddleware, socketLogging];
840
977
  const scope = {
841
978
  module: HttpModule,
842
979
  global: true,
@@ -1819,6 +1956,7 @@ export {
1819
1956
  MemoryIndicator,
1820
1957
  MemoryOptions,
1821
1958
  MemoryThrottleStore,
1959
+ MetricsMiddleware,
1822
1960
  OnClose,
1823
1961
  OnDrain,
1824
1962
  OnMessage,
@@ -1836,7 +1974,6 @@ export {
1836
1974
  Public,
1837
1975
  Put,
1838
1976
  QueryProbe,
1839
- REQUEST_ID_HEADER,
1840
1977
  ROLES,
1841
1978
  Readiness,
1842
1979
  ReadinessOptions,
@@ -1845,6 +1982,7 @@ export {
1845
1982
  RedisThrottleStore,
1846
1983
  RelayConnectionOptions,
1847
1984
  RequestLoggingMiddleware,
1985
+ RequestMetrics,
1848
1986
  Roles,
1849
1987
  SKIP_THROTTLE,
1850
1988
  SkipThrottle,
@@ -1854,6 +1992,7 @@ export {
1854
1992
  StaticOptions,
1855
1993
  THROTTLE,
1856
1994
  TRACEPARENT_HEADER,
1995
+ TRACERESPONSE_HEADER,
1857
1996
  TRACESTATE_HEADER,
1858
1997
  Throttle,
1859
1998
  ThrottleGuard,
@@ -1862,6 +2001,7 @@ export {
1862
2001
  ThrottleStore,
1863
2002
  TraceContext,
1864
2003
  UNMATCHED,
2004
+ UNMATCHED_ROUTE,
1865
2005
  UseGuards,
1866
2006
  ValidationError,
1867
2007
  WsRelay,
package/dist/internal.js CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  toErrorMapper,
37
37
  withCors,
38
38
  withUpgradeRoutes
39
- } from "./chunk-53cs6qek.js";
39
+ } from "./chunk-y85wcdhw.js";
40
40
  import {
41
41
  backoffDelay,
42
42
  executeWithRetry,
@@ -45,8 +45,8 @@ import {
45
45
  isRetryableStatus,
46
46
  retryAfterMs,
47
47
  safeStringify
48
- } from "./chunk-7xrtwbx3.js";
49
- import"./chunk-sz4pvqxy.js";
48
+ } from "./chunk-e8a9c6j2.js";
49
+ import"./chunk-9x3evk19.js";
50
50
  // src/inspect.ts
51
51
  import {
52
52
  collectModules,
@@ -50,6 +50,14 @@ export interface HttpOptions extends AppOptions {
50
50
  * See {@link RequestLoggingMiddleware}.
51
51
  */
52
52
  readonly requestLogging?: boolean | RequestLoggingOptions;
53
+ /**
54
+ * Count requests and time them per route, readable through
55
+ * {@link RequestMetrics}. Off by default; `+35.2 ns` per request when
56
+ * `requestLogging` is on, because the entry it already builds shares the
57
+ * timing. With `requestLogging: false` a `MetricsMiddleware` pays for its own
58
+ * `.then` instead, at +175.9 ns.
59
+ */
60
+ readonly metrics?: boolean;
53
61
  /**
54
62
  * One entry at `listen()` naming every route and gateway served. On by default,
55
63
  * and switched separately from `requestLogging`: one is per process, the other
@@ -0,0 +1,80 @@
1
+ import { type HistogramSnapshot } from '@dunx/core';
2
+ import type { BunRequest, Server } from 'bun';
3
+ import type { RouteContext } from './context.js';
4
+ import type { Middleware, Next } from './middleware.js';
5
+ /** Every path Bun matched nothing for, collapsed into one series. */
6
+ export declare const UNMATCHED_ROUTE = "(unmatched)";
7
+ export interface RouteStats {
8
+ /** The route pattern, so `/users/1` and `/users/2` share one series. */
9
+ readonly route: string;
10
+ readonly method: string;
11
+ readonly count: number;
12
+ /** Keyed by status code as a string, because that is what JSON gives back. */
13
+ readonly byStatus: Readonly<Record<string, number>>;
14
+ /** Nanoseconds. */
15
+ readonly duration: HistogramSnapshot;
16
+ /**
17
+ * The trace of the slowest request on this route so far, which is the only
18
+ * question a p99 provokes: which request was it, and where are its logs.
19
+ */
20
+ readonly slowestTraceId?: string;
21
+ }
22
+ export interface HttpStatsReport {
23
+ readonly routes: readonly RouteStats[];
24
+ /** Read off `Bun.serve` at 14.7 ns rather than counted, so dunx counts nothing. */
25
+ readonly inFlight: number;
26
+ readonly pendingWebSockets: number;
27
+ /** When the counters were last reset, or boot. */
28
+ readonly since: string;
29
+ }
30
+ /**
31
+ * One series per route, keyed on the frozen `RouteContext` that `buildContext`
32
+ * makes once at boot. That object identity is the label set: a `Map` lookup on it
33
+ * is 8.8 ns, where building `${method} ${path}` and hashing it is 206.6 ns.
34
+ *
35
+ * Series count is bounded by the handler count, because `ctx.path` is the route
36
+ * pattern rather than the request's path.
37
+ *
38
+ * Bound by `HttpFactory`'s global wrapper, like `PubSub` and `ClientAddress`: an
39
+ * unbound class self-binds into whichever scope asks first, so a second consumer
40
+ * would be a boot error.
41
+ *
42
+ * `observe` takes everything as parameters and reads no ambient store, which is
43
+ * what keeps it at 35.2 ns folded into the `.then` request logging already
44
+ * allocates.
45
+ */
46
+ export declare class RequestMetrics {
47
+ #private;
48
+ observe(ctx: RouteContext, status: number, durationNs: number, traceId?: string): void;
49
+ snapshot(): HttpStatsReport;
50
+ /**
51
+ * Drops every series rather than zeroing them, so a route that stopped being
52
+ * called stops being reported. A cumulative histogram over a week has a p99
53
+ * reflecting a deploy three days ago; who calls this is the app's decision.
54
+ */
55
+ reset(): void;
56
+ /** Internal: `listen()` hands the bound server to the resolved singleton. */
57
+ attach(server: Server<unknown>): void;
58
+ }
59
+ /**
60
+ * Whether `MetricsMiddleware` is the thing doing the observing.
61
+ *
62
+ * With request logging on - the default - `RequestLoggingMiddleware` observes
63
+ * from the `.then` it already allocates and this middleware would double-count.
64
+ * Exported because `HttpFactory` binds it and `HttpApplication` installs it, and
65
+ * the two disagreeing would mean either no metrics or twice as many.
66
+ */
67
+ export declare const usesMetricsMiddleware: (options: {
68
+ readonly metrics?: boolean;
69
+ readonly requestLogging?: unknown;
70
+ }) => boolean;
71
+ /**
72
+ * Installed by `HttpFactory` only when `requestLogging: false`. With logging on -
73
+ * the default - `RequestLoggingMiddleware` calls `observe` from the `.then` it
74
+ * already allocates, at 35.2 ns against this middleware's 175.9 ns standalone.
75
+ */
76
+ export declare class MetricsMiddleware implements Middleware {
77
+ private readonly metrics;
78
+ constructor(metrics: RequestMetrics);
79
+ handle(_req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
80
+ }
@@ -75,6 +75,8 @@ export declare abstract class HttpOptionsProvider {
75
75
  /** `false` removes the middleware from the chain; an object tunes it. */
76
76
  get requestLogging(): boolean | RequestLoggingOptions;
77
77
  get socketLogging(): boolean | SocketLoggingOptions;
78
+ /** Per-route counts and timings. Off by default; see {@link RequestMetrics}. */
79
+ get metrics(): boolean;
78
80
  /**
79
81
  * Replaces the default mapper. Prefer an `ErrorFilter` class over a bare
80
82
  * function: a class is resolved from the container and can inject.
@@ -2,6 +2,7 @@ import { Logger, RequestContext } from '@dunx/core';
2
2
  import type { BunRequest } from 'bun';
3
3
  import type { RouteContext } from './context.js';
4
4
  import type { Middleware, Next } from './middleware.js';
5
+ import type { RequestMetrics } from './metrics.js';
5
6
  export interface RequestLoggingOptions {
6
7
  /** Bodies past this many characters are logged as a size. Default 2048. `0` omits them. */
7
8
  readonly maxBodyLength?: number;
@@ -17,7 +18,7 @@ export interface RequestLoggingOptions {
17
18
  * materialised string by the time this clones it. */
18
19
  readonly responseBody?: boolean;
19
20
  /**
20
- * Paths to skip entirely: no entry, no `x-request-id`, and no
21
+ * Paths to skip entirely: no entry, no trace, no `traceresponse`, and no
21
22
  * `AsyncLocalStorage` scope, so anything the handler logs is uncorrelated.
22
23
  * `correlateIgnored` buys the correlation back.
23
24
  */
@@ -33,24 +34,37 @@ export interface RequestLoggingOptions {
33
34
  */
34
35
  readonly ignorePrefix?: readonly string[];
35
36
  /**
36
- * Keep the request id and the async scope on an `ignore`d path. Default
37
- * `false`. The path still writes no entry; it gets an id on the response and
38
- * everything the handler logs carries it. Costs ~2.2 us of the ~5.4 us the
39
- * default path spends.
37
+ * Keep the trace and the async scope on an `ignore`d path. Default `false`.
38
+ * The path still writes no entry; it gets a `traceresponse` and everything the
39
+ * handler logs carries the trace. Costs ~2.2 us of the ~5.4 us the default path
40
+ * spends.
40
41
  */
41
42
  readonly correlateIgnored?: boolean;
42
43
  /**
43
44
  * Wrap every request in an `AsyncLocalStorage` scope. Default `true`, +0.91 us.
44
- * It is what lets a service four frames down log `requestId` without being
45
+ * It is what lets a service four frames down log `traceId` without being
45
46
  * handed a request. `correlate: false` skips it; this middleware's own entry is
46
- * unchanged, but every other line the request writes loses its id.
47
+ * unchanged, but every other line the request writes loses its trace.
47
48
  */
48
49
  readonly correlate?: boolean;
49
50
  /**
50
- * Adopt W3C Trace Context, so `traceId`, `spanId` and `parentSpanId` join
51
- * `requestId`. Default `false`: it costs a header read and 8 random bytes, and
52
- * `requestId` already spans two dunx services. `@dunx/http/client` sends the
53
- * adopted trace upstream.
51
+ * Put `traceresponse` on the response. Default `true`, and ~500 ns of the 4.7 us
52
+ * the path costs, which is the largest thing here that can go without losing a
53
+ * field from a line.
54
+ *
55
+ * `false` keeps the trace on this middleware's own lines, in the async scope and
56
+ * on the metrics exemplar, and withholds the header from every response
57
+ * including a failure's: the error mapper stamps from what `TraceContext.adopt`
58
+ * marked, and this stops it marking.
59
+ */
60
+ readonly traceResponse?: boolean;
61
+ /**
62
+ * Adopt W3C Trace Context, putting `traceId`, `spanId`, `parentSpanId` and
63
+ * `traceFlags` on every line the request writes and `traceresponse` on its
64
+ * response. Default `true`, at 49.2 ns to mint both ids plus one header read.
65
+ * `@dunx/http/client` sends the adopted trace upstream.
66
+ *
67
+ * `false` removes it, and a request then carries no correlation id at all.
54
68
  */
55
69
  readonly trace?: boolean;
56
70
  }
@@ -70,6 +84,6 @@ export declare class RequestLoggingMiddleware implements Middleware {
70
84
  #private;
71
85
  private readonly logger;
72
86
  private readonly context;
73
- constructor(logger: Logger, context: RequestContext, options?: RequestLoggingOptions);
87
+ constructor(logger: Logger, context: RequestContext, options?: RequestLoggingOptions, metrics?: RequestMetrics);
74
88
  handle(req: BunRequest, ctx: RouteContext, next: Next): Promise<Response>;
75
89
  }
@@ -1,5 +1,17 @@
1
1
  export declare const TRACEPARENT_HEADER = "traceparent";
2
2
  export declare const TRACESTATE_HEADER = "tracestate";
3
+ /**
4
+ * The span that answered, sent back so a caller can record which of the callee's
5
+ * spans its own span points at. Same four fields as `traceparent`, and the
6
+ * the one correlation id a response carries.
7
+ *
8
+ * A W3C Distributed Tracing Working Group proposal rather than a ratified
9
+ * standard: `traceparent` and `tracestate` are the Recommendation, and the
10
+ * published Trace Context Level 2 Candidate Recommendation Draft covers those two
11
+ * request headers and not this response one. The format is specified and stable,
12
+ * and adoption is thin, so treat a caller reading it as a bonus.
13
+ */
14
+ export declare const TRACERESPONSE_HEADER = "traceresponse";
3
15
  export interface Trace {
4
16
  /** 32 hex digits, shared by every span in the trace. */
5
17
  readonly traceId: string;
@@ -15,14 +27,18 @@ export interface Trace {
15
27
  /**
16
28
  * W3C Trace Context, propagated across services.
17
29
  *
18
- * The whole of it is one header parsed and one header written. There is no
19
- * exporter, no sampler and no dependency: what this buys is that every log line a
20
- * request writes carries the same `traceId` the service upstream logged, so the
21
- * two can be joined without either of them running a collector.
30
+ * The whole of it is one header parsed and two written. There is no exporter, no
31
+ * sampler and no dependency: every log line a request writes carries the same
32
+ * `traceId` the service upstream logged, so the two join without either of them
33
+ * running a collector.
34
+ *
35
+ * `traceId`, `spanId` and `parentSpanId` are the OpenTelemetry log data model's
36
+ * own fields, so a collector that ingests these lines correlates them with spans
37
+ * emitted by anything else speaking the standard. Bun 1.4.0 runs OpenTelemetry's
38
+ * Node instrumentation, and a trace adopted here is the trace those spans join.
22
39
  *
23
- * `@dunx/http` does not turn this on by itself - `requestLogging: { trace: true }`
24
- * does. Adopting a trace costs a header read and 8 random bytes on every request,
25
- * which is not worth spending in a service that has nothing to correlate with.
40
+ * On by default. `requestLogging: { trace: false }` removes it, at which point a
41
+ * request carries no correlation id at all.
26
42
  */
27
43
  export declare class TraceContext {
28
44
  #private;
@@ -32,10 +48,15 @@ export declare class TraceContext {
32
48
  * higher version keeps its first four fields, so a future format still
33
49
  * propagates.
34
50
  *
35
- * With nothing inbound, `traceId` is the request id minus its hyphens - a UUID
36
- * is 16 bytes, exactly a trace id, so there is no second `crypto` call.
51
+ * A trace that arrived is continued with a span of this server's own, and the
52
+ * caller's sampling decision is kept rather than overridden.
53
+ *
54
+ * `expose: false` adopts the trace without marking it for {@link stamp}, so no
55
+ * `traceresponse` is written - by this middleware or by the error mapper, which
56
+ * builds its own `Response` from what was recorded here. Everything inward is
57
+ * unchanged: the scope, the log lines, the metrics exemplar.
37
58
  */
38
- static adopt(req: Request, requestId: string): Trace;
59
+ static adopt(req: Request, expose?: boolean): Trace;
39
60
  /** The trace adopted for this request, if one was. */
40
61
  static of(req: Request): Trace | undefined;
41
62
  /**
@@ -43,5 +64,15 @@ export declare class TraceContext {
43
64
  * parent, so the two link without inventing a span nothing logged.
44
65
  */
45
66
  static header(trace: Pick<Trace, 'traceId' | 'spanId' | 'flags'>): string;
67
+ /**
68
+ * The response, carrying `traceresponse` if this request adopted a trace.
69
+ *
70
+ * The logging middleware sets the header on a response it returns, and a
71
+ * failure is never one: the error mapper builds a fresh `Response` outside the
72
+ * chain, so a guard's 401, a validation 400 and every unmatched 404 would go out
73
+ * bare. Read back from the request rather than threaded through the mapper,
74
+ * which an app writes its own of.
75
+ */
76
+ static stamp(response: Response, req: Request): Response;
46
77
  static sampled(trace: Pick<Trace, 'flags'>): boolean;
47
78
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/http",
3
- "version": "3.1.3",
3
+ "version": "3.2.0",
4
4
  "description": "Bun.serve adapter for the dunx framework: controllers, middleware and WebSocket gateways",
5
5
  "keywords": [
6
6
  "bun",
@@ -56,13 +56,16 @@
56
56
  "typecheck": "tsc --noEmit"
57
57
  },
58
58
  "dependencies": {
59
- "@arkv/shared": "^0.8.0"
59
+ "@arkv/shared": "0.8.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@dunx/core": "workspace:*"
62
+ "@dunx/core": "workspace:*",
63
+ "@opentelemetry/api": "1.9.1",
64
+ "@opentelemetry/core": "2.11.0",
65
+ "@opentelemetry/sdk-trace-node": "2.11.0"
63
66
  },
64
67
  "peerDependencies": {
65
- "@dunx/core": "^3.1.3",
68
+ "@dunx/core": "^3.2.0",
66
69
  "@types/bun": ">=1.3.0"
67
70
  },
68
71
  "peerDependenciesMeta": {
@@ -1,58 +0,0 @@
1
- // @bun
2
- // src/server/trace-context.ts
3
- var TRACEPARENT_HEADER = "traceparent";
4
- var TRACESTATE_HEADER = "tracestate";
5
- var HEX_32 = /^[0-9a-f]{32}$/;
6
- var HEX_16 = /^[0-9a-f]{16}$/;
7
- var HEX_2 = /^[0-9a-f]{2}$/;
8
- var ZERO_TRACE = "0".repeat(32);
9
- var ZERO_SPAN = "0".repeat(16);
10
- var SAMPLED = 1;
11
- var TRACE = Symbol.for("dunx.http.trace");
12
- var mintSpanId = () => Buffer.from(crypto.getRandomValues(new Uint8Array(8))).toString("hex");
13
-
14
- class TraceContext {
15
- static adopt(req, requestId) {
16
- const inbound = TraceContext.#parse(req.headers.get(TRACEPARENT_HEADER));
17
- const state = req.headers.get(TRACESTATE_HEADER);
18
- const trace = {
19
- traceId: inbound?.traceId ?? requestId.replaceAll("-", ""),
20
- spanId: mintSpanId(),
21
- ...inbound === undefined ? {} : { parentSpanId: inbound.spanId },
22
- flags: inbound?.flags ?? "01",
23
- ...inbound !== undefined && state !== null ? { state } : {}
24
- };
25
- req[TRACE] = trace;
26
- return trace;
27
- }
28
- static of(req) {
29
- return req[TRACE];
30
- }
31
- static header(trace) {
32
- return `00-${trace.traceId}-${trace.spanId}-${trace.flags}`;
33
- }
34
- static sampled(trace) {
35
- return (Number.parseInt(trace.flags, 16) & SAMPLED) === SAMPLED;
36
- }
37
- static #parse(header) {
38
- if (header === null)
39
- return;
40
- const parts = header.split("-");
41
- if (parts.length < 4)
42
- return;
43
- const [version, traceId, spanId, flags] = parts;
44
- if (!HEX_2.test(version) || version === "ff")
45
- return;
46
- if (version === "00" && parts.length !== 4)
47
- return;
48
- if (!HEX_32.test(traceId) || traceId === ZERO_TRACE)
49
- return;
50
- if (!HEX_16.test(spanId) || spanId === ZERO_SPAN)
51
- return;
52
- if (!HEX_2.test(flags))
53
- return;
54
- return { traceId, spanId, flags };
55
- }
56
- }
57
-
58
- export { TRACEPARENT_HEADER, TRACESTATE_HEADER, TraceContext };
@@ -1,22 +0,0 @@
1
- export declare const REQUEST_ID_HEADER = "x-request-id";
2
- /**
3
- * The request id, and the only thing that decides a request has one.
4
- *
5
- * The logging middleware sets the header on a response it returns, and a failure
6
- * is never one - the error mapper builds a fresh `Response` outside the chain. So
7
- * a guard's 401, a validation 400 and every unmatched 404 went out with no id.
8
- *
9
- * Recorded against the request rather than threaded through the mapper, which an
10
- * app writes its own of. {@link stamp} reads back what {@link assign} recorded, so
11
- * a path nothing minted an id for is still answered without a header.
12
- */
13
- export declare class RequestIds {
14
- /**
15
- * Called by `RequestLoggingMiddleware` and by nothing else. Splitting minting
16
- * from recording would let a second caller invent an id the log line does not
17
- * carry.
18
- */
19
- static assign(req: Request): string;
20
- /** The response, with this request's id on it if it was ever given one. */
21
- static stamp(response: Response, req: Request): Response;
22
- }