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

- Author: Kaushik Varanasi (Founder & CEO, Rocketgraph)
- Published: August 28, 2026
- Updated: August 28, 2026
- Canonical: https://blog.rocketlog.io/blog/zero-code-opentelemetry-instrumentation
- Publisher: Rocketgraph (https://blog.rocketlog.io) — Rocketgraph is an observability platform that delivers metrics, logs, and traces at scale on object-storage economics, with AI agents that triage issues automatically.

**TL;DR**

We built Fathom — a nine-service bank that moves real money across two hosts — and load-tested it before adding any telemetry. Then we instrumented all of it with zero application-code changes: install the OpenTelemetry distro, `opentelemetry-bootstrap -a install`, run under `opentelemetry-instrument`, and configure with environment variables. Result: one connected trace per transfer across both hosts, HTTP metrics, and trace-correlated logs — with the one caveat that logs need the right log level to flow.

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

**300** — simulated 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.

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

```shell
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.

```shell
opentelemetry-instrument gunicorn --chdir services/$SERVICE --bind 0.0.0.0:$PORT app:app
```

### 3. Point it at your backend with environment variables

```shell
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 reason six independent services become one trace is the `requests` instrumentor: it injects a W3C `traceparent` header on every outbound call, and the instrumented server on the other end continues the same trace. You write zero context-passing code. This is [OpenTelemetry](https://opentelemetry.io/)'s propagation model doing the work.

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

```python
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`.

```python
log.info("transfer declined by fraud: user=%s amount_minor=%d score=%s", user_id, amount, score)
```

> 
Auto-instrumentation *exports* logs and correlates them to traces; it cannot *invent* them. If your services never call the logging API, there is nothing to ship. "Turn on logs" is really two things: enable the exporter, and give it something to export.

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

**1 trace** — spans 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](/blog/ai-incident-triage-explained).

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

```diff
  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 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](https://opentelemetry.io/docs/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 |

> 
Then **shape** what you emit with [Views](https://opentelemetry.io/docs/specs/otel/metrics/sdk/#view): rename, re-bucket, or drop a high-[cardinality](/glossary#cardinality) attribute like `user_id` before it ever leaves the process. One bad label can explode a single metric into millions of series and dominate your bill — Views are how you keep the signal without the cost.

## Where Rocketgraph fits

Fathom is our reference application, and it exports over plain [OpenTelemetry](https://opentelemetry.io/) (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](/about) 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](/blog/ai-incident-triage-explained). If you are weighing options, our [cost comparison of Datadog alternatives](/blog/datadog-alternatives-2026-cost-comparison) 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.

---

About Rocketgraph: Rocketgraph is an observability platform that delivers metrics, logs, and traces at scale on object-storage economics, with AI agents that triage issues automatically.
More articles in markdown: https://blog.rocketlog.io/llms.txt
