-
Notifications
You must be signed in to change notification settings - Fork 2
MetroMan
Prometheus-compatible in-process metrics for Deno, Bun, Node.js, Cloudflare Workers, and browsers.
MetroMan implements the four standard Prometheus metric types
(Counter, Gauge, Histogram, Summary) and a registry class
(MetroMan) that owns metric lifecycle and bulk collection. Metrics
expose three output shapes — JSON, a debug STRING, and the
Prometheus text exposition format — via a single dump(mode) method.
The package is intentionally small and dependency-light: it imports
only @tundralibs/utils (for the base error class) and
@tundralibs/compat (for cross-runtime tests). Storage is in-process
with no I/O — there is no scrape endpoint or HTTP server bundled in —
so it runs unchanged on Workers and in the browser; wiring the
dump() output to a scrape endpoint or a fetch call is left to you.
| Module | Description | Documentation |
|---|---|---|
MetroMan |
Registry — create, store, and bulk-collect metrics | This page |
Counter |
Monotonic counter (values only increase) | MetroMan-Counter |
Gauge |
Up/down value (set, inc, dec) | MetroMan-Gauge |
Histogram |
Bucketed distribution | MetroMan-Histogram |
Summary |
Quantile-based distribution over a sliding window | MetroMan-Summary |
./errors |
MetroManError plus InvalidMetricOptionsError etc. |
MetroMan-Errors |
./types |
MetricOptions, MetricOutput, per-metric option types |
— |
Deno:
deno add @tundralibs/metro-manBun:
bunx jsr add @tundralibs/metro-manNode.js:
npx jsr add @tundralibs/metro-manimport { MetroMan } from '@tundralibs/metro-man';
const metrics = new MetroMan();
const requests = metrics.counter({
name: 'http_requests_total',
help: 'HTTP requests served',
});
const inflight = metrics.gauge({ name: 'http_requests_in_flight' });
const latency = metrics.histogram({
name: 'http_request_seconds',
buckets: [0.05, 0.1, 0.5, 1, 5],
});
// Use them in the request path:
inflight.inc({ route: '/users' });
requests.inc({ route: '/users', status: '200' });
latency.observe(0.083, { route: '/users' });
inflight.dec({ route: '/users' });
// Expose them on /metrics or wherever:
console.log(metrics.collect('PROMETHEUS'));Output (Prometheus text-exposition format):
# HELP http_requests_total HTTP requests served
# TYPE http_requests_total counter
http_requests_total{route="/users",status="200"} 1
# HELP http_requests_in_flight
# TYPE http_requests_in_flight gauge
http_requests_in_flight{route="/users"} 0
…
The registry is optional. Every metric class can be constructed and used directly:
import { Counter } from '@tundralibs/metro-man';
const errors = new Counter({ name: 'app_errors_total' });
errors.inc({ kind: 'timeout' });
console.log(errors.toPrometheus());The registry adds three things on top: a single collect() call that
bulk-renders everything, case-insensitive name lookup via get(), and
a typed has() check. None of those are required if your app only
needs a handful of metrics in known locations.
Every metric implements dump(mode):
| Mode | Returns | Use for |
|---|---|---|
'JSON' |
MetricOutput<T> object |
Structured logging, dashboards, in-process aggregation |
'STRING' |
Bracketed string
|
Ad-hoc debugging and log lines |
'PROMETHEUS' |
Exposition string
|
Serving from a /metrics endpoint |
MetroMan.collect(mode) calls dump(mode) on every registered
metric and concatenates the results (string formats) or builds a
name → MetricOutput map (JSON).
The 'PROMETHEUS' output is terminated with a trailing line feed — the
text-exposition format requires it ("The last line must end with a line
feed character"), and a body without it is rejected at EOF by the strict
parsers (Pushgateway ingestion, promtool check metrics). Every metric's
own toPrometheus() is self-terminated too, so a single metric served
directly to /metrics is equally well-formed. A metric declared but not
yet observed (a histogram or summary registered at startup and scraped
before its first observation) renders header-only — just its # HELP /
# TYPE lines — and that body is equally spec-valid: one terminating line
feed, no blank line, whether served alone or concatenated with other
families by collect('PROMETHEUS').
Every mutation method (inc, set, observe) accepts an optional
labels: Record<string, string>. Each distinct label combination
produces a distinct series. The unlabelled series is keyed as
'no_label'.
import { Counter } from '@tundralibs/metro-man';
const counter = new Counter({ name: 'http_requests_total' });
counter.inc({ method: 'GET', status: '200' });
counter.inc({ method: 'GET', status: '500' });
counter.inc(); // unlabelled seriesCardinality is unbounded and permanent. Every distinct label combination allocates a new series that stays in memory until you call
remove(labels)orreset()— there is no automatic eviction, TTL, or maximum-series cap. Never label with a value drawn from an unbounded set (user ID, raw request path, timestamp): a counter labelled byuser_idkeeps one series per user, forever. Keep label values to a small, bounded set (status,method, a route template rather than the raw path) and push high-cardinality data to logs or traces instead.
Canonicalisation. Label entries are sorted alphabetically by
name when the canonical key is built, so {b:'2', a:'1'} and
{a:'1', b:'2'} resolve to the same series.
Escaping. Label values are escaped per the Prometheus exposition
spec — \ becomes \\, " becomes \", and newlines become a
literal \n. Label names cannot be escaped into validity, so they
are validated instead: a name outside
[A-Za-z_][A-Za-z0-9_]* throws InvalidLabelError wherever labels
enter (inc, dec, set, observe, remove).
help is escaped the same way on the # HELP line, with one
difference: double quotes are left unescaped there — a # HELP line
isn't quoted, so " needs no escaping — only \ and newlines are
rewritten.
Reserved names. Histogram.observe rejects a label called le
and Summary.observe rejects quantile — both clash with the
bucket / quantile labels those renderers emit. The thrown error is
InvalidLabelError.
Construct an empty registry.
Create a Counter and register it under
options.name.
Create a Gauge and register it.
Create a Histogram and register it.
Create a Summary and register it.
Register one or more pre-built metric instances.
Throws:
-
DuplicateMetricError— if any instance's name is already taken, by a previously registered metric or by another instance in the same call. Registration is all-or-nothing: when any name conflicts, no instances are stored. Remove the old metric withremove()orclear()the registry before re-registering.
Case-insensitive existence check.
Case-insensitive lookup.
Throws:
-
MetricNotFoundErrorwhennameis not registered.
Dump some or all metrics in the requested format. Overloads:
collect('JSON', metrics?): Record<string, unknown>
collect('STRING', metrics?): string
collect('PROMETHEUS', metrics?): string
collect(metrics?: string[]): Record<string, unknown> // defaults to JSONWhen metrics is supplied it acts as a filter: unknown names are
skipped silently, and an empty list ([]) selects nothing — it
returns empty output ({} for JSON, '' for the string formats),
not the whole registry. Only an omitted selection dumps every metric.
A name repeated in the list is emitted once — the selection is
de-duplicated, so collect('PROMETHEUS', ['x', 'x']) never produces
two # HELP/# TYPE blocks for the same family (which a Prometheus
scrape would reject).
import { MetroMan } from '@tundralibs/metro-man';
const metrics = new MetroMan();
metrics.counter({ name: 'http_requests_total' });
metrics.counter({ name: 'app_errors_total' });
// Filter to one family by name; an unregistered name ('missing') is
// skipped rather than throwing.
console.log(metrics.collect('PROMETHEUS', ['http_requests_total', 'missing']));
// An empty selection yields empty output, not the whole registry —
// omit `metrics` entirely to dump everything.
console.log(metrics.collect('JSON', [])); // {}Remove the metric registered under name. Returns true if a
metric was actually removed.
Remove every registered metric.
All registered metric names (lower-cased).
Every metric inherits these methods from BaseMetric:
-
dump(mode),toJSON(),toString(),toPrometheus()— render -
reset()— drop every series, keep the metric registered -
remove(labels?)— drop a single series; returnstrueif found
Counter/Gauge expose inc() overloads that accept an optional
amount: inc(), inc(labels), inc(amount), inc(amount, labels).
Gauge additionally exposes dec() with the same overloads and
set(value, labels?). Counter rejects a negative amount. Every
numeric input (inc/dec amounts, set values, observe
observations) must be finite — NaN and ±Infinity throw
InvalidMetricOptionsError, since they would render exposition most
scrapers reject.
- Counter — monotonic counter
- Gauge — up/down value
- Histogram — bucketed distribution
- Summary — quantile distribution with sliding window
- Errors — error classes and matching strategies
-
@tundralibs/slogger/@tundralibs/tracer— the sibling observability pillars (logs / traces); event-emitting packages feed all three from the same seam — see drivers' Observability section for the shape
MIT