npm.io
1.1.1 • Published 3d ago

@pawells/metrics

Licence
MIT
Version
1.1.1
Deps
3
Size
168 kB
Vulns
0
Weekly
0

Metrics Library

CI npm version Node License: MIT GitHub Sponsors

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 the zod/v4 import path)
  • @pawells/typescript-common ^3 (direct dependency; provides BaseError)
  • vitest ~4.1.9 (optional peer; required only when importing the @pawells/metrics/testing subpath)

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 — Throws ZodError if invalid.
  • ValidateMetricDescriptor(obj: unknown): boolean — Returns true if valid, false otherwise.

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 — Throws ZodError if invalid.
  • ValidateMetricValue(obj: unknown): boolean — Returns true if valid, false otherwise.
  • ValidateMetricValueLightweight(obj: unknown): boolean — Lightweight type-guard using typeof checks (no Zod parsing). Used internally by RecordMetric on the hot path. Returns true when the value passes structural validation, false otherwise.

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 — Throws ZodError if not a valid environment.
  • ValidateEnvironment(value: unknown): boolean — Returns true if valid, false otherwise.
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.

Keywords