autotel
Version:
Write Once, Observe Anywhere
291 lines (225 loc) • 10.4 kB
Markdown
name: tune-sampling
description: >
Choose a sampling strategy for an autotel-instrumented service. Covers
head sampling (per-span-kind rates, parent-based, ratio), tail sampling
(keep errors, slow, AI-aware, debug-headers), cost vs cardinality
tradeoffs, and the math for picking rates that hit a target spans/second
budget. Includes recipes for low-volume admin services, high-volume APIs,
AI agents, and Cloudflare Workers.
license: MIT
# Tune sampling
Untuned tracing is either expensive (100 % at scale costs money + drowns dashboards) or unhelpful (1 % loses the failure modes you need to see). The right answer is almost always **head sample most of the boring traffic, tail keep all the interesting traffic**, with explicit overrides for AI calls and customer escalations.
## When to use
- Hitting your observability budget
- Dashboards too sparse to spot anomalies
- "We have the trace IDs but the spans are gone" complaints
- New service launching at scale
- Long-running AI agents producing 50+ spans per request
## The mental model
```
Total cost = (spans/sec × $/span) + (storage_GB × $/GB-month)
↑
Head sampling reduces this directly.
```
Head sampling makes a decision **at span start**: fast but coarse, since it cannot know whether the span will fail.
Tail sampling makes the decision **at span end**: slower and heavier on storage, but precise.
The right mix:
- **Head sample at the entry point** to keep volume tractable.
- **Tail keep** the high-value subset (errors, slow, AI, debug-headered).
- **Don't sample audit spans**: separate processor, see [`build-audit-trails`](../build-audit-trails/SKILL.md).
## Head sampling recipes
### Default for a typical web service
```typescript
init({
service: 'my-app',
sampling: {
rates: {
server: 25, // server entry spans — sample ¼
client: 5, // outbound HTTP — sample 1/20
internal: 5, // internal sub-spans — sample 1/20
},
},
});
```
Children of a sampled root are **all** kept (parent-based propagation is the default). So `server: 25` means 25 % of _user requests_, complete trace each.
### High-volume API (>1 k req/s)
```typescript
sampling: {
rates: { server: 5, client: 1, internal: 1 }, // 5 % → tail keeps errors anyway
tail: keepInterestingTraces,
},
```
### Low-volume admin / internal service (<10 req/s)
100 % is fine. Don't penalise yourself for a service that produces 1 GB of traces a week.
### Cloudflare Workers (per-colo budget)
Workers run distributed. Head sampling is your friend because there's no central queue:
```typescript
defineWorkerFetch(
{
service: { name: 'edge' },
sampling: { rates: { server: 10 } }, // 10 % per colo, scales naturally
},
handler,
);
```
## Tail sampling: keep interesting traces
Tail sampling looks at the full trace (root span + children) before deciding. autotel ships `TailSamplingProcessor`:
```typescript
import { TailSamplingProcessor } from 'autotel/processors';
import { SpanStatusCode } from '@opentelemetry/api';
const tail = new TailSamplingProcessor({
keep: (trace) => {
// 1. Always keep errors
if (trace.localRootSpan.status?.code === SpanStatusCode.ERROR) return true;
if (trace.spans.some((s) => s.status?.code === SpanStatusCode.ERROR))
return true;
// 2. Always keep slow traces (configurable threshold)
if (durationMs(trace.localRootSpan) > 1_000) return true;
// 3. Always keep customer-marked traces
if (trace.localRootSpan.attributes['debug.trace'] === true) return true;
// 4. Always keep AI traces (rare + expensive — full visibility helps)
if (
trace.spans.some(
(s) =>
typeof s.attributes['gen_ai.provider.name'] === 'string' ||
// legacy: third-party instrumentations may still emit gen_ai.system
typeof s.attributes['gen_ai.system'] === 'string',
)
)
return true;
// 5. Otherwise: respect head sampling decision
return false;
},
});
```
### Combining with multi-backend
```typescript
spanProcessors: composeSpanProcessors([
// Drop nothing here — we want the tail processor to see the full trace
new BatchSpanProcessor(localExporter),
tail, // filters before remote export
new BatchSpanProcessor(expensiveRemoteExporter),
]);
```
## AI / LLM-aware sampling
LLM calls produce 5–50 spans per request and are 100× more expensive than a typical handler call. Tradeoffs:
- **Don't head-sample AI handlers below 50 %**: debugging "why did the model loop" requires the full chain.
- **Always tail-keep AI traces**: the `gen_ai.*` attributes flag them.
- **Cost-aware sampling**: keep all calls above a $ threshold:
```typescript
keep: (trace) => {
const cost = trace.spans.reduce(
(acc, s) =>
acc +
(typeof s.attributes['gen_ai.usage.cost.usd'] === 'number'
? (s.attributes['gen_ai.usage.cost.usd'] as number)
: 0),
0,
);
if (cost > 0.1) return true; // any trace > $0.10 → keep
if (cost > 0.01) return Math.random() < 0.5; // > $0.01 → 50 %
return Math.random() < 0.1; // < $0.01 → 10 %
};
```
## Customer-driven sampling (debug header)
Let support flip on full tracing per request:
```typescript
const tail = new TailSamplingProcessor({
keep: (trace) => trace.localRootSpan.attributes['x-debug-trace'] === '1' || /* … */,
})
```
In your middleware:
```typescript
if (request.headers.get('x-debug-trace') === '1') {
useLogger().set({ 'x-debug-trace': '1' });
}
```
Now any user can mark a request as "trace this fully" by sending the header. Invaluable for reproducing customer reports.
## Keeping the trace whole across services
`RandomSampler` rolls the dice in each process. The API can keep a trace its
worker drops, which leaves a waterfall with holes and no way to tell a dropped
span from a fast one. `DeterministicSampler` hashes a key that travels with the
request, so every service reaches the same verdict:
```typescript
import { DeterministicSampler } from 'autotel/sampling';
import { trace } from '@opentelemetry/api';
init({
service: 'checkout',
sampler: new DeterministicSampler({
sampleRate: 0.1,
key: () => trace.getActiveSpan()?.spanContext().traceId,
}),
});
```
Give every service the same rate and the same key. Different rates break the
agreement again.
## Per-key target rates
A single rate cannot fit skewed traffic. At 1 % your busiest endpoint still
floods storage while the rare tenant whose failures you need disappears.
`KeyTargetRateSampler` counts traffic per key over a rolling window and gives
each key its own rate, so every key contributes about the same number of
events:
```typescript
import { KeyTargetRateSampler } from 'autotel/sampling';
new KeyTargetRateSampler({
key: (context) => context.operationName,
targetPerKey: 10, // ~10 events per key per window
windowMs: 30_000,
maxKeys: 1000, // keys past this share one overflow bucket
});
```
The first window keeps everything, because no traffic history exists yet. Rates
apply from the second window on.
## Recording the rate
Sampled counts lie unless you record what you discarded. A query over a 1 %
sample reports a hundredth of your traffic, and the number looks plausible
enough to act on.
autotel writes `autotel.sampling.rate` on the span as "1 in N", so
`COUNT * rate` estimates the population. It appears only when N exceeds 1, so
fully captured spans stay clean. A custom sampler joins in by implementing
`sampleRate()`:
```typescript
import type { Sampler } from 'autotel/sampling';
class TenantSampler implements Sampler {
shouldSample(context) {
/* … */
}
sampleRate() {
return 20; // each kept event stands for 20
}
}
```
Reach for `hashUnitInterval` from `autotel/sampling` when you need the same
spread for your own keys.
## Sizing the rate
Target volume:
```
spans/sec ≈ requests/sec × spans_per_request × head_rate × tail_keep_rate
```
Worked example for a 100 req/s API with 8 spans/req:
| Head rate | Tail keep | Result |
| --------- | -------------------------------- | ------------------------------------------- |
| 100 % | 100 % | 800 spans/sec: expensive |
| 10 % | 100 % (errors + slow + AI ≈ 5 %) | ≈ 110 spans/sec: sweet spot |
| 1 % | 100 % | ≈ 18 spans/sec: too sparse for p99 alerting |
For per-vendor pricing:
- **Honeycomb**: $0.000005 / event for paid plans. 110 spans/sec × 86 400 s = 9.5 M events/day = $48/day.
- **Datadog APM**: ~$1.27/M spans ingested (varies by region). Same volume → ~$12/day.
- **Grafana Cloud**: 100 GB free tier; 110 spans/sec ≈ 5 GB/day.
## Anti-patterns
| Anti-pattern | Fix |
| -------------------------------------------- | ------------------------------------------------------------- |
| 100 % sampling at scale "to be safe" | You're paying 10–100× without proportional value |
| 1 % sampling with no tail keep | You'll miss every interesting failure |
| Forgetting to tail-keep errors | Sampled traces with errors → silent customer pain |
| Same rate for `server` and `internal` | Internal sub-spans are 5–20× more numerous; sample harder |
| Ratio-based sampling on service entry point | Use parent-based: children of a sampled trace stay together |
| Head-sampling AI calls below 50 % | Debugging tool loops requires the full chain |
| Audit spans subject to sampling | Route them to a separate processor (see `build-audit-trails`) |
| Tail processor before exporter (loses spans) | Tail processor goes between head sampler and remote exporter |
| Rate-by-route hand-coded in handlers | Use head sampler + tail keep: declarative, one place |
| Counting sampled spans as the population | Multiply by `autotel.sampling.rate` |
| `RandomSampler` on a multi-service trace | `DeterministicSampler` keyed on the trace id keeps it whole |
| One flat rate over skewed traffic | `KeyTargetRateSampler` gives rare keys their own rate |