LabHub

Blog

Observability Data Pipeline Cost Optimization: Sampling, Filtering, and Tiering Strategies

한국어English日本語

Observability Cost Optimization

Introduction

Observability cost is rising into the top tier of cloud infrastructure spending. The global observability market passed 28.5 billion USD in 2025 and is projected to reach 34.1 billion USD by the end of 2026. According to the Elastic 2026 Observability Survey, 54% of IT decision makers report growing pressure from executives to justify observability spending.

The root cause of the cost explosion is data volume. As microservice architectures have spread, traces account for 60~70% of total observability cost and logs take up 20~30%. When hundreds of services generate millions of spans and logs per second, storage and indexing costs grow exponentially rather than linearly.

However, simply reducing data is not the answer. In the same Elastic survey, 70% of organizations said they prioritize optimizing existing spend over cutting data. The key is to separate signal from noise so that cost falls while the quality of observability holds.

This article covers cost optimization strategies for a telemetry pipeline built around the OpenTelemetry Collector. It explains the difference between Head Sampling and Tail Sampling and how to design policies, how to build a log filtering pipeline, how to manage metric cardinality explosion, and Hot/Warm/Cold storage tiering architecture, centered on production configuration and code examples. It closes with failure cases and recovery procedures plus a cost optimization checklist, so that a platform engineer can apply the guide immediately.

Cost Structure Analysis: Where Does Cost Occur

To optimize observability cost, you first have to understand exactly where and how that cost arises. The cost of a telemetry pipeline can be broadly classified along four axes.

Cost Share by Signal

Signal TypeCost ShareMain Cost DriversOptimization Difficulty
Traces60~70%High cardinality, large payloads, span count explosionHigh
Logs20~30%Unstructured data, high volume, full-text indexingMedium
Metrics5~15%Time series cardinality, label combination explosionMedium
Profiles1~5%CPU/memory profile data sizeLow

Analysis by Cost Stage

Breaking the lifecycle of telemetry data down into the points where cost arises gives the following.

[Generate] --> [Collect/Send] --> [Process] --> [Index] --> [Store] --> [Query]
    |                |                |            |           |           |
   SDK            Network         Collector     Backend     Storage     Compute
overhead         bandwidth      CPU/memory    index I/O    disk/S3   query cost

The core principle of cost optimization is "drop unnecessary data as early in the pipeline as possible". Dropping at the generation stage saves every cost downstream, whereas dropping at the storage stage means the cost of every earlier stage has already been paid. This is called First-Mile Processing or telemetry optimization.

Observability Cost Formula

A formula for roughly estimating monthly observability cost looks like this.

Monthly cost = (ingest volume x ingest unit price) + (stored volume x storage unit price) + (query count x query unit price)

Ingest volume = number of services x RPS per service x span/log ratio x average payload size x retention period

When a large platform processes billions of data points per day, cardinality grows quickly, and it becomes the single largest cost driver across the whole observability stack. Once an index holds millions of series, RAM and disk usage spike, and ingest lag and data drops follow.

Sampling Strategies: Head vs Tail vs Adaptive

Sampling is the strategy that shows the most immediate effect on observability cost. Instead of collecting 100% of every trace, keeping only meaningful data selectively can cut volume dramatically.

Sampling Strategy Comparison

ItemHead SamplingTail SamplingAdaptive Sampling
Decision pointAt trace start (SDK level)After the trace completes (Collector level)Dynamic, from live traffic patterns
Decision basisProbability or trace IDAnalysis of all spans (latency, error, attributes)Traffic volume and error rate per service
Cost reductionHigh (saves network bandwidth too)Medium (everything is sent as far as the Collector)Medium~high
Data qualityLow (error traces can be missed)High (100% of error/high-latency traces kept)High
Implementation complexityLowHigh (needs memory and routing)Very high
Memory useMinimalHigh (buffers every span for decision_wait)Medium
Best fitNon-critical services with extremely high trafficProduction critical servicesLarge platforms with volatile traffic

Head Sampling Configuration

Head Sampling decides at the SDK level whether a trace is created. It is the simplest option and saves network bandwidth as well, but it can miss error or high-latency traces. Consistent Probability Sampling guarantees that every service reaches the same sampling decision for the same trace ID.

# OpenTelemetry Collector - Probabilistic Sampler Processor
processors:
  probabilistic_sampler:
    # Sample only 10% of all traces
    sampling_percentage: 10
    # hash_seed: seed for hashing the trace ID (fixed so multiple Collector instances decide identically)
    hash_seed: 22

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [probabilistic_sampler, batch]
      exporters: [otlp/tempo]

Head Sampling is configured in the Python SDK as follows.

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.sampling import TraceIdRatioBased, ParentBasedTraceIdRatio
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

# 10% Head Sampling - ParentBased follows the parent span decision
sampler = ParentBasedTraceIdRatio(0.1)

provider = TracerProvider(sampler=sampler)

# Efficient export through the batch processor
provider.add_span_processor(
    BatchSpanProcessor(
        OTLPSpanExporter(endpoint="http://otel-collector:4317"),
        max_queue_size=2048,
        max_export_batch_size=512,
        schedule_delay_millis=5000,
    )
)
trace.set_tracer_provider(provider)

Tail Sampling Configuration

Tail Sampling makes the sampling decision after every span of a trace has been collected. It can keep 100% of the traces that errored or ran slow while sampling only normal traces at a low rate, so cost falls without losing data quality.

One caveat: for Tail Sampling to work, every span of the same trace must arrive at the same Collector instance. In a multi-Collector environment a trace-ID-based load balancer or the groupbytrace processor is essential.

# OpenTelemetry Collector - Tail Sampling Processor
# Use this only on a Collector running in Gateway mode
processors:
  tail_sampling:
    # Wait time from the first span received until the decision (waits for the trace to complete)
    decision_wait: 30s
    # Maximum number of traces held in memory
    num_traces: 100000
    # Expected new traces per second (optimizes internal memory allocation)
    expected_new_traces_per_sec: 1000
    policies:
      # Policy 1: keep 100% of traces containing an error
      - name: error-policy
        type: status_code
        status_code:
          status_codes:
            - ERROR

      # Policy 2: keep 100% of traces slower than 500ms
      - name: latency-policy
        type: latency
        latency:
          threshold_ms: 500

      # Policy 3: keep 100% of traces from specific services
      - name: critical-service-policy
        type: string_attribute
        string_attribute:
          key: service.name
          values:
            - payment-service
            - auth-service
            - order-service

      # Policy 4: sample only 5% of the remaining normal traces
      - name: normal-traffic-policy
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

      # Policy 5: composite policy - AND condition
      - name: composite-policy
        type: and
        and:
          and_sub_policy:
            - name: is-health-check
              type: string_attribute
              string_attribute:
                key: http.route
                values:
                  - /healthz
                  - /readyz
                  - /livez
            - name: drop-most
              type: probabilistic
              probabilistic:
                sampling_percentage: 0.1

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]

Tail Sampling Architecture: the 2-Tier Collector Pattern

Running Tail Sampling reliably in production requires a 2-Tier architecture that separates the Agent Collector from the Gateway Collector.

[Service A] --OTLP--> [Agent Collector (DaemonSet)]
[Service B] --OTLP--> [Agent Collector (DaemonSet)]  --trace-ID-based routing-->
[Service C] --OTLP--> [Agent Collector (DaemonSet)]

                                                     [Gateway Collector #1] --> [Tempo]
                                                     [Gateway Collector #2] --> [Tempo]
                                                     [Gateway Collector #3] --> [Tempo]
                                                     (performs Tail Sampling)

The Agent Collector uses the loadbalancing exporter to route spans carrying the same trace ID to the same Gateway.

# Agent Collector configuration - LoadBalancing Exporter
exporters:
  loadbalancing:
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      dns:
        hostname: otel-gateway-headless.observability.svc.cluster.local
        port: 4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [loadbalancing]

Log Filtering Pipeline

Logs are the second largest share of observability cost. Debug logs, health check logs and repetitive infrastructure logs in particular often make up most of the volume while carrying little real analytical value. The Filter Processor and Transform Processor of the OpenTelemetry Collector remove unnecessary logs at the pipeline level so that only the information you need reaches the backend.

Log Level Based Filtering

Dropping DEBUG and TRACE level logs at the Collector in a production environment typically cuts log volume by 30~50%.

# OpenTelemetry Collector - Log Level Filtering
processors:
  # Drop DEBUG/TRACE logs
  filter/drop-debug:
    error_mode: ignore
    logs:
      log_record:
        - 'severity_number < SEVERITY_NUMBER_INFO'

  # Drop specific log body patterns (health check, readiness probe)
  filter/drop-healthcheck:
    error_mode: ignore
    logs:
      log_record:
        - 'IsMatch(body, ".*GET /health.*")'
        - 'IsMatch(body, ".*GET /readyz.*")'
        - 'IsMatch(body, ".*GET /livez.*")'
        - 'IsMatch(body, ".*kube-probe.*")'

  # Keep only logs from a specific namespace
  filter/namespace:
    error_mode: ignore
    logs:
      log_record:
        - 'resource.attributes["k8s.namespace.name"] == "kube-system"'

  # Attribute transform - shrink the payload by removing unnecessary fields
  transform/reduce-attributes:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - delete_key(attributes, "log.file.path")
          - delete_key(attributes, "log.iostream")
          - truncate_all(attributes, 256)
          - limit(attributes, 20)

service:
  pipelines:
    logs:
      receivers: [otlp, filelog]
      processors:
        - memory_limiter
        - filter/drop-debug
        - filter/drop-healthcheck
        - filter/namespace
        - transform/reduce-attributes
        - batch
      exporters: [otlp/loki]

Log Sampling and Aggregation

Instead of storing every log individually, aggregating repetitive logs and recording only a count is also effective. For example, when the same error message occurs 1,000 times per second, store a single record and add a count attribute.

# OpenTelemetry Collector - aggregate logs with Group By Attributes
processors:
  groupbyattrs:
    keys:
      - service.name
      - severity_text
      - log.template

  # Add a count attribute after aggregating duplicate logs
  transform/aggregate-count:
    error_mode: ignore
    log_statements:
      - context: log
        statements:
          - set(attributes["log.dedup_count"], 1) where attributes["log.dedup_count"] == nil

Measuring the Effect of Log Filtering

Once a filtering policy is applied, its effect has to be measured. The Collector own metrics let you track the volume change before and after filtering quantitatively.

#!/bin/bash
# Measure filtering effectiveness from the Collector internal metrics
# Collected from the Prometheus endpoint

# Number of logs received before filtering
RECEIVED=$(curl -s http://localhost:8888/metrics | \
  grep 'otelcol_receiver_accepted_log_records' | \
  awk '{sum += $2} END {print sum}')

# Number of logs exported after filtering
EXPORTED=$(curl -s http://localhost:8888/metrics | \
  grep 'otelcol_exporter_sent_log_records' | \
  awk '{sum += $2} END {print sum}')

# Compute the drop rate
if [ "$RECEIVED" -gt 0 ]; then
  DROP_RATE=$(echo "scale=2; (1 - $EXPORTED / $RECEIVED) * 100" | bc)
  echo "Received logs: $RECEIVED"
  echo "Exported logs: $EXPORTED"
  echo "Drop rate: ${DROP_RATE}%"
  echo "Estimated saving: $(echo "scale=0; $DROP_RATE * 150 / 100" | bc) USD per month (basis: 150 USD/month)"
fi

# Check the drop count per processor
echo ""
echo "=== Drop status by processor ==="
curl -s http://localhost:8888/metrics | \
  grep 'otelcol_processor_dropped_log_records' | \
  sort -t' ' -k2 -rn

Metric Cardinality Management

Metric cardinality explosion is the main reason observability cost becomes unpredictable. Every unique time series requires its own entry in the database index, and once millions of series exist, RAM and disk usage spike, ingest lags, and query performance degrades.

Causes of Cardinality Explosion

Cardinality explosion generally comes from the following label usage patterns.

Risky PatternExampleCardinality GrowthFix
User ID as a labeluser_id="u12345"One series per userRemove the label, move it to logs/traces
Raw request pathpath="/api/users/12345"UnboundedNormalize the path path="/api/users/:id"
Pod namepod="web-7f8c9-xk2m"Grows with every deployUse the Deployment name only
Full error messageerror="Connection refused: 10.0.1.42:3306"A series per IPClassify by error code
Timestamp labelrequest_time="1709856000"A new series every secondRemove it from the labels

Cardinality Control at the Collector Level

The configuration for cleaning up metric labels and controlling cardinality in the OpenTelemetry Collector is as follows.

# OpenTelemetry Collector - metric cardinality management
processors:
  # Remove high-cardinality attributes
  metricstransform/drop-high-cardinality:
    transforms:
      - include: http.server.request.duration
        action: update
        operations:
          # Remove high-cardinality labels such as user_id and session_id
          - action: delete_label_value
            label: user_id
          - action: delete_label_value
            label: session_id

  # Normalize URL paths
  transform/normalize-paths:
    error_mode: ignore
    metric_statements:
      - context: datapoint
        statements:
          - replace_pattern(attributes["url.path"], "^/api/users/[0-9]+", "/api/users/:id")
          - replace_pattern(attributes["url.path"], "^/api/orders/[0-9]+", "/api/orders/:id")
          - replace_pattern(attributes["url.path"], "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", ":uuid")

  # Limit the number of attributes
  attributes/limit:
    actions:
      - key: http.request.header.x-request-id
        action: delete
      - key: http.request.header.authorization
        action: delete

  # Metric aggregation - drop fine-grained labels and send only aggregated metrics
  filter/metrics-allowlist:
    error_mode: ignore
    metrics:
      metric:
        # Allow only specific metrics (allowlist approach)
        - 'name == "http.server.request.duration"'
        - 'name == "http.server.active_requests"'
        - 'name == "process.runtime.go.goroutines"'
        - 'IsMatch(name, "^system\\..*")'

service:
  pipelines:
    metrics:
      receivers: [otlp, prometheus]
      processors:
        - memory_limiter
        - metricstransform/drop-high-cardinality
        - transform/normalize-paths
        - attributes/limit
        - filter/metrics-allowlist
        - batch
      exporters: [prometheusremotewrite/mimir]

The Observability Budget Pattern

A pattern large organizations have adopted recently is the Observability Budget. It defines up front the maximum volume of telemetry each service may emit, and once the budget is exceeded the Collector drops data automatically.

#!/usr/bin/env python3
"""
Observability Budget Enforcer
Script that monitors the telemetry budget per service and raises warnings
"""

import requests
import yaml
import sys
from datetime import datetime

# Observability Budget per service (daily basis)
BUDGETS = {
    "payment-service": {
        "traces_per_day": 5_000_000,
        "logs_per_day": 10_000_000,
        "metrics_series": 50_000,
        "alert_threshold": 0.8,  # warn once 80% is reached
    },
    "user-service": {
        "traces_per_day": 2_000_000,
        "logs_per_day": 5_000_000,
        "metrics_series": 30_000,
        "alert_threshold": 0.8,
    },
    "default": {
        "traces_per_day": 1_000_000,
        "logs_per_day": 3_000_000,
        "metrics_series": 20_000,
        "alert_threshold": 0.8,
    },
}

PROMETHEUS_URL = "http://prometheus:9090"


def query_prometheus(query: str) -> float:
    """Query the current value from Prometheus."""
    resp = requests.get(
        f"{PROMETHEUS_URL}/api/v1/query",
        params={"query": query},
    )
    result = resp.json().get("data", {}).get("result", [])
    if result:
        return float(result[0]["value"][1])
    return 0.0


def check_budget(service_name: str) -> dict:
    """Check the current telemetry usage of a service against its budget."""
    budget = BUDGETS.get(service_name, BUDGETS["default"])
    today = datetime.now().strftime("%Y-%m-%d")

    # Number of traces created today
    traces_today = query_prometheus(
        f'sum(increase(traces_spanmetrics_calls_total{{service_name="{service_name}"}}[24h]))'
    )

    # Number of logs created today
    logs_today = query_prometheus(
        f'sum(increase(loki_distributor_lines_received_total{{service="{service_name}"}}[24h]))'
    )

    # Current number of active metric series
    active_series = query_prometheus(
        f'count({{service_name="{service_name}"}})'
    )

    usage = {
        "service": service_name,
        "date": today,
        "traces": {
            "current": int(traces_today),
            "budget": budget["traces_per_day"],
            "usage_pct": round(traces_today / budget["traces_per_day"] * 100, 1),
        },
        "logs": {
            "current": int(logs_today),
            "budget": budget["logs_per_day"],
            "usage_pct": round(logs_today / budget["logs_per_day"] * 100, 1),
        },
        "metrics_series": {
            "current": int(active_series),
            "budget": budget["metrics_series"],
            "usage_pct": round(active_series / budget["metrics_series"] * 100, 1),
        },
    }

    # Check whether the budget is exceeded
    for signal_type, data in usage.items():
        if isinstance(data, dict) and "usage_pct" in data:
            if data["usage_pct"] >= budget["alert_threshold"] * 100:
                print(
                    f"[WARNING] {service_name}: {signal_type} budget "
                    f"{data['usage_pct']}% used ({data['current']:,} / {data['budget']:,})"
                )

    return usage


if __name__ == "__main__":
    services = sys.argv[1:] if len(sys.argv) > 1 else list(BUDGETS.keys())
    for svc in services:
        if svc == "default":
            continue
        result = check_budget(svc)
        print(f"\n=== {svc} Observability Budget ===")
        for key, val in result.items():
            if isinstance(val, dict):
                print(f"  {key}: {val['current']:,} / {val['budget']:,} ({val['usage_pct']}%)")

Storage Tiering Architecture

Storing all telemetry data in the same storage layer is inefficient from a cost perspective. Hot/Warm/Cold tiering, which keeps the last 7 days on high-performance storage for fast queries and moves older data to low-cost storage, is the core of cost optimization.

Storage Tier Comparison

ItemHot TierWarm TierCold TierArchive
Retention0~7 days7~30 days30~90 days90 days~1 year+
Storage typeNVMe SSD / EBS gp3EBS st1 / S3 StandardS3 Infrequent AccessS3 Glacier
Cost per GB per month$0.10~0.16$0.025~0.045$0.0125$0.004
Query latencymillisecondssecondsminuteshours
Suitable dataLive monitoring, alertingRecent incident analysis, trendsAudit, complianceLegal retention obligations
Cost reduction (vs Hot)baseline70~75%87~92%97%

Elasticsearch Hot-Warm-Cold Architecture

When Elasticsearch is the log backend, automatic tiering can be configured through ILM (Index Lifecycle Management).

# Elasticsearch ILM Policy - log tiering
# PUT _ilm/policy/observability-logs-policy
{
  'policy':
    {
      'phases':
        {
          'hot':
            {
              'min_age': '0ms',
              'actions':
                {
                  'rollover': { 'max_primary_shard_size': '50gb', 'max_age': '1d' },
                  'set_priority': { 'priority': 100 },
                },
            },
          'warm':
            {
              'min_age': '7d',
              'actions':
                {
                  'shrink': { 'number_of_shards': 1 },
                  'forcemerge': { 'max_num_segments': 1 },
                  'allocate': { 'require': { 'data': 'warm' } },
                  'set_priority': { 'priority': 50 },
                },
            },
          'cold':
            {
              'min_age': '30d',
              'actions':
                {
                  'searchable_snapshot':
                    { 'snapshot_repository': 's3-repo', 'force_merge_index': true },
                  'allocate': { 'require': { 'data': 'cold' } },
                  'set_priority': { 'priority': 0 },
                },
            },
          'delete': { 'min_age': '365d', 'actions': { 'delete': {} } },
        },
    },
}

Grafana Tempo + S3 Tiering

When Grafana Tempo is the trace backend, block-storage-based tiering can be configured. Tempo uses object storage (S3) as its backend by default, so S3 Intelligent-Tiering optimizes cost automatically.

S3 Intelligent-Tiering moves data automatically according to access patterns. After 30 days without access it moves data to the Infrequent Access tier for a 40% saving, and after 90 days without access to the Archive Instant Access tier for a 68% saving. T-Mobile applied this strategy to a 1.87PB data lake and cut S3 cost by 40%.

Grafana Mimir Metric Tiering

When Grafana Mimir is the metric backend, the compactor and store-gateway settings combine data retention with downsampling.

# Mimir configuration - metric retention and downsampling
limits:
  # Keep full-resolution metrics for 14 days
  compactor_blocks_retention_period: 14d

compactor:
  # Enable downsampling
  downsample:
    # Downsample to 5m resolution after 7 days
    - resolution: 5m
      retention: 7d
    # Downsample to 1h resolution after 30 days
    - resolution: 1h
      retention: 30d

store_gateway:
  sharding_ring:
    replication_factor: 3

# S3 backend configuration
blocks_storage:
  backend: s3
  s3:
    bucket_name: mimir-metrics
    endpoint: s3.ap-northeast-2.amazonaws.com
    storage_class: INTELLIGENT_TIERING
  bucket_store:
    sync_dir: /data/mimir-sync
    # Index cache keeps query performance up
    index_cache:
      backend: memcached
      memcached:
        addresses: dns+memcached.observability.svc:11211

Integrated Cost Optimization Architecture

Diagramming the full architecture that integrates the sampling, filtering, cardinality management and storage tiering described so far gives the following.

                    [Microservices Cluster]
                    (SDK Head Sampling 10%)
                            |
                            v
            +-------------------------------+
            |   Agent Collector (DaemonSet)  |
            |   - Memory Limiter            |
            |   - Filter/drop-debug         |
            |   - Filter/drop-healthcheck   |
            |   - Attributes/limit          |
            +-------------------------------+
                     |          |          |
                  Traces     Logs      Metrics
                     |          |          |
                     v          v          v
     +---------------+    +---------+  +-----------+
     | Gateway        |    | Gateway |  | Gateway   |
     | Collector      |    | (Logs)  |  | (Metrics) |
     | (Traces)       |    |         |  |           |
     | - Tail Sampling|    | - Group |  | - Cardina |
     | - LoadBalance  |    |   ByAttr|  |   lity    |
     +-------+--------+    +----+----+  +-----+-----+
             |                   |              |
             v                   v              v
     +-------+--------+  +------+------+  +----+------+
     | Tempo          |  | Loki        |  | Mimir     |
     | (S3 + Intelli- |  | (S3 + ILM)  |  | (S3 +     |
     |  gent Tiering) |  |             |  | Downsample|
     +----------------+  +-------------+  +-----------+
             |                   |              |
             v                   v              v
     +--------------------------------------------------+
     |              Grafana Dashboard                    |
     |  - Live monitoring (Hot Tier)                    |
     |  - Incident analysis (Warm Tier)                 |
     |  - Compliance audit (Cold/Archive Tier)          |
     +--------------------------------------------------+

Applying this architecture can be expected to produce the following cost savings.

Optimization LayerTechniqueExpected Volume ReductionCost Effect
SDK Head Sampling10% probabilistic samplingTraces down 90%Large cut in network + storage cost
Agent log filteringDrop DEBUG/health checkLogs down 30~50%Cut in ingest + storage cost
Gateway Tail SamplingKeep error/high latency + 5% of normalRemaining traces down 95%Cut in final storage cost
Cardinality controlRemove high-cardinality labelsSeries count down 50~70%Cut in indexing + query cost
Storage tieringHot/Warm/Cold + S3 ITNone (same data, cheaper storage)Storage cost down 40~68%

Taken together, applying all of these strategies can cut total observability cost by 60~80% while keeping observability of error traces and anomalies at 100%.

Failure Cases and Recovery Procedures

Before applying a cost optimization strategy, work out in advance which failure scenarios it can produce and how to recover from each.

Failure Case 1: Tail Sampling OOM (Out of Memory)

Symptom: the Gateway Collector restarts repeatedly with OOM. The number of spans accumulating in memory during decision_wait exceeds the num_traces limit and memory spikes.

Cause: during a traffic spike the num_traces and decision_wait settings exceed memory capacity. For example, with decision_wait: 60s and 10,000 new traces arriving per second, up to 600,000 traces have to be held in memory.

Recovery procedure:

  1. Reduce decision_wait to 30s or less (a trade-off against the trace completion rate).
  2. Adjust num_traces to fit memory capacity. As a rule of thumb, estimate roughly 1~5KB per trace.
  3. Place the memory_limiter processor ahead of Tail Sampling so data is dropped once the memory threshold is passed.
  4. Scale the Gateway Collector resources vertically, or scale the instance count horizontally.
# Memory Limiter + Tail Sampling combination that prevents OOM
processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 3800 # 200MB of headroom in a 4GB container
    spike_limit_mib: 800 # allow a temporary burst

  tail_sampling:
    decision_wait: 20s # reduced from 30s to 20s
    num_traces: 50000 # calculated from available memory
    expected_new_traces_per_sec: 2500
    policies:
      - name: error-always
        type: status_code
        status_code:
          status_codes: [ERROR]
      - name: latency-always
        type: latency
        latency:
          threshold_ms: 500
      - name: probabilistic-default
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

service:
  pipelines:
    traces:
      receivers: [otlp]
      # memory_limiter must come first
      processors: [memory_limiter, tail_sampling, batch]
      exporters: [otlp/tempo]

Failure Case 2: Blind Spots from Over-Filtering

Symptom: an incident occurs in production but there are no related logs or traces, so the cause cannot be analyzed. The filtering policy was set too aggressively and dropped critical data along with the rest.

Cause: the log filter regex is drawn too broadly and matches relevant logs as well, or metric label removal goes so far that an anomaly on a specific instance can no longer be identified.

Recovery procedure:

  1. Apply Shadow Mode first whenever a filtering policy changes. Nothing is actually dropped; only the count of the data that would be dropped is recorded, so the impact can be assessed in advance.
  2. Apply filtering exception rules for critical services (payment, auth, order).
  3. Use error_mode: ignore so that data is not dropped even when condition evaluation fails.

Failure Case 3: Mimir/Prometheus Ingest Failure from Cardinality Explosion

Symptom: Prometheus or Mimir raises a too many active series error and metric ingest is rejected. Empty panels appear on the Grafana dashboard.

Cause: a newly deployed service puts a unique identifier (UUID, timestamp, Pod name and so on) in a label value, and the series count grows explosively.

Recovery procedure:

  1. Immediately drop the offending label with the metricstransform processor in the Collector.
  2. Temporarily raise the max_global_series_per_user limit in Mimir.
  3. Fix the SDK instrumentation code in the service responsible so that the high-cardinality attribute is removed.
  4. Over the longer term, add a cardinality validation step to the CI/CD pipeline.

Failure Case 4: Query Failures During a Tier Transition

Symptom: while the ILM policy moves an index to the Warm or Cold tier, log queries over that time range fail or time out.

Cause: during the tier transition, shard relocation, force merge and snapshot creation occupy cluster resources and query performance degrades.

Recovery procedure:

  1. Schedule ILM transitions for low-traffic hours (2~5 AM).
  2. Use searchable_snapshot so that queries remain possible during the transition.
  3. Provision enough resources on the Warm/Cold nodes that the transition finishes quickly.

Operational Considerations

Here are the points to keep in mind when applying a cost optimization strategy.

On Sampling

On Filtering

On Cardinality

On Tiering

Cost Optimization Checklist

Use the checklist below to assess how far the current observability pipeline has been cost-optimized.

Phase 1: Quick Wins You Can Apply Now (1~2 weeks)

Phase 2: Mid-Term Optimization (2~4 weeks)

Phase 3: Long-Term Infrastructure Optimization (1~3 months)

Troubleshooting Guide

When Collector Memory Usage Keeps Growing

# 1. Check Collector memory usage
kubectl top pods -n observability -l app=otel-gateway

# 2. Memory profiling with pprof (the extension has to be enabled)
curl -s http://localhost:1777/debug/pprof/heap > heap.prof
go tool pprof -top heap.prof

# 3. Check pipeline state in zPages
curl -s http://localhost:55679/debug/tracez | jq .

# 4. Check the Tail Sampling queue state
curl -s http://localhost:8888/metrics | grep tail_sampling

When a Filtering Policy Does Not Behave as Expected

  1. Change error_mode to propagate so that errors are printed to the log.
  2. Add the debug exporter to the pipeline and inspect what data survives filtering.
  3. Recheck the syntax of the OTTL (OpenTelemetry Transformation Language) expression. Pay particular attention to the difference between accessing body and accessing attributes.
# Pipeline configuration for debugging
exporters:
  debug:
    verbosity: detailed
    sampling_initial: 5
    sampling_thereafter: 200

service:
  pipelines:
    logs/debug:
      receivers: [otlp]
      processors: [filter/drop-debug]
      exporters: [debug]

How to Find the Service Behind a Cardinality Explosion

# Check the top-cardinality metrics in Prometheus
curl -s http://prometheus:9090/api/v1/status/tsdb | \
  jq '.data.seriesCountByMetricName | sort_by(-.value) | .[0:10]'

# Analyze label cardinality for a specific metric
curl -s 'http://prometheus:9090/api/v1/query?query=count(http_server_request_duration_seconds_bucket) by (service_name)' | \
  jq '.data.result | sort_by(-.value[1] | tonumber) | .[0:10]'

# Check active series per tenant in Mimir
curl -s http://mimir:8080/api/v1/cardinality/active_series | \
  jq '.data | sort_by(-.active_series) | .[0:10]'

Conclusion

Observability cost optimization is not simply about reducing data. It is an engineering activity that separates signal from noise precisely, so cost efficiency is maximized while the quality of observability holds.

The strategies covered in this article can be summarized as follows.

  1. Sampling: use Head Sampling to cut network cost as well, and apply Tail Sampling on critical services to keep 100% of error and high-latency traces.
  2. Filtering: drop DEBUG and health check logs at the Collector, and shrink payload size by cleaning up attributes.
  3. Cardinality management: remove high-cardinality labels, normalize URL paths, and control per-service usage with an Observability Budget.
  4. Storage tiering: use a Hot/Warm/Cold architecture to move data to low-cost storage automatically as it ages.

Applying these strategies systematically can cut total observability cost by 60~80% while fully preserving the data incident response needs. Cost optimization is not a one-time task but an operational process that has to be adjusted continuously as services grow. The key is to review the checklist regularly and to keep monitoring the Collector internal metrics and cost trends so that the optimal balance holds.

References

Comments

No comments yet.

Sign in to leave a comment