Перейти к содержимому

Обзор ​

Трейс — одна сквозная операция: входящий запрос, задача очереди, запуск cron. Спан — один замеренный шаг внутри неё: запрос к базе, исходящий HTTP-вызов, рендер. Спаны вкладываются друг в друга и делят id трейса, так что панель может показать всю операцию водопадом.

Для HTTP-сервера большую часть работы делают две интеграции:

  • HttpServerIntegration превращает каждый входящий запрос в трейс с корневым спаном http.server по имени GET /users/42 — или продолжает трейс вызывающей стороны, если запрос его несёт (см. Распределённый трейсинг);
  • HttpTracingIntegration записывает каждый исходящий запрос спаном http.client под активным спаном.

Остальная часть страницы — о замерах собственного кода.

Спаны ​

Оберните код, который нужно замерить, в span() (синхронный) или asyncSpan() (возвращает Promise):

ts
import { asyncSpan, span } from '@buglapse/node'

const report = span('build-report', () => buildReport(rows))

await asyncSpan('db.query', async () => {
    return db.query('select * from orders where id = $1', [id])
}, { op: 'db' })

Оба возвращают то, что вернул колбэк, и завершают спан, когда он вернул значение, разрешился или бросил исключение. Спан, бросивший исключение, отправляется со статусом error до того, как ошибка пойдёт дальше. op — необязательная категория спана (db, cache, queue.process, ...).

Отдельного вызова «начать трейс» нет: спан, начатый без другого активного спана, становится корнем нового трейса.

Вложенные спаны ​

Спан, начатый внутри другого, становится его дочерним — id родителя передавать не нужно:

ts
await asyncSpan('checkout', async () => {
    await asyncSpan('reserve-stock', () => reserveStock(cart))
    await asyncSpan('charge-card', () => chargeCard(order))
})

Получится один трейс: checkout с дочерними reserve-stock и charge-card. Node SDK хранит активный спан в AsyncLocalStorage, поэтому вложенность следует асинхронной цепочке вызовов — через await, таймеры и колбэки промисов, — а параллельные запросы никогда не видят спаны друг друга. Два одновременных запроса строят каждый своё дерево.

Статус и данные ​

Колбэк получает объект спана:

ts
await asyncSpan('sync-inventory', async (span) => {
    const result = await syncInventory()

    span.setData('inventory.provider', result.provider)
    span.metric('inventory.items', result.count)

    if (result.partial) span.setStatus('error')
})
  • setStatus('ok' | 'error' | 'cancelled') — переопределяет статус; по умолчанию спан ok или error, если колбэк бросил исключение.
  • setData(key, value) — прикрепляет к спану любое значение как контекст.
  • metric(name, value) — прикрепляет число, которое рисуется на графике на странице трейса. См. Метрики.

Листовые спаны для параллельной работы ​

startInactiveSpan() создаёт спан, который никогда не становится активным, — вы завершаете его сами, когда операция закончится. Используйте его для листовых операций, которым не нужны свои дочерние спаны, особенно когда запускаете несколько сразу:

ts
import { startInactiveSpan } from '@buglapse/node'

async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
    const span = startInactiveSpan(`cache ${key}`, { op: 'cache' })

    try {
        const hit = await redis.get(key)
        span.setData('cache.hit', hit !== null)
        return hit !== null ? JSON.parse(hit) : await load()
    } finally {
        span.end()
    }
}

await Promise.all([cached('user:42', loadUser), cached('plan:pro', loadPlan)])

Родителем он берёт активный спан, так что внутри запроса попадает в трейс запроса. Спаны, начатые внутри него через span()/asyncSpan(), не считают его родителем. end() можно вызывать больше одного раза.

Так HttpTracingIntegration записывает исходящие запросы.

Трейсы для задач и скриптов ​

Всё, что не является входящим HTTP-запросом, становится трейсом, как только вы оборачиваете его в корневой спан:

ts
import { asyncSpan } from '@buglapse/node'

queue.process('send-invoice', async (job) => {
    await asyncSpan(`job ${job.name}`, () => sendInvoice(job.data), { op: 'queue.process' })
})

Каждый вызов начинает свой трейс — чтобы вместо этого продолжить трейс того, кто поставил задачу, см. Передача вручную. Код вне любого спана — при запуске, в таймере верхнего уровня — в трейс не входит, и события оттуда не несут trace.id.

Связь других событий с активным спаном ​

Пока спан активен, каждое событие, пойманное в его асинхронном контексте, — исключение, лог, вложенный спан — получает trace.id и span.id (самого внутреннего активного спана):

ts
import { asyncSpan, captureException, log } from '@buglapse/node'

await asyncSpan('checkout', async () => {
    log.info('Starting checkout') // связан с трейсом

    try {
        await chargeCard(order)
    } catch (error) {
        captureException(error) // тоже связан с трейсом
    }
})

Благодаря этому панель связывает исключение или лог с трейсом, в котором оно произошло. С HttpServerIntegration всё, что поймано при обработке запроса, связано с трейсом этого запроса.

Распределённый трейсинг ​

Запрос, проходящий через несколько сервисов — страницу, API, который она вызывает, сервис за ним, — становится одним трейсом, если каждый сервис передаёт трейс следующему. SDK Buglapse делают это заголовками W3C Trace Context:

  • traceparent: 00-{trace id}-{span id}-{01|00} — трейс, вызывающий спан и записывается ли трейс;
  • tracestate: buglapse=sr:{rate} — частота сэмплирования, с которой трейс был сохранён, чтобы спаны всех сервисов имели одинаковый вес.

С двумя HTTP-интеграциями больше ничего не нужно:

  • HttpTracingIntegration добавляет заголовки к каждому исходящему запросу, указывая его спан http.client;
  • HttpServerIntegration читает их из каждого входящего запроса, и её спан http.server становится дочерним для спана вызывающей стороны.

Каждый сервис отправляет данные в свой проект. Проекты одной организации склеиваются при открытии трейса: водопад показывает каждый спан с проектом, который его записал, из какого бы проекта вы его ни открыли. Проекты другой организации не включаются никогда.

Сэмплирование между сервисами ​

Решает сервис, начавший трейс: продолженный трейс берёт решение вызывающей стороны, а не бросает tracesSampleRate заново, так что трейс записывают все сервисы или ни один — кроме сервиса с tracesSampleRate: 0, который не записывает никогда. Ошибки и логи незаписанного трейса всё равно несут его trace.id.

Частота сэмплирования трейсов проекта применяется сервером по id трейса, так что два проекта с разными частотами сохраняют вложенные множества: трейс, сохранённый более строгим проектом, сохраняет и другой.

Передача вручную ​

Для всего, что не HTTP-запрос, — сообщения очереди, кадра WebSocket, cron, передающего работу воркеру, — передавайте заголовки сами:

ts
import { asyncSpan, continueTrace, traceHeaders } from '@buglapse/node'

// Отправитель: внутри спана, к которому относится сообщение
await queue.add('send-invoice', { orderId, trace: traceHeaders() })

// Получатель
queue.process('send-invoice', (job) =>
    continueTrace(job.data.trace, () =>
        asyncSpan('job send-invoice', () => sendInvoice(job.data), { op: 'queue.process' })))

traceHeaders() возвращает { traceparent, tracestate } активного спана (или переданного спана — контроллера из startInactiveSpan()). continueTrace(headers, fn) выполняет fn внутри этого трейса: первый спан, начатый в нём, становится дочерним для спана отправителя. Принимает { traceparent, tracestate, sentryTrace, baggage }; если в нём нет ничего пригодного, fn просто выполняется и начинает собственный трейс.

Сервисы на Sentry SDK ​

Сервис на Sentry SDK, который отправляет в Buglapse, участвует в тех же трейсах:

  • Входящие — continueTrace() (а значит, и HttpServerIntegration) понимает и заголовок Sentry sentry-trace, с частотой сэмплирования из baggage. Фронтенд на Sentry SDK продолжается в бэкенд на @buglapse/node без настройки.
  • Исходящие — init({ propagateSentryTrace: true }) добавляет sentry-trace и baggage рядом с traceparent / tracestate, для сервиса на Sentry SDK за вашим.

Сэмплирование ​

tracesSampleRate сохраняет случайную долю трейсов:

ts
init({ dsn: process.env.BUGLAPSE_DSN, tracesSampleRate: 0.1 })

Решение принимается один раз на трейс, так что трейс приходит со всеми спанами или не приходит вовсе. Ошибки и логи из несэмплированного трейса всё равно отправляются и несут его trace.id.

Частота сэмплирования трейсов проекта (Настройки → Фильтры) применяется сервером поверх этого, для любого SDK: она тоже решает по traceId, так что трейсы остаются целыми, а изменение действует со следующей пачки без передеплоя. Оставьте tracesSampleRate равным 1 и пользуйтесь настройкой проекта — снижайте опцию SDK, только чтобы сэкономить трафик на своей стороне. Отсеянные спаны не тарифицируются; они показываются как Sampled в Статистике и использовании, а число трейсов на обзоре экстраполируется по сохранённым трейсам.

Квота ​

Трейсинг тарифицируется по спанам: каждый отправленный спан засчитывается как один в месячный лимит спанов. Сам трейс ничего не стоит — это не отдельное событие, а просто traceId, общий для его спанов. Когда лимит спанов достигнут, новые спаны отклоняются, и трейсинг останавливается до следующего месяца. Метрики спанов едут на своём спане и ничего не стоят — см. Метрики.

Трейсы в панели ​

Раздел Трейсы показывает по строке на корневой спан. Открытый трейс показывает дерево спанов водопадом (перетаскивание — масштаб, прокрутка — сдвиг) с длительностью, именем и метриками каждого спана. ИИ-клиенты получают то же через MCP-сервер Buglapse — инструмент get-trace.