Reading time · 6 minAugust 28, 2026

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.

Kaushik VaranasiKaushik Varanasi

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 telemetry

That 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.

ServiceTierResponsibility
edge-gatewayedgePublic API; verifies the token, routes every request
auth-serviceedgeSignup / login, issues and verifies JWTs
payments-serviceedgeOrchestrates a transfer end to end
accounts-servicecoreAccount lifecycle and balances
ledger-servicecoreDouble-entry postings — the money system of record
fraud-servicecoreRisk 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 install

2. 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:app

3. 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=true

That 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 config

Scope 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:

SignalWhat you getCode changed
TracesOne connected trace per transfer, across both hosts; SQL spans includedNone
MetricsHTTP RED — rate, errors, duration — per service and routeNone
LogsBusiness events (transfer posted, fraud declined), each carrying its trace_idOne shared line + the log calls you'd write anyway
1 tracespans gateway → auth → payments → fraud → ledger across two hosts, from a single transfer

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-metrics

That 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 wantHowCode?
Infra / runtime metrics (host, process, GC)add an instrumentor packagesystem-metrics and friendsnone
Host / container / DB metrics you want even when the app is downrun the OpenTelemetry Collector with hostmetrics, docker_stats, postgresql receiversnone
Business metrics (transfers_total, fraud_declines)a Meter plus counters, in codeyes — 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.

Kaushik Varanasi

Kaushik Varanasi

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.

Read next