Documentation/titan/From prom-client
In this section

From prom-client

prom-client is the de-facto Prometheus client for Node. It is excellent at what it does — instrument code, expose /metrics, hand off to a real Prometheus server. titan-metrics covers the same use case while also persisting samples to a storage backend (memory / SQLite / Postgres), letting you query time-series inside your app and ship cross-pod aggregates without standing up Prometheus first.

This page is the surgical recipe for moving from one to the other.

At a glance

prom-clienttitan-metrics
new Registry()MetricsService (DI-provided singleton)
new Counter({ name, help, labelNames })metrics.recordTyped('counter', name, labels, 1)
new Gauge(...)metrics.recordTyped('gauge', name, labels, v)
new Histogram({ buckets })metrics.recordTyped('histogram', name, labels, v)
register.metrics()metrics.getPrometheusText()
collectDefaultMetrics()collection: { process: true, system: true }
Custom registry per appappName option (tags every sample)
(no built-in storage)storage: { type: 'memory' | 'sqlite' | 'postgres' }
(manual cleanup)retention: { maxAge: '7d' } (automatic)
(manual exposition route)MetricsRpcService over Netron (or roll your own)

A side-by-side concrete example

Suppose you currently instrument an HTTP handler like this:

// prom-client
import { Counter, Histogram, register, collectDefaultMetrics }
  from 'prom-client';

collectDefaultMetrics();

const httpRequests = new Counter({
  name: 'http_requests_total',
  help: 'HTTP requests',
  labelNames: ['route', 'status'],
});

const httpDuration = new Histogram({
  name: 'http_request_duration_ms',
  help: 'HTTP latency (ms)',
  labelNames: ['route'],
  buckets:    [5, 25, 100, 500, 2500],
});

app.post('/orders', async (req, res) => {
  const end = httpDuration.startTimer({ route: '/orders' });
  try {
    const order = await orders.create(req.body);
    res.json(order);
    httpRequests.inc({ route: '/orders', status: '200' });
  } catch (e) {
    httpRequests.inc({ route: '/orders', status: '500' });
    throw e;
  } finally { end(); }
});

app.get('/metrics', async (_req, res) => {
  res.type(register.contentType).end(await register.metrics());
});

The Titan equivalent uses the @Metrics decorator for automatic RPC instrumentation, and recordTyped for your own counters / histograms:

// titan-metrics
import { Module, Service, Inject } from '@omnitron-dev/titan';
import { Public } from '@omnitron-dev/titan/decorators';
import { TitanMetricsModule, MetricsService, METRICS_SERVICE_TOKEN, Metrics }
  from '@omnitron-dev/titan-metrics';

@Service('orders@1.0.0')
class OrdersService {
  constructor(
    @Inject(METRICS_SERVICE_TOKEN) private readonly metrics: MetricsService,
  ) {}

  // @Metrics() auto-records rpc_requests_total,
  // rpc_request_duration_seconds and rpc_errors_total for this method.
  // The optional string only overrides the `method` label.
  @Public()
  @Metrics()
  async create(input: CreateOrder) {
    const order = await this.repo.create(input);
    // Custom metrics use recordTyped — there is no counter/histogram
    // option on @Metrics.
    this.metrics.recordTyped('counter', 'orders_processed_total', { status: 'ok' }, 1);
    return order;
  }
}

@Module({
  imports: [
    TitanMetricsModule.forRoot({
      appName:    'orders-api',
      collection: { enabled: true, process: true, system: true, rpc: true },
      storage:    { type: 'memory' },
    }),
  ],
  providers: [OrdersService],
})
class AppModule {}

If you need the raw Prometheus text:

const text = await metrics.getPrometheusText();
// Serve from any HTTP route, or expose via the RPC service.

Step-by-step migration

1. Install the module, keep prom-client for now

pnpm add @omnitron-dev/titan-metrics

Don't uninstall prom-client yet — you'll cut it last.

2. Boot the module

TitanMetricsModule.forRoot({
  appName:    'my-service',
  collection: { enabled: true, process: true, system: true, rpc: true },
  storage:    { type: 'memory' },  // try sqlite/postgres later
})

collection.process: true replaces collectDefaultMetrics() — RSS, heap, CPU, uptime. (Event-loop lag is not built in; register a gauge yourself if you need it.)

3. Migrate one metric at a time

For each existing counter / gauge / histogram, write the Titan equivalent:

// Before
const userCreated = new Counter({
  name: 'users_created_total',
  help: 'Users created',
  labelNames: ['source'],
});
userCreated.inc({ source: 'web' });

// After
metrics.recordTyped('counter', 'users_created_total', { source: 'web' }, 1);

recordTyped is the canonical API — it keeps the Prometheus registry and storage backend synchronised in a single call.

4. Migrate the exposition route

// Before
app.get('/metrics', async (_req, res) => {
  res.type(register.contentType).end(await register.metrics());
});

// After (HTTP)
app.get('/metrics', async (_req, res) => {
  res.type('text/plain; version=0.0.4').end(await metrics.getPrometheusText());
});

// After (via Netron RPC — what the Omnitron console reads)
// MetricsRpcService (Netron service name 'OmnitronMetrics') is NOT
// auto-registered by the module — expose it yourself, constructing it
// with the injected MetricsService.

5. Drop prom-client

Once every Counter / Gauge / Histogram is gone:

pnpm remove prom-client

Naming conventions

prom-client uses snake_case. titan-metrics does not normalise metric names — getPrometheusText() emits the name exactly as you registered it. Prometheus metric names may only contain [a-zA-Z0-9_:], so a dotted name like users.created.total would be emitted verbatim and produce invalid exposition.

Use snake_case at the call site:

What you recordPrometheus exposition
users_created_totalusers_created_total
http_request_durationhttp_request_duration
orders_processed_totalorders_processed_total

The built-in collectors already follow this (rpc_requests_total, heap_used_bytes, …); match it so your custom metrics scrape cleanly.

Labels

Same advice as prom-client: keep label cardinality small.

  • Good: tier, region, status, route, version.

  • Bad: userId, requestId, email — every value creates a fresh time series.

titan-metrics will happily accept high-cardinality labels and your storage backend will fill up. Discipline lives in the call sites.

Histograms

prom-client lets you set buckets per-histogram. titan-metrics applies one registry-wide bucket set instead: recordTyped('histogram', …) records against the registry's configured buckets (a default set unless the registry is constructed with a custom buckets array) — there is no per-call bucket argument.

Five-to-ten buckets is usually right. Each bucket is a separate time-series, so over-bucketing has real cost.

Default metrics

prom-clienttitan-metrics
collectDefaultMetrics()collection: { process: true }
collectDefaultMetrics({ register })(registry is module-managed; nothing to pass)
heap / RSS / CPU / uptimeIncluded via collection.process; sample at collection.interval (default 5s). Event-loop lag / GC / FDs are not built in.

Persistence — the bit prom-client doesn't do

titan-metrics flushes samples to the storage backend on a fixed 5s cadence. This unlocks queries from inside your app:

const series = await metrics.querySeries({
  names:    ['orders_process_ms'],   // array; filter by metric name(s)
  from:     Date.now() - 3_600_000,
  to:       Date.now(),
  interval: '1m',                    // bucket size as a duration string
});

Use cases:

  • Operator console without Prometheus.

  • Auto-degradation logic that reads its own metrics.

  • Cross-pod aggregation by pointing storage: { type: 'postgres' } at a shared connection.

If you don't need these, the 'memory' backend is essentially free — the ring buffer caps RAM use, the exposition path is identical to a plain registry, and you keep the option of turning persistence on later.

What's different

  • No registry per-instance. The container provides one MetricsService per app; multi-registry scenarios are rare and handled by appName tags or separate processes.

  • recordTyped instead of typed builders. Less verbose, fewer pre-declared objects, but you lose the compile-time guarantee that the label set matches the declaration.

  • @Metrics() decorator auto-instruments a Netron method with the fixed rpc_requests_total / rpc_request_duration_seconds / rpc_errors_total set. It takes only an optional method-label string — for custom metrics you still use recordTyped.

See also