Перейти к содержимому
К программе курса
Распределенная трассировка: от основ до production
10 / 1856%

Стратегии сэмплирования: от простого к adaptive

60 минут

Стратегии сэмплирования: от простого к adaptive

Проблема: 100 000 trace/sec

Представьте: ваш сервис обрабатывает 100 000 запросов в секунду. Если отправлять каждый trace в Jaeger:

  • Объём данных: ~1 ТБ в день (при среднем размере trace 100 KB)
  • Стоимость хранения: $30-50/ТБ/месяц = ~$1000+/месяц
  • Latency overhead: каждый span отправляется по сети
  • Backend перегружен: Jaeger/Tempo не справляются

Решение: Сэмплирование (Sampling) — отправлять только часть traces.

Но как выбрать, какие traces сохранить?


Что такое Sampling?

Sampling — это процесс принятия решения: отправлять trace в backend или нет.

100 requests

    ├─ trace_1 ✅ sampled (отправлен)
    ├─ trace_2 ❌ not sampled
    ├─ trace_3 ❌ not sampled
    ├─ trace_4 ✅ sampled
    ├─ trace_5 ❌ not sampled
    ...
    └─ trace_100 ❌ not sampled
 
Результат: 2% traces отправлены в Jaeger

Ключевой вопрос: Как не потерять важные traces (ошибки, медленные запросы)?


Head-based vs Tail-based Sampling

Head-based Sampling (решение в начале trace)

Момент решения: При создании root span (первый span в trace).

Преимущества:

  • Низкая latency — решение мгновенное
  • Низкий overhead — не нужно хранить spans в памяти
  • Простая реализация

Недостатки:

  • Не знаем будущее — можем пропустить важные ошибки
  • Сложно сэмплировать редкие события
Request начинается → Создаём trace → Решаем: sampled=true/false

                      Если false — все spans игнорируются

Tail-based Sampling (решение в конце trace)

Момент решения: После завершения всех spans в trace.

Преимущества:

  • Знаем всю картину — можем сэмплировать по ошибкам, latency
  • Гарантируем сохранение важных traces

Недостатки:

  • Высокий overhead — нужно буферизовать все spans
  • Сложная архитектура — требуется OpenTelemetry Collector
Request → Spans буферизуются → Trace завершён → Анализируем

                                   Решаем: отправить или нет

В этом уроке: Фокус на head-based sampling (tail-based в уроке 09).


OpenTelemetry Samplers: Head-based

1. AlwaysOn Sampler (100% sampling)

Использование: Development, debugging, low-traffic сервисы.

// Node.js
const { AlwaysOnSampler } = require("@opentelemetry/sdk-trace-base");
 
const sdk = new NodeSDK({
  sampler: new AlwaysOnSampler(),
  // ...
});
# Python
from opentelemetry.sdk.trace.sampling import AlwaysOnSampler
 
provider = TracerProvider(
    sampler=AlwaysOnSampler()
)
// Go
import "go.opentelemetry.io/otel/sdk/trace"
 
tp := trace.NewTracerProvider(
    trace.WithSampler(trace.AlwaysSample()),
)

Результат: Все traces отправляются в backend.


2. AlwaysOff Sampler (0% sampling)

Использование: Отключение трассировки без изменения кода.

const { AlwaysOffSampler } = require("@opentelemetry/sdk-trace-base");
 
const sdk = new NodeSDK({
  sampler: new AlwaysOffSampler(),
});

Результат: Никакие traces не отправляются.

Важно: Spans всё равно создаются (overhead ~100ns), просто не экспортируются.


3. TraceIdRatioBased Sampler (Probability Sampling)

Принцип: Сэмплируем X% traces на основе trace_id.

Как работает:

trace_id = "4bf92f3577b34da6a3ce929d0e0e4736"
 
1. Берём младшие 8 байт trace_id
2. Конвертируем в число от 0 до 1
3. Сравниваем с порогом (ratio)
 
Если число < ratio → sampled = true

Пример: 10% sampling

const { TraceIdRatioBasedSampler } = require("@opentelemetry/sdk-trace-base");
 
const sdk = new NodeSDK({
  sampler: new TraceIdRatioBasedSampler(0.1), // 10%
});
from opentelemetry.sdk.trace.sampling import TraceIdRatioBasedSampler
 
provider = TracerProvider(
    sampler=TraceIdRatioBasedSampler(0.1)  # 10%
)
tp := trace.NewTracerProvider(
    trace.WithSampler(trace.TraceIDRatioBased(0.1)),  // 10%
)

Результат:

100 requests → ~10 traces в Jaeger

Преимущества:

  • Детерминированность: один и тот же trace_id всегда даёт одно решение
  • Консистентность: если root span sampled, все child spans тоже sampled

Недостатки:

  • Можем пропустить редкие ошибки
  • Высоконагруженные endpoints и low-traffic endpoints семплируются одинаково

4. ParentBased Sampler (наследование от родителя)

Принцип: Если parent span sampled, child span тоже sampled.

Зачем? Гарантия полноты trace — не потерять часть spans.

const {
  ParentBasedSampler,
  TraceIdRatioBasedSampler,
  AlwaysOnSampler,
} = require("@opentelemetry/sdk-trace-base");
 
const sdk = new NodeSDK({
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(0.1), // Root spans: 10%
    remoteParentSampled: new AlwaysOnSampler(), // Если parent sampled → всегда sample
    remoteParentNotSampled: new AlwaysOffSampler(), // Если parent not sampled → не sample
  }),
});

Сценарий:

API Gateway (root span):
  - Решение: TraceIdRatio(0.1) → sampled = true
 
  ↓ HTTP request (propagates sampled=true)
 
Auth Service (child span):
  - Решение: parent sampled → sampled = true
 
  ↓ HTTP request (propagates sampled=true)
 
User Service (child span):
  - Решение: parent sampled → sampled = true

Результат: Весь trace сохранён полностью.


5. Custom Samplers: Sampling по условиям

Сценарий: Хотим сэмплировать:

  • 100% ошибок (status_code >= 400)
  • 100% медленных запросов (>1s)
  • 1% остальных

Проблема: Head-based sampling не знает будущее (ошибку/latency).

Решение: Комбинация head-based + parent-based + manual override.

Пример: Custom Sampler (Node.js)

const { Sampler, SamplingDecision } = require("@opentelemetry/sdk-trace-base");
 
class ErrorAwareSampler {
  constructor(baseSampler) {
    this.baseSampler = baseSampler;
  }
 
  shouldSample(context, traceId, spanName, spanKind, attributes, links) {
    // 1. Проверяем атрибуты (если доступны на момент создания)
    const httpStatus = attributes["http.status_code"];
 
    if (httpStatus && httpStatus >= 400) {
      // Ошибка → всегда sample
      return {
        decision: SamplingDecision.RECORD_AND_SAMPLED,
      };
    }
 
    // 2. Для остальных — используем base sampler
    return this.baseSampler.shouldSample(
      context,
      traceId,
      spanName,
      spanKind,
      attributes,
      links
    );
  }
}
 
const sdk = new NodeSDK({
  sampler: new ErrorAwareSampler(new TraceIdRatioBasedSampler(0.01)), // 1% base
});

Ограничение: Атрибуты доступны только если установлены до создания span.


Rate Limiting Sampler

Проблема TraceIdRatio: При спайке трафика (100k → 1M req/sec), количество traces тоже вырастает в 10 раз.

Решение: Rate Limiting — ограничить количество traces/sec.

Пример (Custom Sampler):

class RateLimitingSampler {
  constructor(maxTracesPerSecond) {
    this.maxTracesPerSecond = maxTracesPerSecond;
    this.tracesThisSecond = 0;
    this.currentSecond = Math.floor(Date.now() / 1000);
  }
 
  shouldSample(context, traceId, spanName, spanKind, attributes, links) {
    const now = Math.floor(Date.now() / 1000);
 
    // Новая секунда — сброс счётчика
    if (now !== this.currentSecond) {
      this.currentSecond = now;
      this.tracesThisSecond = 0;
    }
 
    // Проверяем лимит
    if (this.tracesThisSecond < this.maxTracesPerSecond) {
      this.tracesThisSecond++;
      return { decision: SamplingDecision.RECORD_AND_SAMPLED };
    }
 
    return { decision: SamplingDecision.NOT_RECORD };
  }
}
 
const sdk = new NodeSDK({
  sampler: new RateLimitingSampler(100), // Максимум 100 traces/sec
});

Результат:

Traffic: 10k req/sec → 100 traces/sec в Jaeger
Traffic: 100k req/sec → 100 traces/sec в Jaeger (защита)

Jaeger Adaptive Sampling

Проблема: Статическое сэмплирование (10%) не учитывает:

  • Разную нагрузку на разные endpoints (/health vs /checkout)
  • Изменения трафика со временем

Решение: Jaeger Remote Sampling — backend динамически управляет sampling rates.

Как работает Adaptive Sampling

1. Jaeger Collector анализирует поступающие traces
2. Вычисляет статистику: req/sec, error rate по каждому endpoint
3. Отдаёт клиенту sampling strategies через HTTP API
 
Client периодически запрашивает:
  GET http://jaeger-collector:5778/sampling?service=api-gateway
 
Response:
{
  "strategyType": "probabilistic",
  "probabilisticSampling": {
    "samplingRate": 0.1
  },
  "operationSampling": {
    "perOperationStrategies": [
      {
        "operation": "GET /health",
        "probabilisticSampling": {"samplingRate": 0.001}
      },
      {
        "operation": "POST /checkout",
        "probabilisticSampling": {"samplingRate": 0.5}
      }
    ]
  }
}

Результат:

  • /health — 0.1% (низкая важность)
  • /checkout — 50% (критичный endpoint)

Настройка Jaeger Adaptive Sampling

1. Включите adaptive sampling в Jaeger Collector:

# jaeger-all-in-one docker-compose.yml
jaeger:
  image: jaegertracing/all-in-one:1.53
  environment:
    - SAMPLING_STRATEGIES_FILE=/etc/jaeger/sampling.json
  volumes:
    - ./sampling.json:/etc/jaeger/sampling.json

2. Создайте sampling.json:

{
  "service_strategies": [
    {
      "service": "api-gateway",
      "type": "probabilistic",
      "param": 0.1,
      "operation_strategies": [
        {
          "operation": "GET /health",
          "type": "probabilistic",
          "param": 0.001
        },
        {
          "operation": "POST /checkout",
          "type": "probabilistic",
          "param": 0.5
        }
      ]
    }
  ],
  "default_strategy": {
    "type": "probabilistic",
    "param": 0.05
  }
}

3. Настройте клиент для использования remote sampling (OpenTelemetry SDK):

// Node.js — пока нет встроенной поддержки Jaeger remote sampling в OTel
// Используйте Jaeger SDK или ждите OTel Remote Sampler
 
// Временное решение: периодически обновляйте sampler
const axios = require("axios");
 
async function updateSampler() {
  const response = await axios.get(
    "http://jaeger-collector:5778/sampling?service=api-gateway"
  );
  const strategy = response.data;
 
  // Parse strategy и обновите sampler
  // (требуется custom реализация)
}
 
setInterval(updateSampler, 60000); // Обновляем каждую минуту

Sampling Decision Propagation

Проблема: Если API Gateway решил sampled=false, но User Service независимо решил sampled=true — получаем incomplete trace.

Решение: Sampling decision передаётся в traceparent header.

traceparent format:

traceparent: 00-<trace-id>-<parent-id>-<flags>
                                          ^^
                                          |
                                      sampled bit (01 = sampled)

Пример:

API Gateway:
  trace_id: a1b2c3d4
  sampled: false
  traceparent: 00-a1b2c3d4-0001-00 ← flags=00 (not sampled)
 
↓ HTTP request
 
User Service:
  Извлекает traceparent
  Видит flags=00
  ParentBasedSampler → sampled=false
  Spans создаются, но не отправляются

Production Best Practices

1. Начните с консервативного sampling (1-5%)

const sdk = new NodeSDK({
  sampler: new TraceIdRatioBasedSampler(0.01), // 1%
});

Постепенно увеличивайте на основе costs и observability needs.


2. Используйте разные rates для разных сервисов

High-traffic сервисы:

// api-gateway: 0.1% (100k req/sec)
sampler: new TraceIdRatioBasedSampler(0.001);

Low-traffic сервисы:

// background-worker: 100% (10 req/sec)
sampler: new AlwaysOnSampler();

3. Всегда используйте ParentBased wrapper

const sdk = new NodeSDK({
  sampler: new ParentBasedSampler({
    root: new TraceIdRatioBasedSampler(0.01),
  }),
});

Гарантия: Если trace начат — он будет полным.


4. Мониторьте sampling effectiveness

Metrics:

  • traces_sampled_count — количество sampled traces
  • traces_dropped_count — количество dropped traces
  • sampling_rate — фактический % sampling

Пример (Prometheus):

const sampledCounter = new promClient.Counter({
  name: "traces_sampled_total",
  help: "Total sampled traces",
});
 
const droppedCounter = new promClient.Counter({
  name: "traces_dropped_total",
  help: "Total dropped traces",
});

5. Debug mode override

Сценарий: Нужно force-sample конкретный запрос для debugging.

Решение: Custom header X-Debug-Trace: true.

app.use((req, res, next) => {
  if (req.headers["x-debug-trace"] === "true") {
    // Force sample этот trace
    const span = trace.getActiveSpan();
    span.setAttributes({ "debug.forced": true });
 
    // Переопределите sampling decision
    // (требуется custom sampler или OTel Collector)
  }
  next();
});

Cost vs Observability Trade-offs

Сценарий: 1 миллион req/sec

100% sampling:

  • Traces: 1M/sec × 86400s = 86.4B traces/day
  • Storage: ~8.6 PB/day
  • Cost: $200k+/month
  • Observability: ✅ Идеально

1% sampling:

  • Traces: 864M traces/day
  • Storage: ~86 TB/day
  • Cost: $2k/month
  • Observability: ⚠️ Можем пропустить редкие ошибки

0.01% sampling + 100% errors:

  • Normal traces: 8.6M/day
  • Error traces: зависит от error rate (например, 0.1% = 86k/day)
  • Storage: ~1 TB/day
  • Cost: $30/month
  • Observability: ✅ Ошибки видны, latency patterns видны

Рекомендация: Начните с 0.1-1% + tail-based sampling для ошибок (урок 09).


Практическое задание

Задача: Настройте sampling с разными rates для разных endpoints.

Требования:

  1. /health — 0.1% sampling
  2. /api/checkout — 10% sampling
  3. Все остальные — 1% sampling
  4. Используйте ParentBased wrapper

Подсказка: Создайте custom sampler, который проверяет http.route attribute.

Ожидаемый результат:

1000 requests to /health → ~1 trace в Jaeger
1000 requests to /api/checkout → ~100 traces
1000 requests to /api/users → ~10 traces

Следующий урок

В следующем уроке мы научимся корреляции traces с logs и metrics — как связать trace_id с логами, чтобы от ошибки в Jaeger перейти к стеку вызовов в логах.

Теперь вы можете оптимизировать costs, не теряя observability!