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

Обзор ​

Трейс — одна операция от начала до конца (загрузка страницы, оформление заказа, переход между маршрутами SPA); спан — один замеренный шаг внутри неё (вызов fetch, отрисовка, тяжёлое вычисление). Спаны вкладываются друг в друга и разделяют один traceId, так что панель может восстановить всю операцию водопадом — см. Трейсы в панели. Короткая версия — на странице Производительность.

В браузере большую часть делает browserTracingIntegration: она открывает спан pageload для первой загрузки и спан navigation для каждого перехода в SPA, и всё остальное оказывается под ним. Остальная часть страницы — о замерах вашего собственного кода.

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

ts
Buglapse.span('render-cart', () => {
    renderCart(items)
})

await Buglapse.asyncSpan('checkout', async (span) => {
    await chargeCard(order)
    span.metric('order.total', order.total)
})

span() — для синхронной работы, asyncSpan() — для всего, что возвращает Promise; оба завершают (и отправляют) спан автоматически, когда колбэк возвращает значение, выполняется или бросает исключение; исключение внутри всё равно сначала отправит спан, а потом пойдёт дальше.

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

Вызовите span()/asyncSpan() внутри другого — и он автоматически возьмёт объемлющий спан родителем; явный id родителя передавать не нужно:

ts
await Buglapse.asyncSpan('checkout', async () => {
    await Buglapse.asyncSpan('validate-cart', async () => {
        await validateCart(items)
    })

    await Buglapse.asyncSpan('charge-card', async () => {
        await chargeCard(order)
    })
})

Получится один трейс с корневым спаном checkout и дочерними validate-cart/charge-card. «Активный» здесь значит: текущий спан/трейс хранится в одном изменяемом кадре на клиенте, а не в значении, которое вы передаёте через свои функции, — вложенность работает, пока вложенный вызов происходит синхронно внутри внешнего колбэка или после await внутри него (в браузере нет AsyncLocalStorage, поэтому, как только fn уступает управление на await, всё, что параллельно выполняется на том же клиенте, — например, второй, несвязанный asyncSpan(), запущенный параллельно, — может перебить то, какой спан выглядит «активным» для кода, читающего его в промежутке). Для параллельных, перекрывающихся операций используйте startInactiveSpan().

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

span()/asyncSpan() делают свой спан «активным», поэтому держать их открытыми через await небезопасно, если на том же клиенте параллельно замеряется что-то ещё (например, два параллельных fetch). startInactiveSpan() этого избегает: он один раз, синхронно читает активный спан, чтобы взять его родителем, и больше не трогает общее состояние — вызывайте .end(), когда операция действительно закончилась, откуда угодно:

ts
async function fetchWithTiming(url: string) {
    const span = Buglapse.startInactiveSpan(`fetch ${url}`)

    try {
        return await fetch(url)
    } finally {
        span.end()
    }
}

await Promise.all([
    fetchWithTiming('/api/cart'),
    fetchWithTiming('/api/recommendations'),
])

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

В браузере трейс ограничен загрузкой страницы или переходом ​

В отличие от модели «один трейс на операцию» по умолчанию, init() из @buglapse/browser связывает resolveTraceId с id, который создаётся один раз на загрузку страницы и меняется при каждом переходе в SPA (pushState/popstate) — те же границы трейсов, что у Sentry в браузере. Корневой span/asyncSpan создаёт новый трейс, только если на клиенте нет активного, так что все спаны верхнего уровня за один просмотр страницы попадают в один трейс; переход на новый маршрут (или полная перезагрузка) начинает новый. Это намеренно отделено от сессии (getSessionId(), гораздо более долгоживущий id в localStorage, который меняется после 30 минут бездействия) — она группирует визит для здоровья релизов, а не спаны в трейс. Если нужна своя группировка, передайте resolveTraceId в init().

С browserTracingIntegration у каждого такого трейса страницы есть корневой спан (pageload или navigation), и корневые span()/asyncSpan()/ startInactiveSpan(), начатые, пока он открыт, становятся его дочерними, а не отдельными корнями. getActiveRootSpan() возвращает этот корневой спан, пока он открыт, а роутер может сам начать спан перехода — @buglapse/vue делает это для vue-router.

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

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

ts
await Buglapse.asyncSpan('checkout', async () => {
    Buglapse.log.info('starting checkout') // уходит уже с trace.id и span.id

    try {
        await chargeCard(order)
    } catch (error) {
        Buglapse.captureException(error) // это исключение тоже связано с трейсом
    }
})

Так панель связывает лог или исключение с трейсом/спаном, во время которого оно произошло, без передачи id вручную.

Когда активного спана нет — исключение поймано после окончания спана запроса, глобальный обработчик ошибок, лог со страницы в простое, — событие получает открытый спан pageload/navigation, если он есть. Иначе оно всё равно получает trace.id текущего трейса страницы (просто без span.id, потому что указывать не на что). Так ошибка связывается со страницей, на которой произошла, даже если брошена не внутри спана. Этот id — страницы, а не самой ошибки: это трейс, куда идут спаны страницы, и он меняется при переходе.

Метрики спана ​

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

Колбэк span()/asyncSpan() получает объект с методом metric(name, value); вызывайте его сколько угодно раз до возврата из колбэка:

ts
await Buglapse.asyncSpan('checkout', async (span) => {
    const order = await submitOrder(cart)

    span.metric('cart.items', cart.items.length)
    span.metric('order.total', order.total)
})

Результат startInactiveSpan() даёт тот же метод — записывайте метрики в любой момент до end():

ts
async function loadImage(url: string) {
    const span = Buglapse.startInactiveSpan(`image ${url}`, { op: 'resource.img' })

    try {
        const blob = await fetch(url).then((response) => response.blob())
        span.metric('image.bytes', blob.size)
        return blob
    } finally {
        span.end()
    }
}

Несколько правил:

  • Значения — числа. Подходят и целые, и дробные; панель показывает целые как есть, а остальное округляет до двух знаков. Для строк, булевых и объектов используйте span.setData(key, value) — это прикрепляется к спану как контекст, а не строится графиком.
  • Последнее значение побеждает. Повторный metric() с тем же именем на том же спане перезаписывает предыдущее значение — суммирования или усреднения нет. Копите в локальной переменной, если нужен итог, и записывайте один раз в конце.
  • Только пока спан открыт. Записанное после возврата из колбэка (или после end()) не отправится — спан уже покинул клиент.
  • Область — один спан. Вложенный спан не наследует метрики родителя. Используйте одно имя на разных спанах, чтобы сравнивать их по трейсу (см. Трейсы в панели).
  • Имена произвольные. Соглашение область.измерение через точку (order.total, image.bytes) делает их читаемыми; указывайте единицу в имени, если она не очевидна (upload.ms, payload.kb).

Каждый спан и так записывает свою длительность, так что проще всего замерить шаг, обернув его в свой span(). Метрика нужна, когда измеряется не весь спан — например, время до первого байта внутри долгой загрузки:

ts
await Buglapse.asyncSpan('download-report', async (span) => {
    const started = performance.now()
    const response = await fetch('/api/report')
    span.metric('report.ttfb_ms', performance.now() - started)

    const body = await response.arrayBuffer()
    span.metric('report.bytes', body.byteLength)
})

До init() (например, пока грузится скрипт с CDN) span()/asyncSpan()/startInactiveSpan() всё равно выполняют ваш код, но с пустым объектом — вызовы metric() молча игнорируются, так что защищаться не от чего.

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

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

С зарегистрированной httpTracingIntegration запрос fetch / XHR к вашему бэкенду несёт с собой трейс страницы в двух заголовках W3C Trace Context:

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

Бэкенд на @buglapse/node с HttpServerIntegration продолжает трейс: его спан http.server становится дочерним спаном запроса, и страница вместе со всем, что для неё сделал бэкенд, открывается одним водопадом — каждый спан помечен проектом, который его записал, если страница и бэкенд отправляют в разные проекты организации.

ts
import { init } from '@buglapse/browser'
import { httpTracingIntegration } from '@buglapse/browser/integrations'

init({
    dsn: '...',
    integrations: [httpTracingIntegration()],
    // Запросы на тот же домен несут трейс всегда; другие домены — только перечисленные.
    tracePropagationTargets: ['https://api.example.com'],
})
  • Запросы на тот же домен получают заголовки без настройки.

  • Другие домены получают их, только если совпадают с tracePropagationTargets — строка совпадает как подстрока абсолютного адреса, RegExp — по test(). Такой сервер должен разрешать заголовки, иначе CORS-preflight браузера провалит запрос:

    Access-Control-Allow-Headers: traceparent, tracestate
  • Запрос, у которого уже есть заголовок traceparent, не трогается.

  • Сэмплирование решает страница: трейс, который её tracesSampleRate не сохранил, отправляется с флагом «не записывается», и бэкенд для него тоже не записывает спанов. Ошибки на обеих сторонах всё равно несут id трейса.

Без httpTracingIntegration нет спана http.client, под который можно повесить работу бэкенда, так что ничего не передаётся.

Для бэкенда на Sentry SDK, который отправляет в Buglapse, добавьте propagateSentryTrace: true: те же запросы получат ещё и sentry-trace и baggage (их тоже разрешите в CORS).

Чтобы передать трейс через что-то, кроме fetch / XHR (сообщение WebSocket), используйте traceHeaders() — см. Передача вручную.

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

tracesSampleRate сохраняет случайную долю трейсов, как одноимённая опция Sentry:

ts
init({ dsn: '{dsn}', tracesSampleRate: 0.2 })

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

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

Квота ​

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

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

Список Трейсов показывает по строке на корневой спан; открыв строку, вы увидите всё дерево спанов водопадом (перетаскивание — масштаб, прокрутка — сдвиг) с длительностью, именем и метриками каждого спана.

Метрики спанов тоже здесь, в боковой панели:

  • Без выбранного спана она строит графики всех имён метрик, использованных в трейсе, по маленькому линейному графику на имя. Ось X проходит по спанам трейса по порядку, так что одно имя на нескольких спанах (например, image.bytes на каждой загрузке картинки) показывает, как значение меняется по трейсу; число рядом с именем — последнее записанное значение.
  • Выбор спана в водопаде переключает панель на метрики этого спана в виде пар «имя — значение».

ИИ-клиенты получают те же данные через MCP-сервер Buglapse: инструмент get-trace возвращает каждый спан с его временем и метриками.