Blog
prometheusexporterspring-bootfastapimicrometerobservability

Building a Prometheus Exporter — exposing /metrics with Spring Boot and FastAPI

How to build an exporter that Prometheus can scrape over HTTP. We expose metrics with Spring Boot (Micrometer + Actuator) in Java and FastAPI (prometheus-client) in Python, then cover the Prometheus scrape_configs setup and how to verify it — all with code.

Data DynamicsJune 12, 20266 min read

Prometheus uses a pull model. An application doesn't push metrics somewhere; instead, Prometheus periodically sends an HTTP GET to each target's endpoint (/metrics) and scrapes the text. So "building an exporter" really means opening a single HTTP endpoint that returns metrics in the exposition format.

This post builds that endpoint in Java (Spring Boot) and Python (FastAPI), and configures Prometheus to scrape it. If the metric types and naming rules themselves are unclear, read the earlier post "Understanding Prometheus Metrics" first.

How it works

┌────────────┐   GET /metrics (every 15s)   ┌──────────────────┐
│ Prometheus │ ──────────────────────────▶  │  my app (exporter)│
│  (scraper) │ ◀──────────────────────────  │  /metrics endpoint│
└────────────┘   200 OK + metrics text       └──────────────────┘

The response body is human-readable plain text.

# HELP app_requests_total Requests handled
# TYPE app_requests_total counter
app_requests_total{method="GET",path="/health",status="200"} 42

Return this text per spec and any language/framework becomes an exporter. You don't assemble the string by hand — each language's client library handles the metric objects and exposition for you.

Java — Spring Boot (Micrometer + Actuator)

In Spring Boot you normally use Micrometer. Micrometer is a metrics facade, and adding micrometer-registry-prometheus makes Actuator automatically open a /actuator/prometheus endpoint. You don't have to implement the endpoint yourself.

1) Add dependencies

Maven:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
  <groupId>io.micrometer</groupId>
  <artifactId>micrometer-registry-prometheus</artifactId>
  <scope>runtime</scope>
</dependency>

Gradle:

implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'

2) Expose the endpoint

In application.yml, expose the prometheus endpoint externally.

management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus   # enables /actuator/prometheus
  metrics:
    tags:
      application: order-service           # adds an application label to all metrics

That alone opens http://host:8080/actuator/prometheus, already exposing default metrics like JVM stats and HTTP server requests (http_server_requests_seconds).

3) Custom metrics

Inject a MeterRegistry and register Counters, Gauges, and Timers.

import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.Gauge;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicInteger;
 
@Component
public class OrderMetrics {
 
    private final Counter ordersCreated;     // cumulative → counter
    private final Timer processingTimer;     // distribution of processing time
    private final AtomicInteger queueSize = new AtomicInteger(0); // state → gauge
 
    public OrderMetrics(MeterRegistry registry) {
        this.ordersCreated = Counter.builder("orders.created")  // dot notation recommended
                .description("Orders created")
                .tag("channel", "web")                          // a label
                .register(registry);
 
        this.processingTimer = Timer.builder("order.processing")
                .description("Order processing time")
                .register(registry);
 
        Gauge.builder("orders.queue.size", queueSize, AtomicInteger::get)
                .description("Orders waiting to be processed")
                .register(registry);
    }
 
    public void onOrderCreated() {
        ordersCreated.increment();           // counters only increment
    }
 
    public void process(Runnable work) {
        processingTimer.record(work);        // records execution time automatically
    }
 
    public void setQueueSize(int n) {
        queueSize.set(n);                    // gauges can be set / go up and down
    }
}

Watch Micrometer naming — in Micrometer you name metrics with dots (.), and the Prometheus registry converts them at output time. orders.createdorders_created_total (the _total suffix is added automatically for counters); order.processingorder_processing_seconds_count / _sum (a Timer reports seconds). So don't put _total in the name yourself (you'd get _total_total).

4) Usage and output

In your service code you just call it.

@Service
public class OrderService {
    private final OrderMetrics metrics;
    public OrderService(OrderMetrics metrics) { this.metrics = metrics; }
 
    public void createOrder(Order order) {
        metrics.process(() -> {
            // ... actual order processing ...
            metrics.onOrderCreated();
        });
    }
}

The /actuator/prometheus response shows this:

# TYPE orders_created_total counter
orders_created_total{application="order-service",channel="web"} 128.0
# TYPE orders_queue_size gauge
orders_queue_size{application="order-service"} 3.0

Python — FastAPI (prometheus-client)

FastAPI has no built-in mechanism like Actuator, so you create metrics with the official prometheus-client library and open a /metrics route yourself.

pip install fastapi uvicorn prometheus-client

1) Define metrics + the /metrics route

import time
from fastapi import FastAPI, Request, Response
from prometheus_client import (
    Counter, Gauge, Histogram, generate_latest, CONTENT_TYPE_LATEST,
)
 
app = FastAPI()
 
REQUESTS = Counter(
    "app_requests_total", "Requests handled", ["method", "path", "status"]
)
INPROGRESS = Gauge("app_inprogress_requests", "Requests in progress")
LATENCY = Histogram(
    "app_request_duration_seconds", "Request processing time",
    ["method", "path"],
    buckets=[0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
)
 
 
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
    INPROGRESS.inc()
    start = time.perf_counter()
    response = await call_next(request)
    elapsed = time.perf_counter() - start
    INPROGRESS.dec()
 
    # use the route template as the label (avoid cardinality explosion)
    route = request.scope.get("route")
    path = getattr(route, "path", request.url.path)
 
    LATENCY.labels(request.method, path).observe(elapsed)
    REQUESTS.labels(request.method, path, str(response.status_code)).inc()
    return response
 
 
@app.get("/metrics")
def metrics():
    return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
 
 
@app.get("/orders/{order_id}")
def get_order(order_id: int):
    return {"order_id": order_id}

The key here is labeling with the route template (/orders/{order_id}) rather than the raw URL path. If you put /orders/123, /orders/124, … directly into a label, your time series grow without bound — a cardinality explosion.

generate_latest() turns all registered metrics into an exposition-format string, and CONTENT_TYPE_LATEST fills in the correct Content-Type header.

2) Two simpler approaches

You can also mount /metrics as an ASGI app instead of handling it in the middleware.

from prometheus_client import make_asgi_app
app.mount("/metrics", make_asgi_app())

To get automatic request instrumentation as well, a third-party library is convenient.

# pip install prometheus-fastapi-instrumentator
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator().instrument(app).expose(app)   # auto-exposes /metrics + instruments requests

3) Run

uvicorn main:app --host 0.0.0.0 --port 8000
curl -s localhost:8000/metrics | grep app_

Watch multi-worker setups — if you run gunicorn/uvicorn with multiple workers, each worker holds its own counters, so values differ per worker. In that case you must configure prometheus-client's multiprocess mode (PROMETHEUS_MULTIPROC_DIR env var + MultiProcessCollector).

Prometheus configuration

Now register both apps as targets in scrape_configs in prometheus.yml. The point is that the two apps have different endpoint paths (metrics_path).

global:
  scrape_interval: 15s          # default scrape interval
 
scrape_configs:
  # Spring Boot — path is /actuator/prometheus, so it must be specified
  - job_name: "order-service"
    metrics_path: /actuator/prometheus
    static_configs:
      - targets: ["10.0.0.1:8080"]
 
  # FastAPI — default path is /metrics, so metrics_path can be omitted
  - job_name: "fastapi-app"
    static_configs:
      - targets: ["10.0.0.2:8000"]
        labels:
          team: "platform"      # add an extra common label to this target
  • job_name → attached as the job label on every metric.
  • The host:port in targets → becomes the instance label.
  • metrics_path → defaults to /metrics, so FastAPI omits it while Spring Boot sets /actuator/prometheus.
  • For HTTPS/auth, add scheme: https, basic_auth, authorization, etc. to the same block.

After changing the config, reload Prometheus.

# when running with --web.enable-lifecycle
curl -X POST http://localhost:9090/-/reload

Verifying

  1. Confirm each app emits metrics:
curl -s localhost:8080/actuator/prometheus | head
curl -s localhost:8000/metrics | head
  1. On Prometheus's Status → Targets page, confirm both jobs are UP.

  2. Query up in the expression browser to see per-target status (success 1, failure 0).

up{job="order-service", instance="10.0.0.1:8080"} 1
up{job="fastapi-app",   instance="10.0.0.2:8000"} 1
  1. Query your custom metrics right away.
rate(orders_created_total[5m])
sum by (path) (rate(app_requests_total[5m]))

Wrapping up

The essence of an exporter is simple — a single HTTP endpoint at /metrics that returns the exposition format. Spring Boot's Micrometer + Actuator creates that endpoint and the default metrics for you, so two lines of dependency and config get you going; with FastAPI you open one route yourself using prometheus-client. What's left is to (1) define meaningful custom metrics with the right type, (2) keep label cardinality under control, and (3) register them in Prometheus scrape_configs with the correct metrics_path.

As a next step, connect the up, request rate, and latency percentiles you built here to a Grafana dashboard and Alertmanager rules, so the metrics you collect become real operational signals.