Skip to content

Metrics

Contract

@modularityjs/metrics provides driver-agnostic metric recording. MetricsService exposes the three standard instrument shapes:

typescript
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);
  }
}
MethodInstrumentSemantics
increment(name, value?, attributes?)counterMonotonic; value defaults to 1, must be positive
record(name, value, attributes?)histogramDistribution samples (durations, sizes)
gauge(name, value, attributes?)gaugeLast 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.

typescript
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 copy

metrics-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.

typescript
// 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.

typescript
const registry = app.get(MetricsService) as PrometheusMetricsService;
registry.render(); // the scrape body

render() 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 callEmitted asSeries
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. record carries no bucket boundaries and the registry keeps aggregates, not samples, so there is no le series to emit. summary is the honest type for count+sum; a # TYPE histogram with no _bucket series is a metric Prometheus stores broken.
  • Quantiles — same reason, no samples are retained.
  • # HELP lines. The contract carries no descriptions, and a fabricated one is worse than none.
  • OpenMetrics and exemplars. Version 0.0.4 only.
  • Automatic _total suffixing. Renaming a caller's queue.published to queue_published_total behind their back makes the metric unfindable by the name they wrote — put _total in 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).

typescript
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:

MetricInstrumentAttributesNotes
http.requestscountermethod, status_classstatus_class is 2xx / 4xx / 5xx
http.request.duration_mshistogrammethod, status_classrequest-to-send latency
http.requests.in_flightgaugecurrently-processing requests
typescript
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:

MetricInstrumentAttributesRecorded on
queue.publishedcountertopicsuccessful publish / per-topic in batch
queue.publish.errorscountertopicpublish throw
queue.publish.duration_mshistogramtopicevery publish
queue.publish_batch.sizehistogramnon-empty publishBatch
queue.publish_batch.duration_mshistogramevery publishBatch
queue.publish_batch.errorscounterpublishBatch throw
queue.dead_letters.depthgaugetopicevery getDeadLetters read (opportunistic)
queue.dead_letters.purgedcountertopicpurgeDeadLetters 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:

MetricInstrumentAttributesRecorded
queue.consumedcountertopic, consumer, outcomeper handler execution (success/failure)
queue.consume.duration_mshistogramtopic, consumerhandler latency
queue.consume.lag_mshistogramtopic, consumerpublish-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:

MetricInstrumentAttributesRecorded on
cache.hitscounterget returning a value
cache.missescounterget returning undefined
cache.operationscounteroperationevery other successful operation
cache.operation.duration_mshistogramoperationevery operation, success or throw
cache.errorscounteroperationany 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).

typescript
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:

MetricInstrumentAttributesRecorded on
database.transactionscounterpropagation, outcomeevery transaction (commit/rollback)
database.transaction.duration_mshistogrampropagationevery transaction
database.transactions.in_flightgaugecurrently-open transactions
database.pingscounteroutcomeevery ping (success/failure)
database.ping.duration_mshistogramevery 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).