Estimating Telemetry Volume from a Python Service
Every telemetry cost conversation stalls at the same point: nobody knows what the service actually produces. This page covers getting that number — from one trace, one export payload and one request's worth of logs — and turning it into a monthly figure that survives contact with an invoice. It is a task article under telemetry cost and data volume control, part of the Python telemetry pipelines and delivery section.
Prerequisites
pip install "opentelemetry-sdk>=1.27.0,<2.0.0" \
"opentelemetry-exporter-otlp-proto-http>=1.27.0,<2.0.0"
Implementation
Step 1 — Count spans per request from one complete trace. Open a single trace in whatever backend you have and count its spans, excluding the spans belonging to downstream services if you are estimating this service only. This number is the one people guess at most wildly, and the guess is nearly always low, because automatic instrumentation of an ORM produces a span per statement and a request that looks like one query frequently issues a dozen. If the number surprises you, that is itself a finding, and detecting repeated query patterns with traces is the next page to read.
Step 2 — Measure bytes per span from a real export. Point the exporter at a local file-writing endpoint for a minute, or capture one request body, and divide. Estimating this from the span's fields underestimates it substantially, because attributes dominate: a database span carrying a statement, a connection string and a set of semantic convention attributes is several times the size of a bare one.
# capture.py — write one export payload to disk for measurement
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
# Point at a local endpoint that logs bodies; any HTTP echo server works.
provider.add_span_processor(SimpleSpanProcessor(
OTLPSpanExporter(endpoint="http://localhost:8080/v1/traces")))
# measure.py
import gzip, json, pathlib
raw = pathlib.Path("export-body.json").read_bytes()
doc = json.loads(raw)
spans = sum(len(ss["spans"]) for rs in doc["resourceSpans"] for ss in rs["scopeSpans"])
print(f"spans {spans}")
print(f"bytes per span {len(raw) / spans:8.0f} raw")
print(f" {len(gzip.compress(raw)) / spans:8.0f} compressed")
Expected Output:
spans 512
bytes per span 998 raw
129 compressed
Step 3 — Count log records per request. Take one request's log output at the level production actually runs at, and count. Logs deserve separate attention because they have no sampling term: every record produced is a record stored, so the per-request count multiplies directly into the bill. Four records per request at a thousand requests per second is three hundred and forty million records a month, which is a number worth seeing before somebody adds a fifth.
Step 4 — Count active series rather than instruments. A service with twelve metric definitions can have twelve series or twelve thousand, depending entirely on labels. The calculation is per instrument: multiply the cardinality of each of its labels together, then sum across instruments. Histograms multiply again by their bucket count, which is why a latency histogram with a generous ladder is usually the largest single contributor.
# series.py — the calculation that matters, done explicitly
INSTRUMENTS = {
"http_server_duration": {"labels": {"route": 24, "method": 4, "status": 6}, "buckets": 12},
"http_server_requests": {"labels": {"route": 24, "method": 4, "status": 6}, "buckets": 1},
"db_pool_in_use": {"labels": {"pool": 3}, "buckets": 1},
}
total = 0
for name, spec in INSTRUMENTS.items():
combos = 1
for cardinality in spec["labels"].values():
combos *= cardinality
series = combos * spec["buckets"]
total += series
print(f"{name:26s} {combos:6d} combinations × {spec['buckets']:2d} = {series:7d} series")
print(f"{'total':26s} {total:24d} series")
Expected Output:
http_server_duration 576 combinations × 12 = 6912 series
http_server_requests 576 combinations × 1 = 576 series
db_pool_in_use 3 combinations × 1 = 3 series
total 7491 series
Step 5 — Multiply, and apply the right compression assumption. The monthly figure is per-request bytes times request rate times seconds in a month. Whether to use the raw or compressed number depends entirely on the contract, and the two differ by roughly a factor of eight for telemetry payloads, which is more than enough to make an estimate useless if the wrong one is chosen.
Step 6 — Record the assumptions alongside the number. An estimate whose inputs are not written down cannot be updated when one of them changes, and one of them always changes: a new endpoint adds routes, a library upgrade adds spans, a release adds a log statement. Keeping the inputs in a small file next to the service means the next estimate takes two minutes rather than starting over.
What the estimate is for
An estimate that is only used to predict a bill is doing a fraction of the work it could. Three other uses are more valuable.
Sizing the pipeline. Every buffer decision in buffering telemetry during backend outages starts from the production rate, and the same numbers produce it. A fleet that knows its per-request figures can size queues, collectors and network capacity from arithmetic rather than by observing failures.
Comparing services. Bytes per request is comparable across services in a way that totals are not. A service producing ten times its neighbour's per-request volume is either doing something genuinely more complex or has a problem, and the ratio is what prompts the question. In practice the answer is usually an ORM issuing far more statements than the developer believes, which the span count exposes immediately.
Predicting the effect of a change. Before adopting automatic instrumentation for a new library, before adding a label to a metric, before adding a log statement to a hot path — the estimate says what it will cost. A label with fifty values multiplies a metric's series by fifty; seeing that written down changes the decision more reliably than any guideline.
There is also a defensive use. A service whose volume is understood can respond to a cost reduction request with a specific proposal — drop the eleven database spans to one summary span, saving sixty-nine percent — rather than with an unfocused reduction that costs every future investigation. The estimate is what converts a mandate into an engineering decision.
The numbers that are hardest to estimate
Two inputs resist estimation and are worth measuring rather than reasoning about.
The first is log volume under failure. A service producing four records per request in normal operation can produce forty during an incident, because every failure path logs, retries log, and the error handlers that are dormant for months all run at once. The volume peak therefore coincides exactly with the moment the pipeline is under most stress and with the moment the data matters most. Estimating from steady-state traffic understates the peak by an order of magnitude, and a buffer sized on the steady state will not survive the event it exists for. The practical approach is to take the worst hour from the last quarter and use that as the design figure for logs, while using the average for traces.
The second is the effect of automatic instrumentation on a library you have not used before. Adding an instrumentation package for a message queue, a cache or a new database driver can add anywhere between one span per operation and one span per network round trip, and the difference is not documented in a way that can be predicted. Enabling it in staging under representative load for an hour and re-running the span count from step 1 costs almost nothing and answers the question exactly. Doing that before the fleet-wide rollout, rather than after, is the difference between a planned increase and a surprise.
Configuration options
| Input | How to get it | Typical value | Sensitivity |
|---|---|---|---|
| Spans per request | count one real trace | 3–40 | very high |
| Bytes per span | measure one export | 800–1 400 raw | moderate |
| Log records per request | one request at production level | 2–8 | very high |
| Bytes per record | measure one line | 200–600 | moderate |
| Active series | multiply label cardinalities | 500–50 000 | very high |
| Compression ratio | gzip a real payload | 6–10× | high |
| Retention | the contract | 7–30 days | high |
Verification
Check the estimate against reality after a week, because the point of an estimate is to be corrected.
# what the collector actually forwarded, over a day
curl -s localhost:8888/metrics | grep -E 'otelcol_exporter_sent_(spans|log_records)'
# against the estimate
python3 -c "
rps, spans_per_req = 420, 16
print(f'predicted spans/day: {rps * spans_per_req * 86400:,}')"
Expected Output: a prediction within a factor of two, which is close enough to act on.
otelcol_exporter_sent_spans 574_291_002
predicted spans/day: 580,608,000
A prediction that is off by an order of magnitude usually means the span count per request was taken from a simple endpoint rather than a representative one, which is worth correcting before the number is used for anything else.
Common mistakes
Counting spans from the simplest endpoint. Error signature: an estimate several times below reality. Root cause: a health check or a static route used as the sample. Remediation: take the trace of a representative business operation, ideally the one with the highest traffic.
Estimating span size from the schema. Error signature: an estimate half the real figure. Root cause: attributes, not fields, dominate the size. Remediation: measure a real payload.
Ignoring histogram buckets in the series count. Error signature: a metrics estimate an order of magnitude low. Root cause: counting one series per label combination when a histogram produces one per bucket. Remediation: multiply by the bucket count, as in step 4.
Using the wrong compression assumption. Error signature: an estimate eight times the invoice, or an eighth of it. Root cause: raw versus compressed. Remediation: check the contract and measure both.
Estimating once. Error signature: a figure that was accurate a year ago. Root cause: the inputs change with every release. Remediation: keep the inputs in a file and re-run the arithmetic when something notable changes.
Frequently Asked Questions
How many spans does a typical Python request produce?
It varies by an order of magnitude. A minimally instrumented service produces two or three — one server span and one client span. A service with automatic instrumentation for the web framework, the database driver, the HTTP client and the cache routinely produces fifteen to forty, most of them from the database layer.
How large is a span on the wire?
Around one kilobyte raw for a typical instrumented span, dominated by attributes rather than by the span's own fields. Resource attributes are shared across all spans in an export, so they cost far less per span than their size suggests. Compression usually removes seventy to ninety percent.
Do I need to estimate before instrumenting?
For a fleet, yes — the difference between three spans per request and thirty is the difference between a routine bill and a project. For one service, instrument first and measure after a week, because a real measurement takes ten minutes and a good estimate takes longer.
Why is my estimate so far from the invoice?
Usually compression, sometimes retention. A backend billing compressed ingest will charge roughly an eighth of the raw figure, and one billing on stored volume after replication may charge several times it. Check which the contract uses before reconciling.