Skip to content

Repository files navigation

@haskou/metrics

Framework-agnostic instrumentation for TypeScript.

Measure calls, failures, execution time, CPU and memory usage, and emit structured logs without coupling your application to an observability vendor.

CI Codecov npm npm downloads License: MIT

Documentation · Live playground · StackBlitz

Installation

npm install @haskou/metrics
yarn add @haskou/metrics

Runtime compatibility

The core requires ES2022 and performance.now() (or a supplied ClockPort). The package does not declare a Node.js version range; this does not promise support for every historical Node.js release. The package includes ESM and CommonJS builds with matching TypeScript declarations. Node imports and requires share configuration and in-memory buffers, including imports through public subpaths.

Browser bundlers use the native ESM build. Compatibility checks cover Node.js 22, 24 and 26 and a browser bundle executed without Node globals. Other runtimes are not yet part of the verified compatibility matrix.

CPU and memory sampling are selected automatically in Node.js when enabled. Browsers omit those measurements. A custom resourceUsage adapter remains an advanced option. The Prometheus integration is Node-specific. Building and testing this repository uses Node.js 24.18.0; that tooling requirement does not apply to applications consuming the package.

Why?

Application instrumentation tends to leak infrastructure concerns everywhere.

const start = performance.now();

try {
  const user = await createUser(command);

  metricsCounter.inc();
  durationHistogram.observe(performance.now() - start);

  return user;
} catch (error) {
  failureCounter.inc();
  logger.error(error);

  throw error;
}

That code now knows about metrics, timing, logging and whatever observability vendor happens to be in use.

With @haskou/metrics, the operation stays an operation:

import { measure } from "@haskou/metrics";

const user = await measure("users.create", () => createUser(command));

By default this records calls, failures, duration and structured logs.

The instrumentation backend can change without changing the instrumented code.

Zero setup

No configuration is required to start using it.

The default runtime keeps the latest 1,000 metrics and 1,000 logs in separate bounded in-memory buffers. When full, each buffer replaces its oldest record and increments its discarded counter, available through metrics.snapshot():

import { measure, metrics } from "@haskou/metrics";

await measure("users.create", () => createUser(command));

console.table(metrics.snapshot().metrics);
console.table(metrics.snapshot().logs);

Clear the in-memory data when needed:

metrics.clear();

This makes it useful immediately during development, debugging and tests without requiring Prometheus, Grafana or any external service.

Instrument any operation

Functions

Use measure when instrumenting a specific operation:

const result = await measure("payments.process", () => processPayment(payment));

It works with both synchronous and asynchronous operations.

Reusable functions

Use instrumentFunction when the function itself should remain instrumented:

import { instrumentFunction } from "@haskou/metrics";

const measuredCreateUser = instrumentFunction("users.create", createUser);

await measuredCreateUser(command);

Methods

Use the @Metrics decorator when working with classes:

import { Metrics } from "@haskou/metrics";

class UserCreator {
  @Metrics("users.create")
  public async create(): Promise<void> {
    // ...
  }
}

The name can also be inferred automatically:

class UserCreator {
  @Metrics()
  public async create(): Promise<void> {
    // ...
  }
}

This operation is recorded as:

UserCreator.create

Explicit names are recommended for dashboards and alerts that should remain stable after refactors.

What it captures

Signal Default
Calls On
Failures On
Execution duration On
Structured call logs On
Failure logs On
Stack traces On
CPU time Opt-in
RSS and heap usage Opt-in
Memory deltas Opt-in

Enable resource sampling only where you need it. In Node.js, no adapter configuration is required; browsers omit CPU and memory measurements.

await measure("reports.generate", () => generateReport(), {
  recordCpu: true,
  recordMemory: true,
});

The same options work with decorators:

class ReportGenerator {
  @Metrics("reports.generate", {
    recordCpu: true,
    recordMemory: true,
  })
  public async generate(): Promise<void> {
    // ...
  }
}

CPU and memory values describe the Node.js process, so concurrent work and garbage collection can affect the measurements.

Production backends

Instrumentation is independent from the destination that receives the metrics.

Configure infrastructure once at your application's composition root:

import { Registry } from "prom-client";

import { configureMetrics } from "@haskou/metrics";
import { ConsoleLoggerAdapter } from "@haskou/metrics/adapters/console";
import { PrometheusMetricsAdapter } from "@haskou/metrics/adapters/prometheus";

const registry = new Registry();

configureMetrics({
  adapter: new PrometheusMetricsAdapter({
    registry,
    attributeNames: ["service", "environment"],
  }),

  attributes: {
    service: "users-api",
    environment: "production",
  },

  logger: new ConsoleLoggerAdapter(),

  defaults: {
    logCalls: false,
    logFailures: true,
    recordDuration: true,
  },
});

Install prom-client only when using the Prometheus adapter:

npm install prom-client

Your application code remains unchanged:

await measure("users.create", () => createUser(command));

Development can use the built-in in-memory runtime.

Production can send the same instrumentation to Prometheus and structured log infrastructure.

The operation itself does not need to know.

Keep infrastructure outside your application

The library separates instrumentation from its output through ports.

This allows metrics and logs to be mapped to different observability stacks without spreading vendor SDKs through application code.

Typical destinations include:

Destination Mapping
Prometheus / Grafana Counters, histograms and gauges
OpenTelemetry Matching metric instruments
StatsD / Datadog Measurements mapped to vendor APIs
Loki Structured logs
Pino Structured logs
Winston Structured logs
OpenTelemetry Logs Structured logs

Adapters can be implemented against the library contracts when a destination is not provided directly.

Prometheus cardinality

Keep metric attributes low-cardinality and bounded.

Good:

service
environment
route
method
status

Avoid:

userId
requestId
email
full URL
error message
stack trace

Unbounded labels can create enormous Prometheus cardinality and turn a perfectly innocent dashboard into infrastructure arson.

Structured failures

Failed operations are recorded without changing their normal error behaviour:

await measure("payments.process", () => processPayment(payment));

If processPayment throws, instrumentation can record:

  • the failed operation
  • execution duration
  • structured failure information
  • stack trace

The original error still propagates to the caller.

Observability should observe your application, not rewrite its control flow.

Documentation

Full documentation:

https://haskou.github.io/metrics/

Integration guides:

Try it online

The playground lets you run successful and failed operations and inspect the generated metrics, logs and stack traces directly in the browser:

https://haskou.github.io/metrics/playground/

Or edit the example in StackBlitz:

https://stackblitz.com/github/haskou/metrics?startScript=playground:dev

Development

git clone https://github.com/haskou/metrics.git
cd metrics

yarn install
yarn test
yarn build

License

MIT. See LICENSE.txt.

Releases

Packages

Contributors

Languages