Setup Prometheus and OpenTelemetry¶
This page is the recipe for wiring metrics. For the why two instrumentation seams, see Concepts § Instrumentation seams; for the event catalog and operator PromQL playbook, see Reference § Observability.
Prometheus adapter¶
Metric names, label names, status enum, histogram buckets, and
constructor args mirror FastStream's PrometheusMiddleware. One label
value differs: handler is the subscriber function's name, where the
middleware uses the queue name.
# app.py — run with `uvicorn app:app --host 0.0.0.0 --port 8000`
from faststream.asgi import AsgiFastStream, make_ping_asgi
from prometheus_client import REGISTRY, make_asgi_app
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import create_async_engine
from faststream_outbox import OutboxBroker, make_outbox_table
from faststream_outbox.metrics.prometheus import PrometheusRecorder
metadata = MetaData()
outbox_table = make_outbox_table(metadata, table_name="outbox")
engine = create_async_engine("postgresql+asyncpg://outbox:outbox@localhost:5432/outbox")
broker = OutboxBroker(
engine,
outbox_table=outbox_table,
metrics_recorder=PrometheusRecorder(app_name="checkout", registry=REGISTRY),
)
@broker.subscriber("orders", max_workers=4)
async def handle_order(body: dict) -> None: ...
app = AsgiFastStream(
broker,
asgi_routes=[
("/metrics", make_asgi_app(registry=REGISTRY)),
("/healthz", make_ping_asgi(broker, timeout=2.0)),
],
)
AsgiFastStream accepts any ASGI sub-app under asgi_routes; mount
make_asgi_app(REGISTRY) to expose Prometheus exposition without
pulling FastAPI in. make_ping_asgi(broker) is FastStream's built-in
health probe, handy for Kubernetes. It calls broker.ping(), which
takes a connection from the engine's pool, so it also fails while the
application's own sessions hold every pooled connection. Use it as a
readiness probe, or leave the pool room for it if it backs a liveness
probe.
The broker label is always "outbox"; add broker="outbox" to the
PromQL filter to scope dashboards to outbox traffic.
Consume vs publish label set¶
The adapter uses a different label set for consume vs publish, matching upstream verbatim:
- Consume tags by
handler(the subscriber) - Publish tags by
destination(the queue)
See Observability § PromQL playbook for the operator query catalog.
OpenTelemetry adapter¶
Instrument names, units and constructor args follow FastStream's
TelemetryMiddleware, but it records meters only, no spans (use the native middleware
section below if you need spans).
pip install 'faststream-outbox[opentelemetry,prometheus]' \
opentelemetry-exporter-prometheus uvicorn
# app.py — run with `uvicorn app:app --host 0.0.0.0 --port 8000`
from faststream.asgi import AsgiFastStream
from opentelemetry import metrics
from opentelemetry.exporter.prometheus import PrometheusMetricReader
from opentelemetry.sdk.metrics import MeterProvider
from prometheus_client import REGISTRY, make_asgi_app
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import create_async_engine
from faststream_outbox import OutboxBroker, make_outbox_table
from faststream_outbox.metrics.opentelemetry import OpenTelemetryRecorder
# OTel meters → Prometheus reader (scraped at /metrics below)
prometheus_reader = PrometheusMetricReader()
meter_provider = MeterProvider(metric_readers=[prometheus_reader])
metrics.set_meter_provider(meter_provider)
metadata = MetaData()
outbox_table = make_outbox_table(metadata, table_name="outbox")
engine = create_async_engine("postgresql+asyncpg://outbox:outbox@localhost:5432/outbox")
broker = OutboxBroker(
engine,
outbox_table=outbox_table,
metrics_recorder=OpenTelemetryRecorder(meter_provider=meter_provider),
)
@broker.subscriber("orders", max_workers=4)
async def handle_order(body: dict) -> None: ...
app = AsgiFastStream(broker, asgi_routes=[("/metrics", make_asgi_app(registry=REGISTRY))])
The PrometheusMetricReader converts OTel meter data points to
Prometheus exposition format on /metrics; for OTLP push instead,
swap the reader for PeriodicExportingMetricReader(OTLPMetricExporter(...))
and drop the /metrics route.
Instrument names match faststream.opentelemetry.TelemetryMiddleware for
the bus-scope metrics: messaging.process.duration,
messaging.publish.duration, and (when include_messages_counters=True)
messaging.process.messages / messaging.publish.messages. The adapter
adds four outbox-specific counters the middleware can't emit:
messaging.outbox.fetch.batches, messaging.outbox.lease_lost,
messaging.outbox.dlq_written, and messaging.outbox.drain_timeout. Units
and constructor args
(meter_provider, meter, include_messages_counters) follow upstream.
When OutboxTelemetryMiddleware also records meters, pass
include_bus_metrics=False so the messaging.process.* /
messaging.publish.* instruments are recorded once, by the middleware.
The messaging.system="outbox" attribute disambiguates outbox traffic
from Kafka / Rabbit data on the same instruments.
This adapter does not model tracing (spans), because the callable seam can't bracket a span lifecycle. For spans, use the native middleware integration below.
Native middleware (spans + bus parity)¶
For OTel spans wrapping consume_scope / publish_scope and the
exact upstream label / instrument schema, register the native
middleware subclasses via middlewares=[...], using the same
registration pattern as KafkaPrometheusMiddleware /
RabbitTelemetryMiddleware.
Both seams together¶
The recommended setup pairs middleware with the recorder so every event the bus emits and every outbox-internal event lands in one observability stack:
Here OpenTelemetry supplies spans (exported to OTLP) and Prometheus
supplies all metrics (one registry scraped over HTTP). The OTel
middleware runs span-only: it gets no meter_provider, so its meters go to
the global OpenTelemetry meter provider, which is a no-op unless you set one.
Neither endpoint below exposes them.
# app.py — run with `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 \
# uvicorn app:app --host 0.0.0.0 --port 8000`
from faststream.asgi import AsgiFastStream
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from prometheus_client import CollectorRegistry, make_asgi_app
from sqlalchemy import MetaData
from sqlalchemy.ext.asyncio import create_async_engine
from faststream_outbox import OutboxBroker, make_outbox_table
from faststream_outbox.metrics.prometheus import PrometheusRecorder
from faststream_outbox.opentelemetry import OutboxTelemetryMiddleware
from faststream_outbox.prometheus import OutboxPrometheusMiddleware
# ----- OTel SDK -----
resource = Resource.create({"service.name": "my-outbox-service"})
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(tracer_provider)
REGISTRY = CollectorRegistry()
# ----- Outbox broker -----
metadata = MetaData()
outbox_table = make_outbox_table(metadata, table_name="outbox")
engine = create_async_engine("postgresql+asyncpg://outbox:outbox@localhost:5432/outbox")
broker = OutboxBroker(
engine,
outbox_table=outbox_table,
middlewares=[
# Bus-scope spans around consume_scope / publish_scope (span-only; see note above).
OutboxTelemetryMiddleware(tracer_provider=tracer_provider),
OutboxPrometheusMiddleware(registry=REGISTRY, app_name="my-outbox-service"),
],
# Outbox-internal events (fetched, lease_lost, terminal reasons, dlq_written)
# that have no message context and can't reach the middleware bus.
metrics_recorder=PrometheusRecorder(
registry=REGISTRY,
app_name="my-outbox-service",
include_bus_metrics=False,
),
)
@broker.subscriber("orders", max_workers=4)
async def handle_order(body: dict) -> None: ...
app = AsgiFastStream(broker, asgi_routes=[("/metrics", make_asgi_app(registry=REGISTRY))])
Traces flow to OTLP (Jaeger / Tempo / Honeycomb / collector), and every
metric lands on /metrics. The OTel middleware here contributes spans
only, per the note above.
The two seams overlap on the faststream_received_* /
faststream_published_* families. include_bus_metrics=False leaves
those to the middleware, so each consume and publish is counted once and
the recorder adds only the outbox-internal series the middleware can't
see (fetched, lease_lost, terminal reasons, dlq_written). Without
the flag, both record the same families. On one registry, creating the
middleware first makes the recorder raise Duplicated timeseries in
CollectorRegistry, while creating the recorder first makes the
middleware reuse its collectors and count every consume and publish
twice.
The providers set messaging.system = "outbox", matching the
recorder-seam adapters. On consume spans the OTel provider maps row.id →
messaging.message.id, row.queue → messaging.destination_publish.name,
correlation_id → messaging.message.conversation_id, and len(payload) →
messaging.message.payload_size_bytes. On publish spans it maps the target
queue → messaging.destination.name, correlation_id →
messaging.message.conversation_id, and len(cmd.batch_bodies) →
messaging.batch.message_count when >1.