Zero-Code OpenTelemetry: Instrumenting a Real Distributed System
You can add full distributed tracing, metrics, and logs to a Python microservices app without touching application code — install the OpenTelemetry distro, bootstrap the instrumentors, and run each service under the opentelemetry-instrument wrapper. Here is exactly how we did it for Fathom, a nine-service bank, plus the one gotcha that keeps logs from showing up.
Key takeaways
- Zero-code OpenTelemetry auto-instrumentation emits traces, metrics, and logs from an unmodified app — the only per-service change is running it under the opentelemetry-instrument wrapper.
- Instrument a system that already works: build and load-test first, add telemetry second, so what you capture is a real system under real load rather than a demo choreographed to look good.
- Auto-instrumenting the HTTP client library propagates W3C trace context across services automatically — that single behavior stitches independent services into one distributed trace with no context-passing code.
- The most common reason logs 'don't show up' with auto-instrumentation is the Python root logger's default WARNING level, which silently drops INFO events before they reach the exporter.
The fastest way to get traces, metrics, and logs out of a Python microservices app is to change none of its code. OpenTelemetry's zero-code auto-instrumentation does exactly that — and the seam only becomes visible when you apply it to a system that was genuinely built without observability in mind.
So we built one on purpose.
Instrument something that already works
Most instrumentation tutorials start with an app that was born instrumented, so you never see the join. We did it the other way. Fathom is a real distributed system — a customer-facing edge tier on one machine, a core-banking tier with a double-entry ledger on another, plus Postgres. It signed up hundreds of customers and passed a load test with its ledger proven consistent under concurrency before a single span existed.
300simulated customers, hundreds of concurrent transfers, ledger reconciled to zero — before any telemetryThat order matters. Telemetry from a system that was correct and load-tested first is trustworthy precisely because the thing being observed stands on its own. It is not a demo wired to look good in a dashboard.
The architecture
Fathom splits into two tiers on two hosts, so a transfer crosses the network — a real topology, not localhost.
| Service | Tier | Responsibility |
|---|---|---|
edge-gateway | edge | Public API; verifies the token, routes every request |
auth-service | edge | Signup / login, issues and verifies JWTs |
payments-service | edge | Orchestrates a transfer end to end |
accounts-service | core | Account lifecycle and balances |
ledger-service | core | Double-entry postings — the money system of record |
fraud-service | core | Risk score; declines a share of transfers |
A single transfer flows gateway → auth → payments → fraud → ledger, moving money atomically. That is the path we want telemetry to make visible.
Zero-code instrumentation, in three steps
No spans in the code. No providers to wire. No instrumentation module to import. Three moves, none of which touch a service's business logic.
1. Install the distro and bootstrap the instrumentors
opentelemetry-bootstrap inspects the environment and installs an instrumentor for every library it finds — Flask, requests, psycopg2, redis — so you never hand-pick them.
pip install opentelemetry-distro opentelemetry-exporter-otlp-proto-http
opentelemetry-bootstrap -a install2. Run the app under the wrapper
Prefix the start command with opentelemetry-instrument. That is the only difference from running it uninstrumented.
opentelemetry-instrument gunicorn --chdir services/$SERVICE --bind 0.0.0.0:$PORT app:app3. Point it at your backend with environment variables
OTEL_SERVICE_NAME=ledger-service
OTEL_RESOURCE_ATTRIBUTES=service.namespace=fathom
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingress.rocketgraph.app
OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer%20$API_KEY
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=trueThat is the whole integration. Every service's code is unchanged — not even an import.
The gotcha: logs need a log level
Traces and metrics appeared immediately. Logs did not — and this is where most people conclude, wrongly, that they need a custom log bridge. They don't.
The wrapper already attaches an exporting handler to the root logger. The catch is subtler: Python's root logger defaults to WARNING, so INFO business logs are dropped before the handler ever sees them. One line fixes it, no bridge required:
import logging
logging.getLogger("fathom").setLevel(logging.INFO) # once, in shared configScope it to your own logger namespace so you export your business events without dragging in noisy third-party INFO chatter. Once the level is right, the services just log normally and the records flow — each one already carrying the active trace_id.
log.info("transfer declined by fraud: user=%s amount_minor=%d score=%s", user_id, amount, score)What lands in the backend
The same load test — hundreds of concurrent transfers — now produces a fully connected picture, three signals wide:
| Signal | What you get | Code changed |
|---|---|---|
| Traces | One connected trace per transfer, across both hosts; SQL spans included | None |
| Metrics | HTTP RED — rate, errors, duration — per service and route | None |
| Logs | Business events (transfer posted, fraud declined), each carrying its trace_id | One shared line + the log calls you'd write anyway |
Click a declined transfer's trace and its logs are one query away — fraud declined … score=82 sitting exactly where the span shows the decision. That correlation is the entire point of shipping all three signals to one place: traces tell you where, logs tell you why.
Getting more metrics — still no code
Zero-code auto-instrumentation gives you only HTTP metrics out of the box — http.server.duration, http.client.duration, and http.server.active_requests. The reason is simple: opentelemetry-bootstrap installs instrumentors only for the libraries it detects (Flask, requests), and those are the metrics they emit.
Here is the part people miss: getting more is also no code — you add a package, not code. The opentelemetry-instrument wrapper auto-discovers any instrumentor installed in the environment (through entry points), so enriching your metrics is a one-line dependency change:
opentelemetry-distro
opentelemetry-exporter-otlp-proto-http
+ opentelemetry-instrumentation-system-metricsThat single line adds ~15–20 host and process metrics per service — CPU, memory, disk I/O, network, garbage collection, thread and file-descriptor counts — with no Meter, no create_counter, nothing touched in the application. Rebuild, and they flow.
So metrics fall into three buckets, and only one of them needs code:
| What you want | How | Code? |
|---|---|---|
| Infra / runtime metrics (host, process, GC) | add an instrumentor package — system-metrics and friends | none |
| Host / container / DB metrics you want even when the app is down | run the OpenTelemetry Collector with hostmetrics, docker_stats, postgresql receivers | none |
Business metrics (transfers_total, fraud_declines) | a Meter plus counters, in code | yes — no library knows what a "transfer" is |
Where Rocketgraph fits
Fathom is our reference application, and it exports over plain OpenTelemetry (OTLP/HTTP) — the same path any customer's own services use. Nothing about the instrumentation is Rocketgraph-specific; that is deliberate. You instrument once, with open standards, and point the endpoint wherever you like.
Rocketgraph is where that telemetry becomes cheap to keep and fast to search: OTLP in, object-storage economics underneath, usage-based pricing with no per-host fees, and AI agents that triage issues automatically. If you are weighing options, our cost comparison of Datadog alternatives is written to be useful even if you never send us a byte.
Frequently asked questions
How do I instrument a Python app with OpenTelemetry without changing code?
Install opentelemetry-distro and an OTLP exporter, run `opentelemetry-bootstrap -a install` to auto-install instrumentors for the libraries you already use, then start the app under the `opentelemetry-instrument` wrapper. Everything else — endpoint, headers, which signals to export — is configured with OTEL_ environment variables. The application code is never modified.
Does zero-code OpenTelemetry capture logs and metrics, or only traces?
All three, but you have to turn them on. Set OTEL_TRACES_EXPORTER, OTEL_METRICS_EXPORTER, and OTEL_LOGS_EXPORTER to otlp, and enable OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED for logs. Traces and HTTP RED metrics then flow with no code; logs flow once the app actually emits log records at a level that is not filtered out.
Why are my OpenTelemetry logs not showing up?
The usual cause is the log level. Python's root logger defaults to WARNING, so INFO business logs are dropped before the OpenTelemetry handler ever sees them — set the level to INFO and they appear. After that, confirm OTEL_LOGS_EXPORTER=otlp and that the OTLP endpoint and auth header are correct.
How does OpenTelemetry connect spans across microservices?
Through W3C trace-context propagation. The instrumented HTTP client injects a `traceparent` header on every outbound call, and the instrumented server on the other side reads it and continues the same trace. Because the client and server are auto-instrumented, this happens with no context-passing code in your services.

Founder & CEO, Rocketgraph
Kaushik founded Rocketgraph to make observability affordable at any scale. He writes about telemetry economics, object-storage architectures, and using AI agents to triage production incidents.