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

Spans запросов API и PostgreSQL

Структурированные события показывают переходы, но не объясняют длительность вложенных действий. Добавим spans OpenTelemetry к API и PostgreSQL. Span — ограниченный во времени участок работы с именем, началом, окончанием и атрибутами. Trace объединяет связанные spans. Для «Пульса» внешний span обозначит HTTP-запрос, а дочерний покажет запись задания в базу.

Используем ручную инструментацию: код явно задаёт границы и безопасные поля. Автоматическая инструментация здесь не включена, поэтому нет скрытых дополнительных SQL-span или неожиданной записи URL. Это выбор небольшой учебной модели, а не отказ от автоматической инструментации вообще. В большом сервисе её можно добавить, предварительно согласовав дублирование, фильтрацию и правила семантических атрибутов.

API и SDK

OpenTelemetry API предоставляет функции получения tracer и активного контекста. SDK создаёт настоящий provider, выбирает sampler и экспортирует завершённые spans. Если использовать только API без настроенного provider, вызовы могут работать как no-op и не отправлять данные. Поэтому telemetry.mjs инициализируется до приложения; команда лаборатории использует --import, а исходник сервера также явно импортирует модуль.

Версии в примере относятся к одной выбранной семье: API 1.9.0, стабильные SDK-пакеты 2.0.0 и экспериментальный sdk-node 0.200.0. Их номера намеренно различаются: это устройство выпуска проекта, а не признак несовместимости сам по себе. Существование пакетов и определения API прочитаны из первичных источников. Транзитивный lockfile и выполнение стенда ещё отсутствуют, поэтому установка требует отдельного подтверждения среды.

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

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();
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,
  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}));
}
export function observeRequest(){}
export function queueAge(){}
export async function shutdown(){await sdk.shutdown();}

resourceFromAttributes() задаёт сведения об источнике: service.name, service.version и окружение. В API сервис называется pulse-api, в worker позднее pulse-worker. Ресурс относится к процессу, а атрибут span — к отдельной операции. Нельзя записывать request ID в ресурс, иначе он станет характеристикой источника для множества сигналов и исказит группировку. Детекторы хоста отключены, чтобы не собирать лишние идентификаторы автоматически.

SERVER и дочерний SQL-span

Внешний inSpan() получает kind SERVER для HTTP. Имя использует метод и шаблон маршрута, а не полный URL с UUID или query. Внутри него query() создаёт span postgres.INSERT с kind CLIENT. Активный контекст связывает вложенный span с родителем; новая операция не должна вручную копировать trace ID как обычный attribute. Связь хранится в span context.

Полный файл db.mjs состояния 03:

import pg from 'pg';
import { inSpan } from './telemetry.mjs';
export const pool=new pg.Pool({connectionString:process.env.DATABASE_URL,max:5,
  connectionTimeoutMillis:3000,idleTimeoutMillis:30000,statement_timeout:3000});
export async function query(operation,text,values=[]) {
  return inSpan('postgres.'+operation,{kind:2,attributes:{
    'db.system.name':'postgresql','db.operation.name':operation}},()=>pool.query(text,values));
}

В SQL-span записаны db.system.name и db.operation.name. SQL-текст и значения параметров остаются внутри приложения. Это достаточно, чтобы увидеть, какой вид обращения замедлил HTTP, и перейти к отдельному безопасному разбору базы. Наименование postgres.INSERT является нашим устойчивым учебным именем; не следует считать его обязательным именем всех стандартных инструментаций PostgreSQL.

startActiveSpan() делает созданный span текущим на время callback. Именно активность позволяет логам прочитать trace_id и span_id. Само создание span без установки контекста не обязательно изменяет текущую операцию. Такой нюанс объясняет ситуацию, когда trace существует, а события в середине функции оказываются без связи либо указывают на родительский span.

Окончание и ошибка

Span завершается в finally. Иначе исключение или ранний return оставили бы его незавершённым и задержали бы экспорт. Окончание span не означает успешный результат: статус ERROR устанавливается отдельно при техническом отказе. В событие failure записываем ограниченный код, например db_timeout, вместо полного объекта исключения. Stack и message могут содержать значения, которые нельзя отправлять в телеметрию.

Не все неуспешные HTTP-ответы являются одинаковыми ошибками trace. Ожидаемый 401 — нормальный отказ политики входа, а недоступная база при допустимой операции — отказ сервиса. Наш wrapper маркирует исключения, которые выходят из прикладного блока. Если понадобится отдельная политика для конкретных статусов, её задают явно; автоматическое правило «любой 4xx красный» способно исказить картину надёжности.

Для измерения длительности span SDK использует свою модель времени. Метки wall-clock позволяют сопоставлять события между источниками, но часы машин могут расходиться. Длительность внутри процесса и общий порядок между процессами нужно читать осторожно. Позднее trace покажет ожидание между API и worker, однако наличие длинного разрыва ещё не доказывает, что processor выполнял вычисление всё это время.

Первый exporter

В третьем состоянии ConsoleSpanExporter печатает завершённые spans в stdout. Это способ увидеть созданные данные без внешнего сервиса, а не постоянный backend. Нельзя искать историю вчерашних запросов через OpenTelemetry API: API не хранит traces. Collector и Tempo появятся позднее. Такая последовательность помогает отличить ошибку инструментирования от ошибки доставки и хранения.

Ожидаемый trace успешного POST содержит SERVER-span и дочерний SQL-span INSERT. Событие report.accepted происходит после SQL и содержит trace ID внешнего контекста. В ответе можно вернуть диагностический X-Trace-Id; это идентификатор поиска, а не токен доступа. Если его передать в поддержку, оператор всё равно должен иметь свои права на backend и видеть только допустимые данные.

Пример не запускался, поэтому здесь нет снятого дерева, скриншота Console exporter или измеренных длительностей. При собственной проверке сравнивайте структурные признаки: одно дерево, верный parent, окончание всех spans, отсутствие query и SQL-параметров. Число миллисекунд будет зависеть от машины и PostgreSQL. Ценность модели — объяснение связи, а не подстановка заранее обещанного времени.

Shutdown SDK отправляет оставшийся буфер и закрывает exporter после завершения текущих запросов. Внешняя среда должна дать процессу достаточно времени и иметь верхний предел ожидания. При принудительном завершении часть телеметрии может потеряться, хотя задание уже записано. Поэтому trace не является единственным источником факта принятия: сама запись PostgreSQL сохраняет прикладную ответственность независимо от вывода наблюдения.

Теперь API и база связаны интервалами и событиями. Следующий шаг перенесёт контекст через очередь к отдельному worker. Эта передача не произойдёт автоматически из-за одинаковых имён переменных: потребуется сериализация ограниченного W3C carrier и извлечение его в новом процессе перед созданием consumer-span.

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

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

Документация: JavaScript instrumentation, SDK 2.0.