obstack¶
Drop-in observability for FastAPI: structured JSON logs, Prometheus metrics, and OTLP traces
that all carry the same four identity labels — app, service, env, host.
Important
Those four names are the contract. They are spelled identically in every signal so Grafana can
jump from a metric to a trace to a log line without renaming anything. See
docs/labels.md — if this README and that document ever disagree, that
document wins.
Install¶
uv add "obstack[grpc,sqlalchemy]"
| Extra | Pulls in | When |
|---|---|---|
grpc |
opentelemetry-exporter-otlp-proto-grpc |
app → local Alloy on :4317 (the default) |
http |
opentelemetry-exporter-otlp-proto-http |
app → a collector over HTTP/1.1 |
sqlalchemy |
opentelemetry-instrumentation-sqlalchemy |
you pass engine= |
errors |
sentry-sdk |
you set OBS_ERROR_DSN |
Use¶
from fastapi import FastAPI
from obstack import setup_observability
app = FastAPI()
obs = setup_observability(app, engine=engine)
That one call configures structlog, registers the metrics and request-logging middleware,
serves /metrics, and — when OBS_OTLP_ENDPOINT is set — starts exporting traces.
For a process with no FastAPI app:
from obstack import setup_worker_observability
obs = setup_worker_observability(metrics_port=9100)
...
obs.shutdown()
Configure¶
Every setting is an environment variable prefixed OBS_. The agent reads the same four
identity variables from the same .env, which is what keeps the two halves consistent.
| Variable | Default | Notes |
|---|---|---|
OBS_APP |
required | [a-z0-9-]+. Missing or malformed raises ObservabilityConfigError at startup |
OBS_SERVICE |
api |
[a-z0-9-]+ |
OBS_ENV |
local |
local | staging | production |
OBS_HOST |
socket.gethostname() |
lowercased; the agent is authoritative for metrics |
OBS_VERSION |
0.0.0 |
surfaces as fastapi_app_info{version=...} |
OBS_LOG_LEVEL |
INFO |
|
OBS_LOG_FORMAT |
auto |
auto is console when env=local, JSON otherwise — set json explicitly if a collector tails a container running with env=local |
OBS_OTLP_ENDPOINT |
unset | e.g. http://alloy:4317. Unset disables tracing entirely |
OBS_OTLP_PROTOCOL |
grpc |
grpc | http |
OBS_TRACE_SAMPLE_RATIO |
1.0 |
parent-based ratio sampler |
OBS_METRICS_ENABLED |
true |
|
OBS_METRICS_PATH |
/metrics |
always excluded from its own metrics |
OBS_ERROR_DSN |
unset | GlitchTip / Sentry DSN. Events are tagged environment = OBS_ENV, release = <app>-<service>@<version> and server_name = OBS_HOST, so an error joins the other three signals |
setup_observability(app, *, settings=None, engine=None, excluded_paths=()) — pass settings
to bypass the environment, engine to trace SQLAlchemy queries (an AsyncEngine is unwrapped
for you), and excluded_paths for health checks you do not want in the metrics.
What you get¶
Logs — newline-delimited JSON on stdout, one "HTTP" line per request, with trace_id and
span_id injected whenever a span is active. Standard-library loggers (uvicorn, SQLAlchemy) are
routed through the same chain, so every line is JSON. Add your own request-scoped fields with
bind_request_context(tenant="acme"). sqlalchemy.engine, uvicorn.access, httpx and
watchfiles are pinned to WARNING — each of them logs a line per request that duplicates one
the SDK already emits.
Metrics — on an instance-local CollectorRegistry, so importing twice or setting up two apps
in one process cannot collide:
fastapi_requests_total{app,service,env,method,route,status_code}
fastapi_requests_duration_seconds{app,service,env,method,route}
fastapi_exceptions_total{app,service,env,method,route,exception_type}
fastapi_requests_in_progress{app,service,env,method}
fastapi_app_info{app,service,env,version}
route is always the matched route pattern (/items/{item_id}), never the raw path —
unmatched requests collapse to route="" so a scanner cannot mint a series per probed URL.
Exposition is OpenMetrics, which is what makes exemplars survive; a plain-text /metrics
endpoint drops them silently.
Traces — a Resource carrying app / service / env / host plus service.name
("{app}-{service}") and deployment.environment.name. If the exporter cannot be built the SDK
logs at error and runs on without tracing; it never takes the app down for an infrastructure
problem. A missing OBS_APP, by contrast, is a programmer error and fails at startup.
Setup also sets OTEL_SEMCONV_STABILITY_OPT_IN=http if it is unset, so HTTP spans carry
http.request.method, http.response.status_code, url.path and server.address rather than
the pre-1.0 names OpenTelemetry Python still defaults to. This is a process-wide switch read
once, so it applies to every HTTP instrumentation in the process, including ones the SDK does not
install (httpx, requests). Export it yourself — http/dup emits both sets — to keep control
of the timing during a migration; the SDK will not override an explicit value.
Running multiple workers¶
Warning
One worker per container. Scale by running more containers, not more workers.
uvicorn --workers N and fastapi run --workers N fork N processes that share one listening
socket. Each holds its own CollectorRegistry, so consecutive scrapes land on different
workers and a counter appears to move backwards — which Prometheus reads as a counter reset,
making rate() and every dashboard built on it quietly wrong. Nothing logs an error; the
graphs are just false.
The instance-local registry does not save you here: the problem is one endpoint answering from N
independent processes. If you must run multiple workers in one container, set
PROMETHEUS_MULTIPROC_DIR and use prometheus_client's multiprocess collector, and note that
gauges and exemplars behave differently under it.
Develop¶
uv sync --all-extras
uv run pytest
uv run pyright
uv run ruff check .