Перейти к содержанию

Метрики нагрузки и задержки

Trace объясняет отдельный запрос, но для оценки нагрузки и задолженности нужны метрики. Добавим в «Пульс» счётчик HTTP-запросов, гистограмму длительности и gauge возраста старейшей ожидающей задачи. Эти три инструмента отвечают на разные вопросы: сколько работы пришло, насколько быстро отвечает API и насколько отстаёт фоновая часть. Они не требуют сохранения trace каждого запроса.

Метрика состоит из имени, единицы, измеренного значения и ограниченных атрибутов. SDK собирает измерения и exporter передаёт их по выбранному интервалу. В пятом состоянии используется вывод в stdout; постоянный Prometheus появится позднее. Мы ещё не получили графики или измеренный p95, потому что учебный процесс не запускался. Важно сначала правильно определить, что именно измеряет код.

Счётчик запросов

Counter pulse_requests увеличивается один раз при завершении HTTP-обработки. Он включает успешные запросы и отказы, а label status_class позволяет разделить 2xx, 4xx и 5xx. Такой счётчик показывает число событий, а не число одновременных соединений. Для скорости запросов backend позднее вычислит изменение счётчика за окно времени; само значение total постоянно растёт до перезапуска процесса.

Полный файл telemetry.mjs состояния 05:

import { context,trace,propagation,SpanStatusCode } from '@opentelemetry/api';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { W3CTraceContextPropagator } from '@opentelemetry/core';
import { resourceFromAttributes } from '@opentelemetry/resources';
import { ParentBasedSampler,TraceIdRatioBasedSampler } from '@opentelemetry/sdk-trace-base';
import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
const traceExporter=new ConsoleSpanExporter();
import { metrics } from '@opentelemetry/api';
import { PeriodicExportingMetricReader,ConsoleMetricExporter,AggregationType } from '@opentelemetry/sdk-metrics';
const service=process.env.OTEL_SERVICE_NAME || 'pulse-api';
const resource=resourceFromAttributes({'service.name':service,'service.version':'1.0.0',
  'deployment.environment.name':'lab'});
const ratio=Number(process.env.TRACE_RATIO || 1);
if(!Number.isFinite(ratio) || ratio<0 || ratio>1) throw new Error('Invalid TRACE_RATIO');
const sdk=new NodeSDK({resource,autoDetectResources:false,traceExporter,metricReader:new PeriodicExportingMetricReader({exporter:new ConsoleMetricExporter(),exportIntervalMillis:5000}),
  views:[{instrumentName:'pulse_request_duration',aggregation:{type:AggregationType.EXPLICIT_BUCKET_HISTOGRAM,options:{boundaries:[0.005,0.01,0.025,0.05,0.1,0.25,0.5,1,2.5,5]}}}],
  sampler:new ParentBasedSampler({root:new TraceIdRatioBasedSampler(ratio)}),
  textMapPropagator:new W3CTraceContextPropagator()});
sdk.start();
const tracer=trace.getTracer('pulse','1.0.0');
export async function inSpan(name,options,work,parent=context.active()) {
  return tracer.startActiveSpan(name,options,parent,async span=>{
    try { return await work(); }
    catch(error) { span.setStatus({code:SpanStatusCode.ERROR});
      span.addEvent('failure',{'error_code':error.code==='57014'?'db_timeout':'operation_failed'});
      throw error;
    } finally { span.end(); }
  });
}
export function currentCarrier(){const carrier={};propagation.inject(context.active(),carrier);return carrier;}
export function extracted(carrier){return propagation.extract(context.active(),carrier || {});}
const allowed=new Set(['route','status_class','error_code','outcome','attempt']);
export function event(name,attributes={}) {
  const safe=Object.fromEntries(Object.entries(attributes).filter(([key])=>allowed.has(key)));
  const spanContext=trace.getSpan(context.active())?.spanContext();
  console.log(JSON.stringify({time:new Date().toISOString(),event:name,...safe,
    trace_id:spanContext?.traceId,span_id:spanContext?.spanId}));
}
const meter=metrics.getMeter('pulse','1.0.0');
const requests=meter.createCounter('pulse_requests');
const duration=meter.createHistogram('pulse_request_duration',{unit:'s'});
meter.createObservableGauge('pulse_process_up').addCallback(result=>result.observe(1,{service}));
let oldest=0;
meter.createObservableGauge('pulse_queue_oldest',{unit:'s'}).addCallback(result=>result.observe(oldest,{service}));
export function observeRequest(route,status,seconds) {
  const labels={service,route,status_class:Math.floor(status/100)+'xx'};
  requests.add(1,labels);duration.record(seconds,labels);
}
export function queueAge(seconds){oldest=seconds;}
export async function shutdown(){await sdk.shutdown();}

Измерение вызывается в finally, чтобы учитывать и ошибки. Однако нужна дисциплина одной записи на запрос: дополнительный вызов после каждого return удвоил бы часть событий. Имя маршрута — шаблон /v1/reports, /health либо unmatched, а не путь со случайным UUID. service отделяет API от worker. Значения этих полей ограничены моделью приложения и не растут по одному на каждого пользователя.

Класс 4xx объединяет разные отказы и подходит для общей панели. Если продукту нужно разделять отдельные ожидаемые причины, вводят небольшой фиксированный набор outcome, а не произвольное сообщение ошибки. Метрики не предназначены для поиска конкретного отказа по trace ID. Такая связь остаётся в логах и traces; label с индивидуальным идентификатором разрушил бы возможность дешёвого агрегирования.

Гистограмма длительности

Histogram pulse_request_duration записывает секунды от начала HTTP-обработки до завершения прикладного callback. Для интервала используем performance.now(), то есть монотонный источник внутри процесса. Изменение календарных часов не должно дать отрицательную задержку. Эти секунды не включают последующую работу очереди, поэтому гистограмма не измеряет время готовности отчёта для пользователя.

Гистограмма распределяет наблюдения по bucket и хранит count и sum. По bucket можно оценить квантиль, например p95, после агрегирования по нескольким процессам. Это другая величина, чем среднее: небольшое число медленных запросов может почти не менять среднее, но заметно влиять на хвост распределения. При этом квантиль является оценкой по выбранным границам, а не точным временем отдельного запроса.

Для pulse_request_duration SDK получает view с явными границами от пяти миллисекунд до пяти секунд, записанными в секундах. Это даёт полезную детализацию небольших HTTP-задержек. Для промышленного SLO границы выбирают вокруг целевого срока ответа. Если все задержки попали в один широкий bucket, p95 будет грубым. Изменение bucket после выпуска влияет на сравнение исторических графиков. Поэтому единицы, границы и имена считаются частью контракта наблюдения и изменяются осознанно.

Gauge возраста очереди

Worker запрашивает минимальное created_at среди queued и вычисляет возраст в секундах средствами PostgreSQL. Gauge показывает последнее найденное значение. Пустая очередь даёт ноль; растущий возраст означает, что хотя бы одна ожидающая задача долго не начата. Это сильнее простого количества задач: десять свежих заданий могут быть нормальными, а одна очень старая задача может указывать на starvation или остановленный consumer.

В текущем коде gauge обновляется опросом worker. Если worker остановился, значение больше не обновляется, поэтому старый ноль нельзя читать как подтверждение пустой очереди. Позднее правило отсутствия данных поможет заметить такой разрыв. Метрика наблюдается через работающий источник; сама база остаётся более непосредственным местом проверки состояния заданий, когда телеметрия вызывает сомнение.

Возраст queued не охватывает все зависшие processing. Для этого нужен отдельный показатель истёкших lease и времени активной обработки. Мы не называем текущий gauge полной длиной пользовательской задержки. В примере он специально отвечает на один вопрос: как долго ждёт старейшая ещё не начатая работа. Разные состояния очереди должны иметь разные измерения, иначе один показатель смешает причины и потеряет диагностический смысл.

Период экспорта и чтение графика

PeriodicExportingMetricReader отправляет данные каждые пять секунд. Это интервал экспорта, а не шаг опроса PostgreSQL и не обязательный интервал панели Grafana. Далее Prometheus будет опрашивать Collector тоже по своему расписанию. Между прикладным событием и видимым графиком существует несколько стадий, поэтому отсутствие свежей точки сразу после POST не доказывает, что counter не увеличился.

Отдельные экземпляры API имеют свои counters. Backend должен учитывать перезапуски и отличать серии источников, прежде чем суммировать rate. В нашей лаборатории по одному API и worker, а дашборд агрегирует фиксированную метку service. Для нескольких replicas нужны корректные ресурсные и instance-атрибуты и согласованный scrape. Не следует запускать два процесса с неразличимыми сериями и считать их значения одним счётчиком без анализа exporter.

Sampling traces не уменьшает число этих измерений: recorder выполняется независимо от решения сохранить span. Поэтому метрики пригодны для доли ошибок и SLO при неполной трассировке. Если строить количество запросов только из сохранённых traces, результат будет зависеть от sampler и политики отбора. Особенно опасно использовать выборку ошибок как будто она представляет полный поток равномерно.

Ожидаемое изменение пятого состояния — после запроса появляются измерения counter и histogram, а worker обновляет возраст очереди. Конкретные числа и задержки не приведены как фактический вывод. При будущей лаборатории сравните семантику: counter не уменьшает значение произвольно, duration выражена в секундах, пустая очередь даёт ноль, а маршруты остаются шаблонами. Следующая глава ограничит пространство атрибутов и стоимость этих серий.

Предыдущий урок: Передача контекста фоновой задаче · Следующий урок: Кардинальность и безопасные атрибуты

Оглавление серии · Исходники всех состояний.

Документация: OpenTelemetry: metrics, Prometheus instrumentation.