@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/dist/index.d.cts CHANGED
@@ -564,6 +564,12 @@ declare const BYMAX_HEALTH_INDICATORS: unique symbol;
564
564
  * stands that line down in favour of the sink.
565
565
  */
566
566
  declare const BYMAX_HEALTH_TRANSITION_SINK: unique symbol;
567
+ /**
568
+ * Provide the `IUnexpectedErrorSink` that receives the cause of each collapsed
569
+ * `500 BYMAX_INTERNAL_ERROR`. Nothing is bound by default, and with no
570
+ * sink the filter records nothing.
571
+ */
572
+ declare const BYMAX_UNEXPECTED_ERROR_SINK: unique symbol;
567
573
  /**
568
574
  * Provide the `prom-client` `Registry` backing the metrics endpoint. Bound
569
575
  * lazily and only when the metrics feature is enabled.
@@ -636,6 +642,100 @@ interface ITraceContextProvider {
636
642
  getTraceContext(): TraceContext | undefined;
637
643
  }
638
644
 
645
+ /**
646
+ * @fileoverview The unexpected-error contract: a sink that receives the
647
+ * original value behind every collapsed `500 BYMAX_INTERNAL_ERROR`, with the
648
+ * request it failed.
649
+ *
650
+ * This exists because the collapse is what makes the response safe, and that
651
+ * same collapse is what makes the cause unrecoverable. The envelope carries a
652
+ * fixed message by design, an access log sees only the status, and the thrown
653
+ * value is gone the moment the filter replies. Nothing downstream of the filter
654
+ * can get it back, so the filter has to hand it over before it goes.
655
+ *
656
+ * **Structural on purpose.** A sink is matched by shape, not by inheritance, so
657
+ * a logging library can ship a compatible class without importing this package
658
+ * or declaring it as a peer. Nothing here is a class or a value.
659
+ *
660
+ * **What the report leaves out.** No request body, no headers, no query string
661
+ * and no raw URL: `route` is the template the router matched, the same
662
+ * bounded label the timing sample carries. A sink usually writes to a log that
663
+ * outlives the request and is read by more people than the request's sender,
664
+ * and a query string is where tokens and personal data end up. A sink that
665
+ * needs more than this reads it from its own request context.
666
+ *
667
+ * The thrown value itself is passed as is, uncut and unsanitised, whatever
668
+ * `envelope.exposeInternals` says. That option controls the response body,
669
+ * which goes to an untrusted client; a sink is application code at the same
670
+ * trust level as the logger, and a cause cut down to make it safe is not much
671
+ * of a cause. Treat what reaches it as possibly carrying hosts, queries and
672
+ * credentials from a driver's error message, and send it where access is
673
+ * already controlled.
674
+ * @layer Contract
675
+ */
676
+ /**
677
+ * One unexpected error, as the filter saw it when collapsing it.
678
+ *
679
+ * Optional fields are **absent** when unknown, never present holding
680
+ * `undefined`, so a sink that spreads the report into a structured log line
681
+ * writes no empty keys.
682
+ */
683
+ interface UnexpectedErrorReport {
684
+ /** The original thrown value: an `Error`, or anything else that was thrown. */
685
+ readonly error: unknown;
686
+ /** HTTP method, read through the adapter (Express and Fastify neutral). */
687
+ readonly method: string;
688
+ /**
689
+ * Route template the request matched, for example `"/invoices/:id"`, or
690
+ * `UNMATCHED_ROUTE` when it matched none: an error raised by middleware
691
+ * before routing. Never the raw URL.
692
+ */
693
+ readonly route: string;
694
+ /** Correlation id for the request; absent when no provider resolves one. */
695
+ readonly correlationId?: string;
696
+ /**
697
+ * Trace the request ran under; absent when telemetry is off or nothing was
698
+ * recording. Present whatever `telemetry.exposeTraceId` says, because that
699
+ * option controls the response body and the sink is not the response.
700
+ */
701
+ readonly traceId?: string;
702
+ }
703
+ /**
704
+ * Receives the cause of each collapsed `500 BYMAX_INTERNAL_ERROR`.
705
+ *
706
+ * Bind an implementation under `BYMAX_UNEXPECTED_ERROR_SINK` from a module of
707
+ * your own marked `@Global()`. Nothing is bound by default, and with no sink the
708
+ * filter records nothing: this package owns no logger.
709
+ *
710
+ * Called once per unexpected error, never for an `HttpException` and never for
711
+ * a 4xx the framework's body pipeline raised (a malformed JSON body, a payload
712
+ * over the limit) — those are not unexpected, and a sink would otherwise get a
713
+ * flood of client mistakes mixed in with server failures.
714
+ *
715
+ * Called synchronously from the exception filter, before the response is
716
+ * written, so an implementation must be cheap and must not block: hand the
717
+ * report to a logger and return. Whatever it does, the response does not
718
+ * change. A synchronous throw is caught, and an `async record()` that rejects
719
+ * is contained, and both are dropped silently: the filter is the last thing
720
+ * between an error and the client, and anything it reported about its own sink
721
+ * would push aside the failure the caller actually needs to see.
722
+ *
723
+ * Runs alongside the protected `BymaxExceptionFilter.onUnexpectedError` seam,
724
+ * not instead of it; a subclass overriding that seam still receives every call.
725
+ *
726
+ * Takes a single object, matching `ITimingSink.record` and
727
+ * `IHealthTransitionSink.record`, so a later field is an additive change rather
728
+ * than a new positional parameter.
729
+ */
730
+ interface IUnexpectedErrorSink {
731
+ /**
732
+ * Record one unexpected error.
733
+ *
734
+ * @param report - The original thrown value and the request it failed.
735
+ */
736
+ record(report: UnexpectedErrorReport): void;
737
+ }
738
+
639
739
  /**
640
740
  * Neutral view of the current request handed to {@link BymaxExceptionFilter}
641
741
  * mappers and to the {@link BymaxExceptionFilter.onUnexpectedError} seam. It
@@ -665,6 +765,7 @@ interface FilterErrorContext {
665
765
  declare class BymaxExceptionFilter implements ExceptionFilter {
666
766
  private readonly options;
667
767
  private readonly adapterHost;
768
+ private readonly unexpectedErrorSink?;
668
769
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
669
770
  private readonly now;
670
771
  /** The resolved correlation provider, or the no-op fallback when none is bound. */
@@ -679,8 +780,10 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
679
780
  * (from their own, globally-visible module) is not shadowed by one; when
680
781
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
681
782
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
783
+ * @param traceContext - Reader for the active trace; a no-op when unbound.
784
+ * @param unexpectedErrorSink - Receives every unexpected error; unbound by default.
682
785
  */
683
- constructor(options: ResolvedCoreOptions, correlation: ICorrelationIdProvider | undefined, adapterHost: HttpAdapterHost, traceContext?: ITraceContextProvider);
786
+ constructor(options: ResolvedCoreOptions, correlation: ICorrelationIdProvider | undefined, adapterHost: HttpAdapterHost, traceContext?: ITraceContextProvider, unexpectedErrorSink?: IUnexpectedErrorSink | undefined);
684
787
  /**
685
788
  * Format the exception into the stable envelope and reply with it.
686
789
  *
@@ -715,14 +818,25 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
715
818
  catch(exception: unknown, host: ArgumentsHost): void;
716
819
  /**
717
820
  * Select the mapping rule for the exception and build its envelope. An
718
- * unknown error is handed to the observability seam before it collapses, so
719
- * an integration can record the original error with the request context.
821
+ * unknown error is handed to the observability seam once its envelope is
822
+ * built, so an integration can record the original error with the context.
720
823
  *
721
824
  * @param exception - The error that escaped the handler.
722
825
  * @param context - The neutral request context.
826
+ * @param request - The framework request, read only for the sink's route template.
723
827
  * @returns The formatted envelope.
724
828
  */
725
829
  private buildEnvelope;
830
+ /**
831
+ * Hand an unexpected error to the bound sink, if any. Whatever the sink does
832
+ * — throw, or return a promise that rejects — is absorbed: the response must
833
+ * not depend on it. See `IUnexpectedErrorSink` for the contract.
834
+ *
835
+ * @param error - The original thrown value.
836
+ * @param context - The neutral request context.
837
+ * @param request - The framework request, read for its route template only.
838
+ */
839
+ private reportUnexpected;
726
840
  /**
727
841
  * Map an `HttpException` to the envelope. A domain error passes its own code,
728
842
  * message and details through, whether it wrote them flat on the response or
@@ -758,7 +872,7 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
758
872
  private toEnvelope;
759
873
  /**
760
874
  * Observability seam invoked for every unexpected (non-`HttpException`) error
761
- * before it collapses to the generic 500. The base implementation is a no-op:
875
+ * collapsed to the generic 500. The base implementation is a no-op:
762
876
  * this library owns no logger. An integration (for example
763
877
  * `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
764
878
  * the original error with the current request context and correlation id.
@@ -1179,4 +1293,4 @@ declare const BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
1179
1293
  */
1180
1294
  declare function codeForStatus(status: number): string;
1181
1295
 
1182
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, type BuildErrorEnvelopeInput, BymaxCoreModule, type BymaxCoreModuleOptions, BymaxExceptionFilter, BymaxTimingMiddleware, type EnvelopeOptions, type ErrorDetails, type ErrorEnvelope, type FilterErrorContext, type HealthOptions, type ICorrelationIdProvider, type ITimingSink, type ITraceContextProvider, type MetricsOptions, type OpenApiHttpMethod, type OpenApiOperationIdFactory, type OpenApiOperationKey, type OpenApiOptions, type OpenApiSecurityRequirement, type OpenApiSecurityScheme, type OpenApiServerDescriptor, type OperationSecurityMap, type RequestTimingSample, type ResolvedCoreOptions, type TelemetryOptions, TimingInterceptor, type TimingOptions, type TraceContext, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1296
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, type BuildErrorEnvelopeInput, BymaxCoreModule, type BymaxCoreModuleOptions, BymaxExceptionFilter, BymaxTimingMiddleware, type EnvelopeOptions, type ErrorDetails, type ErrorEnvelope, type FilterErrorContext, type HealthOptions, type ICorrelationIdProvider, type ITimingSink, type ITraceContextProvider, type IUnexpectedErrorSink, type MetricsOptions, type OpenApiHttpMethod, type OpenApiOperationIdFactory, type OpenApiOperationKey, type OpenApiOptions, type OpenApiSecurityRequirement, type OpenApiSecurityScheme, type OpenApiServerDescriptor, type OperationSecurityMap, type RequestTimingSample, type ResolvedCoreOptions, type TelemetryOptions, TimingInterceptor, type TimingOptions, type TraceContext, UNMATCHED_ROUTE, type UnexpectedErrorReport, buildErrorEnvelope, codeForStatus };
package/dist/index.d.ts CHANGED
@@ -564,6 +564,12 @@ declare const BYMAX_HEALTH_INDICATORS: unique symbol;
564
564
  * stands that line down in favour of the sink.
565
565
  */
566
566
  declare const BYMAX_HEALTH_TRANSITION_SINK: unique symbol;
567
+ /**
568
+ * Provide the `IUnexpectedErrorSink` that receives the cause of each collapsed
569
+ * `500 BYMAX_INTERNAL_ERROR`. Nothing is bound by default, and with no
570
+ * sink the filter records nothing.
571
+ */
572
+ declare const BYMAX_UNEXPECTED_ERROR_SINK: unique symbol;
567
573
  /**
568
574
  * Provide the `prom-client` `Registry` backing the metrics endpoint. Bound
569
575
  * lazily and only when the metrics feature is enabled.
@@ -636,6 +642,100 @@ interface ITraceContextProvider {
636
642
  getTraceContext(): TraceContext | undefined;
637
643
  }
638
644
 
645
+ /**
646
+ * @fileoverview The unexpected-error contract: a sink that receives the
647
+ * original value behind every collapsed `500 BYMAX_INTERNAL_ERROR`, with the
648
+ * request it failed.
649
+ *
650
+ * This exists because the collapse is what makes the response safe, and that
651
+ * same collapse is what makes the cause unrecoverable. The envelope carries a
652
+ * fixed message by design, an access log sees only the status, and the thrown
653
+ * value is gone the moment the filter replies. Nothing downstream of the filter
654
+ * can get it back, so the filter has to hand it over before it goes.
655
+ *
656
+ * **Structural on purpose.** A sink is matched by shape, not by inheritance, so
657
+ * a logging library can ship a compatible class without importing this package
658
+ * or declaring it as a peer. Nothing here is a class or a value.
659
+ *
660
+ * **What the report leaves out.** No request body, no headers, no query string
661
+ * and no raw URL: `route` is the template the router matched, the same
662
+ * bounded label the timing sample carries. A sink usually writes to a log that
663
+ * outlives the request and is read by more people than the request's sender,
664
+ * and a query string is where tokens and personal data end up. A sink that
665
+ * needs more than this reads it from its own request context.
666
+ *
667
+ * The thrown value itself is passed as is, uncut and unsanitised, whatever
668
+ * `envelope.exposeInternals` says. That option controls the response body,
669
+ * which goes to an untrusted client; a sink is application code at the same
670
+ * trust level as the logger, and a cause cut down to make it safe is not much
671
+ * of a cause. Treat what reaches it as possibly carrying hosts, queries and
672
+ * credentials from a driver's error message, and send it where access is
673
+ * already controlled.
674
+ * @layer Contract
675
+ */
676
+ /**
677
+ * One unexpected error, as the filter saw it when collapsing it.
678
+ *
679
+ * Optional fields are **absent** when unknown, never present holding
680
+ * `undefined`, so a sink that spreads the report into a structured log line
681
+ * writes no empty keys.
682
+ */
683
+ interface UnexpectedErrorReport {
684
+ /** The original thrown value: an `Error`, or anything else that was thrown. */
685
+ readonly error: unknown;
686
+ /** HTTP method, read through the adapter (Express and Fastify neutral). */
687
+ readonly method: string;
688
+ /**
689
+ * Route template the request matched, for example `"/invoices/:id"`, or
690
+ * `UNMATCHED_ROUTE` when it matched none: an error raised by middleware
691
+ * before routing. Never the raw URL.
692
+ */
693
+ readonly route: string;
694
+ /** Correlation id for the request; absent when no provider resolves one. */
695
+ readonly correlationId?: string;
696
+ /**
697
+ * Trace the request ran under; absent when telemetry is off or nothing was
698
+ * recording. Present whatever `telemetry.exposeTraceId` says, because that
699
+ * option controls the response body and the sink is not the response.
700
+ */
701
+ readonly traceId?: string;
702
+ }
703
+ /**
704
+ * Receives the cause of each collapsed `500 BYMAX_INTERNAL_ERROR`.
705
+ *
706
+ * Bind an implementation under `BYMAX_UNEXPECTED_ERROR_SINK` from a module of
707
+ * your own marked `@Global()`. Nothing is bound by default, and with no sink the
708
+ * filter records nothing: this package owns no logger.
709
+ *
710
+ * Called once per unexpected error, never for an `HttpException` and never for
711
+ * a 4xx the framework's body pipeline raised (a malformed JSON body, a payload
712
+ * over the limit) — those are not unexpected, and a sink would otherwise get a
713
+ * flood of client mistakes mixed in with server failures.
714
+ *
715
+ * Called synchronously from the exception filter, before the response is
716
+ * written, so an implementation must be cheap and must not block: hand the
717
+ * report to a logger and return. Whatever it does, the response does not
718
+ * change. A synchronous throw is caught, and an `async record()` that rejects
719
+ * is contained, and both are dropped silently: the filter is the last thing
720
+ * between an error and the client, and anything it reported about its own sink
721
+ * would push aside the failure the caller actually needs to see.
722
+ *
723
+ * Runs alongside the protected `BymaxExceptionFilter.onUnexpectedError` seam,
724
+ * not instead of it; a subclass overriding that seam still receives every call.
725
+ *
726
+ * Takes a single object, matching `ITimingSink.record` and
727
+ * `IHealthTransitionSink.record`, so a later field is an additive change rather
728
+ * than a new positional parameter.
729
+ */
730
+ interface IUnexpectedErrorSink {
731
+ /**
732
+ * Record one unexpected error.
733
+ *
734
+ * @param report - The original thrown value and the request it failed.
735
+ */
736
+ record(report: UnexpectedErrorReport): void;
737
+ }
738
+
639
739
  /**
640
740
  * Neutral view of the current request handed to {@link BymaxExceptionFilter}
641
741
  * mappers and to the {@link BymaxExceptionFilter.onUnexpectedError} seam. It
@@ -665,6 +765,7 @@ interface FilterErrorContext {
665
765
  declare class BymaxExceptionFilter implements ExceptionFilter {
666
766
  private readonly options;
667
767
  private readonly adapterHost;
768
+ private readonly unexpectedErrorSink?;
668
769
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
669
770
  private readonly now;
670
771
  /** The resolved correlation provider, or the no-op fallback when none is bound. */
@@ -679,8 +780,10 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
679
780
  * (from their own, globally-visible module) is not shadowed by one; when
680
781
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
681
782
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
783
+ * @param traceContext - Reader for the active trace; a no-op when unbound.
784
+ * @param unexpectedErrorSink - Receives every unexpected error; unbound by default.
682
785
  */
683
- constructor(options: ResolvedCoreOptions, correlation: ICorrelationIdProvider | undefined, adapterHost: HttpAdapterHost, traceContext?: ITraceContextProvider);
786
+ constructor(options: ResolvedCoreOptions, correlation: ICorrelationIdProvider | undefined, adapterHost: HttpAdapterHost, traceContext?: ITraceContextProvider, unexpectedErrorSink?: IUnexpectedErrorSink | undefined);
684
787
  /**
685
788
  * Format the exception into the stable envelope and reply with it.
686
789
  *
@@ -715,14 +818,25 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
715
818
  catch(exception: unknown, host: ArgumentsHost): void;
716
819
  /**
717
820
  * Select the mapping rule for the exception and build its envelope. An
718
- * unknown error is handed to the observability seam before it collapses, so
719
- * an integration can record the original error with the request context.
821
+ * unknown error is handed to the observability seam once its envelope is
822
+ * built, so an integration can record the original error with the context.
720
823
  *
721
824
  * @param exception - The error that escaped the handler.
722
825
  * @param context - The neutral request context.
826
+ * @param request - The framework request, read only for the sink's route template.
723
827
  * @returns The formatted envelope.
724
828
  */
725
829
  private buildEnvelope;
830
+ /**
831
+ * Hand an unexpected error to the bound sink, if any. Whatever the sink does
832
+ * — throw, or return a promise that rejects — is absorbed: the response must
833
+ * not depend on it. See `IUnexpectedErrorSink` for the contract.
834
+ *
835
+ * @param error - The original thrown value.
836
+ * @param context - The neutral request context.
837
+ * @param request - The framework request, read for its route template only.
838
+ */
839
+ private reportUnexpected;
726
840
  /**
727
841
  * Map an `HttpException` to the envelope. A domain error passes its own code,
728
842
  * message and details through, whether it wrote them flat on the response or
@@ -758,7 +872,7 @@ declare class BymaxExceptionFilter implements ExceptionFilter {
758
872
  private toEnvelope;
759
873
  /**
760
874
  * Observability seam invoked for every unexpected (non-`HttpException`) error
761
- * before it collapses to the generic 500. The base implementation is a no-op:
875
+ * collapsed to the generic 500. The base implementation is a no-op:
762
876
  * this library owns no logger. An integration (for example
763
877
  * `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
764
878
  * the original error with the current request context and correlation id.
@@ -1179,4 +1293,4 @@ declare const BYMAX_GATEWAY_TIMEOUT = "BYMAX_GATEWAY_TIMEOUT";
1179
1293
  */
1180
1294
  declare function codeForStatus(status: number): string;
1181
1295
 
1182
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, type BuildErrorEnvelopeInput, BymaxCoreModule, type BymaxCoreModuleOptions, BymaxExceptionFilter, BymaxTimingMiddleware, type EnvelopeOptions, type ErrorDetails, type ErrorEnvelope, type FilterErrorContext, type HealthOptions, type ICorrelationIdProvider, type ITimingSink, type ITraceContextProvider, type MetricsOptions, type OpenApiHttpMethod, type OpenApiOperationIdFactory, type OpenApiOperationKey, type OpenApiOptions, type OpenApiSecurityRequirement, type OpenApiSecurityScheme, type OpenApiServerDescriptor, type OperationSecurityMap, type RequestTimingSample, type ResolvedCoreOptions, type TelemetryOptions, TimingInterceptor, type TimingOptions, type TraceContext, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1296
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, type BuildErrorEnvelopeInput, BymaxCoreModule, type BymaxCoreModuleOptions, BymaxExceptionFilter, BymaxTimingMiddleware, type EnvelopeOptions, type ErrorDetails, type ErrorEnvelope, type FilterErrorContext, type HealthOptions, type ICorrelationIdProvider, type ITimingSink, type ITraceContextProvider, type IUnexpectedErrorSink, type MetricsOptions, type OpenApiHttpMethod, type OpenApiOperationIdFactory, type OpenApiOperationKey, type OpenApiOptions, type OpenApiSecurityRequirement, type OpenApiSecurityScheme, type OpenApiServerDescriptor, type OperationSecurityMap, type RequestTimingSample, type ResolvedCoreOptions, type TelemetryOptions, TimingInterceptor, type TimingOptions, type TraceContext, UNMATCHED_ROUTE, type UnexpectedErrorReport, buildErrorEnvelope, codeForStatus };
package/dist/index.mjs CHANGED
@@ -158,6 +158,9 @@ var BYMAX_HEALTH_INDICATORS = /* @__PURE__ */ Symbol.for(
158
158
  var BYMAX_HEALTH_TRANSITION_SINK = /* @__PURE__ */ Symbol.for(
159
159
  "@bymax-one/nest-core:health-transition-sink"
160
160
  );
161
+ var BYMAX_UNEXPECTED_ERROR_SINK = /* @__PURE__ */ Symbol.for(
162
+ "@bymax-one/nest-core:unexpected-error-sink"
163
+ );
161
164
  var BYMAX_METRICS_REGISTRY = /* @__PURE__ */ Symbol.for(
162
165
  "@bymax-one/nest-core:metrics-registry"
163
166
  );
@@ -263,6 +266,13 @@ function buildTraceContextProvider() {
263
266
  };
264
267
  }
265
268
 
269
+ // src/contain-rejection.ts
270
+ function containRejection(returned, onFailure) {
271
+ if (typeof returned?.then === "function") {
272
+ Promise.resolve(returned).catch(onFailure);
273
+ }
274
+ }
275
+
266
276
  // src/envelope/error-codes.ts
267
277
  var BYMAX_BAD_REQUEST = "BYMAX_BAD_REQUEST";
268
278
  var BYMAX_VALIDATION_FAILED = "BYMAX_VALIDATION_FAILED";
@@ -326,6 +336,23 @@ function buildErrorEnvelope(input) {
326
336
  };
327
337
  }
328
338
 
339
+ // src/timing/request-info.accessor.ts
340
+ var UNMATCHED_ROUTE = "<unmatched>";
341
+ function readExpressTemplate(request) {
342
+ const path = request.route?.path;
343
+ return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
344
+ }
345
+ function readFastifyTemplate(request) {
346
+ return request.routeOptions?.url;
347
+ }
348
+ function readRequestInfo(request) {
349
+ const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
350
+ return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
351
+ }
352
+ function extractRequestInfo(context) {
353
+ return readRequestInfo(context.switchToHttp().getRequest());
354
+ }
355
+
329
356
  // src/envelope/exception.filter.ts
330
357
  var INTERNAL_ERROR_STATUS = 500;
331
358
  var INTERNAL_ERROR_MESSAGE = "Internal server error";
@@ -406,10 +433,13 @@ var BymaxExceptionFilter = class {
406
433
  * (from their own, globally-visible module) is not shadowed by one; when
407
434
  * nothing is bound, this falls back to a no-op that omits `correlationId`.
408
435
  * @param adapterHost - Host of the live HTTP adapter, resolved lazily per catch.
436
+ * @param traceContext - Reader for the active trace; a no-op when unbound.
437
+ * @param unexpectedErrorSink - Receives every unexpected error; unbound by default.
409
438
  */
410
- constructor(options, correlation, adapterHost, traceContext) {
439
+ constructor(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
411
440
  this.options = options;
412
441
  this.adapterHost = adapterHost;
442
+ this.unexpectedErrorSink = unexpectedErrorSink;
413
443
  /** Clock used to stamp the envelope timestamp; overridable in tests via fake timers. */
414
444
  this.now = () => /* @__PURE__ */ new Date();
415
445
  this.correlation = correlation ?? new NoopCorrelationIdProvider();
@@ -468,19 +498,20 @@ var BymaxExceptionFilter = class {
468
498
  ...correlationId !== void 0 ? { correlationId } : {},
469
499
  ...traceId !== void 0 ? { traceId } : {}
470
500
  };
471
- const envelope = this.buildEnvelope(exception, context);
501
+ const envelope = this.buildEnvelope(exception, context, request);
472
502
  httpAdapter.reply(response, envelope, envelope.statusCode);
473
503
  }
474
504
  /**
475
505
  * Select the mapping rule for the exception and build its envelope. An
476
- * unknown error is handed to the observability seam before it collapses, so
477
- * an integration can record the original error with the request context.
506
+ * unknown error is handed to the observability seam once its envelope is
507
+ * built, so an integration can record the original error with the context.
478
508
  *
479
509
  * @param exception - The error that escaped the handler.
480
510
  * @param context - The neutral request context.
511
+ * @param request - The framework request, read only for the sink's route template.
481
512
  * @returns The formatted envelope.
482
513
  */
483
- buildEnvelope(exception, context) {
514
+ buildEnvelope(exception, context, request) {
484
515
  if (exception instanceof HttpException) {
485
516
  return this.mapHttpException(exception, context);
486
517
  }
@@ -493,11 +524,39 @@ var BymaxExceptionFilter = class {
493
524
  context
494
525
  );
495
526
  }
527
+ const envelope = this.mapUnknown(exception, context);
496
528
  try {
497
529
  this.onUnexpectedError(exception, context);
498
530
  } catch {
499
531
  }
500
- return this.mapUnknown(exception, context);
532
+ this.reportUnexpected(exception, context, request);
533
+ return envelope;
534
+ }
535
+ /**
536
+ * Hand an unexpected error to the bound sink, if any. Whatever the sink does
537
+ * — throw, or return a promise that rejects — is absorbed: the response must
538
+ * not depend on it. See `IUnexpectedErrorSink` for the contract.
539
+ *
540
+ * @param error - The original thrown value.
541
+ * @param context - The neutral request context.
542
+ * @param request - The framework request, read for its route template only.
543
+ */
544
+ reportUnexpected(error, context, request) {
545
+ const sink = this.unexpectedErrorSink;
546
+ if (sink === void 0) {
547
+ return;
548
+ }
549
+ try {
550
+ const report = {
551
+ error,
552
+ method: context.method,
553
+ route: readRequestInfo(request).route,
554
+ ...context.correlationId !== void 0 ? { correlationId: context.correlationId } : {},
555
+ ...context.traceId !== void 0 ? { traceId: context.traceId } : {}
556
+ };
557
+ containRejection(sink.record(report), () => void 0);
558
+ } catch {
559
+ }
501
560
  }
502
561
  /**
503
562
  * Map an `HttpException` to the envelope. A domain error passes its own code,
@@ -587,7 +646,7 @@ var BymaxExceptionFilter = class {
587
646
  }
588
647
  /**
589
648
  * Observability seam invoked for every unexpected (non-`HttpException`) error
590
- * before it collapses to the generic 500. The base implementation is a no-op:
649
+ * collapsed to the generic 500. The base implementation is a no-op:
591
650
  * this library owns no logger. An integration (for example
592
651
  * `@bymax-one/nest-logger`) subclasses the filter and overrides this to record
593
652
  * the original error with the current request context and correlation id.
@@ -606,7 +665,9 @@ BymaxExceptionFilter = __decorateClass([
606
665
  __decorateParam(1, Inject(BYMAX_CORRELATION_PROVIDER)),
607
666
  __decorateParam(2, Inject(HttpAdapterHost)),
608
667
  __decorateParam(3, Optional()),
609
- __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
668
+ __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT)),
669
+ __decorateParam(4, Optional()),
670
+ __decorateParam(4, Inject(BYMAX_UNEXPECTED_ERROR_SINK))
610
671
  ], BymaxExceptionFilter);
611
672
  var PassThroughExceptionFilter = class {
612
673
  constructor(adapterHost) {
@@ -633,15 +694,8 @@ function assertAsyncFeatureEnabled(feature, enabled) {
633
694
  );
634
695
  }
635
696
  }
636
- function selectAsyncExceptionFilter(options, correlation, adapterHost) {
637
- return options.envelope.enabled ? new BymaxExceptionFilter(options, correlation, adapterHost) : new PassThroughExceptionFilter(adapterHost);
638
- }
639
-
640
- // src/contain-rejection.ts
641
- function containRejection(returned, onFailure) {
642
- if (typeof returned?.then === "function") {
643
- Promise.resolve(returned).catch(onFailure);
644
- }
697
+ function selectAsyncExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) {
698
+ return options.envelope.enabled ? new BymaxExceptionFilter(options, correlation, adapterHost, traceContext, unexpectedErrorSink) : new PassThroughExceptionFilter(adapterHost);
645
699
  }
646
700
 
647
701
  // src/discovery.ts
@@ -1352,23 +1406,6 @@ function isFastifyInstance(instance) {
1352
1406
  return typeof instance?.addHook === "function";
1353
1407
  }
1354
1408
 
1355
- // src/timing/request-info.accessor.ts
1356
- var UNMATCHED_ROUTE = "<unmatched>";
1357
- function readExpressTemplate(request) {
1358
- const path = request.route?.path;
1359
- return path === void 0 ? void 0 : `${request.baseUrl ?? ""}${path}`;
1360
- }
1361
- function readFastifyTemplate(request) {
1362
- return request.routeOptions?.url;
1363
- }
1364
- function readRequestInfo(request) {
1365
- const template = readExpressTemplate(request) ?? readFastifyTemplate(request);
1366
- return { method: request.method ?? "", route: template ?? UNMATCHED_ROUTE };
1367
- }
1368
- function extractRequestInfo(context) {
1369
- return readRequestInfo(context.switchToHttp().getRequest());
1370
- }
1371
-
1372
1409
  // src/timing/timing.sample.ts
1373
1410
  function readTraceContext(traceContext) {
1374
1411
  try {
@@ -1527,11 +1564,22 @@ function buildAsyncSlots() {
1527
1564
  return [
1528
1565
  {
1529
1566
  provide: APP_FILTER,
1530
- useFactory: (options, correlation, adapterHost) => selectAsyncExceptionFilter(options, correlation, adapterHost),
1567
+ useFactory: (options, correlation, adapterHost, traceContext, unexpectedErrorSink) => selectAsyncExceptionFilter(
1568
+ options,
1569
+ correlation,
1570
+ adapterHost,
1571
+ traceContext,
1572
+ unexpectedErrorSink
1573
+ ),
1574
+ // Every constructor dependency the sync path resolves through `useClass`
1575
+ // has to be listed here by hand; one left out reaches the filter as its
1576
+ // no-op fallback and nothing fails.
1531
1577
  inject: [
1532
1578
  BYMAX_CORE_OPTIONS,
1533
1579
  { token: BYMAX_CORRELATION_PROVIDER, optional: true },
1534
- HttpAdapterHost
1580
+ HttpAdapterHost,
1581
+ BYMAX_TRACE_CONTEXT,
1582
+ { token: BYMAX_UNEXPECTED_ERROR_SINK, optional: true }
1535
1583
  ]
1536
1584
  }
1537
1585
  ];
@@ -1811,4 +1859,4 @@ TimingInterceptor = __decorateClass([
1811
1859
  __decorateParam(3, Inject(BYMAX_TRACE_CONTEXT))
1812
1860
  ], TimingInterceptor);
1813
1861
 
1814
- export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };
1862
+ export { BYMAX_BAD_GATEWAY, BYMAX_BAD_REQUEST, BYMAX_CLIENT_ERROR, BYMAX_CONFLICT, BYMAX_CORE_OPTIONS, BYMAX_CORRELATION_PROVIDER, BYMAX_FORBIDDEN, BYMAX_GATEWAY_TIMEOUT, BYMAX_HEALTH_INDICATORS, BYMAX_HEALTH_TRANSITION_SINK, BYMAX_INTERNAL_ERROR, BYMAX_METRICS_REGISTRY, BYMAX_NOT_FOUND, BYMAX_NOT_IMPLEMENTED, BYMAX_PAYLOAD_TOO_LARGE, BYMAX_SERVICE_UNAVAILABLE, BYMAX_TIMING_SINK, BYMAX_TOO_MANY_REQUESTS, BYMAX_TRACE_CONTEXT, BYMAX_UNAUTHORIZED, BYMAX_UNEXPECTED_ERROR_SINK, BYMAX_UNPROCESSABLE_ENTITY, BYMAX_UNSUPPORTED_MEDIA_TYPE, BYMAX_VALIDATION_FAILED, BymaxCoreModule, BymaxExceptionFilter, BymaxTimingMiddleware, TimingInterceptor, UNMATCHED_ROUTE, buildErrorEnvelope, codeForStatus };