Metrics
Contract
@modularityjs/metrics provides driver-agnostic metric recording. MetricsService exposes the three standard instrument shapes:
import { Inject, Injectable } from '@modularityjs/di';
import { MetricsService } from '@modularityjs/metrics';
@Injectable()
class MailWorker {
constructor(
@Inject(MetricsService) private readonly metrics: MetricsService,
) {}
async process(batch: unknown[]) {
const startedAt = performance.now();
// ...
this.metrics.increment('mail.sent', batch.length, { transport: 'smtp' });
this.metrics.record(
'mail.batch.duration_ms',
performance.now() - startedAt,
);
this.metrics.gauge('mail.queue.depth', 12);
}
}| Method | Instrument | Semantics |
|---|---|---|
increment(name, value?, attributes?) | counter | Monotonic; value defaults to 1, must be positive |
record(name, value, attributes?) | histogram | Distribution samples (durations, sizes) |
gauge(name, value, attributes?) | gauge | Last write wins per attribute set |
The public methods are template methods: metric names (/^[a-zA-Z][a-zA-Z0-9._/-]*$/) and values are validated uniformly across drivers and throw ValidationException on programmer error; drivers never throw for backend trouble — metrics are a side channel and must not take down business code.
Attributes are dimension labels (Record<string, string | number | boolean>). Every distinct attribute combination is its own series — keep cardinality bounded (no user ids or request ids).
Drivers
metrics-memory
@modularityjs/metrics-memory aggregates in process and is queryable — the driver for tests, the dev profile, and single-process apps. Histograms keep running aggregates (count/sum/min/max), never raw samples, and a maxSeries cap (default 10 000) bounds memory: new series beyond the cap are dropped with a one-time ModularityJsMetricsSeriesLimit warning.
const service = app.get(MetricsService) as MemoryMetricsService;
service.counterValue('mail.sent', { transport: 'smtp' }); // 42
service.histogramStats('mail.batch.duration_ms'); // { count, sum, min, max }
service.snapshot(); // full defensive copymetrics-otel
@modularityjs/metrics-otel bridges to the OpenTelemetry Meter API, creating one OTel instrument per metric name. It talks only to @opentelemetry/api: the SDK, exporter, and MeterProvider setup stay in telemetry-otel (set TelemetryOtelConfig.metricExporter and the NodeSDK registers the global provider). Without a registered provider the OTel API is a silent no-op by design. MetricsOtelConfig.meterProvider accepts an explicit provider — the seam tests use to read metrics back through an in-memory reader.
// modules: [
// ModularityModule,
// TelemetryModule.forRoot({ serviceName: 'orders' }),
// TelemetryOtelModule.forRoot({ metricExporter: new PeriodicExportingMetricReader(...) }),
// MetricsModule,
// MetricsOtelModule,
// ...
// ]metrics-prometheus
@modularityjs/metrics-prometheus is a scrape-shaped in-process registry. Prometheus is a pull system: the process must still hold the current value of every series when the scraper arrives, which is why this is a driver and not a formatter over another driver's state. It keeps the metric name and the attribute map structured per series (exposition re-emits each label as its own name="value" pair), and summaries keep running count/sum aggregates rather than samples, so memory is bounded by maxSeries (default 10 000) alone.
const registry = app.get(MetricsService) as PrometheusMetricsService;
registry.render(); // the scrape bodyrender() produces the Prometheus text exposition format, version 0.0.4, hand-written rather than delegated to a client library — the format is a page of string building, and the alternative is a second registry inside the process that MetricsService would have to be kept in sync with.
| Contract call | Emitted as | Series |
|---|---|---|
increment | # TYPE <name> counter | <name>{labels} <value> |
gauge | # TYPE <name> gauge | <name>{labels} <value> |
record | # TYPE <name> summary | <name>_sum, <name>_count |
Deliberately not supported:
- Histogram buckets.
recordcarries no bucket boundaries and the registry keeps aggregates, not samples, so there is noleseries to emit.summaryis the honest type for count+sum; a# TYPE histogramwith no_bucketseries is a metric Prometheus stores broken. - Quantiles — same reason, no samples are retained.
# HELPlines. The contract carries no descriptions, and a fabricated one is worse than none.- OpenMetrics and exemplars. Version 0.0.4 only.
- Automatic
_totalsuffixing. Renaming a caller'squeue.publishedtoqueue_published_totalbehind their back makes the metric unfindable by the name they wrote — put_totalin the metric name if you want it.
Names are sanitised into Prometheus's character set (., /, - → _), so two distinct framework names can collide. A collision between different metric types cannot be represented at all, so the later one is dropped from the scrape with a one-time ModularityJsPrometheusNameCollision warning naming both — two # TYPE lines for one name would corrupt the whole scrape, not just that metric.
Only one metrics driver wins the preference, so an app that wants Prometheus scraping picks this one instead of metrics-memory; its read API (counterValue / gaugeValue / summaryStats / series) covers the same test and dev-panel needs.
Serving the scrape endpoint
@modularityjs/http-metrics-prometheus mounts GET /metrics and returns registry.render() with Content-Type: text/plain; version=0.0.4 and Cache-Control: no-store (a cached scrape is a flat line in every dashboard drawn from it).
import { MetricsPrometheusModule } from '@modularityjs/metrics-prometheus';
import { HttpMetricsPrometheusModule } from '@modularityjs/http-metrics-prometheus';
const modules = [
// ...HttpModule, HttpFastifyModule, MetricsModule...
MetricsPrometheusModule,
HttpMetricsPrometheusModule,
HttpMetricsModule, // optional: the HTTP instrumentation that fills it
];The route is fixed and version-neutral, for the same reason http-health's probes are: the URL lives in a Prometheus scrape config or a prometheus.io/path annotation, so enabling HttpModuleConfig.versioning must not silently move it to /v1/metrics — a target that stops reporting looks exactly like a target that is down.
The module imports MetricsPrometheusModule, not the abstract MetricsModule. Only the Prometheus driver holds a readable registry, so wiring the endpoint next to metrics-otel (a push exporter with nothing to read back) fails at boot rather than serving an endpoint that always returns nothing.
HttpMetricsPrometheusConfig.bearerToken is unset by default — the normal deployment reaches the endpoint only from inside the cluster, and Prometheus's scrape config is where credentials would otherwise be duplicated per target. Set it (checked in constant time) when the port is exposed beyond the scrape network: a metrics body names your routes, queues, and tenants, which is reconnaissance even when no single value is a secret.
Instrumentation extensions
http-metrics
@modularityjs/http-metrics instruments the abstract HTTP request lifecycle (HttpServer.onRequest / onSend) — driver-agnostic, no Fastify coupling:
| Metric | Instrument | Attributes | Notes |
|---|---|---|---|
http.requests | counter | method, status_class | status_class is 2xx / 4xx / 5xx … |
http.request.duration_ms | histogram | method, status_class | request-to-send latency |
http.requests.in_flight | gauge | — | currently-processing requests |
import { HttpMetricsModule } from '@modularityjs/http-metrics';
const modules = [
// ...HttpModule, HttpFastifyModule, MetricsModule, a metrics driver...
HttpMetricsModule,
];The URL path is deliberately not an attribute — raw paths are unbounded cardinality. Metrics emission is wrapped so backend trouble never fails a request.
queue-metrics
@modularityjs/queue-metrics instruments both sides of the queue — the metrics counterpart to the *-telemetry family. Publish-side rides the @Plugin seam on the contract:
| Metric | Instrument | Attributes | Recorded on |
|---|---|---|---|
queue.published | counter | topic | successful publish / per-topic in batch |
queue.publish.errors | counter | topic | publish throw |
queue.publish.duration_ms | histogram | topic | every publish |
queue.publish_batch.size | histogram | — | non-empty publishBatch |
queue.publish_batch.duration_ms | histogram | — | every publishBatch |
queue.publish_batch.errors | counter | — | publishBatch throw |
queue.dead_letters.depth | gauge | topic | every getDeadLetters read (opportunistic) |
queue.dead_letters.purged | counter | topic | purgeDeadLetters result > 0 |
Consumer-side metrics ride the QueueConsumerMiddlewarePool seam — the contract-level middleware chain every driver runs around each @Consume execution. QueueMetricsModule contributes a ConsumeMetricsMiddleware automatically:
| Metric | Instrument | Attributes | Recorded |
|---|---|---|---|
queue.consumed | counter | topic, consumer, outcome | per handler execution (success/failure) |
queue.consume.duration_ms | histogram | topic, consumer | handler latency |
queue.consume.lag_ms | histogram | topic, consumer | publish-to-consume latency |
Wire QueueMetricsModule next to PluginsModule, the queue driver, and a metrics driver — both sides come with the one module.
cache-metrics
@modularityjs/cache-metrics rides the @Plugin seam on CacheService, so it instruments whichever cache driver is bound:
| Metric | Instrument | Attributes | Recorded on |
|---|---|---|---|
cache.hits | counter | — | get returning a value |
cache.misses | counter | — | get returning undefined |
cache.operations | counter | operation | every other successful operation |
cache.operation.duration_ms | histogram | operation | every operation, success or throw |
cache.errors | counter | operation | any operation that throws |
The hit ratio (cache.hits / (cache.hits + cache.misses)) is the one number that says whether the cache is earning its keep, and it cannot be derived from traces. Reads are counted as a hit or a miss and not also under cache.operations, so hits + misses is exactly the read volume. operation is the contract method name — a fixed handful of values; the cache key and the tag are deliberately never attributes (unbounded cardinality).
import { CacheMetricsModule } from '@modularityjs/cache-metrics';
const modules = [
// ...PluginsModule, CacheModule, a cache driver, MetricsModule, a metrics driver...
CacheMetricsModule,
];database-metrics
@modularityjs/database-metrics instruments TransactionRunner.run — the one seam every @Transactional and every manual transaction passes through — plus DatabaseConnection.ping:
| Metric | Instrument | Attributes | Recorded on |
|---|---|---|---|
database.transactions | counter | propagation, outcome | every transaction (commit/rollback) |
database.transaction.duration_ms | histogram | propagation | every transaction |
database.transactions.in_flight | gauge | — | currently-open transactions |
database.pings | counter | outcome | every ping (success/failure) |
database.ping.duration_ms | histogram | — | every ping |
Both labels are bounded: propagation is REQUIRED or REQUIRES_NEW, outcome is commit or rollback. A REQUIRED call that joins an ambient transaction is still counted — the counter tracks units of transactional work as the caller sees them, not the number of BEGINs the driver issued. Statement-level counters are out of scope: they belong to ORM-specific instrumentation, and a per-statement label would be unbounded cardinality.
Every instrumentation extension wraps emission so a throwing MetricsService never fails the business call; a permanently broken backend surfaces as a once-per-process process.emitWarning instead of silence.
Conformance
All three drivers run describeMetricsServiceContract from @modularityjs/testing — the suite pins counter accumulation, per-attribute series independence, histogram count/sum aggregation, gauge last-write-wins, and the shared validation rules. New drivers must hook into it; the context's read seam is how each driver exposes readback (the memory driver's query API, an in-memory OTel reader).