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.
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"} 42Return 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 metricsThat 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.created→orders_created_total(the_totalsuffix is added automatically for counters);order.processing→order_processing_seconds_count/_sum(a Timer reports seconds). So don't put_totalin 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.0Python — 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-client1) 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 requests3) 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_DIRenv 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 targetjob_name→ attached as thejoblabel on every metric.- The
host:portintargets→ becomes theinstancelabel. 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/-/reloadVerifying
- Confirm each app emits metrics:
curl -s localhost:8080/actuator/prometheus | head
curl -s localhost:8000/metrics | head-
On Prometheus's Status → Targets page, confirm both jobs are
UP. -
Query
upin the expression browser to see per-target status (success1, failure0).
up{job="order-service", instance="10.0.0.1:8080"} 1
up{job="fastapi-app", instance="10.0.0.2:8000"} 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.