Metrics Library
Description
@pawells/metrics is a framework-agnostic observability foundation for Node.js services. It enforces a descriptor-first design: all metrics must be declared via Instrumentation.RegisterDescriptor before any values are recorded, ensuring exporters can create appropriate backend instruments at registration time rather than on first record. The library uses a fan-out pattern where a single Instrumentation.RecordMetric call is forwarded synchronously to every registered exporter, enabling one call site to emit to Prometheus, OpenTelemetry, and other backends simultaneously.
The registry retains every registered descriptor and replays them to exporters added later, so registration order does not cause silent metric loss. A built-in cardinality cap (default 10 000 distinct series per descriptor) guards against label-explosion memory leaks. A diagnostics API (GetDiagnostics, SetDiagnosticHandler) surfaces drops and exporter failures without any logging dependency in the library itself.
This package serves as the base layer for all exporter implementations and framework integrations in the @pawells observability monorepo. It depends on @pawells/typescript-common (for BaseError) and must not import any sibling exporter package.
Requirements
- Node.js
>=22 - TypeScript
~6.0.0 - Zod
^4.4.3(direct dependency; uses thezod/v4import path) - @pawells/typescript-common
^3(direct dependency; providesBaseError) - vitest
~4.1.9(optional peer; required only when importing the@pawells/metrics/testingsubpath)
Installation
yarn add @pawells/metrics
Quick Start
Use the built-in NullExporter as a placeholder, or implement MetricsExporter directly. The example below implements the interface manually to show the full shape:
import {
Instrumentation,
MetricDescriptorTypes,
type MetricsExporter,
type TMetricDescriptor,
type TMetricValue,
} from '@pawells/metrics';
class ConsoleExporter implements MetricsExporter {
readonly Name = 'console';
private readonly descriptors = new Map<string, TMetricDescriptor>();
OnDescriptorRegistered(descriptor: TMetricDescriptor): void {
this.descriptors.set(descriptor.name, descriptor);
}
OnMetricRecorded(value: TMetricValue): void {
// value.descriptorName is a string reference to the registered descriptor.
console.log(`[${value.descriptorName}] = ${value.value}`, value.labels ?? {});
}
async Flush(): Promise<void> {
// flush buffered output to the backend
}
async Shutdown(): Promise<void> {
// release backend resources
}
}
Instrumentation.SetDefaultLabels({ service: 'api' });
Instrumentation.RegisterExporter(new ConsoleExporter());
const httpRequestsDescriptor: TMetricDescriptor = {
name: 'http_requests_total',
type: MetricDescriptorTypes.COUNTER,
description: 'Total number of HTTP requests handled by this service.',
labelNames: ['method', 'endpoint'],
};
Instrumentation.RegisterDescriptor(httpRequestsDescriptor);
Instrumentation.RecordMetric({
descriptorName: 'http_requests_total',
value: 1,
labels: { method: 'GET', endpoint: '/users' },
});
// Graceful shutdown:
await Instrumentation.FlushAll();
await Instrumentation.ShutdownAll();
API Reference
Instrumentation
Static class managing the metric exporter registry and metric dispatch.
Exporter registration
| Method | Signature | Description |
|---|---|---|
RegisterExporter |
(exporter: MetricsExporter) => void |
Register an exporter. All already-retained descriptors are immediately replayed to it via OnDescriptorRegistered. Deduplicates by instance identity and, when exporter.Name is set, by name. |
DeregisterExporter |
(exporter: MetricsExporter) => void |
Remove an exporter from the registry. No-op if not registered. |
Metric pipeline
| Method | Signature | Description |
|---|---|---|
RegisterDescriptor |
(descriptor: TMetricDescriptor) => void |
Register a metric descriptor. Validates format (throws ZodError if invalid), rejects duplicate names (throws MetricsExporterError with code 'METRICS_DUPLICATE_DESCRIPTOR'), rejects registration when the total descriptor count would exceed the SetMaxDescriptors cap (throws MetricsExporterError), retains the descriptor, and notifies all exporters via OnDescriptorRegistered. Throws MetricsExporterError if any exporter fails. |
RecordMetric |
(value: TMetricValue) => void |
Record a metric value. Validates shape using a lightweight type guard (throws plain Error with message 'Invalid metric value structure' if invalid), enforces label-name and cardinality rules (drops and counts violations), merges default labels, then notifies all exporters via OnMetricRecorded. Throws MetricsExporterError if any exporter fails. |
Lifecycle
| Method | Signature | Description |
|---|---|---|
FlushAll |
() => Promise<void> |
Invoke every registered exporter's optional Flush hook concurrently. Throws MetricsExporterError if any exporter rejects. |
ShutdownAll |
() => Promise<void> |
Invoke every registered exporter's optional Shutdown hook concurrently. Throws MetricsExporterError if any exporter rejects. |
Introspection
| Method | Signature | Description |
|---|---|---|
GetDescriptor |
(name: string) => TMetricDescriptor | undefined |
Return the retained descriptor for name, or undefined if not registered. |
ListDescriptors |
() => readonly TMetricDescriptor[] |
Return a snapshot array of all currently registered descriptors. |
Configuration
| Method | Signature | Description |
|---|---|---|
SetDefaultLabels |
(labels: Record<string, string>) => void |
Merge operator-controlled global labels into every recorded value. Recorded labels win on key conflict. Pass {} to clear. Throws RangeError if any key fails the label-name pattern (/^[a-zA-Z_][a-zA-Z0-9_]*$/), is a reserved property name (__proto__, constructor, prototype), or if any value exceeds 1024 characters or contains control characters. |
SetMaxSeriesPerDescriptor |
(limit: number) => void |
Set the per-descriptor distinct-series cap (default 10 000). Values introducing a new series beyond this limit are dropped and counted under droppedCardinality. Throws RangeError if limit is not a positive integer. |
SetMaxDescriptors |
(limit: number) => void |
Set the total descriptor count cap (default 10 000). RegisterDescriptor throws when the limit would be exceeded, guarding against DoS via descriptor registration spam. Throws RangeError if limit is not a positive integer. |
Diagnostics
| Method | Signature | Description |
|---|---|---|
GetDiagnostics |
() => Readonly<IMetricsDiagnostics> |
Return an immutable snapshot of drop and failure counters. |
SetDiagnosticHandler |
(handler: (event: IMetricDiagnosticEvent) => void) => void |
Register a function called synchronously on every drop or exporter failure. Errors thrown by the handler are swallowed to protect the hot path. |
Test isolation
| Method | Signature | Description |
|---|---|---|
Reset |
() => void |
Deregister all exporters and clear all internal state — descriptors, default labels, series tracking, diagnostics counters, and the diagnostic handler. Restores the series cap and the max-descriptors cap to their defaults. Use in afterEach/afterAll hooks. |
MetricsExporter
Interface for custom exporter implementations.
| Member | Type | Description |
|---|---|---|
Name |
string (optional, readonly) |
Stable identifier used for deduplication and diagnostic tagging. Omit for anonymous exporters. |
OnDescriptorRegistered |
(descriptor: TMetricDescriptor) => void |
Called when a metric descriptor is registered (and on replay when the exporter is added after descriptors). Cache the descriptor keyed by descriptor.name for later lookup. |
OnMetricRecorded |
(value: TMetricValue) => void |
Called when a metric value is recorded. Look up the cached descriptor by value.descriptorName. |
Flush |
() => Promise<void> (optional) |
Flush buffered metric data to the backend. Invoked by Instrumentation.FlushAll(). |
Shutdown |
() => Promise<void> (optional) |
Release backend resources. Invoked by Instrumentation.ShutdownAll(). |
Error contract: Every method MAY throw or reject. The registry isolates each exporter — a failure in one never prevents others from being called. Exporters MUST surface failures by throwing rather than swallowing them; swallowing defeats fan-out failure isolation.
MetricsExporterError
Thrown by RegisterDescriptor, RecordMetric, FlushAll, or ShutdownAll when one or more exporters fail. Failing exporters do not prevent other exporters from being called; all failures are collected and reported together.
Extends BaseError from @pawells/typescript-common.
| Property | Type | Description |
|---|---|---|
Code |
string | undefined |
Machine-readable error code (inherited from BaseError). MetricsExporterError.Code.EXPORTER_ERROR ('METRICS_EXPORTER_ERROR') for fan-out failures; MetricsExporterError.Code.DUPLICATE_DESCRIPTOR ('METRICS_DUPLICATE_DESCRIPTOR') when a descriptor name is already registered. |
causes |
readonly Error[] |
Errors thrown by individual exporters, in invocation order. Empty for DUPLICATE_DESCRIPTOR. |
NullExporter
Canonical no-op reference implementation of MetricsExporter. Zero runtime dependencies; never throws. Implements the full contract including Flush and Shutdown.
import { NullExporter } from '@pawells/metrics';
Instrumentation.RegisterExporter(new NullExporter()); // Name defaults to 'null'
Instrumentation.RegisterExporter(new NullExporter('discard')); // custom Name
CallbackExporter
Forwards every descriptor and value event to a user-supplied TMetricSink function as a tagged TMetricSinkEvent. Any error thrown by the sink propagates out of the exporter callback, allowing the registry to isolate and aggregate it. Does not implement Flush or Shutdown.
import { CallbackExporter, type TMetricSink } from '@pawells/metrics';
const sink: TMetricSink = (event) => {
if (event.kind === 'descriptor') { /* event.descriptor */ }
else { /* event.value */ }
};
Instrumentation.RegisterExporter(new CallbackExporter(sink, 'capture'));
TMetricDescriptor / METRIC_DESCRIPTOR_SCHEMA
Zod schema (using zod/v4) and TypeScript type for metric descriptors.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Unique identifier for the metric (pattern: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/). |
type |
MetricDescriptorTypes |
Yes | Metric type: COUNTER, GAUGE, HISTOGRAM, UPDOWN_COUNTER, or SUMMARY. |
description |
string |
Yes | Human-readable description of what the metric measures (min length 1). This is the single required help/description field — the old help field has been removed. |
unit |
string |
No | Unit of measurement (e.g. ms, bytes). |
labelNames |
readonly string[] |
Yes | Label dimension names (pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/ per name). Empty array if no labels. |
buckets |
readonly number[] |
No | Histogram bucket boundaries. Relevant for HISTOGRAM type. |
quantiles |
readonly number[] |
No | Summary quantile targets, each in the inclusive range [0, 1]. Relevant for SUMMARY type. |
Validation helpers:
AssertMetricDescriptor(obj: unknown): asserts obj is TMetricDescriptor— ThrowsZodErrorif invalid.ValidateMetricDescriptor(obj: unknown): boolean— Returnstrueif valid,falseotherwise.
TMetricValue / METRIC_VALUE_SCHEMA
Zod schema (using zod/v4) and TypeScript type for recorded metric values.
| Field | Type | Required | Description |
|---|---|---|---|
descriptorName |
string |
Yes | Name of the registered metric descriptor (pattern: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/). |
value |
number |
Yes | Recorded numeric value. Must be finite — NaN and Infinity are rejected. |
labels |
Record<string, string> |
No | Label key/value pairs. Each value is limited to 1024 characters and must not contain control characters (U+0000–U+001F, U+007F). Keys must match the descriptor's labelNames. |
timestamp |
number |
No | Unix timestamp in milliseconds. Must be a non-negative integer. |
exemplar |
{ value: number; labels?: Record<string, string>; timestamp?: number } |
No | Links this observation to a concrete sampled event (e.g. a trace). exemplar.value must be finite. exemplar.timestamp must be a non-negative integer when present. exemplar.labels is subject to the same 1024-character and control-character constraints as labels. |
Validation helpers:
AssertMetricValue(obj: unknown): asserts obj is TMetricValue— ThrowsZodErrorif invalid.ValidateMetricValue(obj: unknown): boolean— Returnstrueif valid,falseotherwise.ValidateMetricValueLightweight(obj: unknown): boolean— Lightweight type-guard usingtypeofchecks (no Zod parsing). Used internally byRecordMetricon the hot path. Returnstruewhen the value passes structural validation,falseotherwise.
LABEL_NAME_PATTERN
Exported regular expression (/^[a-zA-Z_][a-zA-Z0-9_]*$/) for valid label name characters. Label names must start with a letter or underscore, followed by any combination of letters, digits, and underscores.
import { LABEL_NAME_PATTERN } from '@pawells/metrics';
LABEL_NAME_PATTERN.test('method'); // true
LABEL_NAME_PATTERN.test('2bad'); // false
IMetricsExporterErrorMetadata
Interface for the metadata object carried by a MetricsExporterError. Extends TErrorMetadata from @pawells/typescript-common.
| Property | Type | Description |
|---|---|---|
code |
string |
Stable, machine-readable error code. |
causes |
readonly Error[] |
Every individual error collected during a fan-out, in invocation order. |
MetricDescriptorTypes / METRIC_DESCRIPTOR_TYPES_SCHEMA
Enum of supported metric types, plus the Zod schema (using zod/v4) that validates a type string against it.
| Member | Value | Use Case |
|---|---|---|
COUNTER |
'counter' |
Monotonically increasing counter (e.g. total requests, bytes processed). Never resets or decreases. |
GAUGE |
'gauge' |
Point-in-time value that can increase or decrease (e.g. memory usage, active connections). |
HISTOGRAM |
'histogram' |
Distribution of recorded values across predefined buckets (e.g. request latency, payload size). |
UPDOWN_COUNTER |
'updown_counter' |
Counter that can increase or decrease, emphasizing counting behavior (e.g. queue depth, thread count). |
SUMMARY |
'summary' |
Observations with server-side quantile calculation (e.g. p50, p90, p99). Use quantiles in the descriptor to specify targets. |
import { METRIC_DESCRIPTOR_TYPES_SCHEMA } from '@pawells/metrics';
METRIC_DESCRIPTOR_TYPES_SCHEMA.safeParse('counter'); // { success: true, data: 'counter' }
Environments / ENVIRONMENTS_SCHEMA
Enum of supported application environments, plus the Zod schema (using zod/v4) that validates an environment string against it.
| Member | Value |
|---|---|
Development |
'development' |
Production |
'production' |
Staging |
'staging' |
Test |
'test' |
Validation helpers:
AssertEnvironment(value: unknown): asserts value is Environments— ThrowsZodErrorif not a valid environment.ValidateEnvironment(value: unknown): boolean— Returnstrueif valid,falseotherwise.
import { ENVIRONMENTS_SCHEMA } from '@pawells/metrics';
ENVIRONMENTS_SCHEMA.safeParse('production'); // { success: true, data: 'production' }
Diagnostics types
| Type | Description |
|---|---|
TMetricDiagnosticReason |
'unknown-descriptor' | 'invalid-labels' | 'cardinality' | 'exporter-failure' |
IMetricDiagnosticEvent |
{ reason, descriptorName?, message, cause? } — passed to the diagnostic handler. |
IMetricsDiagnostics |
{ droppedUnknownDescriptor, droppedInvalidLabels, droppedCardinality, exporterFailures } — returned by GetDiagnostics(). |
Testing subpath — @pawells/metrics/testing
Exports runExporterContract(options: IExporterContractOptions): void, a shared Vitest conformance harness. Run it from any exporter package's spec file to assert the vendor-neutral cross-cutting contract without duplicating assertions.
Also exports IExporterContractOptions, IExporterContractSupports.
import { runExporterContract } from '@pawells/metrics/testing';
import { PrometheusExporter } from './exporter.js';
runExporterContract({
name: 'PrometheusExporter',
createExporter: () => new PrometheusExporter(),
supports: { summary: true, histogramBuckets: true },
});
This subpath imports Vitest and is not included in production bundles. Packages that use it must list vitest as an optional peer dependency (or a devDependency if the test suite is always present).
License
MIT — Phillip Aaron Wells. See LICENSE for details.