Skip to content

Metrics API ​

Package: @onebun/metrics

Overview ​

OneBun provides Prometheus-compatible metrics with:

  • Automatic HTTP request metrics
  • System metrics (CPU, memory, event loop)
  • Custom counters, gauges, and histograms

Enabling Metrics ​

In Application ​

typescript
import { OneBunApplication } from '@onebun/core';
import { AppModule } from './app.module';

const app = new OneBunApplication(AppModule, {
  metrics: {
    enabled: true,
    path: '/metrics',
    prefix: 'myapp_',
    collectHttpMetrics: true,
    collectSystemMetrics: true,
    collectGcMetrics: true,
    systemMetricsInterval: 5000,
    defaultLabels: {
      service: 'my-service',
      environment: process.env.NODE_ENV || 'development',
    },
  },
});

Configuration Options ​

typescript
interface MetricsOptions {
  /** Enable/disable metrics (default: true) */
  enabled?: boolean;

  /** HTTP path for metrics endpoint (default: '/metrics') */
  path?: string;

  /** Default labels for all metrics */
  defaultLabels?: Record<string, string>;

  /** Enable HTTP request metrics (default: true) */
  collectHttpMetrics?: boolean;

  /** Enable system metrics (default: true) */
  collectSystemMetrics?: boolean;

  /** Enable GC metrics (default: true) */
  collectGcMetrics?: boolean;

  /** System metrics collection interval in ms (default: 5000) */
  systemMetricsInterval?: number;

  /** Metric name prefix (default: 'onebun_') */
  prefix?: string;

  /** HTTP request duration histogram buckets */
  httpDurationBuckets?: number[];

  /** The Prometheus registry this application writes to and scrapes (default: its own) */
  registry?: Registry;
}

One registry per application ​

Each application owns its own Prometheus Registry. Two applications in one process — which is what multi-service mode is — do not collide on metric names and do not serve each other's series: a scrape of one service returns that service's metrics, stamped with that service's defaultLabels.

Applications that share one registry — prom-client's process-global register, through the registry option below — do collide. With the default prefix the second application throws on a duplicate metric name, its metrics service is never built, and its /metrics answers 404 — while the first application's series have already been restamped with the second one's labels. With a distinct prefix per service, as this page recommends for multi-service, there is no error and both endpoints serve everything, all labelled with whichever service started last.

Two consequences worth knowing:

  • Process-level series are replicated per service. process_cpu_*, memory and event-loop metrics describe the process, and every service in it exposes them. Summing across targets double-counts them; aggregate with max or scrape one service for process-level data.
  • register is not what an application scrapes. A metric built as new Counter({ ..., registers: [register] }) registers, but never appears in any /metrics body. The framework names such metrics once, on the first scrape, in a WARN. Reach the application's registry with metricsService.getRegistry().register, or put an application on the global one:
typescript
import { register } from '@onebun/metrics';

const app = new OneBunApplication(AppModule, {
  metrics: { registry: register },
});

Metrics created through this.metrics in a service or controller, and through metricsService.createCounter() and friends, always land in the owning application's registry.

Decorators record into the application that built the instance. A method decorator has no application at decoration time, but it has this at call time, and the framework stamps every instance it builds with the scope of the application that built it. @Timed(), @Counted(), @Gauged() and WithMetrics() read that stamp, falling back to the process-wide slot for an instance the framework did not build — a plain class, a static method, a detached function reference. In a single-application process the two are the same service.

WARNING

A decorated method on a class the framework never constructed still resolves the process-wide slot, which in a multi-service process belongs to whichever service started last. If the attribution matters, put the method on a @Service() or @Controller() the application builds, or use this.metrics directly.

Outgoing HTTP calls ​

createHttpClient is a free function: it has no application, so it cannot work out which registry to record into. Hand it one:

typescript
import { createHttpClient } from '@onebun/requests';
import { createRequestsMetricsSink } from '@onebun/metrics';

const client = createHttpClient({
  baseUrl: 'https://api.example.com',
  metricsSink: createRequestsMetricsSink(metricsService),
});

Calls land in <prefix>http_client_requests_total and <prefix>http_client_request_duration_seconds, labelled method, host and status_code. Without a sink nothing is recorded — metrics: true alone is not enough.

Outgoing calls have their own family, separate from the server's http_requests_total, so a service's request rate counts only the requests it served, not the calls it made. The label is the host, not the full URL: a URL label such as route="http://127.0.0.1:35379/alpha/ping" would mint a fresh series per path, per query string and per ephemeral port.

Built-in Metrics ​

HTTP Metrics ​

# Request duration histogram
http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.1"}
http_request_duration_seconds_sum{method="GET",route="/api/users",status_code="200"}
http_request_duration_seconds_count{method="GET",route="/api/users",status_code="200"}

# Request counter
http_requests_total{method="GET",route="/api/users",status_code="200"}

System Metrics ​

# Process CPU
process_cpu_seconds_total
process_cpu_user_seconds_total
process_cpu_system_seconds_total

# Memory
process_memory_bytes{type="rss"}
process_memory_bytes{type="heapTotal"}
process_memory_bytes{type="heapUsed"}
process_memory_bytes{type="external"}

# Event loop lag
nodejs_eventloop_lag_seconds

# Active handles and requests
nodejs_active_handles_total
nodejs_active_requests_total

Custom Metrics ​

Accessing MetricsService ​

typescript
import { Service, BaseService } from '@onebun/core';

@Service()
export class OrderService extends BaseService {
  async createOrder(data: OrderData): Promise<Order> {
    // this.metrics is available in any BaseService or Controller
    const counter = this.metrics?.createCounter({
      name: 'orders_created_total',
      help: 'Total number of orders created',
    });

    counter?.inc();
    // ... process order
  }
}

Counter ​

Track cumulative values that only increase.

typescript
// Create counter - returns prom-client Counter object
const ordersCounter = this.metrics?.createCounter({
  name: 'orders_created_total',
  help: 'Total number of orders created',
  labelNames: ['status', 'payment_method'],
});

// Increment using prom-client API
ordersCounter.inc({ status: 'completed', payment_method: 'credit_card' });

// Increment by value
ordersCounter.inc({ status: 'completed', payment_method: 'credit_card' }, 5);

Gauge ​

Track values that can go up and down.

typescript
// Create gauge - returns prom-client Gauge object
const pendingGauge = this.metrics?.createGauge({
  name: 'orders_pending',
  help: 'Number of pending orders',
  labelNames: ['priority'],
});

// Set value using prom-client API
pendingGauge.set({ priority: 'high' }, 42);

// Increment
pendingGauge.inc({ priority: 'high' });

// Decrement
pendingGauge.dec({ priority: 'high' });

Histogram ​

Track distributions of values.

typescript
// Create histogram - returns prom-client Histogram object
const processingHistogram = this.metrics?.createHistogram({
  name: 'order_processing_duration_seconds',
  help: 'Order processing duration in seconds',
  labelNames: ['order_type'],
  buckets: [0.1, 0.5, 1, 2, 5, 10],
});

// Observe value using prom-client API
const startTime = Date.now();
// ... process order ...
const duration = (Date.now() - startTime) / 1000;

processingHistogram.observe({ order_type: 'standard' }, duration);

Decorator-based Metrics ​

The decorator creates its metric on first use, so applying one is enough — there is no createHistogram() to write first.

The name you pass is unprefixed; the scrape shows it prefixed

@Counted('emails_sent_total') with metrics: { prefix: 'myapp_' } appears in /metrics as myapp_emails_sent_total. Query the prefixed name in Prometheus — searching for the name you wrote is its own source of "my metric is missing".

A metric you register yourself before the first call takes precedence. Register it when you want real help text or histogram buckets chosen for your latencies — the decorator's generated ones are generic:

typescript
// In onModuleInit, before the first decorated call
this.metrics.createHistogram({
  name: 'order_processing_duration_seconds',
  help: 'How long an order takes end to end',
  buckets: [0.05, 0.1, 0.5, 1, 5],
});

@Timed() ​

Automatically time method execution.

typescript
import { Timed } from '@onebun/metrics';

@Service()
export class OrderService extends BaseService {
  @Timed('order_processing_duration_seconds')
  async processOrder(orderId: string): Promise<Order> {
    // Method execution time is automatically recorded as a histogram
    return this.doProcess(orderId);
  }
}

@Counted() ​

Automatically count method calls.

typescript
import { Counted } from '@onebun/metrics';

@Service()
export class EmailService extends BaseService {
  @Counted('emails_sent_total')
  async sendEmail(to: string, subject: string): Promise<void> {
    // Counter incremented on each call
    await this.smtp.send({ to, subject });
  }
}

@Gauged() ​

Update a gauge metric after method execution. The callback receives the class instance, so you can read its state. Supports both sync and async callbacks:

typescript
import { Gauged } from '@onebun/metrics';

@Service()
export class QueueService extends BaseService {
  pendingItems: Item[] = [];

  // Sync — read instance state directly
  @Gauged('queue_depth', (self) => self.pendingItems.length)
  async processNext(): Promise<void> {
    const item = this.pendingItems.shift();
    await this.handle(item);
  }

  // Async — query a database, call an API, etc.
  @Gauged('total_items', async (self) => self.repo.count())
  async addItem(item: Item): Promise<void> {
    await this.repo.insert(item);
  }
}

Service Metrics Pattern ​

typescript
import type { Counter, Gauge, Histogram } from 'prom-client';

@Service()
export class PaymentService extends BaseService {
  private paymentsCounter?: Counter<string>;
  private processingHistogram?: Histogram<string>;
  private queueGauge?: Gauge<string>;

  constructor() {
    super();

    // Register custom metrics on service init
    this.registerMetrics();
  }

  private registerMetrics(): void {
    if (!this.metrics) return;

    // Store references to metric objects for later use
    this.paymentsCounter = this.metrics.createCounter({
      name: 'payments_processed_total',
      help: 'Total number of payments processed',
      labelNames: ['status', 'method'],
    });

    this.processingHistogram = this.metrics.createHistogram({
      name: 'payment_processing_seconds',
      help: 'Payment processing duration',
      buckets: [0.1, 0.5, 1, 2, 5, 10],
    });

    this.queueGauge = this.metrics.createGauge({
      name: 'payment_queue_size',
      help: 'Current payment queue size',
    });
  }

  async processPayment(payment: Payment): Promise<PaymentResult> {
    const startTime = Date.now();

    try {
      const result = await this.gateway.charge(payment);

      // Record success using prom-client API
      this.paymentsCounter?.inc({ status: 'success', method: payment.method });

      return result;
    } catch (error) {
      // Record failure
      this.paymentsCounter?.inc({ status: 'failure', method: payment.method });

      throw error;
    } finally {
      // Record duration
      const duration = (Date.now() - startTime) / 1000;
      this.processingHistogram?.observe(duration);
    }
  }

  updateQueueSize(size: number): void {
    this.queueGauge?.set(size);
  }
}

Metrics Endpoint ​

Access metrics at the configured path (default /metrics):

bash
curl http://localhost:3000/metrics

Response (Prometheus format):

# HELP http_request_duration_seconds HTTP request duration in seconds
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.1"} 42
http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="0.5"} 87
http_request_duration_seconds_bucket{method="GET",route="/api/users",status_code="200",le="1"} 95
http_request_duration_seconds_sum{method="GET",route="/api/users",status_code="200"} 12.45
http_request_duration_seconds_count{method="GET",route="/api/users",status_code="200"} 95

# HELP http_requests_total Total number of HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",route="/api/users",status_code="200"} 95

# HELP process_memory_bytes Process memory usage in bytes
# TYPE process_memory_bytes gauge
process_memory_bytes{type="rss"} 52428800
process_memory_bytes{type="heapTotal"} 20971520
process_memory_bytes{type="heapUsed"} 15728640

Prometheus Integration ​

prometheus.yml ​

yaml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'onebun-app'
    static_configs:
      - targets: ['localhost:3000']
    metrics_path: '/metrics'

Grafana Dashboard ​

Common queries for Grafana:

promql
# Request rate (requests per second)
rate(http_requests_total[5m])

# Request duration 95th percentile
histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m]))

# Error rate
sum(rate(http_requests_total{status_code=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))

# Memory usage
process_memory_bytes{type="heapUsed"}

# Custom metric
rate(payments_processed_total{status="success"}[5m])

Complete Example ​

typescript
import { Module, Controller, BaseController, Service, BaseService, Get, Post, Body } from '@onebun/core';
import type { Counter, Gauge, Histogram } from 'prom-client';

// Service with custom metrics
@Service()
export class AnalyticsService extends BaseService {
  private eventsCounter?: Counter<string>;
  private processingHistogram?: Histogram<string>;
  private queueGauge?: Gauge<string>;

  constructor() {
    super();
    this.initMetrics();
  }

  private initMetrics(): void {
    if (!this.metrics) return;

    // Store references to prom-client metric objects
    this.eventsCounter = this.metrics.createCounter({
      name: 'analytics_events_total',
      help: 'Total analytics events',
      labelNames: ['event_type', 'source'],
    });

    this.processingHistogram = this.metrics.createHistogram({
      name: 'analytics_processing_seconds',
      help: 'Analytics event processing time',
      buckets: [0.001, 0.005, 0.01, 0.05, 0.1],
    });

    this.queueGauge = this.metrics.createGauge({
      name: 'analytics_queue_depth',
      help: 'Current queue depth',
    });
  }

  async trackEvent(eventType: string, source: string, data: unknown): Promise<void> {
    const startTime = performance.now();

    try {
      // Process event
      await this.processEvent(eventType, data);

      // Record metrics using prom-client API
      this.eventsCounter?.inc({ event_type: eventType, source });
    } finally {
      const duration = (performance.now() - startTime) / 1000;
      this.processingHistogram?.observe(duration);
    }
  }

  updateQueueDepth(depth: number): void {
    this.queueGauge?.set(depth);
  }

  private async processEvent(eventType: string, data: unknown): Promise<void> {
    // Event processing logic
    this.logger.debug('Processing event', { eventType });
  }
}

// Controller
@Controller('/analytics')
export class AnalyticsController extends BaseController {
  constructor(private analyticsService: AnalyticsService) {
    super();
  }

  @Post('/track')
  async track(@Body() body: { event: string; source: string; data?: unknown }) {
    await this.analyticsService.trackEvent(body.event, body.source, body.data);
    return { tracked: true };
  }
}

// Module
@Module({
  controllers: [AnalyticsController],
  providers: [AnalyticsService],
})
export class AnalyticsModule {}

Released under the MPL-2.0 License.