@3sixty/telemetry-processors 0.20.0-next.2 → 0.20.0-next.3

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.
@@ -1,5 +1,5 @@
1
1
  import { ContextIdStore } from "@3sixty/context";
2
- import { BaseError, Coerce, ComponentFactory, Is, JsonHelper, ObjectHelper } from "@3sixty/core";
2
+ import { BaseError, Coerce, ComponentFactory, DurationConstants, Is, JsonHelper, ObjectHelper } from "@3sixty/core";
3
3
  import { MetricCounterOperation, MetricHelper } from "@3sixty/telemetry-models";
4
4
  import { RestRequestsMetrics } from "./models/restRequestsMetrics.js";
5
5
  import { TelemetryMetricIds } from "./models/telemetryMetricIds.js";
@@ -38,7 +38,7 @@ export class MetricsRouteProcessor {
38
38
  /**
39
39
  * Default interval in milliseconds between drains of the queue.
40
40
  */
41
- static DEFAULT_DRAIN_INTERVAL_MS = 1000;
41
+ static DEFAULT_DRAIN_INTERVAL_MS = DurationConstants.MsPerSecond;
42
42
  /**
43
43
  * Resolved telemetry component.
44
44
  * @internal
@@ -1 +1 @@
1
- {"version":3,"file":"metricsRouteProcessor.js","sources":["../../src/metricsRouteProcessor.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// SPDX-License-Identifier: Apache-2.0.\nimport type {\n\tIBaseRoute,\n\tIBaseRouteProcessor,\n\tIHttpResponse,\n\tIHttpServerRequest\n} from \"@3sixty/api-models\";\nimport { ContextIdStore, type IContextIds } from \"@3sixty/context\";\nimport { BaseError, Coerce, ComponentFactory, Is, JsonHelper, ObjectHelper } from \"@3sixty/core\";\nimport type { ILoggingComponent } from \"@3sixty/logging-models\";\n\nimport {\n\tMetricCounterOperation,\n\tMetricHelper,\n\ttype ITelemetryComponent,\n\ttype ITelemetryMetricValueEntry\n} from \"@3sixty/telemetry-models\";\nimport type { IMetricsRouteProcessorConstructorOptions } from \"./models/IMetricsRouteProcessorConstructorOptions.js\";\nimport type { IPendingMetricRecording } from \"./models/IPendingMetricRecording.js\";\nimport { RestRequestsMetrics } from \"./models/restRequestsMetrics.js\";\nimport { TelemetryMetricIds } from \"./models/telemetryMetricIds.js\";\n\n/**\n * Route processor that records HTTP request counts after each request completes.\n * Emits a single counter labelled with the HTTP method, route template, and status class.\n * Raw request URLs are never used as labels to keep cardinality bounded.\n * Recording is queued off the response path, so the reply never waits for telemetry storage.\n */\nexport class MetricsRouteProcessor implements IBaseRouteProcessor {\n\t/**\n\t * Runtime name for the class.\n\t */\n\tpublic static readonly CLASS_NAME: string = \"MetricsRouteProcessor\";\n\n\t/**\n\t * Default number of request values to retain.\n\t */\n\tpublic static readonly DEFAULT_MAX_HISTORY: number = 10000;\n\n\t/**\n\t * Default path prefixes excluded from metrics recording, the probe and info routes.\n\t * The root entry is matched in full, so the routes below it are still recorded.\n\t */\n\tpublic static readonly DEFAULT_EXCLUDE_PATHS: string[] = [\n\t\t\"/\",\n\t\t\"/favicon.ico\",\n\t\t\"/spec\",\n\t\t\"/livez\",\n\t\t\"/readyz\",\n\t\t\"/info\",\n\t\t\"/health\"\n\t];\n\n\t/**\n\t * Default number of recordings queued before new ones are dropped.\n\t */\n\tpublic static readonly DEFAULT_MAX_PENDING_RECORDINGS: number = 1000;\n\n\t/**\n\t * Default interval in milliseconds between drains of the queue.\n\t */\n\tpublic static readonly DEFAULT_DRAIN_INTERVAL_MS: number = 1000;\n\n\t/**\n\t * Resolved telemetry component.\n\t * @internal\n\t */\n\tprivate readonly _telemetry?: ITelemetryComponent;\n\n\t/**\n\t * The component for logging metric recording failures.\n\t * @internal\n\t */\n\tprivate readonly _logging?: ILoggingComponent;\n\n\t/**\n\t * Paths excluded from metrics recording.\n\t * @internal\n\t */\n\tprivate readonly _excludePaths: string[];\n\n\t/**\n\t * Maximum number of request values retained; 0 retains everything.\n\t * @internal\n\t */\n\tprivate readonly _maxHistory: number;\n\n\t/**\n\t * Maximum number of recordings queued off the response path; 0 queues everything.\n\t * @internal\n\t */\n\tprivate readonly _maxPendingRecordings: number;\n\n\t/**\n\t * Interval in milliseconds between drains of the queue.\n\t * @internal\n\t */\n\tprivate readonly _drainIntervalMs: number;\n\n\t/**\n\t * Recordings queued but not yet handed to the telemetry component.\n\t * @internal\n\t */\n\tprivate readonly _pending: IPendingMetricRecording[];\n\n\t/**\n\t * The timer which drains the queue.\n\t * @internal\n\t */\n\tprivate _drainTimer?: ReturnType<typeof setInterval>;\n\n\t/**\n\t * Is a drain of the queue in progress.\n\t * @internal\n\t */\n\tprivate _isDraining: boolean;\n\n\t/**\n\t * Number of recordings dropped since the last one that was recorded.\n\t * @internal\n\t */\n\tprivate _droppedRecordings: number;\n\n\t/**\n\t * Create a new instance of MetricsRouteProcessor.\n\t * @param options The options for the processor.\n\t */\n\tconstructor(options?: IMetricsRouteProcessorConstructorOptions) {\n\t\tthis._telemetry = ComponentFactory.getIfExists<ITelemetryComponent>(\n\t\t\toptions?.telemetryComponentType\n\t\t);\n\t\tthis._logging = ComponentFactory.getIfExists<ILoggingComponent>(options?.loggingComponentType);\n\t\tthis._excludePaths =\n\t\t\toptions?.config?.excludePaths ?? MetricsRouteProcessor.DEFAULT_EXCLUDE_PATHS;\n\n\t\tconst cfgMaxHistory =\n\t\t\tCoerce.integer(options?.config?.maxHistory) ?? MetricsRouteProcessor.DEFAULT_MAX_HISTORY;\n\t\tthis._maxHistory = cfgMaxHistory > 0 ? cfgMaxHistory : 0;\n\n\t\tconst cfgMaxPending =\n\t\t\tCoerce.integer(options?.config?.maxPendingRecordings) ??\n\t\t\tMetricsRouteProcessor.DEFAULT_MAX_PENDING_RECORDINGS;\n\t\tthis._maxPendingRecordings = cfgMaxPending > 0 ? cfgMaxPending : 0;\n\n\t\tconst cfgDrainInterval =\n\t\t\tCoerce.integer(options?.config?.drainIntervalMs) ??\n\t\t\tMetricsRouteProcessor.DEFAULT_DRAIN_INTERVAL_MS;\n\t\tthis._drainIntervalMs =\n\t\t\tcfgDrainInterval > 0 ? cfgDrainInterval : MetricsRouteProcessor.DEFAULT_DRAIN_INTERVAL_MS;\n\n\t\tthis._pending = [];\n\t\tthis._isDraining = false;\n\t\tthis._droppedRecordings = 0;\n\t}\n\n\t/**\n\t * Returns the class name of the component.\n\t * @returns The class name of the component.\n\t */\n\tpublic className(): string {\n\t\treturn MetricsRouteProcessor.CLASS_NAME;\n\t}\n\n\t/**\n\t * Register all REST request metrics and start the timer which drains the queued recordings.\n\t * @returns A promise that resolves when all metrics have been registered.\n\t */\n\tpublic async start(): Promise<void> {\n\t\t// Guarded so a second start cannot leave the first interval running.\n\t\tif (Is.undefined(this._telemetry) || !Is.undefined(this._drainTimer)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// A value is recorded for every request, so the history is capped.\n\t\tawait MetricHelper.createMetrics(\n\t\t\tthis._telemetry,\n\t\t\tRestRequestsMetrics.map(metric => ({\n\t\t\t\t...metric,\n\t\t\t\tmaxHistory: this._maxHistory > 0 ? this._maxHistory : undefined\n\t\t\t}))\n\t\t);\n\n\t\tthis._drainTimer = globalThis.setInterval(async () => {\n\t\t\ttry {\n\t\t\t\tawait this.drainPending();\n\t\t\t} catch (err) {\n\t\t\t\tawait this.logDrainFailed(err);\n\t\t\t}\n\t\t}, this._drainIntervalMs);\n\t}\n\n\t/**\n\t * Clear the drain timer and hand over the recordings which are still queued.\n\t * When a drain is already running it finishes the batch it took, and anything queued after\n\t * that is left unwritten rather than holding the shutdown open.\n\t * @returns A promise that resolves when the queued recordings have been handed over.\n\t */\n\tpublic async stop(): Promise<void> {\n\t\tif (!Is.undefined(this._drainTimer)) {\n\t\t\tglobalThis.clearInterval(this._drainTimer);\n\t\t\tthis._drainTimer = undefined;\n\t\t}\n\n\t\tawait this.drainPending();\n\t}\n\n\t/**\n\t * Queue a counter increment after the request completes.\n\t * @param request The HTTP request.\n\t * @param response The HTTP response.\n\t * @param route The matched route definition, if any.\n\t * @param contextIds The context IDs for the request.\n\t * @param processorState Shared state for the current request processor chain.\n\t */\n\tpublic async post(\n\t\trequest: IHttpServerRequest,\n\t\tresponse: IHttpResponse,\n\t\troute: IBaseRoute | undefined,\n\t\tcontextIds: IContextIds,\n\t\tprocessorState: { [id: string]: unknown }\n\t): Promise<void> {\n\t\tif (Is.undefined(this._telemetry)) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst routePath = route?.path ?? \"unknown\";\n\n\t\t// Entries are prefixes, apart from the root which is matched in full.\n\t\tif (this._excludePaths.some(p => (p === \"/\" ? routePath === \"/\" : routePath.startsWith(p)))) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (this._maxPendingRecordings > 0 && this._pending.length >= this._maxPendingRecordings) {\n\t\t\tthis._droppedRecordings++;\n\t\t\treturn;\n\t\t}\n\n\t\t// The reply is sent once the post hooks resolve, so the recording is left for the drain\n\t\t// timer rather than written here.\n\t\tthis._pending.push({\n\t\t\tcontextIds: ObjectHelper.clone(contextIds),\n\t\t\tmethod: request.method ?? \"unknown\",\n\t\t\troute: routePath,\n\t\t\tstatusCode: response.statusCode ?? 0,\n\t\t\tstatusClass: this.toStatusClass(response.statusCode ?? 0)\n\t\t});\n\t}\n\n\t/**\n\t * Hand the recordings queued so far to the telemetry component, batched by context.\n\t * @returns A promise that resolves when those recordings have been handed over.\n\t * @internal\n\t */\n\tprivate async drainPending(): Promise<void> {\n\t\t// One drain at a time, so a telemetry component slower than the request rate is not\n\t\t// called concurrently by every tick of the timer.\n\t\tif (this._isDraining) {\n\t\t\treturn;\n\t\t}\n\t\tthis._isDraining = true;\n\n\t\ttry {\n\t\t\t// Only the recordings queued so far are written, so requests arriving during the\n\t\t\t// drain extend the next one rather than this one.\n\t\t\tconst recordings = this._pending.splice(0, this._pending.length);\n\n\t\t\tconst dropped = this._droppedRecordings;\n\t\t\tthis._droppedRecordings = 0;\n\t\t\tif (dropped > 0) {\n\t\t\t\tawait this.logRecordsDropped(dropped);\n\t\t\t}\n\n\t\t\tconst batches = new Map<\n\t\t\t\tstring,\n\t\t\t\t{ contextIds: IContextIds; values: ITelemetryMetricValueEntry[] }\n\t\t\t>();\n\t\t\tfor (const recording of recordings) {\n\t\t\t\t// Canonical key, so the same context ids group whatever order their keys are in.\n\t\t\t\tconst key = JsonHelper.canonicalize(recording.contextIds);\n\t\t\t\tlet batch = batches.get(key);\n\t\t\t\tif (Is.undefined(batch)) {\n\t\t\t\t\tbatch = { contextIds: recording.contextIds, values: [] };\n\t\t\t\t\tbatches.set(key, batch);\n\t\t\t\t}\n\t\t\t\tbatch.values.push({\n\t\t\t\t\tid: TelemetryMetricIds.RestRequests,\n\t\t\t\t\tvalue: MetricCounterOperation.Increment,\n\t\t\t\t\tcustomData: {\n\t\t\t\t\t\tmethod: recording.method,\n\t\t\t\t\t\troute: recording.route,\n\t\t\t\t\t\tstatusCode: recording.statusCode,\n\t\t\t\t\t\tstatusClass: recording.statusClass\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tfor (const batch of batches.values()) {\n\t\t\t\t// One call per context, so a tenant-less batch costs a single tenant query and\n\t\t\t\t// fan-out instead of one per request. Recorded in the requests' own context, and\n\t\t\t\t// a batch which fails is logged rather than re-queued, so nothing accumulates\n\t\t\t\t// while the component is unavailable.\n\t\t\t\tawait ContextIdStore.run(batch.contextIds, async () =>\n\t\t\t\t\tMetricHelper.metricValues(this._telemetry, batch.values, async err =>\n\t\t\t\t\t\tthis.logRecordsFailed(err, batch.values.length)\n\t\t\t\t\t)\n\t\t\t\t);\n\t\t\t}\n\t\t} finally {\n\t\t\tthis._isDraining = false;\n\t\t}\n\t}\n\n\t/**\n\t * Log that a batch of request metrics could not be recorded.\n\t * @param err The error the telemetry component raised.\n\t * @param count The number of metrics in the batch.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logRecordsFailed(err: unknown, count: number): Promise<void> {\n\t\tawait this._logging?.log({\n\t\t\tlevel: \"warn\",\n\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\tts: Date.now(),\n\t\t\tmessage: \"metricRecordsFailed\",\n\t\t\terror: BaseError.fromError(err),\n\t\t\tdata: { count }\n\t\t});\n\t}\n\n\t/**\n\t * Log that the queue could not be drained.\n\t * @param err The error the drain raised.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logDrainFailed(err: unknown): Promise<void> {\n\t\tawait this._logging?.log({\n\t\t\tlevel: \"warn\",\n\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\tts: Date.now(),\n\t\t\tmessage: \"metricDrainFailed\",\n\t\t\terror: BaseError.fromError(err)\n\t\t});\n\t}\n\n\t/**\n\t * Log the recordings dropped because the queue was full.\n\t * @param count The number of recordings dropped.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logRecordsDropped(count: number): Promise<void> {\n\t\ttry {\n\t\t\tawait this._logging?.log({\n\t\t\t\tlevel: \"warn\",\n\t\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\t\tts: Date.now(),\n\t\t\t\tmessage: \"metricRecordsDropped\",\n\t\t\t\tdata: { count }\n\t\t\t});\n\t\t} catch {\n\t\t\t// Carried into the next recording rather than lost, and absorbed so a broken logging\n\t\t\t// component cannot reject the drain.\n\t\t\tthis._droppedRecordings += count;\n\t\t}\n\t}\n\n\t/**\n\t * Derive the status class label from a numeric HTTP status code.\n\t * @param statusCode The HTTP status code.\n\t * @returns The status class label (1xx, 2xx, 3xx, 4xx, 5xx, or unknown).\n\t * @internal\n\t */\n\tprivate toStatusClass(statusCode: number): string {\n\t\tif (statusCode >= 500) {\n\t\t\treturn \"5xx\";\n\t\t}\n\t\tif (statusCode >= 400) {\n\t\t\treturn \"4xx\";\n\t\t}\n\t\tif (statusCode >= 300) {\n\t\t\treturn \"3xx\";\n\t\t}\n\t\tif (statusCode >= 200) {\n\t\t\treturn \"2xx\";\n\t\t}\n\t\tif (statusCode >= 100) {\n\t\t\treturn \"1xx\";\n\t\t}\n\t\treturn \"unknown\";\n\t}\n}\n"]}
1
+ {"version":3,"file":"metricsRouteProcessor.js","sources":["../../src/metricsRouteProcessor.ts"],"names":[],"mappings":"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","sourcesContent":["// Copyright 2026 IOTA Stiftung.\n// Copyright 2026 3Sixty Trade Ltd.\n// SPDX-License-Identifier: Apache-2.0.\nimport type {\n\tIBaseRoute,\n\tIBaseRouteProcessor,\n\tIHttpResponse,\n\tIHttpServerRequest\n} from \"@3sixty/api-models\";\nimport { ContextIdStore, type IContextIds } from \"@3sixty/context\";\nimport {\n\tBaseError,\n\tCoerce,\n\tComponentFactory,\n\tDurationConstants,\n\tIs,\n\tJsonHelper,\n\tObjectHelper\n} from \"@3sixty/core\";\nimport type { ILoggingComponent } from \"@3sixty/logging-models\";\n\nimport {\n\tMetricCounterOperation,\n\tMetricHelper,\n\ttype ITelemetryComponent,\n\ttype ITelemetryMetricValueEntry\n} from \"@3sixty/telemetry-models\";\nimport type { IMetricsRouteProcessorConstructorOptions } from \"./models/IMetricsRouteProcessorConstructorOptions.js\";\nimport type { IPendingMetricRecording } from \"./models/IPendingMetricRecording.js\";\nimport { RestRequestsMetrics } from \"./models/restRequestsMetrics.js\";\nimport { TelemetryMetricIds } from \"./models/telemetryMetricIds.js\";\n\n/**\n * Route processor that records HTTP request counts after each request completes.\n * Emits a single counter labelled with the HTTP method, route template, and status class.\n * Raw request URLs are never used as labels to keep cardinality bounded.\n * Recording is queued off the response path, so the reply never waits for telemetry storage.\n */\nexport class MetricsRouteProcessor implements IBaseRouteProcessor {\n\t/**\n\t * Runtime name for the class.\n\t */\n\tpublic static readonly CLASS_NAME: string = \"MetricsRouteProcessor\";\n\n\t/**\n\t * Default number of request values to retain.\n\t */\n\tpublic static readonly DEFAULT_MAX_HISTORY: number = 10000;\n\n\t/**\n\t * Default path prefixes excluded from metrics recording, the probe and info routes.\n\t * The root entry is matched in full, so the routes below it are still recorded.\n\t */\n\tpublic static readonly DEFAULT_EXCLUDE_PATHS: string[] = [\n\t\t\"/\",\n\t\t\"/favicon.ico\",\n\t\t\"/spec\",\n\t\t\"/livez\",\n\t\t\"/readyz\",\n\t\t\"/info\",\n\t\t\"/health\"\n\t];\n\n\t/**\n\t * Default number of recordings queued before new ones are dropped.\n\t */\n\tpublic static readonly DEFAULT_MAX_PENDING_RECORDINGS: number = 1000;\n\n\t/**\n\t * Default interval in milliseconds between drains of the queue.\n\t */\n\tpublic static readonly DEFAULT_DRAIN_INTERVAL_MS: number = DurationConstants.MsPerSecond;\n\n\t/**\n\t * Resolved telemetry component.\n\t * @internal\n\t */\n\tprivate readonly _telemetry?: ITelemetryComponent;\n\n\t/**\n\t * The component for logging metric recording failures.\n\t * @internal\n\t */\n\tprivate readonly _logging?: ILoggingComponent;\n\n\t/**\n\t * Paths excluded from metrics recording.\n\t * @internal\n\t */\n\tprivate readonly _excludePaths: string[];\n\n\t/**\n\t * Maximum number of request values retained; 0 retains everything.\n\t * @internal\n\t */\n\tprivate readonly _maxHistory: number;\n\n\t/**\n\t * Maximum number of recordings queued off the response path; 0 queues everything.\n\t * @internal\n\t */\n\tprivate readonly _maxPendingRecordings: number;\n\n\t/**\n\t * Interval in milliseconds between drains of the queue.\n\t * @internal\n\t */\n\tprivate readonly _drainIntervalMs: number;\n\n\t/**\n\t * Recordings queued but not yet handed to the telemetry component.\n\t * @internal\n\t */\n\tprivate readonly _pending: IPendingMetricRecording[];\n\n\t/**\n\t * The timer which drains the queue.\n\t * @internal\n\t */\n\tprivate _drainTimer?: ReturnType<typeof setInterval>;\n\n\t/**\n\t * Is a drain of the queue in progress.\n\t * @internal\n\t */\n\tprivate _isDraining: boolean;\n\n\t/**\n\t * Number of recordings dropped since the last one that was recorded.\n\t * @internal\n\t */\n\tprivate _droppedRecordings: number;\n\n\t/**\n\t * Create a new instance of MetricsRouteProcessor.\n\t * @param options The options for the processor.\n\t */\n\tconstructor(options?: IMetricsRouteProcessorConstructorOptions) {\n\t\tthis._telemetry = ComponentFactory.getIfExists<ITelemetryComponent>(\n\t\t\toptions?.telemetryComponentType\n\t\t);\n\t\tthis._logging = ComponentFactory.getIfExists<ILoggingComponent>(options?.loggingComponentType);\n\t\tthis._excludePaths =\n\t\t\toptions?.config?.excludePaths ?? MetricsRouteProcessor.DEFAULT_EXCLUDE_PATHS;\n\n\t\tconst cfgMaxHistory =\n\t\t\tCoerce.integer(options?.config?.maxHistory) ?? MetricsRouteProcessor.DEFAULT_MAX_HISTORY;\n\t\tthis._maxHistory = cfgMaxHistory > 0 ? cfgMaxHistory : 0;\n\n\t\tconst cfgMaxPending =\n\t\t\tCoerce.integer(options?.config?.maxPendingRecordings) ??\n\t\t\tMetricsRouteProcessor.DEFAULT_MAX_PENDING_RECORDINGS;\n\t\tthis._maxPendingRecordings = cfgMaxPending > 0 ? cfgMaxPending : 0;\n\n\t\tconst cfgDrainInterval =\n\t\t\tCoerce.integer(options?.config?.drainIntervalMs) ??\n\t\t\tMetricsRouteProcessor.DEFAULT_DRAIN_INTERVAL_MS;\n\t\tthis._drainIntervalMs =\n\t\t\tcfgDrainInterval > 0 ? cfgDrainInterval : MetricsRouteProcessor.DEFAULT_DRAIN_INTERVAL_MS;\n\n\t\tthis._pending = [];\n\t\tthis._isDraining = false;\n\t\tthis._droppedRecordings = 0;\n\t}\n\n\t/**\n\t * Returns the class name of the component.\n\t * @returns The class name of the component.\n\t */\n\tpublic className(): string {\n\t\treturn MetricsRouteProcessor.CLASS_NAME;\n\t}\n\n\t/**\n\t * Register all REST request metrics and start the timer which drains the queued recordings.\n\t * @returns A promise that resolves when all metrics have been registered.\n\t */\n\tpublic async start(): Promise<void> {\n\t\t// Guarded so a second start cannot leave the first interval running.\n\t\tif (Is.undefined(this._telemetry) || !Is.undefined(this._drainTimer)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// A value is recorded for every request, so the history is capped.\n\t\tawait MetricHelper.createMetrics(\n\t\t\tthis._telemetry,\n\t\t\tRestRequestsMetrics.map(metric => ({\n\t\t\t\t...metric,\n\t\t\t\tmaxHistory: this._maxHistory > 0 ? this._maxHistory : undefined\n\t\t\t}))\n\t\t);\n\n\t\tthis._drainTimer = globalThis.setInterval(async () => {\n\t\t\ttry {\n\t\t\t\tawait this.drainPending();\n\t\t\t} catch (err) {\n\t\t\t\tawait this.logDrainFailed(err);\n\t\t\t}\n\t\t}, this._drainIntervalMs);\n\t}\n\n\t/**\n\t * Clear the drain timer and hand over the recordings which are still queued.\n\t * When a drain is already running it finishes the batch it took, and anything queued after\n\t * that is left unwritten rather than holding the shutdown open.\n\t * @returns A promise that resolves when the queued recordings have been handed over.\n\t */\n\tpublic async stop(): Promise<void> {\n\t\tif (!Is.undefined(this._drainTimer)) {\n\t\t\tglobalThis.clearInterval(this._drainTimer);\n\t\t\tthis._drainTimer = undefined;\n\t\t}\n\n\t\tawait this.drainPending();\n\t}\n\n\t/**\n\t * Queue a counter increment after the request completes.\n\t * @param request The HTTP request.\n\t * @param response The HTTP response.\n\t * @param route The matched route definition, if any.\n\t * @param contextIds The context IDs for the request.\n\t * @param processorState Shared state for the current request processor chain.\n\t */\n\tpublic async post(\n\t\trequest: IHttpServerRequest,\n\t\tresponse: IHttpResponse,\n\t\troute: IBaseRoute | undefined,\n\t\tcontextIds: IContextIds,\n\t\tprocessorState: { [id: string]: unknown }\n\t): Promise<void> {\n\t\tif (Is.undefined(this._telemetry)) {\n\t\t\treturn;\n\t\t}\n\n\t\tconst routePath = route?.path ?? \"unknown\";\n\n\t\t// Entries are prefixes, apart from the root which is matched in full.\n\t\tif (this._excludePaths.some(p => (p === \"/\" ? routePath === \"/\" : routePath.startsWith(p)))) {\n\t\t\treturn;\n\t\t}\n\n\t\tif (this._maxPendingRecordings > 0 && this._pending.length >= this._maxPendingRecordings) {\n\t\t\tthis._droppedRecordings++;\n\t\t\treturn;\n\t\t}\n\n\t\t// The reply is sent once the post hooks resolve, so the recording is left for the drain\n\t\t// timer rather than written here.\n\t\tthis._pending.push({\n\t\t\tcontextIds: ObjectHelper.clone(contextIds),\n\t\t\tmethod: request.method ?? \"unknown\",\n\t\t\troute: routePath,\n\t\t\tstatusCode: response.statusCode ?? 0,\n\t\t\tstatusClass: this.toStatusClass(response.statusCode ?? 0)\n\t\t});\n\t}\n\n\t/**\n\t * Hand the recordings queued so far to the telemetry component, batched by context.\n\t * @returns A promise that resolves when those recordings have been handed over.\n\t * @internal\n\t */\n\tprivate async drainPending(): Promise<void> {\n\t\t// One drain at a time, so a telemetry component slower than the request rate is not\n\t\t// called concurrently by every tick of the timer.\n\t\tif (this._isDraining) {\n\t\t\treturn;\n\t\t}\n\t\tthis._isDraining = true;\n\n\t\ttry {\n\t\t\t// Only the recordings queued so far are written, so requests arriving during the\n\t\t\t// drain extend the next one rather than this one.\n\t\t\tconst recordings = this._pending.splice(0, this._pending.length);\n\n\t\t\tconst dropped = this._droppedRecordings;\n\t\t\tthis._droppedRecordings = 0;\n\t\t\tif (dropped > 0) {\n\t\t\t\tawait this.logRecordsDropped(dropped);\n\t\t\t}\n\n\t\t\tconst batches = new Map<\n\t\t\t\tstring,\n\t\t\t\t{ contextIds: IContextIds; values: ITelemetryMetricValueEntry[] }\n\t\t\t>();\n\t\t\tfor (const recording of recordings) {\n\t\t\t\t// Canonical key, so the same context ids group whatever order their keys are in.\n\t\t\t\tconst key = JsonHelper.canonicalize(recording.contextIds);\n\t\t\t\tlet batch = batches.get(key);\n\t\t\t\tif (Is.undefined(batch)) {\n\t\t\t\t\tbatch = { contextIds: recording.contextIds, values: [] };\n\t\t\t\t\tbatches.set(key, batch);\n\t\t\t\t}\n\t\t\t\tbatch.values.push({\n\t\t\t\t\tid: TelemetryMetricIds.RestRequests,\n\t\t\t\t\tvalue: MetricCounterOperation.Increment,\n\t\t\t\t\tcustomData: {\n\t\t\t\t\t\tmethod: recording.method,\n\t\t\t\t\t\troute: recording.route,\n\t\t\t\t\t\tstatusCode: recording.statusCode,\n\t\t\t\t\t\tstatusClass: recording.statusClass\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tfor (const batch of batches.values()) {\n\t\t\t\t// One call per context, so a tenant-less batch costs a single tenant query and\n\t\t\t\t// fan-out instead of one per request. Recorded in the requests' own context, and\n\t\t\t\t// a batch which fails is logged rather than re-queued, so nothing accumulates\n\t\t\t\t// while the component is unavailable.\n\t\t\t\tawait ContextIdStore.run(batch.contextIds, async () =>\n\t\t\t\t\tMetricHelper.metricValues(this._telemetry, batch.values, async err =>\n\t\t\t\t\t\tthis.logRecordsFailed(err, batch.values.length)\n\t\t\t\t\t)\n\t\t\t\t);\n\t\t\t}\n\t\t} finally {\n\t\t\tthis._isDraining = false;\n\t\t}\n\t}\n\n\t/**\n\t * Log that a batch of request metrics could not be recorded.\n\t * @param err The error the telemetry component raised.\n\t * @param count The number of metrics in the batch.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logRecordsFailed(err: unknown, count: number): Promise<void> {\n\t\tawait this._logging?.log({\n\t\t\tlevel: \"warn\",\n\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\tts: Date.now(),\n\t\t\tmessage: \"metricRecordsFailed\",\n\t\t\terror: BaseError.fromError(err),\n\t\t\tdata: { count }\n\t\t});\n\t}\n\n\t/**\n\t * Log that the queue could not be drained.\n\t * @param err The error the drain raised.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logDrainFailed(err: unknown): Promise<void> {\n\t\tawait this._logging?.log({\n\t\t\tlevel: \"warn\",\n\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\tts: Date.now(),\n\t\t\tmessage: \"metricDrainFailed\",\n\t\t\terror: BaseError.fromError(err)\n\t\t});\n\t}\n\n\t/**\n\t * Log the recordings dropped because the queue was full.\n\t * @param count The number of recordings dropped.\n\t * @returns A promise that resolves when the entry has been logged.\n\t * @internal\n\t */\n\tprivate async logRecordsDropped(count: number): Promise<void> {\n\t\ttry {\n\t\t\tawait this._logging?.log({\n\t\t\t\tlevel: \"warn\",\n\t\t\t\tsource: MetricsRouteProcessor.CLASS_NAME,\n\t\t\t\tts: Date.now(),\n\t\t\t\tmessage: \"metricRecordsDropped\",\n\t\t\t\tdata: { count }\n\t\t\t});\n\t\t} catch {\n\t\t\t// Carried into the next recording rather than lost, and absorbed so a broken logging\n\t\t\t// component cannot reject the drain.\n\t\t\tthis._droppedRecordings += count;\n\t\t}\n\t}\n\n\t/**\n\t * Derive the status class label from a numeric HTTP status code.\n\t * @param statusCode The HTTP status code.\n\t * @returns The status class label (1xx, 2xx, 3xx, 4xx, 5xx, or unknown).\n\t * @internal\n\t */\n\tprivate toStatusClass(statusCode: number): string {\n\t\tif (statusCode >= 500) {\n\t\t\treturn \"5xx\";\n\t\t}\n\t\tif (statusCode >= 400) {\n\t\t\treturn \"4xx\";\n\t\t}\n\t\tif (statusCode >= 300) {\n\t\t\treturn \"3xx\";\n\t\t}\n\t\tif (statusCode >= 200) {\n\t\t\treturn \"2xx\";\n\t\t}\n\t\tif (statusCode >= 100) {\n\t\t\treturn \"1xx\";\n\t\t}\n\t\treturn \"unknown\";\n\t}\n}\n"]}
package/docs/changelog.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.20.0-next.3](https://github.com/3sixtyglobal/telemetry/compare/telemetry-processors-v0.20.0-next.2...telemetry-processors-v0.20.0-next.3) (2026-11-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * require the telemetry component in the metrics collector service ([#25](https://github.com/3sixtyglobal/telemetry/issues/25)) ([2fd5170](https://github.com/3sixtyglobal/telemetry/commit/2fd5170fa0c9fb7a958c49edc0eee123bb2806e3))
9
+
10
+
11
+ ### Dependencies
12
+
13
+ * The following workspace dependencies were updated
14
+ * dependencies
15
+ * @3sixty/telemetry-models bumped from 0.20.0-next.2 to 0.20.0-next.3
16
+
3
17
  ## [0.20.0-next.2](https://github.com/3sixtyglobal/telemetry/compare/telemetry-processors-v0.20.0-next.1...telemetry-processors-v0.20.0-next.2) (2026-10-26)
4
18
 
5
19
 
@@ -66,7 +66,7 @@ Default number of recordings queued before new ones are dropped.
66
66
 
67
67
  ### DEFAULT\_DRAIN\_INTERVAL\_MS {#default_drain_interval_ms}
68
68
 
69
- > `readonly` `static` **DEFAULT\_DRAIN\_INTERVAL\_MS**: `number` = `1000`
69
+ > `readonly` `static` **DEFAULT\_DRAIN\_INTERVAL\_MS**: `number` = `DurationConstants.MsPerSecond`
70
70
 
71
71
  Default interval in milliseconds between drains of the queue.
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sixty/telemetry-processors",
3
- "version": "0.20.0-next.2",
3
+ "version": "0.20.0-next.3",
4
4
  "description": "Route processors for recording telemetry metrics.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,7 +20,7 @@
20
20
  "@3sixty/core": "next",
21
21
  "@3sixty/logging-models": "next",
22
22
  "@3sixty/nameof": "next",
23
- "@3sixty/telemetry-models": "0.20.0-next.2"
23
+ "@3sixty/telemetry-models": "0.20.0-next.3"
24
24
  },
25
25
  "main": "./dist/es/index.js",
26
26
  "types": "./dist/types/index.d.ts",