Observability that arrives with an import.
A Python package that gives a FastAPI service distributed tracing — HTTP spans, a span per application function, database spans, tenant attributes and trace-linked logs — for about eight lines of setup. It replaced a commercial APM with Grafana Tempo and took roughly $21,000 a year off the bill. Nobody asked for it.
00The one-pager
Everything below in a single screen.
Debugging across FastAPI microservices was guesswork, and the commercial APM licence was a real line item. Manual instrumentation solves the first problem only if engineers actually do it — and across dozens of services, they do not.
Make instrumentation a property of importing the library, not of remembering to decorate. Discover the codebase's own functions by parsing it, then wrap them at process start.
- AST pass collects every def / async def.
- Second pass excludes functions passed as arguments.
- Result cached against the git commit hash.
- At boot, sweep sys.modules and wrap with wrapt.
- In-process filter drops fast, successful spans.
The package end to end — design, all of the code, the rollout across services, the Tempo/Grafana backend that replaced the APM, and the scaling design for what happens when trace volume grows past a single Tempo container.
- Wrapping breaks functions used as values.
- Sync and async need different wrappers.
- Two AST passes at boot cost cold-start time.
- Function-level tracing multiplies span volume.
Adopted across the organisation's FastAPI services, commercial APM retired, and per-function timing visible inside every traced request — with the span budget controlled in-process rather than paid for downstream.
01The two-minute answer
Problem → idea → the interesting technical bit → the scale story → outcome.
“We had a fleet of Python FastAPI services and two problems: debugging a slow request meant reading logs and guessing, and we were paying a commercial APM for the privilege. The obvious fix is OpenTelemetry, but plain OTel gives you HTTP-level spans — to see inside a request you have to decorate functions by hand, and across dozens of services that does not happen.”
“So I built mtptrace. A service adds one constructor call and a few environment variables, and it gets HTTP spans, a span for every one of its own functions, PostgreSQL query spans, tenant and user attributes on the request span, and the trace ID in every log line so Grafana can link logs to traces.”
“The interesting part is how it finds the functions. At build time it parses the repository with Python's ast module, collects every function definition, and then does a second pass to find functions that are passed as arguments somewhere — those must not be wrapped, because wrapping replaces the object the caller receives. That result is cached against the git commit hash, so process start is a JSON read rather than a full repo scan. At boot it walks sys.modules and wraps everything eligible with wrapt, choosing a sync or async wrapper per function.”
“Function-level tracing multiplies span volume, so the package filters in-process: a custom span processor drops spans under a duration threshold unless they carry an error, and it drops orphan BigQuery client spans. A dropped span costs no network, no Kafka and no storage — which is the cheapest possible place to make that decision.”
“It replaced the commercial APM with Grafana Tempo and saved about $21K a year. I've also designed what this looks like past a single Tempo container — collector gateway, distributed Tempo with Kafka sharded by trace ID, block-builders and live-stores over object storage — and the conclusion there is that sampling policy, not infrastructure tuning, is the dominant cost lever.”
02Scope & ownership
This one is simpler to describe than most projects, because there is very little to disown: it was my idea, my code, and my rollout.
| Dimension | Fact |
|---|---|
| Duration | October 2024 → August 2025, alongside delivery work — roughly 10 months. |
| Contributors | Effectively solo: 50 merged pull requests, nearly all commits mine, with small contributions from two other engineers. |
| My role | Author and owner — design, implementation, rollout, support, and the backend it exports to. |
| Mine | The package, the instrumentation strategy, the span-volume policy, the FastAPI instrumentor, the Tempo/Grafana migration, the scaling design. |
| Not mine | OpenTelemetry SDK and Tempo internals. I configure, deploy and extend them; I did not write them. |
| Adoption | Consuming services installed it from a shared repository and integrated in a single PR each. |
“mtptrace was mine end to end — the idea, the package, the rollout and the backend migration. The parts I did not build are the OpenTelemetry SDK and Tempo itself; my work sits on top of both and extends the OTel FastAPI instrumentor where we needed more than it gave us.”
How many services ended up on it, and how many teams. “Org-wide” is weaker than “N services across M teams”, and this is the number that turns a library into a platform story.
03How it was built
The commit history is the honest version of the roadmap: a working export path first, then months of making auto-instrumentation correct, then performance, then optionality.
Oct 2024 — spans leaving the process
OTLP export working end to end, the tracer turned into a configurable class rather than a script, and a custom propagator replacing the middleware experiment.
Oct–Nov 2024 — making wrapping correct
Static methods, coroutines needing their own wrapper, thread-executed functions, multi-module instrumentation, and the big one: excluding functions passed as arguments to other functions.
Nov 2024 — request context
A custom FastAPI instrumentor putting tenant and user ID on the request span, an env resource attribute, and the span duration threshold moved to an environment variable so it could be tuned per service without a release.
Dec 2024 — noise removal
Orphan BigQuery client spans were arriving as parentless roots and cluttering the trace list. They are now dropped in the span processor.
Mar–May 2025 — cold start
Two full-repository AST passes at every boot were costing startup latency on Cloud Run. Moved to a build-time cache file keyed on the git commit hash, so a container start reads JSON instead of walking the tree.
Jun–Aug 2025 — optionality
Third-party package instrumentation with fully-qualified span names, and a backend switch so the same function-level instrumentation can emit to Datadog or to OpenTelemetry — chosen per service by a flag.
04The problem
Three things were true at once, and only the third one makes this an interesting project.
- Debugging was archaeology. A slow endpoint meant reading logs across services and inferring where the time went. There was no per-function timing inside a request.
- The APM was a line item. Commercial application performance monitoring has a licence cost that scales with hosts and volume, and it was not obviously earning it.
- The obvious fix does not survive contact with a fleet. OpenTelemetry gives HTTP-level spans cheaply. Seeing inside a request means decorating functions by hand — which is a task that competes with feature work in every sprint, in every service, forever. Instrumentation that depends on discipline decays.
“The technical problem was not ‘how do I create a span’. It was ‘how do I make good instrumentation the default state of a service rather than an ongoing chore’. Once you phrase it that way, the answer has to be automatic, and automatic means discovering the codebase rather than annotating it.”
05The integration contract
The design goal was a cost of adoption low enough that no team has to schedule it. This is the whole integration.
# server_class.py
from tracer.opentelemetry.tracer import OpenTelemetryTracer
def setup_tracing(self):
OpenTelemetryTracer(
fastapi_app=self.app,
service_name=os.getenv("K_SERVICE", "inventory-smart"),
tracer_url=os.getenv("APM_TRACE_URL"),
modules_to_instrument=["inventory_smart"],
).instrument()
# pyproject.toml
mtptrace = { git = "…/mtp-trace-backend.git", branch = "main", extras = [] }modules_to_instrument is the only argument that requires a thought: it is the top-level package of the repository, and it is what keeps the instrumentor from wrapping the entire dependency tree.
Everything else is an environment variable
| Variable | Effect |
|---|---|
| K_SERVICE | Becomes service.name — the Cloud Run service name. |
| APM_TRACE_URL | The OTLP endpoint. The only thing the package knows about the backend. |
| DEPLOY_ENV | Becomes the env resource attribute, so prod and staging traces separate. |
| span_duration_threshold_ms | The in-process span floor. Defaults to 10 ms. |
| OTEL_SAMPLER | always_on / always_off / partial, with OTEL_SAMPLER_RATIO. |
| enable_function_instrumentor | Kill switch for function-level wrapping without a code change. |
| enable_tracer_logs | Swaps in an exporter that prints each span — the debugging escape hatch. |
Every one of those exists because something went wrong once and needed to be changed without a release. A kill switch on a library that touches every function in a service is not a nicety.
06How the wrapping actually works
Three phases, deliberately separated by when they run: once per commit, once per process, and once per call.
Phase 1 — discover the codebase
A tree walk over every .py file, parsed with ast, collecting the name of every function and coroutine defined anywhere in the repository and in any explicitly named package.
class FunctionCollectorHelper(ast.NodeVisitor):
def visit_FunctionDef(self, node):
self.defined_functions.add(node.name)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
self.defined_functions.add(node.name)
self.generic_visit(node)Phase 2 — cache it against the commit
Both AST passes are proportional to repository size and they ran on every container start. On Cloud Run, where containers start often, that is a cold-start tax on every request that happens to land on a fresh instance. The fix is a cache file whose validity is defined by the commit that produced it.
cache = FunctionsListCacheHandler().load_cache()
latest_hash = FunctionsListCacheHandler().get_git_commit_hash()
if cache.get("hash") == latest_hash:
return # nothing changed, nothing to recompute
# …otherwise rescan and write .functions.list.cache:
{ "hash": "<git rev-parse HEAD>",
"all_functions": [...],
"functions_passed_as_argument": [...] }The commit hash is the right key because it is exactly the thing that determines the answer. A stale cache is not possible: either the code is the code the cache was built from, or the cache is ignored.
Phase 3 — wrap what is already imported
At process start the instrumentor iterates sys.modules, keeps the modules whose names start with one of the configured prefixes, and walks each module's namespace — rebinding eligible functions, and descending into classes to rebind eligible methods.
for module_name in sys.modules:
if self.should_instrument_module(module_name):
for name, obj in sys.modules[module_name].__dict__.items():
if self.is_method(obj):
setattr(module, name, self.instrument_function(obj))
elif self.is_class(obj):
self.instrument_all_methods_in_class(obj)Wrapping itself is wrapt, with a sync and an async variant selected by inspect.iscoroutinefunction. Each wrapper opens a span, records function.execution_time_ms in a finally block so the timing survives an exception, and re-raises untouched so application error handling is unaffected.
What that one line actually changes
The whole design rests on setattr(module, name, wrapped), and it is worth being precise about it, because it is the question an interviewer will push on. No decorator is ever written into anyone's source. Nothing on disk is rewritten. What happens is a name rebind in a live namespace.
setattr is the dynamic form of dot-assignment — obj.attr = v and setattr(obj, "attr", v) compile to the same thing. It is needed here only because the attribute name is not known when the code is written; it arrives as a loop variable. And a module, once imported, is just an object whose globals live in its __dict__. So for a service file that defines create_order, the instrumentor is performing exactly this:
# sys.modules["inventory_smart.services.order"].__dict__
BEFORE "create_order" ─────────────────► <function create_order at 0x104a2b0>
AFTER "create_order" ──► <FunctionWrapper> ──┐
│ .__wrapped__
<function create_order at 0x104a2b0>
# equivalent to running, from outside the file:
# inventory_smart.services.order.create_order = wrapperThe original function object is never touched. It is still the same object at the same address, with the same bytecode; the wrapper merely holds a reference to it, retrievable as __wrapped__. One dictionary value was reassigned. That is the entire mutation.
Why this is enough to trace a whole repository: Python resolves a global or attribute lookup at call time, not at import time. So the swap takes effect for every subsequent call without any cooperation from the calling code — including calls made inside the same file, which resolve through the same __dict__.
| Call site | Traced? | Why |
|---|---|---|
| orders.create_order(p) | yes | attribute lookup on the module happens at call time |
| create_order(p) inside the same file | yes | global lookup resolves through the same module namespace |
| from …order import create_order | yes | the importing module has its own binding, and the loop visits that module too |
| HANDLERS = {"order": create_order} | no | the raw object was copied into a container at import time; no name to rebind |
That last row is the whole reason the second AST pass exists — it is the same failure described in what made it hard. It also explains why the loop walks every matching module rather than only the file where each function was defined: each import statement created a separate binding, and every one of them has to be caught.
The class case is the same mechanism with a different container. Rebinding in cls.__dict__ means every instance — including ones already constructed — picks up the wrapper on its next call, because instance attribute lookup falls through to the class.
The loop mutates module.__dict__ while iterating .items() over it. That is normally a RuntimeError: dictionary changed size during iteration. It is safe only because setattr overwrites an existing key, so the dict never resizes. Adding a new name inside that loop would break it.
07What made it hard
Wrapping a function is easy. Wrapping every function in a codebase you did not write, without changing its behaviour, is where the months went.
1. Functions used as values
This is the failure that shaped the design. If a function is passed somewhere as an argument — a callback, a key=, a handler registered in a table — then replacing the module attribute with a wrapper changes the object that the receiving code holds. Identity comparisons, signature introspection and equality checks all shift underneath it.
The fix is a second AST pass that looks at every call site, reads the arguments, and collects any argument that names a function the first pass knows about. Those names are excluded from wrapping.
class FunctionArgumentFinderHelper(ast.NodeVisitor):
def visit_Call(self, node):
for arg in node.args:
if isinstance(arg, ast.Name) and arg.id in self.all_functions:
self.excluded_functions.add(arg.id)
elif isinstance(arg, ast.Attribute) and isinstance(arg.value, ast.Name):
if arg.attr in self.all_functions:
self.excluded_functions.add(arg.attr)
self.generic_visit(node)2. Sync and async are different animals
A single wrapper cannot serve both. An async def returns a coroutine which must be awaited inside the span's context, or the span closes before the work happens and every async function reports near-zero duration. Two wrappers, selected by inspection.
3. Spans with no parent
Function-level instrumentation fires wherever the function is called — including background jobs, startup code and worker threads that are not part of any request. Those produce parentless spans that arrive in the trace list as thousands of fake single-span “traces”. The wrapper checks for a valid current span first, and if there is none, runs the function and emits nothing.
current_span = get_current_span()
if not current_span or not current_span.get_span_context().is_valid:
return wrapped(*args, **kwargs) # not part of a request — do not trace4. Static methods, class methods, threads
Each needed its own detection pass — the history has commits for all three. The lesson is that “a function” in Python is several different objects depending on how it is bound, and code that wraps blindly will get some of them wrong in ways that surface as an AttributeError in someone else's service at deploy time.
5. Third-party packages need different span names
For the service's own code a bare function name is readable. For an instrumented dependency it is ambiguous, so those spans get the fully-qualified module.QualName instead.
08Span volume control
Function-level instrumentation has an obvious failure mode: it multiplies span count per request by the call depth of the application. Left alone, a library like this makes the observability bill the new problem. So the package filters before anything leaves the process.
class DurationFilterSpanProcessor(BatchSpanProcessor):
def on_end(self, span: ReadableSpan):
# orphan BigQuery client spans arrive as parentless roots — drop them
if span.kind == SpanKind.INTERNAL and "bigquery" in span.name.lower() \
and not span.parent:
return
duration_ms = (span.end_time - span.start_time) / 1_000_000
if duration_ms >= self.duration_threshold_ms \
or span.status.status_code == StatusCode.ERROR:
super().on_end(span)
# else: dropped in-process. no network, no Kafka, no storage.Two properties of this are worth defending out loud. First, errors bypass the threshold entirely — a 2 ms function that raised is exactly the span you need, and a duration filter that drops it is worse than no filter. Second, the filter runs before the exporter, so a dropped span costs nothing anywhere downstream. Sampling at the collector would already have paid for serialisation and a network hop.
A fast, successful request has a deliberately incomplete waterfall. You cannot use these traces to audit every call in a healthy request — only to find where an unhealthy one spent its time. That was the right call for a debugging tool under a cost constraint, and it is the first thing I would revisit if the use case moved toward profiling.
Above the filter sits the standard OTel sampler, configurable per service as always_on, always_off, or partial with a ratio — so a high-traffic service can drop whole traces while a low-traffic one keeps everything.
09Request context & trace–log correlation
A trace is only useful if you can find the one you want. The stock OTel FastAPI instrumentor produces a span per request but knows nothing about the application's notion of a tenant or a user, so the package subclasses it and supplies its own span-detail callback.
def _get_default_span_details(scope):
route = _get_route_details(scope) # matched Starlette route
method = scope.get("method", "")
tenant = scope.get("state", {}).get("tenant")
user_id = scope.get("state", {}).get("user_id")
attributes = {}
if route: attributes[SpanAttributes.HTTP_ROUTE] = route
if tenant: attributes["tenant"] = tenant
if user_id: attributes["user_id"] = user_id
return f"{method} {route}", attributesUsing the matched route rather than the raw path is what keeps span names low-cardinality: GET /orders/{id} is one name, not one per order. Tenant and user ride as attributes, which are searchable without becoming part of the name.
Logs point at traces
The other half of the story lives in the shared logging utilities: the current OpenTelemetry trace ID is injected into every log message, and Grafana's derived fields turn it into a link. That is what makes the workflow “this log line looks wrong → open its trace” a click rather than an investigation.
AiopgInstrumentor supplies the last piece — PostgreSQL queries appear as spans in the same waterfall, so a slow function and the slow query inside it sit next to each other.
10Provider independence
The discovery and eligibility machinery — which functions exist, which must not be wrapped, what a span should be called — is entirely independent of where spans go. Late in the project that became explicit: the same instrumentor emits to either backend, chosen per service.
def instrument_function(self, func):
if self.instrumentation_type == InstrumentationType.DATADOG:
return ddtrace.tracer.wrap(name=self.get_span_name(func))(func)
return self._otel_instrument_function(func)That is a small amount of code for a large amount of freedom. The expensive, hard-won part of this project is the part that decides what to instrument; the part that decides where it goes is a branch. Keeping that boundary clean is what let the organisation move off a commercial APM without rewriting anything in the services.
11Known limits
Bring these up before the interviewer finds them. Knowing where your own design is weak reads as seniority; being surprised by it does not.
| Limit | Consequence | What I would do |
|---|---|---|
| Eligibility is matched on bare function name | Two functions sharing a name in different modules share a fate — exclude one, exclude both. | Key on module.qualname throughout; the AST pass already knows the module. |
| The AST exclusion pass is heuristic | It catches direct call arguments, not functions stored in dicts, returned, or referenced via keywords. | Extend to keyword arguments and assignments, and let a service pin a name explicitly. |
| Wrapping walks sys.modules at boot | Only what is already imported gets wrapped — later imports are missed. | An import hook would make coverage independent of import order. |
| The propagator drops inbound W3C trace context | Each service starts its own trace rather than joining the caller's. | Re-enable trace-context propagation behind a flag; the reason it was removed no longer applies cleanly. |
| The duration filter hides fast work | Healthy requests have incomplete waterfalls. | Keep it, but sample a small percentage of requests unfiltered for a complete picture. |
| Diagnostics use print | Setup output does not obey the host application's log configuration. | Route through the module logger — a small fix that should have happened earlier. |
“The thing I'd change first is that eligibility is keyed on the bare function name. It was the fastest thing that worked and it held up, but two same-named functions in different modules are treated as one — moving to a fully-qualified key is the correctness fix I'd prioritise.”
12One container → a cluster
What we ran is the small version: services export OTLP straight to a Tempo instance. The interesting design question — and the one worth having an answer to — is what changes when trace volume grows past what one process can hold.
| Mode | Fits | Scaling behaviour |
|---|---|---|
| Monolithic Tempo | Local, dev, modest volume | One process holding every role; scale vertically and hope. |
| Distributed Tempo | Production, high trace volume | Distributors, Kafka, block-builders, live-stores, queriers and query frontends each scale on their own signal. |
“We'd run 100 Tempo servers” is the wrong mental model. It is one distributed cluster with many replicas per component, coordinated through a ring and a Kafka write-ahead log. The object storage bucket is storage — it is not the thing that makes them a cluster.
Two independent decisions follow from separating write and read. A query spike must not take ingestion down, and needing more write throughput must not force you to pay for more query capacity. That is the whole argument for the distributed mode in one sentence.
13The trace write path
Why a collector gateway sits in the middle
Every service could point OTLP straight at the Tempo distributor. Putting a collector gateway in between buys three things: services know exactly one URL forever, batching and filtering happen somewhere that can be changed without redeploying applications, and the backend can be swapped without touching a single service's configuration.
Autoscaling the gateway on CPU alone is the mistake worth naming. CPU catches decode and processing saturation, but the queue can grow because Tempo is slow while the collector sits at 40%. The signal that actually means “add replicas” is exporter queue saturation together with accepted spans per replica; dropped spans are an alert, not a scaling input, because by then you are already losing data.
Kafka as the write-ahead log
Inside distributed Tempo, distributors validate and rate-limit OTLP, then write serialised trace records into Kafka. Block-builders consume that stream to build Parquet blocks and flush them to object storage; live-stores consume the same stream to serve recent traces before any block exists.
topic: tempo-traces key: tenant_id + trace_id partition: hash(trace_id) % partition_count value: Tempo's internal serialised span batch
Sharding by trace ID is the load-bearing decision: it keeps one trace's spans on one logical consumer path, which is what makes reassembling the trace tractable later. Partition count is the scale ceiling — too few and adding block-builders does nothing, because there are no more partitions for them to own.
Worth being clear about the boundary: none of that producer/consumer code is something you write. Tempo ships it. The engineering is in partitioning, capacity, retention and monitoring decisions.
14The trace read path
Grafana points at the query frontend, never at individual queriers and never at the write path. The frontend plans and splits a request, queriers execute the subqueries, and results merge before the waterfall renders.
| Read | Served from | Scaled by |
|---|---|---|
| Recent trace by ID | Live-store first — the block-builder may not have flushed yet | Live-store replicas, recent window, querier concurrency |
| Historical trace by ID | Object storage blocks, using metadata, index and bloom filters to skip the rest | Queriers, frontend, cache, object-storage read throughput |
| Search by service / route / error | Frontend splits by time and block; queriers scan the relevant metadata | Querier count, cache, query limits, attribute discipline |
A single trace ID can have spans in a live-store and across several blocks, depending on when the request happened relative to the last flush. The read path merges them into one parent/child graph; the user never learns where any of it was stored.
Reads are far cheaper than writes here — writes happen for every sampled request, reads happen when an engineer is debugging. So the read path starts small, but it needs guardrails: default query windows, maximum lookback, and frontend limits, because one careless seven-day wildcard search can cost more than a thousand normal queries.
15Sizing at 1M spans/sec
“A million a second” is meaningless until you say a million what. One request becomes one trace; one trace becomes many spans. Spans per second is the unit that drives collector, Kafka, Tempo and storage load — and 1M requests/sec at 10 spans each is 10M spans/sec, an order of magnitude different.
spans_per_sec = requests_per_sec × sampled_ratio × spans_per_sampled_trace raw_ingest_MBps = spans_per_sec × wire_bytes_per_span / 1e6 stored_TB_day = spans_per_sec × stored_bytes_per_span × 86,400 / 1e12 retained_TB = stored_TB_day × retention_days
| Scenario | Stored bytes/span | TB/day | 30-day retained |
|---|---|---|---|
| 1M spans/sec, small spans | 300 B | 25.9 | 778 TB |
| 1M spans/sec, medium spans | 800 B | 69.1 | 2,074 TB |
| 1M spans/sec, large spans | 1,500 B | 129.6 | 3,888 TB |
| 1M req/sec, 10 spans each | 800 B | 691.2 | 20,736 TB |
Span size varies by 5× across those rows, and it moves storage, Kafka throughput, network and block-building pressure by the same factor. That is why any honest starting topology is a range to benchmark rather than a number to deploy.
| Layer | Starting point | Scale on |
|---|---|---|
| Collector gateway | 30–80 replicas | Accepted spans/sec, queue saturation |
| Tempo distributors | 20–60 replicas | OTLP ingest rate, CPU, network |
| Kafka topic | 512–2,048 partitions | Measured MB/sec and consumer parallelism |
| Block-builders | Grow to match partition parallelism | Kafka lag, block flush latency |
| Live-stores | Partition- and zone-aware | Recent trace volume, memory |
| Queriers | 5–20 replicas | Query latency and concurrency |
| Query frontend | 2–6 replicas | Availability and query splitting |
The load test that makes the numbers real
Four phases — 50K, 250K, 500K, 1M spans/sec — with realistic attribute payloads, because synthetic minimal spans understate every cost in the system. Then two more: kill collectors and block-builders mid-load to watch recovery, and run trace-by-ID and search queries during write load to prove reads do not starve ingestion.
Pass criteria are about saturation, not throughput: queues drain after bursts, dropped spans near zero, Kafka lag bounded and draining, flush latency within target, recent traces queryable within the expected delay.
16The cost model
This is the section that changes how people think about observability, so it is worth leading with the conclusion: at this scale, cost is a volume problem, and the volume dial is sampling policy — not anything in the infrastructure.
The same architecture, the same code, the same retention: $871K a month or $9K a month depending on one configuration decision. Every infrastructure optimisation available in this pipeline lives inside the rounding error of that choice.
| Input | Effect | Lever |
|---|---|---|
| Sampling rate | Linear | Low default sampling for fast successful requests; keep all errors and slow traces |
| Spans per request | Linear | Cap instrumentation depth, exclude hot utility functions |
| Bytes per span | Linear | Attribute allowlist; truncate or drop large events |
| Retention | Near-linear on storage | Short hot window, lifecycle to colder tiers |
| Query behaviour | Querier CPU + object-storage reads | Query limits, caching, default time windows |
“This is why the duration filter lives in the library rather than in the collector. Function-level instrumentation is the thing that multiplies span volume, so the cheapest possible place to decide a span is not worth keeping is inside the process that created it — before serialisation, before the network, before Kafka, before storage.”
17Logs: label cardinality is the whole game
Traces were half the platform. The other half is logs, and the mental model is completely different: Loki is not Elasticsearch. It does not index every word. It indexes labels and stores log lines in compressed chunks — which is what makes it cheap, and what makes it entirely dependent on label design.
A Loki stream is one tenant plus one exact label set. Entries for a stream accumulate into a chunk; chunks flush to object storage when they fill, age out, or go idle.
At 1M events/sec with around 10K healthy streams, each stream sees ~100 events/sec and chunks fill before they age out. Promote trace_id to a label and you have ~1M streams each seeing about one event per second — every chunk flushes on idle timeout while nearly empty, and you have converted a cheap log store into millions of tiny objects, huge ingester memory and slow reads.
The field you most want for correlation — the trace ID — is the single worst thing you can make a label. It goes in the structured log body or in structured metadata, and Grafana derived fields turn it into a link to Tempo. Correlation preserved, cardinality untouched.
18Loki write & read
Batching is what protects the application
At 1M events/sec the application must never call Loki synchronously per line. An agent — Alloy, Fluent Bit, Logstash — batches, parses, enriches, retries and absorbs backpressure. The event rate is fixed; the request rate is a choice.
| Batch size | Log events/sec | HTTP pushes/sec |
|---|---|---|
| 100 per push | 1,000,000 | 10,000 |
| 1,000 per push | 1,000,000 | 1,000 |
| 5,000 per push | 1,000,000 | 200 |
A starting shape at 1M events/sec and 10K queries/sec
Assuming ~512 B lines, 5:1 compression, 30-day retention and replication factor 3 — about 44 TB/day raw, 8.8 TB/day compressed, ~265 TB retained.
| Component | Count | Pressure signal |
|---|---|---|
| Gateway | 12–20 | HTTP RPS, p95, 5xx |
| Distributors | 32–48 | Lines/sec, bytes/sec, rate-limited pushes |
| Ingesters | 96–144 | Active streams, chunk memory, WAL, flush queue |
| Index gateway | 16–32 | Lookup latency, cache hit rate |
| Query frontend | 20–40 | Queue time, cache hit rate |
| Query scheduler | 6–12 | Queue depth |
| Queriers | 150–300 | CPU, chunks fetched/sec, bytes scanned |
| Cache nodes | 30–80 | Hit ratio, evictions, memory |
| Compactor | 1 + standby | Compaction and retention lag |
The ingester count looks disproportionate until you remember replication factor 3: the fleet absorbs roughly 3M replicated writes/sec internally, and holds active chunks in memory while it does. Queriers are large for the opposite reason — they are CPU-bound on decompressing and scanning chunks, so read cost is driven by how selective the queries are, not by how many there are.
Rough order for the whole self-managed fleet: $100K–$250K a month, dominated by compute rather than by the ~$6K of object storage. Cost control is the same shape as for traces — drop debug and info logs at source, keep labels low-cardinality, shorten retention, and improve cache hit rate.
19Dashboards & failure modes
Every dashboard here shows traffic and saturation. Traffic alone tells you how much is arriving; saturation tells you whether the next layer is keeping up, which is the thing that actually fails.
| Signal | Healthy | Bad |
|---|---|---|
| Collector accepted spans/sec | Tracks traffic and sampling | Step change right after enabling function instrumentation |
| Exporter queue saturation | Small, drains after bursts | Monotonically rising |
| Dropped / refused spans | At or near zero | Anything sustained — this is data loss |
| Kafka consumer lag | Bounded and draining | Block-builders or live-stores falling behind |
| Block flush duration | Predictable | Object storage or network bottleneck |
| Query p95 / p99 | Low for trace-by-ID | Debugging slows down exactly when it matters |
| Loki active streams | Stable, bounded per tenant | Climbing — someone added a high-cardinality label |
Observability must fail without taking the application with it. Export is asynchronous and batched, agent queues are bounded, and when a queue is full the right behaviour is to drop low-value telemetry rather than block a business request. A tracing library that can stall a request is worse than no tracing library.
20Decision record
| Decision | Why | Alternative & trade-off |
|---|---|---|
| Auto-discover functions via AST | Instrumentation that does not depend on anyone remembering | Decorators — explicit and precise, but they decay across a fleet |
| wrapt for wrapping | Preserves signatures, metadata and descriptor behaviour properly | Hand-rolled functools.wraps — subtly wrong for methods and descriptors |
| Build-time cache keyed on commit hash | Removes two repo scans from every cold start | Scan at boot — simpler, but a startup-latency tax forever |
| In-process duration filter | The cheapest place to discard a span is where it was made | Collector-side sampling — already paid for serialisation and a hop |
| Errors always exported | A 2 ms failing span is the one you need most | A pure duration filter — simpler, and it hides the bugs |
| Route-based span names | Keeps span names low-cardinality | Raw paths — one span name per ID, and useless aggregation |
| Tempo + Grafana over commercial APM | Object storage instead of a licensed index; ~$21K/yr | Commercial APM — richer out of the box, and priced accordingly |
| Backend as a flag | Discovery logic is independent of destination | Hard-wiring OTel — less code, no migration path |
| Collector gateway at scale | Services know one URL; the backend can change behind it | Direct-to-Tempo — one less hop, and every service coupled to it |
| Kafka sharded by trace ID | Keeps a trace's spans on one consumer path | Random sharding — spans scattered, reassembly expensive |
| trace_id in the log body, not a Loki label | Correlation without stream explosion | trace_id as a label — instant, obvious, and it destroys the cluster |
21Alternatives, argued fairly
- Just use OpenTelemetry auto-instrumentation. It is excellent, and we use it — for FastAPI and for the database. What it does not do is tell you which of your functions took the time, and that was the actual debugging complaint. mtptrace is a layer above it, not a replacement for it.
- Decorators, done properly. Explicit, precise, no AST tricks, no surprises. Rejected on a social argument rather than a technical one: instrumentation that requires ongoing effort per function converges on the functions somebody was debugging last quarter. If the team were five services instead of dozens, this is probably the right answer.
- A Python profiler or sampling profiler. Better for CPU attribution, worse for the question being asked — which was “where did this request, for this tenant, spend its time”. Traces answer that; profiles do not.
- Keep the commercial APM. Genuinely less work, and better out-of-the-box UI. Rejected on cost once it was clear Tempo plus Grafana covered the debugging workflow we actually used. Trade-off accepted: we own the backend now, including its failure modes.
- Elasticsearch for logs instead of Loki. Full-text indexing is more powerful and much more expensive at this volume. Loki's bet — index labels, compress the rest — is the right one, as long as the label discipline holds. That “as long as” is the whole risk.
22Adoption & influence
A library nobody installs is a side project. Most of the design decisions here are really adoption decisions wearing technical clothes.
- Adoption cost is the product. One constructor call and a dependency line. Every piece of configuration that could be an environment variable is one, so integrating is a PR and not a project.
- Ship the escape hatch with the feature. A kill switch, a per-service threshold, a span-logging exporter. Teams adopt something they can turn off at 2am without a release far more readily than something they cannot.
- Documentation is part of the rollout. A README with the exact code block, the exact environment variables, the known failure (“Cannot install mtptrace” means repository access, not a bug), and a recorded demo of the Grafana workflow.
- DevOps had a separate contract. Their side was environment variables, the Tempo deployment and the Grafana datasource — which is why the responsibility split above is drawn explicitly.
- The logging change was somebody else's repository. Trace–log correlation needed the shared logging utilities to emit the trace ID, so that landed as a PR into a repo I do not own, with its owners.
One team that resisted or hit a real problem during rollout, what the objection was, and how it was resolved. Rollout friction handled well is a stronger leadership signal than the adoption count.
23Mentorship
This project has a natural mentoring surface that a feature project does not: every integrating team had to be walked through what the library does to their code, and several engineers debugged instrumentation problems with it.
- Teaching the mental model. “Your function is being replaced at import time” is an unfamiliar idea for most application engineers, and it is the thing that makes the failure modes make sense rather than look like magic.
- Turning a bug report into a lesson. The exclusion problem showed up as “my callback broke”. Explaining why teaches how Python treats functions as objects — which is a lesson that outlasts this library.
- Handing over real surface area. The most useful thing to delegate on a platform project is a whole capability with its own failure modes, not a ticket.
One engineer: where they started, the specific technical gap, what you actually did (pairing, scoped ownership, design-level review rather than diff-level), and — the part that counts — what they owned afterwards and any level change that followed.
“X was strong at feature work but had not owned anything other teams depended on. I handed them [component] with the failure modes as the spec, reviewed their design rather than their diffs, and had them run the integration conversation with two adopting teams. They ended up owning that component, including support, and moved to [level] at the next cycle.”
24Self-initiated work
This is the project to lead with when someone asks for something proactive rather than assigned. It has the three properties that question is actually testing.
| What they are testing | Evidence here |
|---|---|
| Did you identify the problem yourself? | No ticket existed. The complaint was ambient — debugging was slow and the APM was expensive. |
| Did it reach beyond your own team? | Adopted across the organisation's FastAPI services, by teams with no obligation to take it. |
| Is there a number? | ~$21,000 a year, plus a capability that did not exist before. |
| Did you finish it? | Ten months, 50 PRs, through cold-start optimisation and a backend switch — not a prototype. |
“mtptrace started as nobody's project. We had a slow-debugging problem and an APM bill, and the standard answer — add OpenTelemetry decorators — was never going to survive across dozens of services. So I built a package that discovers a codebase's own functions and instruments them automatically, made adoption about eight lines, and rolled it out. It replaced the commercial APM with Tempo and Grafana and saved roughly $21K a year.”
Worth pairing with the reliability and persistence work on the PO allocation platform — that shows self-direction inside an assigned project; this one shows it with no project at all.
25Learning curve
- Metaprogramming is a support commitment. Code that rewrites other people's code produces failures in repositories you have never read, reported by engineers who did not opt in at the function level. Every feature after the first month was really about making those failures rarer or easier to explain.
- Adoption cost dominates capability. A more powerful library that takes a day to integrate loses to a simpler one that takes ten minutes. That is not a compromise; it is the actual optimisation target for platform work.
- The expensive phase and the frequent phase should not be the same phase. Two AST passes are fine once per commit and wrong once per container start. Moving work across that boundary — rather than making it faster — was the whole cold-start fix.
- Telemetry volume is a product decision. Function-level tracing makes span count a function of call depth. Deciding what not to keep turned out to be as much of the design as deciding what to capture.
Ramp-up worth naming honestly: OpenTelemetry's provider/processor/exporter model took real time to internalise, and the first version leaned on print debugging because span pipelines are hard to inspect from the outside — which is why a span-logging exporter is now a first-class feature.
26Outcome & metrics
| Metric | Before | After |
|---|---|---|
| Trace-storage cost | Commercial APM licence | Tempo on object storage — ~$21K/yr saved |
| Cost to instrument a service | Per-function decorators, forever | ~8 lines, once |
| Per-function timing in a request | Not available | Default for every adopting service |
| Log → trace navigation | Manual correlation by timestamp | One click via derived fields |
| Database query visibility | Separate investigation | In the same waterfall |
| Backend lock-in | One vendor | OTel or Datadog, per service, by flag |
Services and teams on it at peak; any measured change in time-to-diagnose; the number of production issues found with it that would otherwise have been guesswork. One concrete incident — “we found X in minutes because the trace showed Y” — is worth more than the adoption count.
27The hardest part
Instrumenting code without changing its behaviour
Adding a span is trivial. Guaranteeing that replacing several thousand callables across a repository you did not write leaves every one of them behaving identically is not. Python makes a function an object, and that object gets stored in dictionaries, passed as a callback, compared by identity, inspected for a signature, bound as a method, wrapped in a descriptor, and scheduled on an event loop — and rebinding the name breaks a different one of those each time.
The insight that made it tractable was that the dangerous cases are visible in the source. If a function is passed somewhere as a value, that appears in the AST as an argument to a call — so the codebase can be asked, statically, which functions must be left alone. The design followed from that: a discovery pass, an exclusion pass, and a wrap that only touches what survives both.
It is also the reason the project took ten months rather than three. Each new failure mode — static methods, coroutines, threads, parentless spans — was a real bug in someone's service first and a commit second.
The runner-up: making it not cost what it saved
Two full-repository AST passes on every container start, and a span per function call in a system with deep call stacks. Both were solved by moving work rather than optimising it — the analysis to build time behind a commit-hash cache, and the volume decision into the process, before anything is serialised.
28Question drill
Read the question, answer it out loud, then open the card and compare.
Scope, ownership, leadership
What exactly did you own here?
All of it: the idea, the package, the instrumentation strategy, the volume policy, the FastAPI instrumentor, the rollout, and the Tempo/Grafana backend that replaced the commercial APM.
Not mine: the OpenTelemetry SDK and Tempo internals. I extend and operate both — the custom FastAPI instrumentor is a subclass of theirs, not a rewrite.
Why should anyone believe this was self-initiated?
No ticket, no mandate, no vendor push. It was adopted by teams that had no obligation to take it, which is the only real evidence a platform project was worth building — and it retired a paid product, which is the number.
How did you get other teams to adopt it?
- Made integration ~8 lines and one dependency line.
- Shipped a kill switch and per-service tuning so adoption was reversible without a release.
- Wrote the README as a copy-paste path including the one error people hit.
- Landed the trace–log correlation change in the shared logging repo, with its owners.
The package
How does it find the functions to instrument?
An AST pass over every .py file in the repository (and any explicitly named package) collecting FunctionDef and AsyncFunctionDef names. A second pass collects functions that appear as arguments to calls — those are excluded. The result is cached against the git commit hash. At boot, sys.modules is swept and eligible callables are rebound to wrapt wrappers.
Why exclude functions that are passed as arguments?
Because wrapping replaces the object the receiver holds. A function handed to something else as a callback, a sort key or a retry helper is being used as a value — and identity comparisons, signature introspection and equality checks all change under a wrapper. This showed up as real breakage, and the AST pass is the static way to find every such case before wrapping anything.
Why wrapt rather than a plain decorator?
wrapt preserves signatures, metadata and — importantly — descriptor behaviour, so bound methods, class methods and static methods keep working. A hand-rolled functools.wraps decorator is subtly wrong for several of those, and “subtly wrong” in a library that touches every function is the worst possible outcome.
Why cache on the git commit hash?
Because the commit is exactly what determines the answer. Same commit, same functions — so a cache hit is provably valid and a stale cache is impossible. It turned two full-repository AST walks per container start into a JSON read, which mattered on Cloud Run where cold starts are frequent.
Doesn't a span per function explode your span volume?
Yes, and that is designed for. A custom span processor drops spans below a duration threshold (default 10 ms) before export, with two exceptions: errors are always kept regardless of duration, and orphan BigQuery client spans are always dropped. The filter runs in-process, so a discarded span costs no serialisation, no network, no Kafka and no storage.
Above that sits the normal OTel sampler, configurable per service.
Why is the filter in the library rather than in the collector?
Because the cheapest place to discard telemetry is where it was created. Filtering at the collector means you have already paid to serialise the span and put it on the network. The library is the only place with the information and the position to drop it for free.
What happens to a function called outside a request?
Nothing is emitted. The wrapper checks for a valid current span and, if there is none, calls through untouched. Without that check, background jobs and startup code produce thousands of parentless single-span traces that make the trace list useless.
Sync and async — one wrapper or two?
Two, selected by inspect.iscoroutinefunction. An async function returns a coroutine, so the span has to stay open across the await; a single wrapper closes it before the work happens and every async function reports near-zero duration.
How do logs connect to traces?
The trace ID is injected into every log message by the shared logging utilities, and Grafana derived fields turn it into a link to the trace. At Loki scale the important corollary is that trace_id must stay in the log body — never a label — or the stream count explodes.
What would you change about the package today?
- Key eligibility on module.qualname, not the bare name.
- Extend the exclusion pass to keyword arguments and assignments.
- Use an import hook so coverage does not depend on import order.
- Re-enable W3C trace-context propagation behind a flag for cross-service traces.
- Sample a small percentage of requests unfiltered, so some waterfalls are complete.
- Route diagnostics through the logger instead of print.
At scale
Someone says '1M requests per second'. First question?
A million of what. Requests, traces and spans are three different units, and spans is the one that drives collector, Kafka, Tempo and storage load. 1M requests/sec at 10 spans each is 10M spans/sec — every number downstream changes by 10×.
Why a collector gateway instead of exporting straight to Tempo?
Services get one stable URL for the life of the system; batching, filtering and enrichment become changeable without redeploying applications; and the backend can be replaced without touching service config. The cost is one extra hop and a fleet to operate.
How do you autoscale the collector?
Not on CPU alone. CPU catches processing saturation, but the queue can grow while CPU is fine because the thing downstream is slow. Scale on accepted spans/sec per replica and exporter queue saturation, with CPU and memory as supporting signals. Dropped spans are an alert, not a scaling input — by then you are already losing data. And never scale collectors when the real bottleneck is Tempo or Kafka.
Why is Kafka in the Tempo write path, and why shard by trace ID?
Kafka is the durable write-ahead log between distributors and consumers, which lets block-builders and live-stores recover from downtime without losing the window. Sharding on trace ID keeps all of a trace's spans on one logical consumer path — random sharding scatters them and makes reassembly expensive. Partition count is the real ceiling: too few and extra block-builders have nothing to own.
Grafana has a trace ID. Where does that trace actually come from?
Grafana asks the query frontend, which plans and schedules the work. A querier checks the live-store for recent spans and uses block metadata, indexes and bloom filters to find historical ones, fetching only the matching blocks from object storage. The spans are merged into one tree before the waterfall renders — and a single trace can legitimately be split across both sources.
What dominates the cost at 1M spans/sec?
Volume — and therefore sampling policy. At 1M req/sec with 20 spans each and 800 B per span, 100% sampling is roughly $871K/month of storage alone; 10% is ~$87K; 1% is ~$9K. No infrastructure optimisation in this pipeline competes with that dial. After sampling, the levers are spans per request, bytes per span and retention — all linear.
Why is label cardinality the central Loki decision?
A Loki stream is one exact label set, and chunks are per stream. With ~10K low-cardinality streams at 1M events/sec each stream gets ~100 events/sec and chunks fill properly. Promote trace_id to a label and you get ~1M streams at ~1 event/sec, every chunk flushing on idle timeout while nearly empty — ingester memory explodes, object count explodes, reads slow down. Loki indexes labels, not words; the label set is the schema.
Why are there so many more Loki queriers than distributors?
Different bottlenecks. Distributors are stateless and cheap — validate, hash, fan out. Queriers are CPU-bound on fetching chunks from object storage, decompressing and scanning them, so cost tracks how much data a query must touch rather than how many queries there are. One wide seven-day search can outweigh thousands of narrow ones, which is why query limits matter more than querier count.
What happens to the application if the observability backend goes down?
Nothing it can feel. Export is asynchronous and batched by the SDK, log agents own bounded queues with retry, and when a queue fills the correct behaviour is to drop low-value telemetry rather than block a request. An observability system that can stall business traffic has inverted its own purpose.
29What not to say
| Do not say | Say instead |
|---|---|
| “I built an observability platform.” | I built the instrumentation library and ran the Tempo/Grafana migration; the SDK and Tempo are open source. |
| “It traces everything automatically.” | It traces the modules you name, minus functions used as values, minus what you exclude — and drops fast successful spans. |
| “We ran Tempo at a million spans a second.” | We ran the small deployment. The 1M spans/sec design is a sizing exercise I did, and I'll say which parts are measured. |
| “Monkey-patching every function is fine.” | It is invasive, and most of the work was finding the cases where it changes behaviour. |
| “It saved $21K, so it paid for itself.” | $21K/yr in trace storage, plus a per-function debugging capability that did not exist before. |
| “Loki is basically Elasticsearch but cheaper.” | Loki indexes labels, not words — cheaper because it does less, which is why label design carries the whole system. |
30Gaps to fill before the interview
Four facts this write-up cannot supply, and each one is a question you will be asked.
- Adoption. Services and teams on it at peak — “org-wide” needs a number behind it.
- Debugging impact. Any measured change in time-to-diagnose, plus one concrete incident it cracked.
- Cost detail. How the ~$21K/yr was calculated, and what it was replacing.
- Mentorship. One engineer: gap, coaching, what they owned afterwards.
“The principle behind mtptrace was that good instrumentation has to be the default state of a service rather than an ongoing chore. Everything else — parsing the codebase to find its functions, excluding the ones used as values, caching that against the commit, filtering spans before they cost anything — follows from refusing to make engineers remember.”
That's the package and the platform behind it. The allocation platform deep dive is the other one — or the terminal on the home page takes questions.
← back to projects