04 — Collection & Edge Processing¶
The thinnest, most operationally critical layer in the entire stack. Above it is application code your platform team doesn't own. Below it is storage cost that, once written, you cannot un-pay. Everything you do here — batch, redact, sample, drop — is leverage; everything you forget here is either a $50k/month invoice surprise, a PII incident, or a 3 a.m. page about a dropped trace nobody can replay.
This chapter sits between 03 — Instrumentation and 05 — Transport & Buffering. Producers (SDKs, exporters, eBPF probes) hand off here; backends (TSDB, log store, span store) receive from here. If you redesign one layer of your observability stack per quarter, this is the one with the highest ROI per engineer-week.
1. Why a Collection Layer At All¶
You can technically point an OpenTelemetry SDK directly at a vendor endpoint and call it a day. Don't. The collection layer exists because of four invariants no application process can satisfy on its own:
- The producer cannot enforce fleet-wide policy. Sampling, redaction, cardinality budgets, tenant tagging — these are platform decisions. Asking every service to implement them in code is N×M coupling and means the redaction bug ships in 80 services.
- The producer cannot survive backend outages. When Tempo is down for 40 minutes, you do not want an
OTLP send failedto either (a) crash the producer, (b) consume all its memory, or © silently drop. You want a buffer somewhere, owned by someone whose pager goes off when it fills. - The producer cannot do post-hoc decisions. Tail sampling needs all spans of a trace assembled in one place. The producer only sees its own spans. The decision must move to a stateful midpoint.
- The producer should not know the backend. Switching from Tempo to Honeycomb or from Loki to ClickHouse should be a collector config change, not a fleet redeploy.
This is the same argument that gave us reverse proxies in front of web servers in 1998. It generalizes.
┌──────────────────────────────────────────────────────────────────────────┐
│ WORKLOAD PROCESSES (your developers' code) │
│ SDK / exporter / eBPF / file appender — 1000s of producers │
└──────────────────────────────┬───────────────────────────────────────────┘
OTLP / Prom expo / syslog / file
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ AGENT TIER (per-node DaemonSet or sidecar) │
│ - Host enrichment (k8s metadata, hostname, region) │
│ - File tailing (containerd, journald, /var/log) │
│ - Local batching, retry, OTLP/Prom push to gateway │
│ - SHOULD be small, dumb, and CPU-cheap │
└──────────────────────────────┬───────────────────────────────────────────┘
OTLP/gRPC (compressed, batched)
▼
┌──────────────────────────────────────────────────────────────────────────┐
│ GATEWAY TIER (centralized, typically Deployment+HPA) │
│ - Tail sampling (stateful) │
│ - PII redaction, attribute denylist │
│ - Cardinality drop / hash / bucket │
│ - Persistent queue (WAL on disk) for backend outages │
│ - Vendor fan-out: same data → Mimir + Loki + Tempo + Kafka │
└─────────┬──────────────┬──────────────┬──────────────────┬───────────────┘
▼ ▼ ▼ ▼
Mimir/ Loki/ Tempo/ Kafka
VM/M3 ClickHouse ClickHouse (transport — 05)
This agent + gateway two-tier is the production default. It is not the only valid topology, but every alternative ("just an agent", "just a gateway", "direct to vendor") is a deliberate downgrade you should be able to defend in a design review.
1.1 Push vs pull¶
The metric pipeline has a religious split here; logs and traces are uncontroversial (push only).
| Dimension | Pull (Prometheus scrape) | Push (OTLP, statsd, remote_write) |
|---|---|---|
| Discovery | Server-side (k8s SD, EC2 SD, Consul) | Client knows endpoint |
| Auth | Server walks the fleet, has fleet creds | Each producer must hold creds |
| Durability across producer restart | Fine — server retries the next interval | Producer needs durable buffer |
| Short-lived workloads (CronJob, Lambda) | Bad fit — process exits before scrape | Natural fit — pushgateway / OTLP push |
| Cardinality control | Centralized at server (metric_relabel_configs) |
Must be done at producer or in-collector |
| "Did the target answer?" signal | Free (up{} series) |
Must be inferred from arrival pattern |
| Federation across networks | Server must reach every target | Producers reach one endpoint |
The pragmatic reading: pull for stable, long-lived services on a flat network you control; push for everything else. Real platforms run both. The OTel Collector embraces this by exposing prometheus as a receiver (it scrapes targets, like a Prometheus would) and prometheusremotewrite as both an exporter and a receiver. You can stitch any topology.
1.2 Agent vs gateway responsibilities¶
The split is not arbitrary. Use this as the default and have a reason when you deviate.
| Concern | Agent (per-node) | Gateway (fleet) |
|---|---|---|
Reading host signals (/proc, /sys, journald) |
Yes | No |
| Tailing container/file logs | Yes | No |
K8s metadata enrichment (pod, namespace, node) |
Yes (RBAC scoped to the node) | Sometimes (if agent skipped it) |
| Batching, compression | Yes (small batches) | Yes (large batches before fan-out) |
| In-memory retry / queueing | Yes (small) | Yes (large, often with file-backed WAL) |
| Tail sampling | No (single agent only sees one node's spans) | Yes — must be here |
| PII redaction | Sometimes | Always |
| Cardinality drops | Sometimes | Always |
| Tenant tagging / multi-tenant routing | No | Yes |
| Vendor fan-out (same data → 2+ backends) | No | Yes |
| Auth secrets to backends (API keys) | No | Yes (one place to rotate) |
| Talks directly to backend store | Avoid | Yes |
Why the agent never holds backend creds: every node holding a vendor API key is N×N rotation. Every node having a network path to the vendor is your egress firewall surface area. Push to a gateway over mTLS inside the cluster, push to vendor from one place.
Why tail sampling cannot live at the agent: spans of a single trace are emitted by multiple processes on multiple nodes. An agent only sees the spans from its node. Tail sampling needs the whole trace co-located in memory; that is the gateway's job.
2. OpenTelemetry Collector — Deep Dive¶
The OpenTelemetry Collector (otelcol) is the de facto standard for both agent and gateway today. It supersedes the Jaeger Agent, the Zipkin collector, the OpenCensus service, and is on track to replace Fluent Bit and Vector for the OTLP-shaped subset of the log pipeline (it hasn't yet for reasons we'll cover in §3).
2.1 The architecture in one diagram¶
┌──────────────────────────────────────────────┐
│ otelcol process │
│ │
OTLP/gRPC ───▶ │ ┌──────────┐ ┌────────────┐ ┌─────────┐ │ ─▶ Mimir
OTLP/HTTP ───▶ │ │ Receivers│──▶│ Processors │──▶│Exporters│ │ ─▶ Tempo
Prom scrape ─▶ │ └──────────┘ └────────────┘ └─────────┘ │ ─▶ Loki
filelog ──────▶│ ▲ ▲ │ │ ─▶ Kafka
syslog ───────▶│ │ │ │ │
│ └── Pipelines ─┴───────────────┘ │
│ (one per signal type, or split fan) │
│ │
│ Extensions (cross-cutting): │
│ health_check, pprof, zpages, │
│ memory_ballast, file_storage, oauth2client │
└──────────────────────────────────────────────┘
Three first-class concepts:
- Receiver — reads data from somewhere (network listener, file tailer, scrape).
- Processor — transforms data in-flight (batch, filter, transform, redact, sample).
- Exporter — writes data somewhere (OTLP push, remote_write, Loki push, Kafka).
- Pipeline — an ordered chain receiver → processors → exporter, scoped to a signal type (
traces,metrics,logs). - Extension — out-of-band capability that isn't in the data path (health endpoint, profiling, persistent queue storage).
A pipeline is a list, not a graph. To do fan-out (same data → 2 places), you list two exporters in one pipeline. To do fork (same data → different processing → different places), you define two pipelines that share a receiver.
2.2 Configuration model¶
Everything is YAML. The shape is rigid:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
memory_limiter:
check_interval: 1s
limit_mib: 1800
spike_limit_mib: 400
batch:
send_batch_size: 8192
timeout: 5s
exporters:
otlphttp/tempo:
endpoint: https://tempo-distributor.tempo.svc:4318
prometheusremotewrite:
endpoint: https://mimir-distributor.mimir.svc/api/v1/push
extensions:
health_check:
endpoint: 0.0.0.0:13133
file_storage:
directory: /var/lib/otelcol/queue
timeout: 1s
service:
extensions: [health_check, file_storage]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [prometheusremotewrite]
The naming convention is type or type/instance. You can have multiple otlp receivers (otlp/internal, otlp/external) listening on different ports with different configs. Same for processors and exporters.
Order in the processors: list under a pipeline matters. The collector pushes batches through that list in order. memory_limiter must come first (so it can refuse load when the heap is full); batch should come last among the buffering processors.
2.3 Core, contrib, custom builds¶
otelcol ships in three flavors:
| Distribution | Components | Use it when |
|---|---|---|
Core (otelcol) |
OTLP receivers/exporters, batch, memory_limiter, attributes, resource | Educational; rarely shipped to prod |
Contrib (otelcol-contrib) |
Hundreds of components: tail_sampling, k8sattributes, transform, loadbalancing, prometheusremotewrite, lokiexporter, clickhouse, loki, filelog |
The default for production gateways and most agents |
Custom (built with ocb / OpenTelemetry Collector Builder) |
Whatever subset you specify | When you want a small binary, or you've forked a component |
Contrib is what you run in production unless the binary size or attack surface matters (FedRAMP environments, embedded edge boxes). Then build a custom one with ocb:
# builder-config.yaml — fed to `ocb --config builder-config.yaml`
dist:
name: otelcol-prod
description: Custom collector for $COMPANY
output_path: ./build
version: 0.115.0
extensions:
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/healthcheckextension v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/extension/storage/filestorage v0.115.0
receivers:
- gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.115.0
processors:
- gomod: go.opentelemetry.io/collector/processor/batchprocessor v0.115.0
- gomod: go.opentelemetry.io/collector/processor/memorylimiterprocessor v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/tailsamplingprocessor v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/k8sattributesprocessor v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/processor/transformprocessor v0.115.0
exporters:
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/prometheusremotewriteexporter v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/lokiexporter v0.115.0
- gomod: go.opentelemetry.io/collector/exporter/otlpexporter v0.115.0
- gomod: github.com/open-telemetry/opentelemetry-collector-contrib/exporter/loadbalancingexporter v0.115.0
The builder produces a single Go binary. Pin component versions; don't track latest.
2.4 The four processors you cannot live without¶
memory_limiter — the only thing standing between you and OOMKill¶
The collector does not have memory backpressure to its receivers by default. If receive rate > export rate and there's no memory_limiter, the heap grows until the kernel reaps the pod, the WAL on disk corrupts, and on restart you've lost in-flight batches.
processors:
memory_limiter:
check_interval: 1s # how often to check heap usage
limit_mib: 1800 # soft cap — start refusing
spike_limit_mib: 400 # hard cap = limit + spike → start GC
When the soft cap is hit, the limiter starts returning errors back through receivers. OTLP/gRPC receivers translate this to RESOURCE_EXHAUSTED, which OTel SDKs respect with backoff. Place it first in every pipeline. Place it after a batch and you've already used the memory you were trying to protect.
Watch otelcol_processor_refused_* and otelcol_process_runtime_total_alloc_bytes to know it's working.
batch — the throughput multiplier¶
Without batch, every span/sample/log line traverses the export call path individually. With it, you group up to N items or T seconds:
processors:
batch:
send_batch_size: 8192 # target size
send_batch_max_size: 16384 # never exceed this
timeout: 5s # max delay before flushing partial batch
Rules of thumb:
- For traces:
send_batch_size: 512–8192,timeout: 1–5s. - For metrics:
send_batch_size: 8192,timeout: 10s(metrics tolerate more delay). - For logs:
send_batch_size: 1000–10000,timeout: 1–5s.
batch is conventionally placed after memory_limiter and after sampling/redaction processors but before the exporter. The mistake we'll cover in §11 is putting it before tail_sampling, which then has to materialize the entire batch in memory while it waits for the trace assembly window.
Retry & queue (in the exporter, not a processor)¶
Each exporter has a sending_queue and a retry_on_failure block. They are easy to mis-tune.
exporters:
otlphttp/tempo:
endpoint: https://tempo-distributor:4318
sending_queue:
enabled: true
num_consumers: 10 # parallelism out
queue_size: 5000 # in-memory items
storage: file_storage # ← named extension; persistent queue
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 5m # after this, drop
timeout: 30s
Without storage:, the queue is in memory only — if the pod restarts, the queue is gone. With storage: file_storage and the matching extension, the queue is a WAL on disk and survives restarts:
extensions:
file_storage:
directory: /var/lib/otelcol/queue
timeout: 1s
compaction:
directory: /var/lib/otelcol/queue
on_start: true
on_rebound: true
rebound_needed_threshold_mib: 10
rebound_trigger_threshold_mib: 20
This is the single most important durability knob in the gateway. Without it, a 30-minute Mimir outage during a deploy of the collector = data loss. With it, the collector buffers to disk for as long as the disk lasts.
The cost: write amplification (every batch is now persisted before being sent). For metrics, the cost is fine. For high-volume logs, you may run out of disk before you run out of patience — size your PVC accordingly. Provision 4–6× your peak send rate × max retention and have a circuit breaker that paginates if the queue depth crosses 80%.
attributes and resource — different things, often confused¶
attributesprocessor: modifies span attributes / log attributes / metric data point attributes (the per-event K/V pairs).resourceprocessor: modifies the resource (the entity producing the data — the service, host, k8s pod). One resource is shared by many events from the same producer.
If you need to add cluster=us-east-1 to every span produced by every workload, that's a resource mutation, not an attributes mutation. Putting cluster in attributes wastes bytes and breaks resource-based aggregation in stores like Tempo.
processors:
resource:
attributes:
- key: deployment.environment
value: prod
action: upsert
- key: cluster
value: us-east-1
action: insert
attributes:
actions:
- key: http.user_agent # noisy, drop it
action: delete
- key: db.statement # PII risk; hash
action: hash
2.5 Telemetry of telemetry¶
The collector emits its own metrics on :8888/metrics (configurable). The ones you must alert on:
| Metric | Meaning | Alert when |
|---|---|---|
otelcol_receiver_accepted_spans (and _metric_points, _log_records) |
Inbound throughput per receiver/transport | drops or spikes |
otelcol_receiver_refused_* |
Inbound rejected (e.g., memory_limiter pushing back) | sustained >0 |
otelcol_processor_dropped_spans |
Items dropped by processors (sampler dropping intentionally is fine; others are bugs) | by-processor breakdown |
otelcol_exporter_sent_* |
Outbound throughput | sudden drop |
otelcol_exporter_send_failed_* |
Failed sends (after retry exhausted) | sustained >0 |
otelcol_exporter_queue_size |
In-flight items in sending queue | > 80% of queue_size |
otelcol_exporter_queue_capacity |
Configured queue capacity | constant; for ratios |
otelcol_processor_batch_batch_size_trigger_send |
Batches sent because they hit max size (good) | — |
otelcol_processor_batch_timeout_trigger_send |
Batches sent because timeout fired (= you're not full) | sustained → reduce batch size |
otelcol_process_runtime_heap_alloc_bytes |
Heap | climbing toward limit_mib |
otelcol_process_uptime |
Restarts | unexpected drops |
The collector that monitors itself is itself observable. Scrape :8888 from your normal Prometheus, build a dedicated dashboard for it, and treat the gateway tier as a first-class service with its own SLOs (see chapter 13). "Our metrics are slow today" almost always traces back to gateway saturation or a downed exporter.
2.6 Common collector gotchas¶
A focused list (the §11 grand-list at the end of the chapter has more):
- Receivers do not back-pressure SDKs by default. Without
memory_limiter, you OOM. With it, the SDK retries and you keep your data. batchaftertail_samplingis fine.batchbeforetail_samplingruins everything: the sampler now needs to keep the whole batch in memory fordecision_wait, multiplying memory by batch size.k8sattributesneeds RBAC. It calls the k8s API to look up pod metadata. Withoutpods/listandpods/watch, it logs warnings and emits no labels. The fields you expected on every span are silently absent; queries break.- OTLP/HTTP and OTLP/gRPC are not interchangeable for receiving compressed payloads. Some SDKs send
gzipover HTTP/JSON, some sendgzipover HTTP/protobuf. Pin the receiver to known protocols and reject the rest at the network edge. prometheusremotewriteexporter encodes timestamps as ms. OTLP carries them as ns. If you don't put aprometheustranslator in the pipeline (or useprometheusremotewritedirectly which does it for you), you can lose sub-ms resolution. For Mimir at 15s scrape this is invisible; for sub-second high-frequency metrics, it matters.extensions:block is a separate top-level field. If you putfile_storageunderprocessors:it silently won't load. The collector logsunknown component file_storageat startup; people miss this when looking atkubectl logsfor the running pod (the message scrolled off).
3. Logs: Fluent Bit, Vector, and Why Logstash Doesn't Show Up Anymore¶
The OTel Collector now has a filelog receiver that can replace much of what Fluent Bit and Vector do. It is rapidly catching up but is not yet the operational default for the log path because:
- It uses more memory than Fluent Bit (Go GC vs C).
- Multiline parsing was a late addition and is less battle-tested.
- The Loki and OpenSearch exporters are in
contriband have rougher edges than the Vector or Fluent Bit equivalents.
So today, the real production patterns are:
- Fluent Bit as the agent (DaemonSet, tiny, C, predictable).
- Vector as either agent or gateway when you need richer transformation logic (VRL).
- OTel Collector for traces and metrics; sometimes also logs if your shop is OTLP-end-to-end.
- Logstash is dead for new builds. It is heavy (JVM), eager to consume memory, and its plugin ecosystem is largely subsumed by Vector and OTel. You'll see it in old ELK installs; don't add it to a green-field stack.
3.1 Fluent Bit deep dive¶
What it is: a 1-3 MB resident, C-based agent. CRI/JSON tail parsing, k8s metadata enrichment, ~80 input plugins, ~50 output plugins, Lua filters. SIMD-accelerated parsers. Streams in records, not files.
The data model: (timestamp, tag, record). The tag is a routing key (think Kafka topic). Inputs assign tags; filters and outputs match on tag globs.
Pipeline: INPUT → PARSER → FILTER → BUFFER → OUTPUT. The buffer is per-output and on-disk if you enable it.
A representative DaemonSet config:
[SERVICE]
Flush 1
Daemon Off
Log_Level info
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
storage.path /var/log/flb-storage/
storage.sync normal
storage.checksum off
storage.backlog.mem_limit 64M
[INPUT]
Name tail
Path /var/log/containers/*.log
Path_Key filename
Parser cri
Tag kube.<namespace>.<pod_name>.<container_name>
Tag_Regex (?<pod_name>[^_]+)_(?<namespace>[^_]+)_(?<container_name>.+)-
Read_from_Head true
Refresh_Interval 5
Rotate_Wait 30
Skip_Long_Lines On
DB /var/log/flb-tail.db
DB.Sync Normal
Mem_Buf_Limit 50MB
storage.type filesystem
multiline.parser cri,docker
[FILTER]
Name kubernetes
Match kube.*
Kube_URL https://kubernetes.default.svc:443
Merge_Log On
Keep_Log Off
K8S-Logging.Parser On
K8S-Logging.Exclude On
Annotations Off
Labels On
Buffer_Size 32k
[FILTER]
Name modify
Match *
Remove kubernetes_labels.pod-template-hash
Remove kubernetes_labels.controller-revision-hash
[OUTPUT]
Name loki
Match kube.*
Host loki-distributor.loki.svc
Port 3100
Labels cluster=us-east-1, $kubernetes['namespace_name'], $kubernetes['container_name']
Auto_Kubernetes_Labels Off
Line_Format json
storage.total_limit_size 5G
Retry_Limit 5
Notes on the non-obvious bits:
Read_from_Head true. Without this, after a Fluent Bit restart, it tails from the end of files. Anything written between the crash and the restart is silently dropped. This is one of the most common quietly-losing-logs misconfigs.DB. Fluent Bit uses an SQLite file to remember offsets per file. Mount this on a persistent volume or it forgets after restart and either re-reads everything (Read_from_Head true) or skips everything (default).storage.type filesystem. Without it, the buffer is memory-only. With it, when Loki is down, the buffer drains to/var/log/flb-storage/and survives restarts.Mem_Buf_Limit. When the in-memory buffer hits this, the input pauses (this is Fluent Bit's backpressure). Withstorage.type filesystemandstorage.backlog.mem_limitset, the agent keeps reading and chunks overflow to disk.multiline.parser cri,docker. CRI splits long log lines at 16K. Without a multiline parser, a single Java stack trace becomes 30 separate "log lines" with garbled JSON. Thecriparser reassembles them by looking at the partial-tag flag.kubernetesfilter. This is what enriches withnamespace_name,pod_name,container_name,labels. It calls the kube-apiserver. RBAC:pods/list,pods/watch. Without RBAC, you get unenriched records and no error in the user-facing logs.
3.2 Vector deep dive¶
What it is: a Rust-based, single-binary log/metric/trace shipper, with its own DSL ("VRL" — Vector Remap Language). Higher memory footprint than Fluent Bit (~50–200MB working set for a moderately-loaded gateway) but vastly more expressive transformation.
The data model: events of three types — Log, Metric, Trace. Every event is a typed record; VRL is statically checked against the schema.
Pipeline: sources → transforms → sinks, declared in TOML or YAML. Components are composable like a graph (one source can feed many transforms, transforms can fan out into different sinks).
[sources.k8s_logs]
type = "kubernetes_logs"
glob_minimum_cooldown_ms = 100
use_apiserver_cache = true
[transforms.parse_json]
type = "remap"
inputs = ["k8s_logs"]
source = '''
# decode the message field as JSON if it parses
structured, err = parse_json(.message)
if err == null {
. = merge(., structured)
}
# drop fields we never query on
del(.kubernetes.pod_uid)
del(.kubernetes.pod_ips)
# redact obvious PII patterns
.message = redact(.message, filters: ["us_social_security_number"])
# bucket high-cardinality user IDs into a hash
if exists(.user_id) {
.user_id_hash = sha2(string!(.user_id), variant: "SHA-256")
del(.user_id)
}
'''
[transforms.route_by_severity]
type = "route"
inputs = ["parse_json"]
route.errors = '.level == "error" || .level == "fatal"'
route.audit = '.event_type == "audit"'
[sinks.loki_app]
type = "loki"
inputs = ["route_by_severity._unmatched"]
endpoint = "https://loki-distributor.loki.svc:3100"
encoding.codec = "json"
labels.cluster = "us-east-1"
labels.namespace = "{{ kubernetes.pod_namespace }}"
labels.app = "{{ kubernetes.pod_labels.app }}"
buffer.type = "disk"
buffer.max_size = 5_000_000_000
[sinks.loki_errors]
type = "loki"
inputs = ["route_by_severity.errors"]
endpoint = "https://loki-distributor.loki.svc:3100"
labels.tier = "errors"
labels.namespace = "{{ kubernetes.pod_namespace }}"
encoding.codec = "json"
[sinks.s3_audit]
type = "aws_s3"
inputs = ["route_by_severity.audit"]
bucket = "company-audit-logs"
key_prefix = "year=%Y/month=%m/day=%d/"
compression = "zstd"
encoding.codec = "ndjson"
buffer.type = "disk"
buffer.max_size = 50_000_000_000
VRL is the differentiator. Anything Fluent Bit does in a Lua filter is one line in VRL, type-checked, with bench-marked CPU cost. Multi-sink fan-out with per-sink filtering is native. The cost is operational footprint and config complexity.
3.3 The comparison table¶
| Dimension | Fluent Bit | Vector | OTel Collector (filelog) |
|---|---|---|---|
| Language / runtime | C | Rust | Go |
| Resident memory (idle) | 1–10 MB | 30–80 MB | 50–150 MB |
| CPU per MB log/sec ingested (rough) | best | ~1.5× FB | ~2× FB |
| K8s metadata enrichment | First-class | First-class | Yes (k8sattributes) |
| Multiline (Java stack, Python traceback) | Good (CRI/Docker built-in parsers) | Good | OK; rougher edges |
| Transformation language | Lua (limited) | VRL (rich, typed) | OTTL (rich, typed) |
| Hot reload of config | Yes (since 2.0) | Yes | Yes (reloader) |
| Disk buffer / WAL | Yes (storage.type filesystem) |
Yes (buffer.type disk) |
Yes (file_storage extension) |
| Output plugins | ~50 | ~40 | huge in contrib |
| Native OTLP support | OTLP output exists, marginal | OTLP source/sink (improving) | Native |
| Best as | Per-node agent | Gateway, any tier | Traces/metrics agent and gateway, increasingly logs too |
When to pick which:
- Fleet-wide logs at <10K nodes, want minimum CPU per node: Fluent Bit DaemonSet.
- Need rich per-event transformation, multi-sink routing, schema-as-code: Vector at the gateway tier (often after a Fluent Bit agent, sometimes Vector all the way down).
- You're already running OTel Collector for traces/metrics and want one binary: OTel Collector with
filelogreceiver, accepting that you'll do more tuning. - You need an event-streaming / ETL hybrid (e.g., logs → Kafka → ClickHouse with derived metrics): Vector. It handles all three signals natively and the routing primitive is unbeatable.
4. Prometheus Scrape Model — In Detail¶
The OTel Collector can scrape too (its prometheus receiver embeds a fairly faithful Prometheus scrape engine), but most platforms still run Prometheus itself for the scrape tier and remote_write into the longer-term store. The semantics below are Prometheus's; understand them precisely.
4.1 Service discovery¶
Prometheus does not maintain a static target list in production. SD plugins fetch targets from a source on a poll:
| SD | Source | Use it for |
|---|---|---|
kubernetes_sd_configs |
Kube API: pods, endpoints, services, nodes, ingresses | Anything in k8s |
ec2_sd_configs |
AWS EC2 DescribeInstances | EC2 fleets outside k8s |
consul_sd_configs |
Consul catalog | HashiCorp / Nomad shops |
file_sd_configs |
A JSON file on disk | Hand-rolled or scripted target lists |
dns_sd_configs |
SRV records | Service mesh-adjacent |
static_configs |
Hardcoded list | Lab and bootstrap |
Each SD emits a list of __address__ plus a bag of "meta" labels (e.g., __meta_kubernetes_pod_label_app). Those meta labels are not stored — they are inputs to the relabeling pipeline.
4.2 relabel_configs vs metric_relabel_configs¶
These two run at different stages and confusing them causes more wasted hours than any other Prometheus topic.
SD discovers target ──▶ relabel_configs ──▶ scrape /metrics ──▶ metric_relabel_configs ──▶ TSDB
(filter targets, (filter samples,
rewrite labels) rewrite labels)
relabel_configsruns once per target per SD refresh. It decides whether to scrape this target at all, and what labels to attach (e.g., turn__meta_kubernetes_pod_label_appintoapp).metric_relabel_configsruns once per sample per scrape. It decides whether to keep the sample and whether to rewrite its labels.
The first is cheap (rare, small input set); the second is expensive (every sample, hot path).
If you want to drop a target, do it in relabel_configs. Doing it in metric_relabel_configs still pays the scrape cost.
If you want to drop a high-cardinality label from a metric, you must use metric_relabel_configs (the label doesn't exist until you've scraped).
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
# Only scrape pods with annotation prometheus.io/scrape=true
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"
# Override port with annotation if present
- source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
action: replace
regex: ([^:]+)(?::\d+)?;(\d+)
replacement: $1:$2
target_label: __address__
# Promote pod label `app` to a Prom label
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: app
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
metric_relabel_configs:
# Drop the customer_id label everywhere — kills cardinality
- action: labeldrop
regex: customer_id
# Drop a metric we know is broken / too noisy
- source_labels: [__name__]
regex: 'go_memstats_alloc_bytes_total'
action: drop
# Bucket request_id into "exists / not exists" if anything still emits it
- source_labels: [request_id]
regex: '.+'
target_label: request_id
replacement: 'present'
4.3 honor_labels¶
A subtle and dangerous flag. By default, when a scraped metric has a label that conflicts with the target's labels (e.g., the metric carries instance="foo" but the target's instance is bar), Prometheus renames the metric's label to exported_instance. With honor_labels: true, the metric's label wins.
You almost always want the default (honor_labels: false) except when scraping the Pushgateway or another aggregator that's emitting metrics on behalf of other producers. In that case the metric's labels are the source of truth and you must set honor_labels: true.
4.4 Federation, remote_write, and Agent mode — pick one¶
Three mechanisms get metrics out of one Prometheus into another store:
| Mechanism | Direction | What it sends | Use it for |
|---|---|---|---|
Federation (/federate) |
Pull (the upstream pulls) | A subset of series matched by label selectors, every scrape interval | Hierarchical Prom-of-Proms; small-fanout aggregation |
remote_write |
Push | Every sample, in real time, after relabel | Long-term storage (Mimir, Cortex, VM, Thanos Receiver) |
Agent mode (--enable-feature=agent) |
Push (forward only) | Every sample, no local TSDB at all | Edge clusters that should forward but never query locally |
Federation is the legacy of the pre-remote_write era. It scales poorly (the upstream's scrape interval × selector cost) and is brittle (a long pull blocks the next one). Use it only for narrow rollups or for compatibility with old hierarchies. New deployments should use remote_write or Agent mode.
Agent mode runs Prometheus with no query, no local storage, no compactor — only scrape and remote_write. Memory drops by 10×. You get back the up{} and metadata machinery that's awkward to replicate from a pure OTel scrape pipeline.
# prometheus --enable-feature=agent --config.file=agent.yaml
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'app'
kubernetes_sd_configs: [...]
remote_write:
- url: https://mimir-distributor.mimir.svc/api/v1/push
queue_config:
capacity: 20000
max_samples_per_send: 5000
max_shards: 200
min_shards: 50
batch_send_deadline: 5s
For deeper remote_write tuning (queue sizing, shard math, WAL compression), see the GPU observability 08-prometheus-metrics-design-and-cardinality.md chapter §7 — same mechanism, same knobs.
4.5 Pushgateway — anti-pattern unless you know why¶
Pushgateway exists to let short-lived jobs (cron, batch, CI) push their metrics so Prometheus can later scrape them. It is widely misused. The official docs are explicit: don't use it as a general push proxy for service metrics.
Reasons: - Pushgateway is a stateful intermediary. Series live there until manually deleted. A misconfigured deploy can leave dead series visible forever. - It cannot be sharded. One instance is the bottleneck for everyone using it. - It has no per-tenant quotas, no series limits.
The right use: a finite, well-defined set of CronJobs/CI builds that emit a known small set of metrics with stable labels. The push-and-forget pattern; you delete the group when the job is removed. Anything else — use OTLP push to the OTel Collector.
5. Tail Sampling for Traces¶
Head sampling decides at trace start (or at the first span) whether to keep a trace. The decision is encoded in the trace_id so all participants stay consistent. It is cheap and stateless. It is also blind: at decision time, no one knows whether this trace will end up being the one production failure in a million.
You want both:
HEAD SAMPLING TAIL SAMPLING
(per-process, decisive at start) (per-collector, decisive after assembly)
Pros: cheap, simple, stateless Pros: keeps the rare error trace
consistent across services Catches actual P99 latency
no extra infra Composes policies (status, latency, attr)
Cons: blind to outcome Cons: stateful, memory-hungry
tail of the latency dist ~30s decision window delay
drops the rare error Requires a load balancer in front
Real systems run head sampling at a low base rate (1%, configured in the SDK) for cost protection, and then tail sampling on top to over-keep the interesting tail.
5.1 The assembly window problem¶
A trace with N spans across M services arrives at the collector tier as a stream of OTLP messages, not as one bundled trace. Spans of the same trace can arrive seconds apart (the leaf was a 5s DB query; the root span finishes after that). The collector must:
- Hold spans by
trace_idin memory. - Wait long enough for "all" spans to arrive. This is
decision_wait; the standard value is 30s. - Apply policies to the assembled trace and keep / drop.
You cannot escape the wait. A trace whose root span exceeds decision_wait will be sampled with an incomplete view (later spans arrive, are checked against the already-made decision, and if "drop" was decided, are dropped — even if those late spans had an error).
Set decision_wait to longer than P99.9 of trace duration, and tag traces longer than that with a "long" attribute at the SDK so downstream systems know they may be missing tail spans.
5.2 The stateful collector pitfall — loadbalancing exporter¶
If your gateway tier has 4 collector replicas, span A of trace X may land on replica 1 and span B on replica 3. Each sees a fragment; neither can assemble the trace. Tail sampling silently degrades to "sample by individual span" — meaningless.
Solution: a two-tier gateway. First tier is a pool of stateless collectors whose only job is to route by trace_id to a stable second-tier replica. Second tier is the stateful tail-samplers.
# Tier 1 — stateless router
exporters:
loadbalancing:
routing_key: traceID # ← critical
protocol:
otlp:
timeout: 1s
tls:
insecure: true
resolver:
dns:
hostname: otelcol-tail-sampler-headless.observability.svc
port: 4317
interval: 5s
timeout: 1s
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [loadbalancing]
The loadbalancing exporter hashes traceID to the replica list. As long as the resolver returns the same set of replicas, span A and span B for trace X land on the same replica, and tail sampling works. Use a headless service so DNS returns all replica IPs.
Tier 2 (the otelcol-tail-sampler Deployment behind the headless service) runs the tail_sampling processor.
5.3 The tail_sampling processor¶
processors:
tail_sampling:
decision_wait: 30s
num_traces: 100000 # max in-memory traces
expected_new_traces_per_sec: 5000
policies:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 1000 }
- name: rare-service
type: string_attribute
string_attribute:
key: service.name
values: ["fraud-engine", "payments"]
- name: tagged-debug
type: boolean_attribute
boolean_attribute: { key: app.debug, value: true }
- name: rate-limit-by-service
type: composite
composite:
max_total_spans_per_second: 1000
policy_order: [errors, slow, baseline]
composite_sub_policy:
- name: errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow
type: latency
latency: { threshold_ms: 500 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 1 }
- name: baseline
type: probabilistic
probabilistic: { sampling_percentage: 1 }
Policy types you will actually use:
| Type | Decision basis | When to reach for it |
|---|---|---|
status_code |
OTel span status (ERROR, OK, UNSET) |
Always — the cheapest "interesting trace" filter |
latency |
Trace duration vs threshold | Catch the long tail |
string_attribute / numeric_attribute / boolean_attribute |
Match on attr value | Keep traces from a specific tenant, debug-flagged, or rare service |
rate_limiting |
Cap total spans/sec kept (regardless of policy) | Cost protection backstop |
probabilistic |
Random N% | Baseline, must be present |
and |
AND of sub-policies | "Errors AND from prod AND in checkout" |
composite |
Try sub-policies in order with a global span/sec cap | The realistic production policy |
The OR is implicit: if any policy says keep, the trace is kept.
5.4 The memory math¶
Memory per trace ≈ sum of span sizes. A typical span in OTLP protobuf is ~1–4 KB; a typical microservice trace has ~30 spans. Call it 100 KB/trace.
Plus protocol overhead, GC slack, etc. — budget 1.5–2× this.
The tuning loop:
1. Set num_traces and expected_new_traces_per_sec based on observed traffic plus 30% headroom.
2. Watch otelcol_processor_tail_sampling_sampling_decision_latency and otelcol_processor_tail_sampling_global_count_traces_on_decisions.
3. If memory climbs near memory_limiter, reduce num_traces first (you'll start dropping the oldest-but-still-undecided traces) before reducing decision_wait.
4. For deeper SLO-driven sampling policy (per-tenant budgets, per-service caps), see chapter 13.
5.5 Why you cannot batch before tail-sampling¶
batch flushes when full or when timeout fires. If batch is upstream of tail_sampling and decision_wait is 30s, the batch has to either:
- Sit and wait for
tail_sampling(defeating the purpose of batching, and the spans for one trace are now spread across many small unfinished batches), or - Be flushed and then tail_sampling has to keep them in memory anyway (doubling the memory cost).
Always: tail_sampling first, then batch, then exporter.
6. Edge-Side Cardinality and PII Control¶
The collection layer is the last place you can intervene cheaply. Once a high-cardinality label is in the TSDB, removing it costs you a re-write of every block that contains it. Once PII is in the log store, you owe a deletion campaign and possibly a notification.
6.1 Cardinality-killing transformations¶
For metrics, the OTel processors are attributes, transform, filter, and metricstransform. The Prometheus equivalent is metric_relabel_configs. Use them to:
- Drop a label entirely.
customer_idshould never be on a metric. - Hash a label. When you need to differentiate customers in a metric but can't store the raw ID. SHA-256 truncated to 64 bits, encoded base32. Same customer → same hash, but no PII linkage.
- Bucket a label. Bucket
latency_bucketby quantile range,customer_tierby enterprise/standard/free. - Drop a metric outright if it's never queried.
processors:
transform/cardinality:
metric_statements:
- context: datapoint
statements:
# Drop the high-cardinality user_id from rate metrics
- delete_key(attributes, "user_id") where metric.name == "http_requests_total"
# Hash session_id
- set(attributes["session_hash"], SHA256(attributes["session_id"]))
where metric.name == "session_active"
- delete_key(attributes, "session_id") where metric.name == "session_active"
# Bucket request size into orders of magnitude
- set(attributes["size_bucket"],
Concat(["10^", string(Int(Log10(attributes["request_bytes"])))], ""))
where metric.name == "http_request_size_bytes"
filter/drop_metrics:
metrics:
metric:
- 'name == "go_memstats_*_objects"' # nobody queries this
For traces and logs, cardinality is less of a concern (the store doesn't index everything by default), but PII matters more.
6.2 PII redaction¶
processors:
attributes/pii:
actions:
- key: http.request.body # never log request bodies
action: delete
- key: db.statement
action: hash
- key: user.email
action: delete
- key: http.url
action: update
from_attribute: http.url
# Strip query string
# Use a `transform` processor for regex; this is illustrative
transform/redact_log_body:
log_statements:
- context: log
statements:
# Mask credit card numbers in body
- replace_pattern(body, "\\b(?:\\d[ -]*?){13,16}\\b", "[REDACTED-CARD]")
# Mask SSNs
- replace_pattern(body, "\\b\\d{3}-\\d{2}-\\d{4}\\b", "[REDACTED-SSN]")
# Mask email addresses except domain
- replace_pattern(body, "([a-zA-Z0-9._%+-]+)@", "[REDACTED]@")
Why redact at the agent/gateway, not the store:
- Once it's at the store, it's been on disk and in memory across multiple machines. Even if you delete it, you have a hot replica, an object-store block, a backup snapshot, an LTS replica. Deleting one is not deleting all.
- Most stores don't support partial-record redaction. You can delete a log line; you cannot delete the credit card from inside a log line and leave the rest.
- It violates the "if you don't need it, don't store it" rule. Storage is the wrong place to filter — the further left you push redaction, the smaller your blast radius.
- Compliance scope. Auditors map PII to the systems that touch it. Every system the data crosses unredacted is in scope. Pinning redaction to the agent contracts the scope to that one tier.
The hard part of redaction is the long tail: a custom application puts SSNs in a log message, your regex doesn't match the format, you've shipped them. Two countermeasures:
- Schema-as-code for log fields (OTel semantic conventions plus your own internal extension). Reject events that violate.
- A canary tester. A scheduled CI job emits a known fake-PII event into staging logs; an automated check searches for it in the store 5 minutes later. If it's there, the redaction is broken.
7. Backpressure, Retry, and Durability¶
The single most important property of the collection layer: never let telemetry block the application. The Sentry SDK pioneered this principle in the early 2010s ("if Sentry is down, your app continues"); every modern observability stack inherits it.
The chain of buffers:
Producer (SDK)
├─ in-memory queue (small, e.g. 2048 spans)
├─ retry with exponential backoff
└─ on overflow → drop oldest (loudly logged metric)
│
▼
Agent (collector or Fluent Bit)
├─ in-memory queue
├─ disk buffer (file_storage / storage.type filesystem)
└─ on overflow → drop or backpressure to producer
│
▼
Gateway
├─ in-memory queue
├─ persistent queue (file_storage extension)
├─ retry with exponential backoff
└─ on persistent failure → drop with metric
│
▼
Transport (Kafka — chapter 05)
└─ Durable for backend outage of arbitrary length
│
▼
Backend store
Each tier should be sized so the next-shorter-tier outage is absorbed. Specifically:
| Tier outage absorbed | Required buffer somewhere |
|---|---|
| Brief gateway hiccup (<60s) | Agent in-memory queue |
| Gateway restart (rolling deploy) | Agent in-memory + small disk |
| Backend (Mimir/Tempo) outage of N minutes | Gateway disk WAL ≥ N × send rate |
| Backend outage > 1h, multi-tenant | Kafka transport tier (chapter 05) |
| Cross-region backend loss | Same, replicated |
7.1 The "drop on overflow vs block producer" tradeoff¶
You configure each level to either:
- Drop on overflow. When the buffer is full, discard new data. Loud metric (
refused_*), no producer impact. - Block producer. When the buffer is full, refuse new data — the producer's send call returns an error, and the SDK's behavior dictates retry / drop.
Drop on overflow is the default and the right default for application latency. Block-producer is correct for high-stakes signals (audit logs, billing events) where missing one is worse than slowing the application. Most platforms split:
- Default pipeline: drop on overflow, large file-backed queue.
- Audit pipeline: block producer (or even synchronous write to Kafka), separate quotas, separate dashboards.
The choice is made per-pipeline. Pretending one knob fits everything is the error.
7.2 The persistent queue gotcha¶
file_storage survives restarts. It does not survive node loss. If the collector pod's PVC is on local SSD (emptyDir or hostPath), node failure = queue loss.
Two patterns:
- Local SSD + Kafka tier downstream. The collector's file_storage absorbs short outages; Kafka absorbs long ones. You accept that a node loss might lose <30s of buffered data.
- Network-attached durable storage (EBS, PD, Azure Disk). Survives node loss, but write latency is higher and a slow disk now affects collector throughput.
In practice, (1) is the default for trace/log data. (2) is rare and only for compliance-driven deployments where the collector is the durability tier and there is no Kafka.
8. Operational Concerns¶
8.1 Sizing collectors¶
Rule of thumb (based on contrib OTel Collector v0.115, x86_64, no exotic processors):
| Signal | Throughput per vCPU | Memory per 10K events/sec |
|---|---|---|
| Traces (OTLP, no tail sampling) | ~30K spans/sec | ~256 MB |
Traces (with tail sampling, num_traces=100K) |
~10K spans/sec | ~10 GB (the trace cache) |
| Metrics (OTLP, batch only) | ~50K data points/sec | ~256 MB |
Metrics (with transform/filter/k8sattributes) |
~25K data points/sec | ~512 MB |
| Logs (OTLP, batch only) | ~40K records/sec | ~256 MB |
| Logs (with redaction) | ~15K records/sec | ~512 MB |
These are starting points; benchmark your config in staging before sizing prod.
For Fluent Bit at the agent: ~25-50 MB/sec/core ingestion, 10-50 MB resident memory. For Vector: ~10-30 MB/sec/core, 50-200 MB resident.
8.2 HPA on collectors¶
CPU-based HPA works for the stateless tier (the loadbalancing router). For the stateful tail-sampling tier, scaling out invalidates routing — half the trace_ids re-hash to new replicas, and you lose assembly for ~decision_wait. So:
- Stateless tier: HPA on CPU + queue depth (
otelcol_exporter_queue_size). - Stateful tier: vertical scaling (bigger pods) over horizontal. If you must scale horizontally, do it during a maintenance window or accept ~30s of degraded sampling.
For HPA on queue depth (a metric, not CPU), use kube-prometheus-stack's prometheus-adapter or KEDA:
# KEDA ScaledObject
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: otelcol-gateway-hpa
spec:
scaleTargetRef:
name: otelcol-gateway
minReplicaCount: 4
maxReplicaCount: 32
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.observability.svc:9090
threshold: '0.7'
query: |
max(otelcol_exporter_queue_size / otelcol_exporter_queue_capacity)
8.3 Rolling upgrades without losing data¶
The collector's graceful shutdown: 1. Stop accepting new connections (drop from Service endpoints). 2. Drain receivers (allow open OTLP streams to finish). 3. Drain processors (flush batch, finalize tail sampling decisions for traces in window). 4. Drain exporters (flush sending queue). 5. Exit.
Configure:
# In the pod spec
terminationGracePeriodSeconds: 60
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 10 && /otelcol --shutdown"]
The preStop sleep gives the kube-proxy time to remove the pod from Service endpoints (the lame-duck period) before SIGTERM hits the process.
For the tail-sampling tier, also consider a decision_wait-aligned drain. A pod terminating mid-window means in-flight traces never reach a decision. With file_storage, the in-flight cache survives a restart — but only if you've enabled it for the tail sampler, which is uncommon.
8.4 Multi-region collection topology¶
Three patterns, in order of preference:
- Region-local agent + region-local gateway, cross-region replication via Kafka or via storage layer. Each region's collectors talk only to their region's storage; chapter 05 / chapter 06 handle cross-region. Lowest WAN cost; survives region isolation.
- Region-local agent, central gateway in one region. WAN-heavy; central gateway is a SPOF. Use only when your storage tier is also single-region.
- One global gateway, agents push directly across regions. Don't.
8.5 Secrets management¶
OTLP exporters often need API keys (Honeycomb, Datadog, Lightstep). The OTel Collector reads them via env vars or files; never in plain config:
exporters:
otlphttp/honeycomb:
endpoint: https://api.honeycomb.io
headers:
x-honeycomb-team: ${env:HONEYCOMB_API_KEY}
Mount the secret as a file or env var via your normal k8s secret mechanism (or external-secrets-operator with Vault/AWS Secrets Manager). Rotate via deployment restart.
For mTLS to internal backends (Mimir, Tempo, Loki), use cert-manager + reload triggered by the file_storage extension's filewatch. Hot-reload on cert rotation is supported and worth setting up — restart-on-rotate is a worse user experience for the platform team.
9. A Worked Example — Production Gateway Config¶
Tier-2 OTel Collector gateway (post-loadbalancing router) doing: OTLP ingest, batching, redaction, tail sampling, cardinality drops, and fan-out to Mimir + Tempo + Loki, with persistent queue and memory_limiter correctly placed.
# otelcol-gateway-tier2.yaml
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
max_recv_msg_size_mib: 32
http:
endpoint: 0.0.0.0:4318
processors:
# 1. ALWAYS FIRST. Refuse work when the heap is over the soft cap.
memory_limiter:
check_interval: 1s
limit_mib: 7000 # pod has 8Gi, leave 1Gi headroom for GC
spike_limit_mib: 1000
# 2. Enrich with cluster + region + tenant — resource not attributes.
resource/enrich:
attributes:
- { key: deployment.environment, value: prod, action: upsert }
- { key: cluster, value: us-east-1, action: insert }
# 3. Look up k8s metadata for the source pod (RBAC: pods/list, pods/watch).
k8sattributes:
auth_type: serviceAccount
passthrough: false
extract:
metadata: [k8s.namespace.name, k8s.pod.name, k8s.node.name]
labels:
- { tag_name: k8s.app, key: app, from: pod }
- { tag_name: k8s.team, key: team, from: pod }
pod_association:
- sources:
- { from: resource_attribute, name: k8s.pod.uid }
- sources:
- { from: connection }
# 4. Redact PII from spans and logs. (Metrics pipeline doesn't use this one.)
attributes/redact:
actions:
- { key: http.request.body, action: delete }
- { key: http.user_agent, action: delete }
- { key: db.statement, action: hash }
- { key: user.email, action: delete }
- { key: http.target, action: update, from_attribute: http.target } # see transform below
transform/redact_body:
log_statements:
- context: log
statements:
- replace_pattern(body, "\\b(?:\\d[ -]*?){13,16}\\b", "[REDACTED-CARD]")
- replace_pattern(body, "\\b\\d{3}-\\d{2}-\\d{4}\\b", "[REDACTED-SSN]")
# 5. Drop high-cardinality metric labels before remote_write.
transform/cardinality:
metric_statements:
- context: datapoint
statements:
- delete_key(attributes, "user_id")
- delete_key(attributes, "session_id")
- delete_key(attributes, "request_id")
- delete_key(attributes, "trace_id")
# Bucket http.target into route templates if it leaked raw paths
- set(attributes["http.route"], attributes["http.target"])
where attributes["http.route"] == nil
# 6. Tail sampling. Comes BEFORE batch.
tail_sampling:
decision_wait: 30s
num_traces: 100000
expected_new_traces_per_sec: 5000
policies:
- { name: errors, type: status_code, status_code: { status_codes: [ERROR] } }
- { name: slow, type: latency, latency: { threshold_ms: 1000 } }
- { name: payments, type: string_attribute,
string_attribute: { key: service.name, values: [payments, fraud-engine] } }
- { name: baseline, type: probabilistic, probabilistic: { sampling_percentage: 1 } }
# 7. Batch is LAST. Large batches → fewer exporter RPCs → lower CPU.
batch:
send_batch_size: 8192
send_batch_max_size: 16384
timeout: 5s
exporters:
prometheusremotewrite/mimir:
endpoint: https://mimir-distributor.mimir.svc/api/v1/push
headers:
X-Scope-OrgID: ${env:TENANT_ID}
sending_queue:
enabled: true
num_consumers: 10
queue_size: 10000
storage: file_storage
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 5m
timeout: 30s
resource_to_telemetry_conversion:
enabled: true # promote resource attrs to metric labels (controlled by our transform)
otlp/tempo:
endpoint: tempo-distributor.tempo.svc:4317
tls: { insecure: true }
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
storage: file_storage
retry_on_failure: { enabled: true, max_elapsed_time: 5m }
loki:
endpoint: https://loki-distributor.loki.svc:3100/loki/api/v1/push
headers:
X-Scope-OrgID: ${env:TENANT_ID}
default_labels_enabled:
exporter: false
job: true
sending_queue:
enabled: true
num_consumers: 10
queue_size: 5000
storage: file_storage
retry_on_failure: { enabled: true, max_elapsed_time: 5m }
extensions:
health_check:
endpoint: 0.0.0.0:13133
pprof:
endpoint: 0.0.0.0:1777
zpages:
endpoint: 0.0.0.0:55679
file_storage:
directory: /var/lib/otelcol/queue
timeout: 5s
compaction:
directory: /var/lib/otelcol/queue
on_start: true
on_rebound: true
rebound_needed_threshold_mib: 100
rebound_trigger_threshold_mib: 200
service:
extensions: [health_check, pprof, zpages, file_storage]
telemetry:
logs: { level: info }
metrics:
level: detailed
address: 0.0.0.0:8888
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource/enrich, k8sattributes,
attributes/redact, tail_sampling, batch]
exporters: [otlp/tempo]
metrics:
receivers: [otlp]
processors: [memory_limiter, resource/enrich, k8sattributes,
transform/cardinality, batch]
exporters: [prometheusremotewrite/mimir]
logs:
receivers: [otlp]
processors: [memory_limiter, resource/enrich, k8sattributes,
transform/redact_body, batch]
exporters: [loki]
Annotations on what's load-bearing:
memory_limiteris first in every pipeline. The collector cannot OOM into a corrupted file_storage WAL.tail_samplingis in the traces pipeline only and comes beforebatch. Memory is dominated by the trace cache, sized vianum_traces.transform/cardinalityis in the metrics pipeline only. Without it,user_idleaks from a misbehaving service and Mimir's per-tenant series limits kick in 6 hours later.redactprocessors run beforebatchso the redacted form is what's sent. Redacting after batch would mean the batch buffer holds raw PII for up totimeout.- All exporters use
sending_queuewithstorage: file_storage. PVC is sized for ~30 minutes of peak send rate; beyond that we depend on chapter 05's Kafka tier. retry_on_failure.max_elapsed_time: 5mmeans we give up on a sample after 5 minutes of retries. Tune up if downstream backups span longer.prometheusremotewrite.headers.X-Scope-OrgIDcarries the tenant ID for Mimir multi-tenancy (chapter 19).
10. Common Pitfalls Specific to the Collection Layer¶
In rough order of "frequency we see it in design reviews".
batchplaced beforetail_sampling. The sampler waits 30s; the batch sits in memory for those 30s. Memory blows up. Fix:tail_samplingfirst,batchsecond.memory_limiterplaced afterbatch(or omitted). Heap climbs uncapped during traffic spikes; pod is OOMKilled mid-flush; persistent queue can corrupt. Fix:memory_limiteris always first.- Tail-sampling tier behind a vanilla Service (round-robin LB). Spans for one trace land on different replicas, no replica has the whole trace, sampling silently degrades. Fix:
loadbalancingexporter withrouting_key: traceIDin front. k8sattributesprocessor without RBAC. Pod metadata silently absent; downstream queries that filter onk8s.namespacereturn nothing. Fix:pods/list,pods/watchon a dedicated ServiceAccount; verify withkubectl auth can-i.- Fluent Bit
tailwithoutRead_from_Head: true. After restart, agent skips everything written during the gap. No alert; logs just disappear. Fix:Read_from_Head trueplus a persistent DB file atDB. - No persistent queue on the gateway. A 10-minute backend incident during a deploy = 10 minutes of telemetry lost. Fix:
file_storageextension on a sized PVC; size for the longest backend MTTR you've seen. relabel_configsconfused withmetric_relabel_configs. Relabeling samples inrelabel_configs(which only runs at SD time) does nothing. Filtering targets inmetric_relabel_configsruns after the scrape — too late. Fix: target-level concerns inrelabel_configs; sample-level inmetric_relabel_configs.- High-cardinality labels added at the producer, drop attempted at the TSDB. Wrong layer — by then you've paid the ingest cost. Fix: drop at the collector with
transform/attributes/metric_relabel_configs; consider rejecting unknown labels at the gateway via a denylist. - Pushgateway used as a general-purpose push proxy. Series persist forever, no quotas, single point of failure. Fix: OTel Collector OTLP push for service metrics; reserve Pushgateway for short-lived batch jobs only.
- OTLP exporter without
compression: gziporcompression: zstdset. Default is no compression; you pay 4-10× egress. Fix: explicitly set compression on every exporter; verify inotelcol_exporter_sent_bytes. - Two collectors on the same node both subscribing to the same source. Common with side-by-side migration (Fluent Bit + OTel Collector both tailing
/var/log/containers/*.log). Logs ingested twice, charged twice. Fix: one tailer per file class; the other should be downstream of it (OTel Collector'sfilelogcan read from Fluent Bit's forward output, etc.). - No
terminationGracePeriodSecondstuned. Pod gets SIGTERM, drains take longer than the grace, kubelet sends SIGKILL, in-flight batches die. Fix:terminationGracePeriodSeconds: 60plus apreStopsleep ≥ 10s for the lame-duck period. honor_labels: trueset on a normal scrape (because someone copied a Pushgateway example). Targets' own labels (instance,job) get clobbered by metric labels of the same name; queries that depend oninstance=<host>go sideways. Fix: default tohonor_labels: false; turn it on only for true federation/aggregation sources.
11. Glossary / Mental Model Summary¶
| Term | Precise meaning in this chapter |
|---|---|
| Agent | Per-node collector process. Reads host signals, tails files, pushes to gateway. |
| Gateway | Centralized collector tier. Stateful processing, vendor fan-out, durability. |
| Receiver | Component that ingests data from somewhere into the collector. |
| Processor | In-flight transformation between receiver and exporter. |
| Exporter | Component that writes to a backend / next hop. |
| Pipeline | Ordered chain receivers → processors → exporter for one signal type. |
| Extension | Out-of-band component (health, pprof, file_storage). Not in the data path. |
| Memory limiter | Backpressure processor that refuses receives when heap is full. |
| Persistent queue | Disk-backed exporter queue (file_storage) that survives restarts. |
| Head sampling | Per-process decision at trace start; cheap, blind. |
| Tail sampling | Decision after trace assembly, in a stateful collector tier. |
| Decision wait | Window the tail sampler holds spans before deciding. Must exceed P99.9 trace duration. |
| Loadbalancing exporter | Routes by traceID to keep all spans of a trace on one downstream replica. |
| Relabel configs | Run at SD time; decide whether to scrape a target and what labels it has. |
| Metric relabel configs | Run per sample after scrape; decide whether to keep a sample and rewrite its labels. |
| Honor labels | When true, scraped metric's own labels win conflicts with target's labels. Used for Pushgateway-class targets only. |
| Agent mode (Prometheus) | --enable-feature=agent: scrape + remote_write only, no local TSDB. |
| Pushgateway | Stateful intermediary for short-lived job metrics. Anti-pattern for general use. |
| Resource (OTel) | The producing entity (service/host/pod). One per process. |
| Attributes (OTel) | Per-event K/V pairs (per span/log/data point). |
The single mental model to take away: the collection layer is the only point in the stack that has full observability semantics, low latency, and the ability to refuse work. Everything upstream is application code (you can't refuse it). Everything downstream is storage cost (you can't unwrite it). Decisions made here — what to keep, what to drop, what to redact, when to backpressure — define both the cost and the correctness of the rest of the pipeline.
TL;DR. Run an agent + gateway two-tier with OTel Collector everywhere it makes sense, Fluent Bit at the agent for cheap log shipping, Vector wherever you need richer transformation, and Prometheus in --enable-feature=agent mode for pull-based metric collection. Always place memory_limiter first and batch last in every pipeline; place tail_sampling before batch and put a loadbalancing exporter in front of any tail-sampling tier; redact PII at the gateway before exporters; drop high-cardinality labels here, not at the TSDB; configure the file_storage extension on every exporter for backend-outage durability and back it with Kafka (chapter 05) for outages longer than your disk; size decision_wait to exceed P99.9 trace duration; treat the collector itself as a first-class service with its own SLOs, dashboards, and pages — because once this layer fails, everything downstream is asymptotically less useful.