@futdevpro/nts-dynamo 1.15.155 → 1.15.157

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.
Files changed (48) hide show
  1. package/README.md +10 -10
  2. package/__documentations/2026-08-22-bfr-ctm-018-content-free-endpoint-errors.md +54 -0
  3. package/__documentations/2026-08-22-bfr-ctm-020-content-free-request-telemetry.md +65 -0
  4. package/__documentations/2026-08-22-content-free-process-error-boundary.md +21 -1
  5. package/build/_models/control-models/endpoint-params.control-model.d.ts +6 -0
  6. package/build/_models/control-models/endpoint-params.control-model.d.ts.map +1 -1
  7. package/build/_models/control-models/endpoint-params.control-model.js +71 -146
  8. package/build/_models/control-models/endpoint-params.control-model.js.map +1 -1
  9. package/build/_models/interfaces/request-telemetry-settings.interface.d.ts +10 -0
  10. package/build/_models/interfaces/request-telemetry-settings.interface.d.ts.map +1 -0
  11. package/build/_models/interfaces/request-telemetry-settings.interface.js +3 -0
  12. package/build/_models/interfaces/request-telemetry-settings.interface.js.map +1 -0
  13. package/build/_models/interfaces/request-telemetry-snapshot.interface.d.ts +29 -0
  14. package/build/_models/interfaces/request-telemetry-snapshot.interface.d.ts.map +1 -0
  15. package/build/_models/interfaces/request-telemetry-snapshot.interface.js +3 -0
  16. package/build/_models/interfaces/request-telemetry-snapshot.interface.js.map +1 -0
  17. package/build/_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum.d.ts +1 -0
  18. package/build/_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum.d.ts.map +1 -1
  19. package/build/_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum.js +1 -0
  20. package/build/_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum.js.map +1 -1
  21. package/build/_services/core/request-telemetry.control-service.d.ts +51 -0
  22. package/build/_services/core/request-telemetry.control-service.d.ts.map +1 -0
  23. package/build/_services/core/request-telemetry.control-service.js +286 -0
  24. package/build/_services/core/request-telemetry.control-service.js.map +1 -0
  25. package/build/_services/route/routing-module.service.d.ts.map +1 -1
  26. package/build/_services/route/routing-module.service.js +13 -10
  27. package/build/_services/route/routing-module.service.js.map +1 -1
  28. package/build/_services/server/app.server.d.ts +5 -0
  29. package/build/_services/server/app.server.d.ts.map +1 -1
  30. package/build/_services/server/app.server.js +13 -0
  31. package/build/_services/server/app.server.js.map +1 -1
  32. package/build/index.d.ts +3 -0
  33. package/build/index.d.ts.map +1 -1
  34. package/build/index.js +3 -0
  35. package/build/index.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/_models/control-models/endpoint-params.control-model.spec.ts +87 -2
  38. package/src/_models/control-models/endpoint-params.control-model.ts +93 -182
  39. package/src/_models/interfaces/request-telemetry-settings.interface.ts +9 -0
  40. package/src/_models/interfaces/request-telemetry-snapshot.interface.ts +30 -0
  41. package/src/_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum.ts +1 -0
  42. package/src/_services/core/request-telemetry.control-service.spec.ts +217 -0
  43. package/src/_services/core/request-telemetry.control-service.ts +381 -0
  44. package/src/_services/route/routing-module.service.ts +27 -18
  45. package/src/_services/server/app.server.ts +29 -9
  46. package/src/_services/shared.static-service.spec.ts +57 -57
  47. package/src/_services/shared.static-service.ts +48 -48
  48. package/src/index.ts +5 -2
@@ -1,14 +1,20 @@
1
1
  import { Request, Response } from 'express';
2
2
 
3
3
  import {
4
- DyFM_AnyError,
5
- DyFM_Array, DyFM_Endpoint_SettingsBase, DyFM_EnvironmentFlag, DyFM_Error, DyFM_getConstructionStackLocation, DyFM_HttpCallType,
6
- DyFM_Log, DyFM_Object
4
+ DyFM_AnyError, DyFM_Array, DyFM_Endpoint_SettingsBase, DyFM_Error, DyFM_getConstructionStackLocation,
5
+ DyFM_HttpCallType, DyFM_Log, DyFM_Object
7
6
  } from '@futdevpro/fsm-dynamo';
8
7
 
9
8
  import { DyNTS_global_settings } from '../../_collections/global-settings.const';
10
9
  import { DyNTS_ClientSafeError_Util } from '../../_collections/client-safe-error.util';
11
10
  import { DyNTS_RouteSecurity } from '../../_enums/route-security.enum';
11
+ import {
12
+ DyNTS_SafeDiagnostic_ControlService
13
+ } from '../../_modules/server/safe-diagnostic/safe-diagnostic.control-service';
14
+ import {
15
+ DyNTS_SafeDiagnosticStage_Type
16
+ } from '../../_modules/server/safe-diagnostic/safe-diagnostic-stage.type-enum';
17
+ import { DyNTS_SafeDiagnostic_Interface } from '../../_modules/server/safe-diagnostic/safe-diagnostic.interface';
12
18
  import { DyNTS_GlobalService } from '../../_services/core/global.service';
13
19
  import { DyNTS_RequestHunter } from '../../_services/core/request-hunter.service';
14
20
 
@@ -167,12 +173,11 @@ export class DyNTS_Endpoint_Params<
167
173
 
168
174
  this.stackLocation = DyFM_getConstructionStackLocation();
169
175
  } catch (error) {
170
- DyFM_Log.error(
171
- `\nEndpoint params setup failed: name: '${set.name}' ` +
172
- `(security: ${set.security}) endpoint: ${set.endpoint}` +
173
- `\nERROR:` +
174
- `\n`, error
175
- );
176
+ DyNTS_SafeDiagnostic_ControlService.report({
177
+ code: 'DYNTS|ENDPOINT|CONSTRUCTION_FAILURE',
178
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
179
+ error: error,
180
+ });
176
181
 
177
182
  throw error;
178
183
  }
@@ -222,11 +227,14 @@ export class DyNTS_Endpoint_Params<
222
227
  // jegyzes. Reszletes indoklas: `DyNTS_RequestHunter`.
223
228
  const hunterId: number = DyNTS_RequestHunter.getInstance().begin(this.name);
224
229
  try {
225
- let issuer: string;
226
230
  try {
227
231
  issuer = DyNTS_GlobalService?.getAuthService()?.getIssuerFromRequest(req);
228
232
  } catch (error) {
229
- DyFM_Log.warn(DyFM_Error.getAnyMessage(error));
233
+ DyNTS_SafeDiagnostic_ControlService.report({
234
+ code: 'DYNTS|ENDPOINT|ISSUER_RESOLUTION_FAILURE',
235
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
236
+ error: error,
237
+ });
230
238
  issuer = 'unknown-issuer';
231
239
  }
232
240
 
@@ -294,7 +302,7 @@ export class DyNTS_Endpoint_Params<
294
302
  }
295
303
  }
296
304
  } catch (error) {
297
- this.error(req, res, error, issuer);
305
+ await this.error(req, res, error, issuer);
298
306
  } finally {
299
307
  DyNTS_RequestHunter.getInstance().end(hunterId);
300
308
  }
@@ -336,100 +344,28 @@ export class DyNTS_Endpoint_Params<
336
344
  autoSecondTry?: boolean
337
345
  ): Promise<void> {
338
346
  try {
339
- let msg: string = `Endpoint caught an error. '${this.name}' (${this.endpoint})`;
340
-
341
- msg += this.getPathParamsLogContent(req);
342
- if (error instanceof DyFM_Error) {
343
- msg += `\n ErrorCode: ${error._errorCode}`;
344
- }
345
- msg += `\n error: "${(error as DyFM_Error)?._message ?? (error as Error)?.message}"`;
346
- msg += '\n at ' + this.stackLocation;
347
-
348
- DyFM_Log.testError(msg);
349
-
350
- if (
351
- DyNTS_global_settings.log_settings.highDetailedLogs ||
352
- !(error instanceof DyFM_Error)
353
- ) {
354
- DyFM_Log.H_error(
355
- `Endpoint "${this.endpoint}" caught an error. (${this.name})`,
356
- '\n ERROR:', error
357
- );
358
- } else if (DyNTS_global_settings.log_settings.api_errors) {
359
- error.logSimple(`Endpoint "${this.endpoint}" caught an error. (${this.name})`);
360
- }
361
-
362
- if ((error as DyFM_Error)?.flag?.includes('DYNAMO')) {
363
- if (!(error as DyFM_Error).additionalContent) {
364
- (error as DyFM_Error).additionalContent = {};
365
- }
366
- (error as DyFM_Error).additionalContent.endpointInfo = msg;
367
- }
347
+ const status: number = DyNTS_Endpoint_Params.resolveErrorStatus(error);
348
+ const diagnostic: DyNTS_SafeDiagnostic_Interface = DyNTS_SafeDiagnostic_ControlService.report({
349
+ code: 'DYNTS|ENDPOINT|FAILURE',
350
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
351
+ error: error,
352
+ status: status,
353
+ });
368
354
 
369
- // FR-071 (2026-06-06) — rich debug-descriptive request-context.
370
- // Per memory [[feedback_rich_error_handling]] minden errornak debug-level
371
- // descriptive-nek kell lennie + persisted to errors table. Az endpoint-
372
- // params catch-blokkjaban minden errorhoz csatoljuk a request snapshot-ot
373
- // (URL, method, headers KULCSAI ertek nelkul, query, params, origin,
374
- // user-agent), igy a TEST environment-en a kliens-oldali fejleszto a
375
- // response-bol latja MI hianyzott a request-bol, NEM csak az error-code-ot.
376
- // Header-VALUEK nem szivarognak ki — csak a kulcs-listat es az auth-header
377
- // alakjat (length + first 7 chars hogy lassek "Bearer "-rel kezd-e).
378
355
  try {
379
- const headerKeys: string[] = Object.keys(req?.headers ?? {});
380
- const authHeader: unknown = req?.headers?.['authorization'];
381
- const authHeaderShape: string = typeof authHeader === 'string'
382
- ? `present (len=${authHeader.length}, startsWith="${authHeader.substring(0, 7)}")`
383
- : 'MISSING';
384
- const requestSnapshot: Record<string, unknown> = {
385
- url: req?.originalUrl ?? req?.url,
386
- method: req?.method,
387
- origin: req?.headers?.origin ?? null,
388
- referer: req?.headers?.referer ?? null,
389
- userAgent: req?.headers?.['user-agent'] ?? null,
390
- hostHeader: req?.headers?.host ?? null,
391
- headerKeyCount: headerKeys.length,
392
- headerKeys: headerKeys.sort(),
393
- hasAuthorizationKey: headerKeys.includes('authorization'),
394
- authHeaderShape: authHeaderShape,
395
- hasSecretKeyKey: headerKeys.includes('secret-key'),
396
- hasAdminKeyKey: headerKeys.includes('x-admin-key'),
397
- hasContentTypeKey: headerKeys.includes('content-type'),
398
- params: req?.params,
399
- query: req?.query,
400
- remoteAddress: req?.ip ?? null,
401
- };
402
- if (error instanceof DyFM_Error) {
403
- if (!(error as DyFM_Error).additionalContent) {
404
- (error as DyFM_Error).additionalContent = {};
405
- }
406
- (error as DyFM_Error).additionalContent.requestDebug = requestSnapshot;
407
- }
408
- // Plusz: 1-soros debug log a server-side log-buffer-ben.
409
- DyFM_Log.warn(
410
- `[FR-071 req-debug] ${req?.method ?? '?'} ${req?.originalUrl ?? req?.url ?? '?'} ` +
411
- `(endpoint=${this.name}, origin=${req?.headers?.origin ?? 'none'}, ` +
412
- `auth=${headerKeys.includes('authorization') ? 'yes' : 'NO'}, ` +
413
- `secretKey=${headerKeys.includes('secret-key') ? 'yes' : 'no'}, ` +
414
- `adminKey=${headerKeys.includes('x-admin-key') ? 'yes' : 'no'}, ` +
415
- `errorCode=${(error as DyFM_Error)?._errorCode ?? '(raw)'})`
356
+ await DyNTS_GlobalService.globalErrorHandler?.(
357
+ error,
358
+ req,
359
+ res,
360
+ issuer
416
361
  );
417
- } catch /* (snapshotErr) */ {
418
- // ha a snapshot kepzese maga is bukik, ne maszek bele az error-flow-ba.
362
+ } catch (handlerError) {
363
+ DyNTS_SafeDiagnostic_ControlService.report({
364
+ code: 'DYNTS|ENDPOINT|GLOBAL_HANDLER_FAILURE',
365
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
366
+ error: handlerError,
367
+ });
419
368
  }
420
-
421
- await DyNTS_GlobalService.globalErrorHandler?.(
422
- error,
423
- req,
424
- res,
425
- issuer
426
- ).catch((err): void => {
427
- DyFM_Log.warn(
428
- 'DyNTS_GlobalService.globalErrorHandler failed to handle error: ',
429
- err
430
- );
431
- DyFM_Log.warn('It will proceed as normal.');
432
- });
433
369
 
434
370
  // FR-067 (2026-06-06): non-DyFM_Error wrap.
435
371
  // Korabban a plain Error / axios HTTP error nyersen ment kifele
@@ -442,25 +378,12 @@ export class DyNTS_Endpoint_Params<
442
378
  // betesszuk a DyFM_Error `___status` mezojebe, igy a kovetkezo blokk
443
379
  // mar konzekvensen olvasni tudja.
444
380
  if (!(error instanceof DyFM_Error)) {
445
- const errAny: { statusCode?: unknown; status?: unknown; message?: string } =
446
- error as { statusCode?: unknown; status?: unknown; message?: string };
447
- const downstreamRaw: unknown = errAny?.statusCode ?? errAny?.status;
448
- const downstreamStatus: number | undefined = typeof downstreamRaw === 'number'
449
- ? downstreamRaw
450
- : (typeof downstreamRaw === 'string' && downstreamRaw !== '' && !isNaN(Number(downstreamRaw)))
451
- ? Number(downstreamRaw)
452
- : undefined;
453
- const wrapStatus: number = (
454
- downstreamStatus !== undefined &&
455
- downstreamStatus >= 100 &&
456
- downstreamStatus <= 599
457
- ) ? downstreamStatus : 500;
458
381
  error = new DyFM_Error({
459
- error: error as Error,
460
- message: errAny?.message ?? `Unhandled error in endpoint "${this.name}"`,
461
- errorCode: `${DyNTS_global_settings.systemShortCodeName}|DyNTS-EPP-${this.name ?? 'UNKNOWN'}`,
382
+ message: 'Unhandled endpoint failure.',
383
+ errorCode: this.getClientErrorCode(),
462
384
  userMessage: 'An internal error occurred.\nPlease contact the responsible development team.',
463
- status: wrapStatus,
385
+ additionalContent: { diagnostic: diagnostic },
386
+ status: status,
464
387
  });
465
388
  }
466
389
 
@@ -513,20 +436,7 @@ export class DyNTS_Endpoint_Params<
513
436
  // `___status`-en kívül a `statusCode` (Express convention) es `status`
514
437
  // (axios / general HTTP) mezőket is. Igy a downstream HTTP-hivasok
515
438
  // (pl. axios-bol jovo error) helyes HTTP-status-szal továbbítódnak.
516
- const errorAny: { statusCode?: unknown; status?: unknown } = error as { statusCode?: unknown; status?: unknown };
517
- const resolvedStatusRaw: unknown = (error as DyFM_Error)?.___status
518
- ?? errorAny?.statusCode
519
- ?? errorAny?.status;
520
- const resolvedStatus: number | null = typeof resolvedStatusRaw === 'number'
521
- ? resolvedStatusRaw
522
- : (typeof resolvedStatusRaw === 'string' && !isNaN(Number(resolvedStatusRaw)))
523
- ? Number(resolvedStatusRaw)
524
- : null;
525
- if (resolvedStatus !== null && resolvedStatus >= 100 && resolvedStatus <= 599) {
526
- res.status(resolvedStatus);
527
- } else {
528
- res.status(500);
529
- }
439
+ res.status(DyNTS_Endpoint_Params.resolveErrorStatus(error));
530
440
 
531
441
  // Kliens-safe strip ENV-FÜGGETLENÜL (BFR-FDPAUTHSERVICE-002): a korábbi prod-only blokk a
532
442
  // test/dev környezetben TELJES DyFM-leaket hagyott (__localStack + ___systemVersion + issuer).
@@ -537,15 +447,8 @@ export class DyNTS_Endpoint_Params<
537
447
  res.send(clientSafeError);
538
448
 
539
449
  if (this.logRequest) {
540
- DyFM_Log.error(
541
- ` <<<===== '${this.name}' error sent: ${(error as DyFM_Error)?._message ?? ''}`
542
- );
543
- if (this.logResponseContent) {
544
- DyFM_Log.error(` error response: ${DyNTS_Endpoint_Params.previewBody(clientSafeError)}`);
545
- }
450
+ DyFM_Log.error('[DyNTS endpoint error response sent]', diagnostic);
546
451
  }
547
-
548
- DyFM_Log.error('');
549
452
 
550
453
  } catch (errorLvl2) {
551
454
  this.multiLevelError(
@@ -571,33 +474,11 @@ export class DyNTS_Endpoint_Params<
571
474
  autoSecondTry: boolean
572
475
  }
573
476
  ): void {
574
- if (DyNTS_global_settings.log_settings.highDetailedLogs) {
575
- const msg =
576
- `\n\nDYNAMO MULTILEVEL ERROR:DyNTS_Endpoint_Params: error: ` +
577
- `(${this.name}, ${this.endpoint})` +
578
- `\n(DYNAMO MULTILEVEL ERROR means, that the ERROR HANDLING is ALSO FAILED, ` +
579
- `and the error message was not sent.)` +
580
- `\nthisEndpointStackLocation: ${this.stackLocation}`;
581
-
582
- if (errorLvl2 instanceof DyFM_Error) {
583
- errorLvl2.logSimple(msg);
584
- } else {
585
- DyFM_Log.H_error(msg, '\nERROR:', errorLvl2, '\n');
586
- }
587
- } else {
588
- const msg =
589
- `\n\nDYNAMO MULTILEVEL ERROR:DyNTS_Endpoint_Params: error: ` +
590
- `(${this.name}, ${this.endpoint})` +
591
- `\nthisEndpointStackLocation: ${this.stackLocation}` +
592
- `\n(DYNAMO MULTILEVEL ERROR means, that the ERROR HANDLING is ALSO FAILED, ` +
593
- `and the error message was not sent.)`;
594
-
595
- if (errorLvl2 instanceof DyFM_Error) {
596
- errorLvl2.logSimple(msg);
597
- } else {
598
- DyFM_Log.H_error(msg, '\nERROR:', errorLvl2, '\n');
599
- }
600
- }
477
+ DyNTS_SafeDiagnostic_ControlService.report({
478
+ code: 'DYNTS|ENDPOINT|ERROR_HANDLER_FAILURE',
479
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
480
+ error: errorLvl2,
481
+ });
601
482
 
602
483
  try {
603
484
  JSON.stringify(errorInputs.error);
@@ -608,8 +489,11 @@ export class DyNTS_Endpoint_Params<
608
489
  try {
609
490
  resolvedError = DyFM_Object.resolveCirculation(errorInputs.error);
610
491
  } catch (errorLvl3) {
611
- DyFM_Log.error(' ...response object is not resolvable!');
612
- DyFM_Log.error(' ...', errorLvl3);
492
+ DyNTS_SafeDiagnostic_ControlService.report({
493
+ code: 'DYNTS|ENDPOINT|CIRCULATION_RESOLUTION_FAILURE',
494
+ stage: DyNTS_SafeDiagnosticStage_Type.endpoint,
495
+ error: errorLvl3,
496
+ });
613
497
  }
614
498
 
615
499
  if (resolvedError) {
@@ -633,19 +517,46 @@ export class DyNTS_Endpoint_Params<
633
517
  }
634
518
  }
635
519
 
636
- if (
637
- DyNTS_global_settings.log_settings.highDetailedLogs ||
638
- !(errorLvl2 instanceof DyFM_Error)
639
- ) {
640
- DyFM_Log.H_error(
641
- `Endpoint "${this.endpoint}" caught an error and FAILED TO RESOLVE. (${this.name})`,
642
- '\n ERROR:', errorLvl2
643
- );
644
- } else {
645
- errorLvl2.logSimple(
646
- `Endpoint "${this.endpoint}" caught an error and FAILED TO RESOLVE. (${this.name})`
647
- );
520
+ }
521
+
522
+ /** Resolves conventional HTTP status fields without inspecting raw error content. */
523
+ private static resolveErrorStatus(error: unknown): number {
524
+ try {
525
+ if (error === null || (typeof error !== 'object' && typeof error !== 'function')) {
526
+ return 500;
527
+ }
528
+
529
+ const statusRaw: unknown = Reflect.get(error, '___status')
530
+ ?? Reflect.get(error, 'statusCode')
531
+ ?? Reflect.get(error, 'status');
532
+ const status: number = typeof statusRaw === 'number' ? statusRaw : Number(statusRaw);
533
+
534
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 500;
535
+ } catch {
536
+ return 500;
537
+ }
538
+ }
539
+
540
+ /** Preserves the public error-code shape while excluding arbitrary endpoint-name characters. */
541
+ private getClientErrorCode(): string {
542
+ const systemCode: string = DyNTS_Endpoint_Params.sanitizeCodePart(
543
+ DyNTS_global_settings.systemShortCodeName,
544
+ 'DYNTS'
545
+ );
546
+ const endpointCode: string = DyNTS_Endpoint_Params.sanitizeCodePart(this.name, 'UNKNOWN');
547
+
548
+ return `${systemCode}|DyNTS-EPP-${endpointCode}`;
549
+ }
550
+
551
+ /** Bounds developer-owned identifiers to the established error-code alphabet. */
552
+ private static sanitizeCodePart(value: unknown, fallback: string): string {
553
+ if (typeof value !== 'string') {
554
+ return fallback;
648
555
  }
556
+
557
+ const sanitized: string = value.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 64);
558
+
559
+ return sanitized || fallback;
649
560
  }
650
561
 
651
562
  private getPathParamsLogContent(req: Request): string {
@@ -0,0 +1,9 @@
1
+ /** Opt-in, content-free request telemetry settings for one Dynamo application. */
2
+ export interface DyNTS_RequestTelemetry_Settings {
3
+ /** Enables correlation headers and event-driven aggregate metrics. Default: false. */
4
+ enabled?: boolean;
5
+ /** Maximum distinct declared route templates retained before overflow aggregation. Default: 128. */
6
+ maxRouteCardinality?: number;
7
+ /** Inclusive duration bucket upper bounds in milliseconds. Default: 25, 100, 250, 1000, 5000. */
8
+ durationBucketUpperBoundsMs?: number[];
9
+ }
@@ -0,0 +1,30 @@
1
+ /** One bounded duration bucket in the content-free request telemetry snapshot. */
2
+ export interface DyNTS_RequestDurationBucket_Interface {
3
+ upperBoundMs: number | null;
4
+ count: number;
5
+ }
6
+
7
+ /** One declared route-template aggregate. No concrete URL or request value is retained. */
8
+ export interface DyNTS_RequestRouteMetric_Interface {
9
+ method: string;
10
+ routeTemplate: string;
11
+ count: number;
12
+ abortedCount: number;
13
+ serverErrorCount: number;
14
+ totalDurationMs: number;
15
+ maxDurationMs: number;
16
+ }
17
+
18
+ /** Synchronous, immutable-by-copy snapshot of the event-driven request counters. */
19
+ export interface DyNTS_RequestTelemetrySnapshot_Interface {
20
+ contractVersion: 'dynts-request-telemetry/1';
21
+ correlationHeader: 'X-Correlation-Id';
22
+ requestsStarted: number;
23
+ requestsCompleted: number;
24
+ requestsAborted: number;
25
+ inFlight: number;
26
+ droppedRouteTemplates: number;
27
+ statusClasses: Record<string, number>;
28
+ durationBuckets: DyNTS_RequestDurationBucket_Interface[];
29
+ routes: DyNTS_RequestRouteMetric_Interface[];
30
+ }
@@ -5,6 +5,7 @@ export enum DyNTS_SafeDiagnosticStage_Type {
5
5
  databaseConnect = 'database-connect',
6
6
  databaseRuntime = 'database-runtime',
7
7
  eventLoopDiagnostic = 'event-loop-diagnostic',
8
+ endpoint = 'endpoint',
8
9
  expressFallback = 'express-fallback',
9
10
  globalErrorHandler = 'global-error-handler',
10
11
  httpServer = 'http-server',
@@ -0,0 +1,217 @@
1
+ import { EventEmitter } from 'events';
2
+ import { NextFunction, Request, Response } from 'express';
3
+
4
+ import {
5
+ DyNTS_RequestRouteMetric_Interface,
6
+ DyNTS_RequestTelemetrySnapshot_Interface
7
+ } from '../../_models/interfaces/request-telemetry-snapshot.interface';
8
+ import { DyNTS_RequestTelemetry_ControlService } from './request-telemetry.control-service';
9
+
10
+ class ResponseStub extends EventEmitter {
11
+ public readonly headers: Record<string, string> = {};
12
+ private responseStatusCode: number = 200;
13
+
14
+ /** Returns the response status used by completion aggregation. */
15
+ public get statusCode(): number {
16
+ return this.responseStatusCode;
17
+ }
18
+
19
+ /** Updates the response status used by completion aggregation. */
20
+ public set statusCode(value: number) {
21
+ this.responseStatusCode = value;
22
+ }
23
+
24
+ /** Stores one response header for middleware assertions. */
25
+ public setHeader(name: string, value: string): void {
26
+ this.headers[name] = value;
27
+ }
28
+
29
+ /** Reads one stored response header for middleware composition. */
30
+ public getHeader(name: string): string | undefined {
31
+ return this.headers[name];
32
+ }
33
+ }
34
+
35
+ class ThrowingHeaderResponseStub extends ResponseStub {
36
+ /** Simulates a response implementation rejecting header mutation. */
37
+ public override setHeader(_name: string, _value: string): void {
38
+ void _name;
39
+ void _value;
40
+
41
+ throw new Error('simulated header failure');
42
+ }
43
+ }
44
+
45
+ class ThrowingStatusResponseStub extends ResponseStub {
46
+ /** Simulates a late response implementation failure after listener installation. */
47
+ public override get statusCode(): number { // eslint-disable-line @futdevpro/dynamo/no-getter-logic
48
+ throw new Error('simulated status failure');
49
+ }
50
+
51
+ /** Accepts setup writes while the read side remains deliberately unavailable. */
52
+ public override set statusCode(_value: number) {}
53
+ }
54
+
55
+ describe('| BFR-CTM-020 content-free request telemetry', (): void => {
56
+ const service: DyNTS_RequestTelemetry_ControlService = DyNTS_RequestTelemetry_ControlService.getInstance();
57
+
58
+ beforeEach((): void => service._resetForTesting());
59
+
60
+ it('| replaces caller correlation and counts finish exactly once', (): void => {
61
+ service.configure({ enabled: true, durationBucketUpperBoundsMs: [ 50 ], maxRouteCardinality: 4 });
62
+ const req: Request = createRequest('GET');
63
+ const res: ResponseStub = new ResponseStub();
64
+ const next: NextFunction = createNext();
65
+
66
+ service.createMiddleware()(req, response(res), next);
67
+ service.identifyRoute('/pipeline/:pipelineId')(req, response(res), next);
68
+ res.statusCode = 503;
69
+ res.emit('finish');
70
+ res.emit('close');
71
+
72
+ const snapshot: DyNTS_RequestTelemetrySnapshot_Interface = service.snapshot();
73
+
74
+ expect(res.headers['X-Correlation-Id']).toMatch(/^[0-9a-f-]{36}$/u);
75
+ expect(res.headers['X-Correlation-Id']).not.toBe('caller-value');
76
+ expect(res.headers['Access-Control-Expose-Headers']).toContain('X-Correlation-Id');
77
+ expect(snapshot.requestsStarted).toBe(1);
78
+ expect(snapshot.requestsCompleted).toBe(1);
79
+ expect(snapshot.requestsAborted).toBe(0);
80
+ expect(snapshot.inFlight).toBe(0);
81
+ expect(snapshot.statusClasses['5xx']).toBe(1);
82
+ expect(snapshot.routes).toEqual([ jasmine.objectContaining({
83
+ method: 'GET',
84
+ routeTemplate: '/pipeline/:pipelineId',
85
+ count: 1,
86
+ abortedCount: 0,
87
+ serverErrorCount: 1,
88
+ }) ]);
89
+ expect(service.getCorrelationId(req)).toBeUndefined();
90
+ });
91
+
92
+ it('| records close without finish as an abort and removes both listeners', (): void => {
93
+ service.configure({ enabled: true });
94
+ const req: Request = createRequest('POST');
95
+ const res: ResponseStub = new ResponseStub();
96
+
97
+ service.createMiddleware()(req, response(res), createNext());
98
+ expect(res.listenerCount('finish')).toBe(1);
99
+ expect(res.listenerCount('close')).toBe(1);
100
+ res.emit('close');
101
+
102
+ const snapshot: DyNTS_RequestTelemetrySnapshot_Interface = service.snapshot();
103
+
104
+ expect(snapshot.requestsAborted).toBe(1);
105
+ expect(snapshot.requestsCompleted).toBe(0);
106
+ expect(snapshot.inFlight).toBe(0);
107
+ expect(res.listenerCount('finish')).toBe(0);
108
+ expect(res.listenerCount('close')).toBe(0);
109
+ });
110
+
111
+ it('| caps declared route cardinality and never retains invalid route content', (): void => {
112
+ service.configure({ enabled: true, maxRouteCardinality: 1 });
113
+ completeRequest(service, '/first/:id');
114
+ completeRequest(service, '/second/:id');
115
+ completeRequest(service, '/unsafe?secret=:value');
116
+
117
+ const snapshot: DyNTS_RequestTelemetrySnapshot_Interface = service.snapshot();
118
+
119
+ expect(snapshot.droppedRouteTemplates).toBe(2);
120
+ expect(snapshot.routes.map(
121
+ (route: DyNTS_RequestRouteMetric_Interface): string => route.routeTemplate
122
+ )).toEqual([ '__other__', '/first/:id' ]);
123
+ expect(JSON.stringify(snapshot)).not.toContain('secret');
124
+ expect(snapshot.routes.length).toBe(2);
125
+ });
126
+
127
+ it('| caps distinct templates without collapsing allowed methods on one retained template', (): void => {
128
+ service.configure({ enabled: true, maxRouteCardinality: 1 });
129
+ completeRequest(service, '/pipeline/:id', 'GET');
130
+ completeRequest(service, '/pipeline/:id', 'POST');
131
+ completeRequest(service, '/another/:id', 'GET');
132
+
133
+ const snapshot: DyNTS_RequestTelemetrySnapshot_Interface = service.snapshot();
134
+
135
+ expect(snapshot.droppedRouteTemplates).toBe(1);
136
+ expect(snapshot.routes.map(
137
+ (route: DyNTS_RequestRouteMetric_Interface): string => `${route.method}:${route.routeTemplate}`
138
+ )).toEqual([ 'GET:__other__', 'GET:/pipeline/:id', 'POST:/pipeline/:id' ]);
139
+ });
140
+
141
+ it('| is a no-op while disabled', (): void => {
142
+ service.configure({ enabled: false });
143
+ const req: Request = createRequest('GET');
144
+ const res: ResponseStub = new ResponseStub();
145
+
146
+ service.createMiddleware()(req, response(res), createNext());
147
+
148
+ expect(res.headers['X-Correlation-Id']).toBeUndefined();
149
+ expect(res.listenerCount('finish')).toBe(0);
150
+ expect(service.snapshot().requestsStarted).toBe(0);
151
+ });
152
+
153
+ it('| cannot leak header setup failures or create phantom in-flight requests', (): void => {
154
+ service.configure({ enabled: true });
155
+ const req: Request = createRequest('GET');
156
+ const res: ThrowingHeaderResponseStub = new ThrowingHeaderResponseStub();
157
+
158
+ expect((): void => service.createMiddleware()(req, response(res), createNext())).not.toThrow();
159
+ expect(service.snapshot().requestsStarted).toBe(0);
160
+ expect(service.snapshot().inFlight).toBe(0);
161
+ });
162
+
163
+ it('| contains late telemetry failures without changing response lifecycle semantics', (): void => {
164
+ service.configure({ enabled: true });
165
+ const req: Request = createRequest('GET');
166
+ const res: ThrowingStatusResponseStub = new ThrowingStatusResponseStub();
167
+
168
+ service.createMiddleware()(req, response(res), createNext());
169
+
170
+ expect((): boolean => res.emit('finish')).not.toThrow();
171
+ expect(service.snapshot().inFlight).toBe(0);
172
+ });
173
+
174
+ it('| rejects conflicting or unbounded configuration with stable codes', (): void => {
175
+ expect((): void => service.configure({ enabled: true, maxRouteCardinality: 0 }))
176
+ .toThrowError('DYNTS|REQUEST_TELEMETRY|MAX_ROUTE_CARDINALITY_INVALID');
177
+ expect((): void => service.configure({ enabled: true, durationBucketUpperBoundsMs: [ 100, 50 ] }))
178
+ .toThrowError('DYNTS|REQUEST_TELEMETRY|DURATION_BUCKETS_INVALID');
179
+
180
+ service.configure({ enabled: true });
181
+ expect((): void => service.configure({ enabled: true, maxRouteCardinality: 5 }))
182
+ .toThrowError('DYNTS|REQUEST_TELEMETRY|CONFIGURATION_CONFLICT');
183
+ });
184
+ });
185
+
186
+ function completeRequest(
187
+ service: DyNTS_RequestTelemetry_ControlService,
188
+ routeTemplate: string,
189
+ method: string = 'GET'
190
+ ): void {
191
+ const req: Request = createRequest(method);
192
+ const res: ResponseStub = new ResponseStub();
193
+ const next: NextFunction = createNext();
194
+
195
+ service.createMiddleware()(req, response(res), next);
196
+ service.identifyRoute(routeTemplate)(req, response(res), next);
197
+ res.emit('finish');
198
+ }
199
+
200
+ function createRequest(method: string): Request {
201
+ // Express's runtime object is intentionally narrowed to fields the middleware is allowed to read.
202
+ // eslint-disable-next-line @futdevpro/dynamo/no-as-cast
203
+ return { method: method, headers: { 'x-correlation-id': 'caller-value' } } as unknown as Request;
204
+ }
205
+
206
+ function response(res: ResponseStub): Response {
207
+ // The stub implements the exact response lifecycle/header surface used by this middleware.
208
+ // eslint-disable-next-line @futdevpro/dynamo/no-as-cast
209
+ return res as unknown as Response;
210
+ }
211
+
212
+ function createNext(): NextFunction {
213
+ /** Deliberately empty Express continuation used by the isolated middleware tests. */
214
+ const next: NextFunction = (): void => undefined;
215
+
216
+ return next;
217
+ }