@bymax-one/nest-core 1.6.0 → 1.6.2

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/CHANGELOG.md CHANGED
@@ -11,6 +11,139 @@ heading here.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [1.6.2] - 2026-10-22
15
+
16
+ An unexpected error became `500 BYMAX_INTERNAL_ERROR` and the application had no
17
+ way to record what it was. `BymaxCoreModule` builds the filter itself, and the
18
+ only seam — the protected `onUnexpectedError` — is reachable only by subclassing
19
+ a filter the module never lets you substitute. So the access log saw the status
20
+ and nothing else, and a 500 on a read endpoint left no recoverable cause.
21
+
22
+ **This patch adds public surface**, which a patch number cannot tell you: the
23
+ `BYMAX_UNEXPECTED_ERROR_SINK` token and the `IUnexpectedErrorSink` and
24
+ `UnexpectedErrorReport` types. All are additive and nothing is removed or
25
+ renamed, so the digit stays at a patch under this project's versioning policy.
26
+
27
+ **Apply to a derived backend:** nothing is required; with no sink bound the
28
+ filter behaves as before. To record each unexpected error's cause, bind an
29
+ `IUnexpectedErrorSink` under `BYMAX_UNEXPECTED_ERROR_SINK` from a `@Global()`
30
+ module of your own, as the README's "Recording the cause of a 500" shows. A
31
+ backend registered through `forRootAsync` with `telemetry.exposeTraceId` on
32
+ starts serving `traceId` in its error envelopes, which it was configured to do
33
+ and did not; see Fixed.
34
+
35
+ ### Added
36
+
37
+ - **`BYMAX_UNEXPECTED_ERROR_SINK`, `IUnexpectedErrorSink` and
38
+ `UnexpectedErrorReport`.** The filter calls `record(report)` once per
39
+ unexpected error, before replying, with the original thrown value, the
40
+ method, the matched route template (`<unmatched>` before routing), and the
41
+ correlation and trace ids when they resolve. An `HttpException` and an
42
+ exposed `4xx` from the body pipeline never reach it.
43
+
44
+ The report carries no raw URL, query string, headers or body: a log outlives
45
+ the request and a query string is where tokens end up. The thrown value is
46
+ passed uncut whatever `envelope.exposeInternals` says, since that option
47
+ governs the response and the sink is not the response.
48
+
49
+ The response never depends on the sink. A synchronous throw is caught and an
50
+ `async record()` rejection is contained, both silently. The interface is
51
+ structural, so a logging library can ship a compatible class without
52
+ importing this package. The protected `onUnexpectedError` seam is unchanged
53
+ and runs alongside the sink.
54
+
55
+ ### Fixed
56
+
57
+ - **`forRootAsync` never put the trace id in the error envelope.** The async
58
+ filter slot is built by a factory whose injection list did not include
59
+ `BYMAX_TRACE_CONTEXT`, so the filter fell back to its no-op reader: with
60
+ `telemetry.exposeTraceId` on, `forRoot` served `traceId` and `forRootAsync`
61
+ silently did not. The factory now injects every dependency the sync path
62
+ resolves through the class.
63
+
64
+ ## [1.6.1] - 2026-09-05
65
+
66
+ A library contributing an OpenAPI component under a name the application already
67
+ declared had its entry discarded, and neither side could tell. The document
68
+ validated, the served output looked exactly as it would have had nothing been
69
+ offered, and the contributing library received no signal at all — so the failure
70
+ was indistinguishable from success at both ends.
71
+
72
+ Two changes, and they are independent: an empty placeholder no longer counts as a
73
+ declaration, and every genuine collision is now reported.
74
+
75
+ **This patch adds public surface**, which a patch number cannot tell you:
76
+ `discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type it holds.
77
+ Both are additive and nothing is removed or renamed, so the digit stays at a
78
+ patch under this project's versioning policy — which means this paragraph, rather
79
+ than the version, is what says there is something new to look for.
80
+
81
+ **Apply to a derived backend:** nothing is required, and no served document loses
82
+ anything it carried. A schema the application declares with a shape of its own
83
+ still wins, exactly as before. What changes is that a schema present only as
84
+ `{"type":"object","properties":{}}` — which `@nestjs/swagger` emits for every DTO
85
+ in a package built without its plugin — is now filled from the contribution
86
+ rather than preferred over it. Read `discarded` on the `OpenApiMountOutcome` to
87
+ see what a contributor offered and did not place; the boot log reports the same
88
+ entries.
89
+
90
+ ### Added
91
+
92
+ - **`discarded` on `OpenApiMountOutcome`, and the `DiscardedComponent` type.**
93
+ Lists every contributed component entry the document already declared with a
94
+ shape of its own, naming the contributor, the member and the entry. Present
95
+ whenever the document mounted, empty when every contribution was placed — an
96
+ empty array reports that the merge looked and found none, which absence would
97
+ confuse with not having looked.
98
+
99
+ Carries the contributor's label because a collision is only actionable if you
100
+ know whose entry lost: an application with several contributing libraries would
101
+ otherwise be told a name was dropped without being told by whom.
102
+
103
+ A warning reports the same entries on the way up, elided into a count on the
104
+ same reasoning as the unsecured-operation line beside it — a library contributing a
105
+ schema per DTO turns one collision into a hundred, and the version that prints
106
+ them all is the one an operator learns to skip.
107
+
108
+ ### Changed
109
+
110
+ - **A schema that describes no shape no longer counts as a declaration.** The
111
+ merge still prefers what the document carries: a consumer authored theirs on
112
+ purpose, and a library silently overwriting it is the worse defect wearing the
113
+ other sign. But `@nestjs/swagger` emits `{"type":"object","properties":{}}` for
114
+ every DTO in a package built without its plugin, so the name is never actually
115
+ missing and a contributed schema under it would never be written. The more
116
+ correct the library's own schema, the more completely it was discarded, because
117
+ a correct schema and an empty one collide on the same key.
118
+
119
+ This is the rule `mergeResponses` already applied to responses, arriving
120
+ through a different placeholder — a `200` carrying a description and no
121
+ `content` occupies its status the same way. Filling one in is additive: it
122
+ constrains nothing before and describes a shape after.
123
+
124
+ The test is deliberately narrow. `type` must be absent or `object`, because
125
+ `{"type":"string"}` carries no properties and is nonetheless complete;
126
+ reading "no properties" as "no shape" would discard a real schema, which is
127
+ the failure this path exists to prevent. An empty `required` and a
128
+ `description` do not change that; any other keyword, or a single property,
129
+ makes the entry a declaration. A description the document
130
+ wrote survives being filled in, the same carve-out `mergeResponses` makes.
131
+
132
+ The carve-out applies to `schemas` alone. A parameter carries `name` and `in`
133
+ rather than `properties`, so a schema-shaped emptiness test reads every one of
134
+ them as a placeholder and would let a contributor overwrite one the consumer
135
+ wrote — the precedence failure inverted, arriving through the fix for the
136
+ other one.
137
+
138
+ This package's own catalogue, under `openapi.includeCoreSchemas`, is placed by
139
+ the same rule and before any library. A placeholder under a catalogue name is
140
+ filled with the package's definition, so a library offering that name meets a
141
+ declaration and is reported rather than outranking the package.
142
+
143
+ - **`fastify` and `@nestjs/platform-fastify` devDependency floors raised to
144
+ `^5.12.5` and `^11.2.4`.** Shipped in the tarball's manifest only; `dist/` is
145
+ unchanged by them.
146
+
14
147
  ## [1.6.0] - 2026-08-29
15
148
 
16
149
  A readiness check that fails without rejecting left no record an operator would
@@ -1003,4 +1136,6 @@ have regressed from. They are kept because the reasoning is worth having.
1003
1136
  [1.5.2]: https://github.com/bymaxone/nest-core/compare/v1.5.1...v1.5.2
1004
1137
  [1.5.3]: https://github.com/bymaxone/nest-core/compare/v1.5.2...v1.5.3
1005
1138
  [1.6.0]: https://github.com/bymaxone/nest-core/compare/v1.5.3...v1.6.0
1006
- [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.0...HEAD
1139
+ [1.6.1]: https://github.com/bymaxone/nest-core/compare/v1.6.0...v1.6.1
1140
+ [1.6.2]: https://github.com/bymaxone/nest-core/compare/v1.6.1...v1.6.2
1141
+ [Unreleased]: https://github.com/bymaxone/nest-core/compare/v1.6.2...HEAD
package/README.md CHANGED
@@ -516,8 +516,9 @@ requirement.
516
516
  ## 🔑 DI Tokens
517
517
 
518
518
  Every token is a `Symbol`. `BYMAX_CORRELATION_PROVIDER`,
519
- `BYMAX_HEALTH_INDICATORS` and `BYMAX_HEALTH_TRANSITION_SINK` are consumed with
520
- `@Optional()` and are not bound by the module: provide either from your own module to supply your own
519
+ `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK` and
520
+ `BYMAX_UNEXPECTED_ERROR_SINK` are consumed with `@Optional()` and are not bound
521
+ by the module: provide any of them from your own module to supply your own
521
522
  implementation, otherwise the internal fallback in the last column applies.
522
523
  `BYMAX_TIMING_SINK` and `BYMAX_METRICS_REGISTRY` behave differently on
523
524
  `forRootAsync`, where options resolve after the module is defined: there the
@@ -535,6 +536,7 @@ below.
535
536
  | `BYMAX_TIMING_SINK` | `ITimingSink` | internal no-op, or the metrics bridge when timing and metrics are both enabled |
536
537
  | `BYMAX_HEALTH_INDICATORS` | `IHealthIndicator[]` | treated as an empty indicator set |
537
538
  | `BYMAX_HEALTH_TRANSITION_SINK` | `IHealthTransitionSink` | no sink; readiness transitions go to Nest's logger instead (binding one stands that line down) |
539
+ | `BYMAX_UNEXPECTED_ERROR_SINK` | `IUnexpectedErrorSink` | no sink; the cause of a `500 BYMAX_INTERNAL_ERROR` is not recorded anywhere |
538
540
  | `BYMAX_METRICS_REGISTRY` | the `prom-client` `Registry` | bound when metrics are enabled; on `forRootAsync` always registered, guarded-placeholder when off |
539
541
  | `BYMAX_TRACE_CONTEXT` | `ITraceContextProvider` | bound on every path: the OpenTelemetry reader when telemetry is enabled, a no-op that resolves no trace otherwise |
540
542
 
@@ -667,6 +669,62 @@ what exhausts the stack leaves a wide margin: one consumer runs `32`, against
667
669
  the ~2000 levels that overflow. Walk the parsed body iteratively — a recursive
668
670
  depth check on a hostile payload overflows the stack it was written to protect.
669
671
 
672
+ ### Recording the cause of a 500
673
+
674
+ An error the filter does not recognise collapses to a fixed
675
+ `500 BYMAX_INTERNAL_ERROR`, and that collapse is the point: the client learns
676
+ nothing about your internals. It also means that once the response is written,
677
+ the thrown value is gone — an access log sees only the status. Bind an
678
+ `IUnexpectedErrorSink` and the filter hands it the original value first:
679
+
680
+ ```typescript
681
+ import { Global, Logger, Module } from '@nestjs/common'
682
+ import { BYMAX_UNEXPECTED_ERROR_SINK } from '@bymax-one/nest-core'
683
+ import type { IUnexpectedErrorSink, UnexpectedErrorReport } from '@bymax-one/nest-core'
684
+
685
+ export class UnexpectedErrorLogger implements IUnexpectedErrorSink {
686
+ private readonly logger = new Logger('UnexpectedError')
687
+
688
+ record({ error, method, route, correlationId }: UnexpectedErrorReport): void {
689
+ this.logger.error(
690
+ `${method} ${route} failed (correlationId=${correlationId ?? '-'})`,
691
+ error instanceof Error ? error.stack : String(error)
692
+ )
693
+ }
694
+ }
695
+
696
+ @Global()
697
+ @Module({
698
+ providers: [{ provide: BYMAX_UNEXPECTED_ERROR_SINK, useClass: UnexpectedErrorLogger }],
699
+ exports: [BYMAX_UNEXPECTED_ERROR_SINK]
700
+ })
701
+ export class ErrorReportingModule {}
702
+ ```
703
+
704
+ The sink is called once per unexpected error, on both registration paths, and
705
+ for nothing else: an `HttpException` is a deliberate answer and an exposed `4xx`
706
+ from the body pipeline is a client mistake, so neither reaches it. The report
707
+ carries the thrown value as it was thrown, the method, the route **template**
708
+ (`/invoices/:id`, or `<unmatched>` for an error raised before routing), and the
709
+ correlation and trace ids when they resolve. It carries no raw URL, query
710
+ string, headers or body.
711
+
712
+ The response never depends on the sink. A sink that throws, or an
713
+ `async record()` that rejects, is absorbed and the client receives the same
714
+ envelope it would have received with no sink bound, so a broken logger cannot
715
+ turn one failure into two. It runs synchronously before the reply, so hand the
716
+ report off and return.
717
+
718
+ The value is passed uncut whatever `envelope.exposeInternals` says, since that
719
+ option governs the response body and the sink is not the response. A driver's
720
+ error message can carry hosts and query fragments: send the report where access
721
+ is already controlled.
722
+
723
+ `IUnexpectedErrorSink` is matched by shape, so a library can ship a compatible
724
+ class without importing this package. Subclassing `BymaxExceptionFilter` and
725
+ overriding `onUnexpectedError` still works and runs alongside the sink; the
726
+ sink is what reaches the filter `BymaxCoreModule` registers itself.
727
+
670
728
  ## ⏱️ Request Timing
671
729
 
672
730
  One `RequestTimingSample` is delivered to whatever implements `ITimingSink` for
@@ -1163,10 +1221,35 @@ can emit it once and never branch:
1163
1221
 
1164
1222
  | Result | Meaning |
1165
1223
  | ------------------------------------------ | ------------------------------------------------------- |
1166
- | `{ mounted: true, path }` | The UI and the document are served at `path`. |
1224
+ | `{ mounted: true, path, discarded }` | The UI and the document are served at `path`. |
1167
1225
  | `{ mounted: false, reason: 'disabled' }` | `openapi.enabled` is off. |
1168
1226
  | `{ mounted: false, reason: 'production' }` | The runtime is production. Nothing was built or served. |
1169
1227
 
1228
+ `discarded` lists every component entry a library contributed that the served
1229
+ document already defined with a shape of its own — a `DiscardedComponent` per
1230
+ entry, naming the contributor, the member and the name. It is empty when every
1231
+ contribution was placed, and the boot log reports the same entries.
1232
+
1233
+ A collision is not an error and does not stop the boot: the merge prefers
1234
+ whatever holds the name already. That is usually your own declaration, and you
1235
+ authored it on purpose — but it may also be a core schema this package
1236
+ contributes under `openapi.includeCoreSchemas`, or an earlier contributor's
1237
+ entry, since contributors merge in class-name order. The report names what was
1238
+ dropped and by whom, not who owns the definition that won, so read the document
1239
+ before deciding what to remove.
1240
+
1241
+ It is reported because it is otherwise unobservable from either side: the
1242
+ contributing library cannot tell its entry lost, the application cannot tell one
1243
+ was offered, and the served document looks the same either way.
1244
+
1245
+ A schema present only as `{"type":"object","properties":{}}` is **not** a
1246
+ declaration for this purpose. `@nestjs/swagger` emits exactly that for every DTO
1247
+ in a package built without its plugin, so such a name is filled from the
1248
+ contribution rather than preferred over it, and does not appear in `discarded`.
1249
+ An empty `required: []` and a `description` do not change that; anything else
1250
+ does. `{"type":"object"}` and `{}` are deliberate declarations — "any object" and
1251
+ "any value" — and are kept.
1252
+
1170
1253
  ### Production is a closed door
1171
1254
 
1172
1255
  `NODE_ENV` decides, and the decision is fail-closed: only `development` and
@@ -1714,26 +1797,27 @@ in the sections above.
1714
1797
 
1715
1798
  ### `.` (root)
1716
1799
 
1717
- | Export | Kind | Description |
1718
- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- | ---------------------------------------------------------------------------------- |
1719
- | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1720
- | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1721
- | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1722
- | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1723
- | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1724
- | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1725
- | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1726
- | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1727
- | `BymaxExceptionFilter` | class | The envelope exception filter. |
1728
- | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1729
- | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1730
- | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
1731
- | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
1732
- | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
1733
- | `TimingInterceptor` | class | Deprecated: superseded by `BymaxTimingMiddleware`, which the module registers. |
1734
- | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1735
- | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1736
- | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1800
+ | Export | Kind | Description |
1801
+ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
1802
+ | `BymaxCoreModule` | class | The dynamic module: `forRoot` and `forRootAsync`. |
1803
+ | `BymaxCoreModuleOptions`, `EnvelopeOptions`, `TimingOptions`, `HealthOptions`, `MetricsOptions`, `TelemetryOptions`, `OpenApiOptions`, `OpenApiServerDescriptor`, `OpenApiSecurityScheme`, `ResolvedCoreOptions` | types | The options surface and its resolved shape. |
1804
+ | `OpenApiSecurityRequirement`, `OpenApiHttpMethod`, `OpenApiOperationKey`, `OperationSecurityMap` | types | The operation-key contract a sibling library targets to ship its own security map. |
1805
+ | `OpenApiOperationIdFactory` | type | Names the operations in the generated document. |
1806
+ | `BYMAX_CORE_OPTIONS`, `BYMAX_CORRELATION_PROVIDER`, `BYMAX_TIMING_SINK`, `BYMAX_HEALTH_INDICATORS`, `BYMAX_HEALTH_TRANSITION_SINK`, `BYMAX_UNEXPECTED_ERROR_SINK`, `BYMAX_METRICS_REGISTRY`, `BYMAX_TRACE_CONTEXT` | tokens | The DI tokens; see the [token table](#-di-tokens). |
1807
+ | `IHealthTransitionSink`, `HealthTransition`, `HealthTransitionCause` | types | The readiness-transition contract; also on `./health`. |
1808
+ | `ICorrelationIdProvider` | type | The correlation-provider contract. |
1809
+ | `ITraceContextProvider`, `TraceContext` | types | The trace-context contract and the identifiers it resolves. |
1810
+ | `BymaxExceptionFilter` | class | The envelope exception filter. |
1811
+ | `FilterErrorContext` | type | The neutral request context passed to the filter's observability seam. |
1812
+ | `IUnexpectedErrorSink`, `UnexpectedErrorReport` | types | Receives the cause behind each collapsed `500 BYMAX_INTERNAL_ERROR`; see [Recording the cause of a 500](#recording-the-cause-of-a-500). |
1813
+ | `buildErrorEnvelope` | function | Pure builder assembling an `ErrorEnvelope`. |
1814
+ | `ErrorEnvelope`, `ErrorDetails`, `BuildErrorEnvelopeInput` | types | The envelope contract and its builder input. |
1815
+ | `BymaxTimingMiddleware` | class | The request-timing middleware, applied to every route when timing is enabled. |
1816
+ | `UNMATCHED_ROUTE` | constant | The bounded label recorded when a request matched no route (`<unmatched>`). |
1817
+ | `TimingInterceptor` | class | Deprecated: superseded by `BymaxTimingMiddleware`, which the module registers. |
1818
+ | `ITimingSink`, `RequestTimingSample` | types | The timing-sink contract and its sample shape. |
1819
+ | `BYMAX_BAD_GATEWAY` … `BYMAX_VALIDATION_FAILED` | constants | The full error-code catalog (see [Error envelope](#-error-envelope)). |
1820
+ | `codeForStatus` | function | Derives a catalog code from an HTTP status. |
1737
1821
 
1738
1822
  ### `./pagination`
1739
1823
 
@@ -1773,6 +1857,7 @@ in the sections above.
1773
1857
  | -------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------ |
1774
1858
  | `applyBymaxOpenApi` | function | Builds and mounts the document; call it before `app.listen()`. |
1775
1859
  | `OpenApiMountOutcome` | type | What the helper did: mounted at a path, or skipped with a reason. |
1860
+ | `DiscardedComponent` | type | A contributed component entry the document already declared. |
1776
1861
  | `OpenApiSkipReason` | type | Why it was skipped: `'disabled'` or `'production'`. |
1777
1862
  | `BymaxOpenApiContributor`, `BYMAX_OPENAPI_CONTRIBUTOR_METADATA` | decorator, constant | Marks a provider as describing its own routes, and the metadata key behind it. |
1778
1863
  | `IOpenApiContributor`, `OpenApiFragment`, `OpenApiFragmentObject`, `OpenApiHandlerKey` | types | The contributor contract and the shape of what it returns. |
package/dist/index.cjs CHANGED
@@ -160,6 +160,9 @@ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
160
160
  var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
161
161
  "@bymax-one/nest-core:health-transition-sink"
162
162
  );
163
+ var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
164
+ "@bymax-one/nest-core:unexpected-error-sink"
165
+ );
163
166
  var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
164
167
  "@bymax-one/nest-core:metrics-registry"
165
168
  );
@@ -265,6 +268,13 @@ function buildTraceContextProvider() {
265
268
  };
266
269
  }
267
270
 
271
+ // src/contain-rejection.ts
272
+ function containRejection(returned, onFailure) {
273
+ if (typeof returned?.then === "function") {
274
+ Promise.resolve(returned).catch(onFailure);
275
+ }
276
+ }
277
+
268
278
  // src/envelope/error-codes.ts
269
279
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
270
280
  var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
@@ -328,6 +338,23 @@ function buildErrorEnvelope(input) {
328
338
  };
329
339
  }
330
340
 
341
+ // src/timing/request-info.accessor.ts
342
+ var UNMATCHED_ROUTE = "<unmatched>";
343
+ function readExpressTemplate(request) {
344
+ const path = request.route?.path;
345
+ return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
346
+ }
347
+ function readFastifyTemplate(request) {
348
+ return request.routeOptions?.url;
349
+ }
350
+ function readRequestInfo(request) {
351
+ const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
352
+ return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
353
+ }
354
+ function extractRequestInfo(context) {
355
+ return readRequestInfo(context.switchToHttp().getRequest());
356
+ }
357
+
331
358
  // src/envelope/exception.filter.ts
332
359
  var INTERNAL_ERROR_STATUS = 500;
333
360
  var INTERNAL_ERROR_MESSAGE = "Internal server error";
@@ -408,10 +435,13 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
408
435
  * (from their own, globally-visible module) is not shadowed by one; when
409
436
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
410
437
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
438
+ * @param traceContext - Reader for the active trace; a no-op when unbound.
439
+ * @param unexpectedErrorSink - Receives every unexpected error; unbound by default.
411
440
  */
412
- constructor(options, correlation, adapterHost, traceContext) {
441
+ constructor(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
413
442
  this.options = options;
414
443
  this.adapterHost = adapterHost;
444
+ this.unexpectedErrorSink = unexpectedErrorSink;
415
445
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
416
446
  this.now = () => /* @__PURE__ */ new Date();
417
447
  this.correlation = correlation ?? new NoopCorrelationIdProvider();
@@ -470,19 +500,20 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
470
500
  ...correlationId !== void 0 ? { correlationId } : {},
471
501
  ...traceId !== void 0 ? { traceId } : {}
472
502
  };
473
- const envelope = this.buildEnvelope(exception, context);
503
+ const envelope = this.buildEnvelope(exception, context, request);
474
504
  httpAdapter.reply(response, envelope, envelope.statusCode);
475
505
  }
476
506
  /**
477
507
  * Select the mapping rule for the exception and build its envelope. An
478
- * unknown error is handed to the observability seam before it collapses, so
479
- * an integration can record the original error with the request context.
508
+ * unknown error is handed to the observability seam once its envelope is
509
+ * built, so an integration can record the original error with the context.
480
510
  *
481
511
  * @param exception - The error that escaped the handler.
482
512
  * @param context - The neutral request context.
513
+ * @param request - The framework request, read only for the sink's route template.
483
514
  * @returns The formatted envelope.
484
515
  */
485
- buildEnvelope(exception, context) {
516
+ buildEnvelope(exception, context, request) {
486
517
  if (exception instanceof common.HttpException) {
487
518
  return this.mapHttpException(exception, context);
488
519
  }
@@ -495,11 +526,39 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
495
526
  context
496
527
  );
497
528
  }
529
+ const envelope = this.mapUnknown(exception, context);
498
530
  try {
499
531
  this.onUnexpectedError(exception, context);
500
532
  } catch {
501
533
  }
502
- return this.mapUnknown(exception, context);
534
+ this.reportUnexpected(exception, context, request);
535
+ return envelope;
536
+ }
537
+ /**
538
+ * Hand an unexpected error to the bound sink, if any. Whatever the sink does
539
+ * — throw, or return a promise that rejects — is absorbed: the response must
540
+ * not depend on it. See `IUnexpectedErrorSink` for the contract.
541
+ *
542
+ * @param error - The original thrown value.
543
+ * @param context - The neutral request context.
544
+ * @param request - The framework request, read for its route template only.
545
+ */
546
+ reportUnexpected(error, context, request) {
547
+ const sink = this.unexpectedErrorSink;
548
+ if (sink === void 0) {
549
+ return;
550
+ }
551
+ try {
552
+ const report = {
553
+ error,
554
+ method: context.method,
555
+ route: readRequestInfo(request).route,
556
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
557
+ ...context.traceId !== void 0 ? { traceId: context.traceId } : {}
558
+ };
559
+ containRejection(sink.record(report), () => void 0);
560
+ } catch {
561
+ }
503
562
  }
504
563
  /**
505
564
  * Map an `HttpException` to the envelope. A domain error passes its own code,
@@ -589,7 +648,7 @@ exports.BymaxExceptionFilter = class BymaxExceptionFilter {
589
648
  }
590
649
  /**
591
650
  * Observability seam invoked for every unexpected (non-`HttpException`) error
592
- * before it collapses to the generic 500. The base implementation is a no-op:
651
+ * collapsed to the generic 500. The base implementation is a no-op:
593
652
  * this library owns no logger. An integration (for example
594
653
  * `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
595
654
  * the original error with the current request context and correlation id.
@@ -608,7 +667,9 @@ exports.BymaxExceptionFilter = __decorateClass([
608
667
  __decorateParam(1, common.Inject(BYMAX_CORRELATION_PROVIDER)),
609
668
  __decorateParam(2, common.Inject(core.HttpAdapterHost)),
610
669
  __decorateParam(3, common.Optional()),
611
- __decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT))
670
+ __decorateParam(3, common.Inject(BYMAX_TRACE_CONTEXT)),
671
+ __decorateParam(4, common.Optional()),
672
+ __decorateParam(4, common.Inject(BYMAX_UNEXPECTED_ERROR_SINK))
612
673
  ], exports.BymaxExceptionFilter);
613
674
  var PassThroughExceptionFilter = class {
614
675
  constructor(adapterHost) {
@@ -635,15 +696,8 @@ function assertAsyncFeatureEnabled(feature, enabled) {
635
696
  );
636
697
  }
637
698
  }
638
- function selectAsyncExceptionFilter(options, correlation, adapterHost) {
639
- return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
640
- }
641
-
642
- // src/contain-rejection.ts
643
- function containRejection(returned, onFailure) {
644
- if (typeof returned?.then === "function") {
645
- Promise.resolve(returned).catch(onFailure);
646
- }
699
+ function selectAsyncExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
700
+ return options.envelope.enabled ? new exports.BymaxExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) : new PassThroughExceptionFilter(adapterHost);
647
701
  }
648
702
 
649
703
  // src/discovery.ts
@@ -1354,23 +1408,6 @@ function isFastifyInstance(instance) {
1354
1408
  return typeof instance?.addHook === "function";
1355
1409
  }
1356
1410
 
1357
- // src/timing/request-info.accessor.ts
1358
- var UNMATCHED_ROUTE = "<unmatched>";
1359
- function readExpressTemplate(request) {
1360
- const path = request.route?.path;
1361
- return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
1362
- }
1363
- function readFastifyTemplate(request) {
1364
- return request.routeOptions?.url;
1365
- }
1366
- function readRequestInfo(request) {
1367
- const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
1368
- return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
1369
- }
1370
- function extractRequestInfo(context) {
1371
- return readRequestInfo(context.switchToHttp().getRequest());
1372
- }
1373
-
1374
1411
  // src/timing/timing.sample.ts
1375
1412
  function readTraceContext(traceContext) {
1376
1413
  try {
@@ -1529,11 +1566,22 @@ function buildAsyncSlots() {
1529
1566
  return [
1530
1567
  {
1531
1568
  provide: core.APP_FILTER,
1532
- useFactory: (options, correlation, adapterHost) => selectAsyncExceptionFilter(options, correlation, adapterHost),
1569
+ useFactory: (options, correlation, adapterHost, traceContext, unexpectedErrorSink) => selectAsyncExceptionFilter(
1570
+ options,
1571
+ correlation,
1572
+ adapterHost,
1573
+ traceContext,
1574
+ unexpectedErrorSink
1575
+ ),
1576
+ // Every constructor dependency the sync path resolves through `useClass`
1577
+ // has to be listed here by hand; one left out reaches the filter as its
1578
+ // no-op fallback and nothing fails.
1533
1579
  inject: [
1534
1580
  BYMAX_CORE_OPTIONS,
1535
1581
  { token: BYMAX_CORRELATION_PROVIDER, optional: true },
1536
- core.HttpAdapterHost
1582
+ core.HttpAdapterHost,
1583
+ BYMAX_TRACE_CONTEXT,
1584
+ { token: BYMAX_UNEXPECTED_ERROR_SINK, optional: true }
1537
1585
  ]
1538
1586
  }
1539
1587
  ];
@@ -1833,6 +1881,7 @@ exports.BYMAX_TIMING_SINK = BYMAX_TIMING_SINK;
1833
1881
  exports.BYMAX_TOO_MANY_REQUESTS = BYMAX_TOO_MANY_REQUESTS;
1834
1882
  exports.BYMAX_TRACE_CONTEXT = BYMAX_TRACE_CONTEXT;
1835
1883
  exports.BYMAX_UNAUTHORIZED = BYMAX_UNAUTHORIZED;
1884
+ exports.BYMAX_UNEXPECTED_ERROR_SINK = BYMAX_UNEXPECTED_ERROR_SINK;
1836
1885
  exports.BYMAX_UNPROCESSABLE_ENTITY = BYMAX_UNPROCESSABLE_ENTITY;
1837
1886
  exports.BYMAX_UNSUPPORTED_MEDIA_TYPE = BYMAX_UNSUPPORTED_MEDIA_TYPE;
1838
1887
  exports.BYMAX_VALIDATION_FAILED = BYMAX_VALIDATION_FAILED;