Build a Prometheus Metrics Exporter Service
A complete Prometheus exporter: counters, gauges, histograms, label discipline, a pull endpoint, and tests that assert metric names and buckets.
Project goal
Expose application and business metrics in Prometheus text format with consistent names, bounded label cardinality, and histogram buckets that fit the workload.
Final features
- Counter helper
- Histogram buckets
- HTTP middleware
- Render endpoint
- Business gauge
Architecture
A registry holds counters, gauges, and histograms. A /metrics endpoint renders the registry in the exposition format. Middleware instruments HTTP requests, and business code records domain events through typed helpers.
Prerequisites
- Node.js 20 LTS or later
- A Prometheus server or compatible scraper
- Basic knowledge of metric types
Dependencies
- prom-client ^15.1.3 — Metric types and registry
- express ^4.21.0 — HTTP surface for /metrics
Directory structure
metrics-exporter/
├── src/registry.ts
├── src/http-metrics.ts
├── src/render.ts
├── src/business.ts
├── tests/metrics.test.ts
└── package.json
Phases
1. Counter helper
Record monotonically increasing events with bounded labels.
const requests = new Counter({ name: 'http_requests_total', labelNames: ['route', 'status'] });
Expected behavior: Counters must never decrease; a reset signals a process restart.
2. Histogram buckets
Choose buckets that match the service latency profile.
const latency = new Histogram({ name: 'http_request_seconds', buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5] });
Expected behavior: Default buckets often miss the tail that matters for SLOs.
3. HTTP middleware
Observe duration and count by route with a low-cardinality route label.
const routeLabel = routeFromTemplate(req);
latency.observe({ route: routeLabel }, seconds);
Expected behavior: Using the raw path as a label explodes cardinality.
4. Render endpoint
Expose the registry in text format with a content type scrape targets expect.
res.set('Content-Type', registry.contentType).send(await registry.metrics());
Expected behavior: The exposition format and content type must match the spec.
5. Business gauge
Track queue depth or active jobs as a gauge that can go up and down.
queueDepth.set({ queue: 'email' }, await queue.size());
Expected behavior: Gauges reflect current state, not cumulative totals.
Testing
Scrape /metrics and assert that the expected metric names appear, that a request increments the counter, and that histogram buckets are monotonic.
Troubleshooting
Cardinality explosion
A user id or raw path became a label; replace it with a bounded route template.
Metrics reset unexpectedly
Each scrape imports a fresh module copy of the registry, so it appears empty; use a single module instance.
Deployment
Run the exporter in-process with the app so metrics reflect the real process, and configure the scraper with a stable job name and instance label. Keep exposition cheap; a large registry slows every scrape.
Known limitations
- The exporter exposes metrics over HTTP pull only.
- No exemplars or native histograms.
- Cardinality is bounded manually, not enforced automatically.
Project overview
Metrics are only useful if their names are stable and their label sets are bounded, and both properties are easy to lose a few sprints after the first counter is added. This build defines a small registry with typed helpers so instrumentation stays consistent.
You will register counters, gauges, and histograms, instrument HTTP requests with middleware, record business events through typed helpers, and render the registry at a scrape endpoint in the standard exposition format.
Label cardinality is the silent failure of metrics systems. A label that takes a user id or a request path with variables turns one series into millions, and the monitoring system pays the price in memory and query time.
The exposition format is a contract, so the service emits names and label sets a scraper can rely on, and it computes values cheaply so a scrape never becomes a load event.
Each phase adds one piece: a counter, a histogram, HTTP middleware, the render endpoint, and a business gauge, so the exporter is useful after any of them.
The project is aimed at teams that have added metrics by copy-paste and now cannot tell which of three similarly named counters a dashboard actually queries. It replaces that drift with a small typed layer, so a metric has one definition and one owner, and a naming mistake becomes a compile error rather than a silent second series.
It is deliberately unexciting: a registry, some helpers, middleware, and a render path, with no agent or sidecar. That keeps the instrumentation close to the code that knows what the event means.
What you will build
The exporter provides five capabilities, each verified by scraping a running service:
- Counter helper
- Histogram buckets
- HTTP middleware
- Render endpoint
- Business gauge
A counter helper records events consistently; a histogram captures latency with buckets chosen for the workload; middleware instruments HTTP without touching every handler; the render endpoint exposes the registry; and a business gauge reflects domain state.
The design keeps instrumentation in a small library rather than scattered across handlers, so a naming or labeling rule is enforced in one place and cannot drift by copy-paste.
Architecture and data model
A registry holds counters, gauges, and histograms. A /metrics endpoint renders the registry in the exposition format. Middleware instruments HTTP requests, and business code records domain events through typed helpers.
A registry holds counters, gauges, and histograms created through typed helpers, so a metric is always created with a name and a bounded set of label keys.
The scrape endpoint renders the registry in the exposition format, computing values from in-memory state so a scrape is cheap and does not touch the database.
HTTP middleware records request count and duration once, rather than each handler doing it, which keeps the labels consistent across endpoints.
Histogram buckets are chosen from the expected latency distribution, because default buckets that do not match the workload produce a histogram that cannot answer the question it exists for.
Because the registry is created once and shared, a metric is defined in one module and referenced everywhere else, so a label set is decided once. When a label is added, every call site that omits it fails to compile, which turns a cardinality change into a visible diff rather than a discovery in a scrape.
Prerequisites and dependencies
You need a Prometheus-compatible scraper and a place to write the metric definitions that both the app and the exporter agree on.
- Node.js 20 LTS or later
- A Prometheus server or compatible scraper
- Basic knowledge of metric types
The dependencies cover the HTTP surface and the metrics library, with no external store on the hot path:
prom-client(^15.1.3) — Metric types and registryexpress(^4.21.0) — HTTP surface for /metrics
pnpm init
pnpm add prom-client express
pnpm add -D typescript tsx vitest
Fix the metric names and the label keys in the helper signatures, because a renamed metric breaks every dashboard and alert that referenced it. Record the scrape interval in configuration so bucket choice can be matched to it.
Directory structure
Separate the metric definitions, the HTTP middleware, the business helpers, and the render endpoint so naming rules live with the definitions:
metrics-exporter/
├── src/registry.ts
├── src/http-metrics.ts
├── src/render.ts
├── src/business.ts
├── tests/metrics.test.ts
└── package.json
Phase 1: Counter helper
Record monotonically increasing events with bounded labels.
const requests = new Counter({ name: 'http_requests_total', labelNames: ['route', 'status'] });
Counters must never decrease; a reset signals a process restart.
Start with a counter helper that takes a fixed set of label keys, so two call sites cannot accidentally create the same metric with different labels.
Phase 2: Histogram buckets
Choose buckets that match the service latency profile.
const latency = new Histogram({ name: 'http_request_seconds', buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5] });
Default buckets often miss the tail that matters for SLOs.
Add a histogram with buckets chosen from the observed latency, since buckets that are too coarse or too fine make the histogram useless for percentiles.
Phase 3: HTTP middleware
Observe duration and count by route with a low-cardinality route label.
const routeLabel = routeFromTemplate(req);
latency.observe({ route: routeLabel }, seconds);
Using the raw path as a label explodes cardinality.
Instrument HTTP requests with middleware, recording method, route template, and status rather than the raw path, because a raw path with an id creates unbounded cardinality.
Phase 4: Render endpoint
Expose the registry in text format with a content type scrape targets expect.
res.set('Content-Type', registry.contentType).send(await registry.metrics());
The exposition format and content type must match the spec.
Render the registry at a scrape endpoint, and compute values at scrape time from in-memory state so the endpoint stays cheap regardless of traffic.
Phase 5: Business gauge
Track queue depth or active jobs as a gauge that can go up and down.
queueDepth.set({ queue: 'email' }, await queue.size());
Gauges reflect current state, not cumulative totals.
Add a business gauge or counter for a domain event, using a typed helper so the recording site is one line and the label set is validated.
Testing
Scrape /metrics and assert that the expected metric names appear, that a request increments the counter, and that histogram buckets are monotonic.
Scrape the endpoint and assert that the expected metric names appear with the expected label keys, which catches a renamed metric before a dashboard breaks.
Drive a request and assert that the request counter increments, since instrumentation that is wired to the wrong layer will still compile and export nothing.
Assert that histogram buckets are monotonic and sum to the observation count, because a malformed histogram silently produces wrong percentiles.
Assert that a request to many distinct paths does not create unbounded label values, which is the cardinality failure that usually appears only in production.
Scrape the endpoint from a running process rather than asserting against the registry object, because the property that matters is what a scraper sees, and a metric registered but not rendered satisfies an in-memory assertion while being invisible in practice.
Run the suite against a real listener on an ephemeral port, so the scrape path and the middleware are exercised the way a client will use them.
pnpm vitest run
Deployment and operations
Run the exporter in-process with the app so metrics reflect the real process, and configure the scraper with a stable job name and instance label. Keep exposition cheap; a large registry slows every scrape.
Run the exporter in-process with the application so the metrics reflect the real process rather than a sidecar’s view of it.
Configure the scraper with a stable job name and instance label, because a changing label makes the same series look like a new one and breaks all rate calculations.
Keep the exposition cheap: a large registry slows every scrape, and a scrape is a periodic cost that grows with the number of series.
Alert on scrape failures and on scrape duration, since a slow scrape is the first sign that cardinality has grown beyond what the system can serve.
Give the scrape endpoint its own listener or path that skips the application middleware, so a scrape is not counted as application traffic and does not appear in the latency histogram it reports.
Version control the metric definitions alongside the code that emits them, so a rename is reviewed in the same change that alters the dashboards.
Security considerations
Metrics can leak route names and business volumes. Restrict /metrics to the monitoring network and avoid labels containing user identifiers or unbounded values.
Bound the label values at the helper, so a caller cannot pass an arbitrary string that becomes a new series; use route templates rather than paths.
Never put user identifiers, tokens, or free text into a label, because labels are stored, indexed, and often visible to anyone with dashboard access.
Keep the scrape endpoint on an internal listener where possible, so metric internals are not exposed to the public internet.
Review metric additions like schema changes, because a new high-cardinality label is a resource commitment for every future scrape.
Never label a metric with a value the caller controls, because each new value becomes a stored series; a request id or a user id in a label is a slow-motion denial of service against the monitoring system rather than a useful dimension.
Keep the metric definitions free of any value that comes from a request body, because the body is the one input guaranteed to be controlled by the caller.
Observability
Track scrape duration and registry size. Alert on a metric that disappears entirely, which usually means a rename rather than a healthy zero.
Track the number of series and the scrape duration, because cardinality growth shows up as scrape duration before it shows up as a bill.
Watch histogram bucket fill, since buckets that are almost always empty or almost always full mean the bucket boundaries do not fit the workload.
Chart error and latency metrics per route template, because a per-path breakdown is the one that a cardinality cap allows and operators need.
Alert on a sudden change in the number of series, because an accidental label addition usually appears as a step in series count.
Alert on the rate of series creation rather than on the total, because a healthy system adds series slowly and a burst is the signature of a missing template or an accidental label addition.
Measure the exposition size in bytes as well as the series count, because bytes are what the scraper transfers and the two can diverge when labels are long.
Track the scrape success rate from the monitoring side, because a scraper that cannot reach the endpoint is a gap in coverage that looks exactly like calm.
Troubleshooting
Cardinality explosion. A user id or raw path became a label; replace it with a bounded route template.
Metrics reset unexpectedly. Each scrape imports a fresh module copy of the registry, so it appears empty; use a single module instance.
If a dashboard query is slow, check the series count for the metric, because high cardinality is the usual cause and is usually a label that should have been a template.
If a metric is missing, check the registry and the scrape target, since a metric that is registered but never rendered is invisible to the scraper.
If a metric appears twice with slightly different names, search the definitions module for both spellings, because the usual cause is a second counter created at a call site instead of through the shared helper.
If a metric looks flat, check that the recording call site is on the path actually taken, since instrumentation in a branch that is rarely reached is flat by construction.
Limitations and next steps
- The exporter exposes metrics over HTTP pull only.
- No exemplars or native histograms.
- Cardinality is bounded manually, not enforced automatically.
The exporter covers counters, gauges, and histograms but not summaries with client-side quantiles, which keeps the surface small and the compute predictable.
There is no automatic cardinality limiting at the registry; the discipline is enforced by helper signatures and by review rather than by a runtime cap.
Metrics are in-memory and reset on restart, so rates across a restart are computed from a counter reset, which the scraper handles but dashboards must expect.
There is no built-in aggregation or recording rules; those are configured in the monitoring system rather than in the exporter.
The exporter does not implement exemplars, native histograms, or client-side summaries, so it covers the portable subset of the format and leaves advanced features to the monitoring system, which keeps the surface stable across versions at the cost of some expressiveness.
There is no automatic removal of stale series, so a metric that is no longer emitted lingers until the monitoring system applies its own staleness policy.