---
title: "Tracing: OpenTracing"
description: "Трейсинг в go-kit: opentracing-go, спаны и trace id, инъекция через kithttp.ServerBefore, TraceServer и TraceClient, Jaeger, uber-trace-id и чтение трейса в UI."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/08-tracing/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Tracing: OpenTracing

*Курс «go-kit: микросервисный тулкит», урок 8 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/08-tracing/*

Логи из урока 06 рассказывают истории одного сервиса, метрики из урока 07 — темпы и хвосты, но ни то, ни другое не видит путь запроса сквозь несколько сервисов. Четвёртый слой наблюдаемости — распределённый трейсинг. Подход go kit tracing держится на паре пакетов: opentracing-go описывает модель спанов, а tracing/opentracing из go-kit превращает её в те же обёртки, к которым ты привык, — RequestFunc HTTPToContext в ServerBefore делает Span-инъекцию в контекст, TraceServer и TraceClient создают спаны на endpoint'ах, Jaeger собирает дерево трейса по trace id. Ты настроишь трейсер в main, разберёшь SpanContext и carrier, свяжешь спаны с Failer-ошибками из урока 02 и увидишь один запрос целиком в Jaeger UI.

### Зачем трейсинг: слепая зона логов и метрик

До сих пор stringsvc жил в одном процессе, и всех наблюдательных слоёв хватало: лог из урока 06 отвечал «что случилось с этим запросом», метрики из урока 07 — «сколько запросов и как быстро». Но микросервис по определению соседствует с другими сервисами: запрос идёт через балансировщик, транспорт, endpoint, потом улетает в базу или в соседний stringsvc, и уже там что-то ломается. Логи двух сервисов коррелировать нечем: у каждого свой request_id из ServerFinalizer, своя нумерация, свои таймстемпы — склеивать их руками после каждого инцидента невозможно. Метрики дают p90 каждого сервиса по отдельности, но на вопрос «где именно запрос провёл двести миллисекунд» честный ответ — «не знаю, в первом или во втором». Эту слепую зону закрывает распределённый трейсинг: вся операция называется трейсом и имеет один trace id на всю цепочку, а внутри трейса живут спаны — именованные отрезки времени с тегами и событиями: «транспорт принял», «endpoint отработал», «исходящий вызов к соседу занял 180 мс». Подход go kit tracing не изобретает свою модель: go-kit реализует стандарт OpenTracing теми же двумя механизмами, что и логи с метриками, — RequestFunc на транспорте и endpoint.Middleware на endpoint'е. Поэтому к концу урока трейсинг встанет в stringsvc рядом с loggingMiddleware и instrumentingMiddleware, как будто всегда там стоял.

### Модель OpenTracing: Tracer, Span, SpanContext

Открой github.com/opentracing/opentracing-go — весь пакет это горстка интерфейсов, и стоит выучить четыре имени. Tracer — фабрика и почтальон: StartSpan(operationName, opts...) создаёт спан, Inject и Extract пакуют и распаковывают контекст трассировки для передачи по сети. Span — отрезок времени: StartSpan возвращает его, span.Finish() фиксирует длительность, SetTag(key, value) вешает метки вроде span.kind=server, LogFields(...) записывает события внутри отрезка. SpanContext — самое хитрое и самое важное: это не сам спан, а минимальные данные для связи спанов между процессами — trace id, id этого спана, решение о сэмплинге; именно SpanContext, а не спан, путешествует по сети. Carrier — конверт, в который SpanContext упаковывается: opentracing.HTTPHeadersCarrier оборачивает http.Header, и Inject пишет туда заголовок uber-trace-id, Extract читает его обратно; помимо HTTPHeaders есть форматы TextMap и Binary, но для веб-сервисов нужен первый. Наконец, ссылки: StartSpan с опцией ChildOf(parent) объявляет, что новый спан — ребёнок родительского, а FollowsFrom — что работа параллельна родителю, например публикация в очередь, на которую мы не ждём ответа. Весь урок ты будешь встречать только ChildOf, но знать оба термина полезно. Правило разбиения простое: одна нога сетевого вызова — один спан; у входящего запроса — server-спан, у исходящего — client-спан, внутри процесса — сколько осмысленных отрезков, столько спанов.

### Span-инъекция через ServerBefore: HTTPToContext

```go
import (
        kithttp "github.com/go-kit/kit/transport/http"
        kitot "github.com/go-kit/kit/tracing/opentracing"
        stdopentracing "github.com/opentracing/opentracing-go"
)

tracer := stdopentracing.GlobalTracer() // NoopTracer, пока не вызван SetGlobalTracer

mux.Handle("/uppercase", kithttp.NewServer(
        upper,
        decodeUppercaseRequest,
        encodeResponse,
        kithttp.ServerBefore(kitot.HTTPToContext(tracer, "HTTP /uppercase", logger)),
))
```

HTTPToContext возвращает kithttp.RequestFunc — тот же тип, что PopulateRequestContext из урока 04, поэтому она честно встаёт в ServerBefore. Внутри — tracer.Extract(opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header)): из заголовков входящего запроса достаётся SpanContext, затем tracer.StartSpan(operationName, ext.RPCServerOption(wireContext)) создаёт спан с тегом span.kind=server и кладёт его в контекст через opentracing.ContextWithSpan. Это и есть Span-инъекция: дальше любой endpoint-middleware достанет спан из ctx.

### Как работает инъекция: Extract, ErrSpanContextNotFound и имя операции

Разберём функцию по строчкам, потому что здесь три решения, которые определяют весь дальнейший трейсинг. Первое — Extract вернёт ошибку opentracing.ErrSpanContextNotFound, если заголовков нет: так будет с самым первым запросом цепочки, от клиента, который ещё не знает о трейсинге. Это не сбой: ext.RPCServerOption(wireContext) спроектирован под оба случая — есть wireContext, и спан станет ChildOf удалённого родителя; нет — и спан станет корнем нового трейса. Транспорт продолжает работать без трейсинга, ничего не падает и не деградирует. Второе — имя операции, второй аргумент HTTPToContext: «HTTP /uppercase» — распространённая конвенция «транспорт + маршрут», по которой в Jaeger UI удобно группировать входящие вызовы; ниже TraceServer создаст второй спан с именем «uppercase» — и дерево сразу читается: транспорт снаружи, endpoint внутри. Третье — почему спан кладётся именно в context.Context, а не в поле request-структуры: endpoint из урока 02 — функция от ctx и request, декодер уже съел http.Request, и единственный канал, который доживает до endpoint-middleware, — это ctx. Тот же контракт ты увидишь в gRPC-транспорте: там инъекцией занимается ContextToGRPC через метаданные gRPC, но идея идентична. Заметь и порядок ServerBefore: HTTPToContext можно ставить первой в списке — ей всё равно, что делают соседи, она читает только http.Request.

### TraceServer: спан на endpoint middleware

```go
upper = loggingMiddleware(logger)(upper)                             // урок 06: снаружи
upper = instrumentingMiddleware(requestCount, requestLatency)(upper) // урок 07: внутри
upper = kitot.TraceServer(tracer, "uppercase")(upper)                // урок 08: глубже всех

// внутри пакета tracing/opentracing это выглядит так:
// parentSpan := opentracing.SpanFromContext(ctx)
// span := tracer.StartSpan("uppercase", opentracing.ChildOf(parentSpan.Context()))
// defer span.Finish()
// ctx = opentracing.ContextWithSpan(ctx, span)
// return next(ctx, request)
```

TraceServer(tracer, operationName, opts ...EndpointOption) — обычный endpoint.Middleware: ищет родителя через opentracing.SpanFromContext, заводит дочерний спан, defer span.Finish() гарантирует закрытие при любом исходе, обновлённый ctx передаётся дальше. Тег span.kind=server добавляется автоматически — потому и Server, а рядом есть TraceClient для исходящих вызовов. Опции EndpointOption позволяют добавить теги через WithTags, динамическое имя через WithOperationNameFunc и игнорировать бизнес-ошибки через WithIgnoreBusinessError.

### Бизнес-ошибки тоже видны: Failer в спане

Самая ценная деталь TraceEndpoint — работа с ошибками, и она снова про два канала, к которым ты привык с уроков 02 и 05. Если endpoint вернул err != nil — дедлайн, недоступная база, сломанный декодер — middleware записывает в спан стандартное событие ошибки через ext.LogError: в Jaeger такой спан покрасится как ошибочный, и его можно найти фильтром по error-тэгу. Но бизнес-ошибки, как ты помнишь, едут другим путём: адаптер кладёт их в Err-поле response и возвращает err = nil. TraceEndpoint проверяет response на endpoint.Failer: если Failed() вернул не-nil, в спан пишется событие с полем gokit.business.error и текстом ошибки, затем LogError помечает спан как сбойный. Значит, ErrEmpty — честная «пустая строка на входе» — будет видна в Jaeger так же ярко, как отказ базы, хотя транспорт вернул аккуратный 400 из уроков 05. Если для твоего сервиса бизнес-ошибки — норма жизни и шуметь не хочется, включи опцию WithIgnoreBusinessError(true): событие запишется, но спан не покрасится. Отдельная ветка — lb.RetryError из пакета sd/lb: когда endpoint внутри балансировщика, каждая неудачная попытка попадает в спан отдельным полем gokit.retry.error.N — пригодится, когда в уроке 11 будем собирать прокси. Итого: TraceServer уже умеет всё, что ты писал руками в логах, — и сразу привязывает это к trace id.

### Jaeger в main: конфигурация трейсера

```go
import (
        jaegercfg "github.com/jaegertracing/jaeger-client-go/config"
)

cfg := jaegercfg.Configuration{
        ServiceName: "stringsvc",
        Sampler:  &jaegercfg.SamplerConfig{Type: "const", Param: 1},
        Reporter: &jaegercfg.ReporterConfig{LocalAgentHostPort: "localhost:6831"},
}
tracer, closer, err := cfg.NewTracer()
if err != nil {
        log.Fatal(err)
}
defer closer.Close()

stdopentracing.SetGlobalTracer(tracer) // теперь GlobalTracer() вернёт jaeger

logger.Log("msg", "HTTP", "addr", ":8080")
log.Fatal(http.ListenAndServe(":8080", mux))
```

NewTracer возвращает opentracing.Tracer и io.Closer: closer.Close() в defer аккуратно досылает хвостовой батч спанов при выключении. Sampler const с Param 1 трассирует каждый запрос — для разработки; Reporter отправляет спаны батчами по UDP на jaeger-agent (LocalAgentHostPort), Jaeger UI живёт на localhost:16686. SetGlobalTracer делает трейсер глобальным — именно его теперь вернёт GlobalTracer(), который мы записали в tracer в начале main. Короткий путь — cfg.InitGlobalTracer(serviceName), он сам зовёт SetGlobalTracer и возвращает только closer.

### Сэмплинг: const, probabilistic, remote

Параметр Sampler из конфигурации — это решение «трассировать ли вообще», и оно принимается в момент StartSpan: недосэмплированный запрос не создаёт спанов ни у себя, ни у соседей — трейс обрывается тихо, без ошибок. Типов сэмплера в Jaeger четыре, и выбор — вопрос трафика и денег. const с Param 1 или 0 — всё или ничего: единственно правильный вариант для разработки и для малонагруженных внутренних сервисов. probabilistic с Param 0.01 — случайный один процент: стандартная продовая настройка для высоконагруженных сервисов, потому что стоимость растёт линейно с трафиком, а польза от тысяч одинаковых успешных трейсов сомнительна. ratelimiting — не больше N трейсов в секунду: честный потолок нагрузки на инфраструктуру трейсинга. remote — самое взрослое: стратегию присылает агент или collector, и ты меняешь процент выборки в консоли Jaeger, не передеплоивая ни одного сервиса; так и настраивают продовые кластеры. Стоит знать и термин head-based sampling: решение принимается в голове трейса и наследуется всеми спанами — поэтому недосэмплированный запрос нельзя «доотрассировать» потом; противоположность — tail-based sampling, где решение принимают после завершения трейса и хранят только интересные хвосты с ошибками, но это уже работа collector-пайплайна, а не клиента. Начни с const 1, поставь probabilistic, когда трейсинг начнёт жрать заметную долю бюджета — и переведи на remote, когда сервисов станет больше пяти.

### Анатомия одного запроса в Jaeger

| Элемент | Что это | В stringsvc |
| --- | --- | --- |
| Trace | вся операция: дерево спанов с одним trace id | POST /uppercase сквозь балансировщик и два сервиса |
| Span | именованный отрезок с таймингами и тегами | «HTTP /uppercase» на транспорте |
| Дочерний span | ChildOf-спан, заведённый внутри endpoint-обёртки | «uppercase» от TraceServer |
| SpanContext | travel-данные для связи процессов: trace id + span id | уехал в заголовке uber-trace-id |
| Tag | пара ключ-значение на спане | span.kind=server, http.method=POST |
| Log-событие | событие внутри отрезка спана | gokit.business.error: empty string |
| Baggage | пары ключ-значение, едущие через всю цепочку | не для user_id — это дорога в заголовки |

### Исходящие вызовы: ContextToHTTP и TraceClient

Входящая половина готова, но трейс живёт, только когда уезжает вместе с запросом к соседу. Для исходящих у go-kit есть зеркальная пара. ContextToHTTP(tracer, logger) возвращает ту же kithttp.RequestFunc, но для исходящих: она достаёт спан из контекста, проставляет стандартные теги http.method и http.url, достаёт хост с портом в тег peer.hostname и вызывает tracer.Inject(span.Context(), opentracing.HTTPHeaders, opentracing.HTTPHeadersCarrier(req.Header)) — SpanContext упаковывается в заголовки исходящего http.Request, в тот самый uber-trace-id. Подключается она в клиентском транспорте: httptransport.NewClient(method, target, encodeRequest, decodeResponse, httptransport.ClientBefore(kitot.ContextToHTTP(tracer, logger))) — симметрично ServerBefore из урока 04. А TraceClient(tracer, operationName) — это TraceServer с тегом span.kind=client: оберни ею endpoint, который делает исходящий вызов, и у ноги появится свой отрезок с длительностью. Правило читается само: каждая нога сетевого вызова — client-спан у отправителя и server-спан у получателя; получатель через свой HTTPToContext делает Extract и джойнится к трейсу — Jaeger склеит два сервиса в одно дерево автоматически. Отдельная любопытная деталь: в коде TraceEndpoint есть ветка lb.RetryError — go-kit логирует каждую попытку балансировщика отдельным полем, так что даже ретраи видны в трейсе как события, а не как один серый отрезок.

### Trace id в логах: склейка трёх слоёв

```go
import (
        jaeger "github.com/jaegertracing/jaeger-client-go"
        stdopentracing "github.com/opentracing/opentracing-go"
)

func traceIDMiddleware(logger log.Logger) endpoint.Middleware {
        return func(next endpoint.Endpoint) endpoint.Endpoint {
                return func(ctx context.Context, request interface{}) (interface{}, error) {
                        if span := stdopentracing.SpanFromContext(ctx); span != nil {
                                if sc, ok := span.Context().(jaeger.SpanContext); ok {
                                        logger.Log("trace_id", sc.TraceID().String())
                                }
                        }
                        return next(ctx, request)
                }
        }
}
```

Простейший мост между слоями: middleware достаёт спан из ctx, кастует SpanContext к jaeger-типу и пишет trace id в лог тем самым kitlog из урока 06. Теперь grep по trace_id находит все строки запроса во всех сервисах, а по нему же трейс открывается в Jaeger UI одним кликом. Цена — импорт jaeger-пакета в middleware: жёсткая зависимость, которую принято изолировать на транспортном уровне; альтернатива — отдавать trace id в заголовке ответа и собирать корреляцию на клиентах.

### Читаем трейс: дерево, длительности и хвосты

Открой Jaeger UI на localhost:16686, выбери сервис stringsvc, операцию HTTP /uppercase — и получи список трейсов с гистограммой длительностей. Внутри трейса — дерево: server-спан «HTTP /uppercase» шириной во весь запрос, внутри него дочерний «uppercase» от TraceServer, рядом — client-спаны исходящих вызовов, а у соседа — его server-спан, джойнутый по uber-trace-id. Читать стоит сверху вниз: разница между началом родителя и началом ребёнка — время до endpoint'а, включая декодер и все middleware; ширина ребёнка — время сервиса; промежуток после Finish до конца родителя — время энкодера и сети. Пустой длинный хвост при коротком ребёнке — спрашивай, что происходило в обёртках: ретраи, лимитеры, дедлайны. Фильтр tags=error=true отберёт сбойные трейсы — сюда попадают и инфраструктурные ошибки, и gokit.business.error от Failer, о которых шла речь выше. Практика с логами из урока 06 замыкает петлю: trace_id в логе и в спане — одна и та же строка, поэтому от строки лога ты за один клик попадаешь в трейс, а из трейса — обратно в конкретные строки логов каждого сервиса. Метрики из урока 07 остаются слоем суммарного ответа: p90 говорит, что хвост существует, трейс — где именно он образовался. Три инструмента не дублируют, а дополняют друг друга: метрики — «есть ли проблема», логи — «что случилось», трейс — «где именно по пути».

> **Честно о судьбе OpenTracing.** Проекты opentracing/opentracing-go и jaegertracing/jaeger-client-go переведены в режим сопровождения: индустрия объединилась вокруг OpenTelemetry, и новые проекты стартуют с otel-SDK. Почему мы всё равно учим opentracing: go-kit поставляет рабочие обёртки именно под эту модель, а сама модель — Tracer, Span, SpanContext, Inject/Extract, carrier — переехала в OpenTelemetry практически без изменений. Освоив инъекцию через ServerBefore и спаны через TraceServer, ты пересядешь на otel-версию за вечер: меняются имена, а не идеи.

> **Честно о запуске кода.** Урок требует установленного Go, модулей github.com/go-kit/kit, github.com/opentracing/opentracing-go, github.com/jaegertracing/jaeger-client-go и поднятого Jaeger: docker run -p 16686:16686 -p 6831:6831/udp jaegertracing/all-in-one — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода. Локально: собери stringsvc с трейсером, дёрни POST /uppercase с заголовком uber-trace-id и без него, а потом найди оба трейса в Jaeger UI на localhost:16686.

### Пять правил трейсинга в go-kit

1. Корневой спан создаёт транспорт: HTTPToContext в ServerBefore, до декодера; имя — «транспорт + маршрут».
2. TraceServer оборачивает endpoint и заводит дочерний спан; TraceClient — исходящие вызовы; каждая нога сети — пара client/server.
3. SpanContext путешествует в заголовках через HTTPHeadersCarrier: uber-trace-id у Jaeger, traceparent в мире W3C.
4. Ошибки попадают в спан сами: TraceEndpoint пишет ext.LogError для err и gokit.business.error для Failer.
5. Сэмплинг настраивается в main: const 1 для разработки, probabilistic или remote для прода; решение наследуется всем трейсом.

**Вопрос:** Первый запрос цепочки приходит вовсе без заголовков трейсинга. Что сделает HTTPToContext?

- Вернёт ошибку в канал err — клиент получит 500
- ✅ Extract вернёт ErrSpanContextNotFound, а HTTPToContext всё равно создаст корневой спан нового трейса
- Спан не создастся, и весь запрос пройдёт без трейсинга
- Запишет заголовок uber-trace-id в ответ клиенту

Отсутствие заголовков — норма для корня цепочки: Extract вернёт ErrSpanContextNotFound, ext.RPCServerOption с пустым wireContext приведёт к StartSpan без ChildOf, и спан станет корнем нового трейса. Транспорт работает как раньше; ошибка не попадает ни в канал err, ни в лог уровня error. Заголовок в ответ никто не пишет — контекст передаётся только на исходящих вызовах через ContextToHTTP.

**Вопрос:** Сервис вернул UppercaseResponse с Err: "empty string" и err = nil. Что запишет TraceServer в спан?

- Ничего: канал err пуст, спан уйдёт зелёным
- ✅ Событие с полем gokit.business.error и ext.LogError — спан пометится ошибочным
- Только тег http.status_code=400
- Спан не запишется вовсе: Finish не вызовется

TraceEndpoint проверяет response на endpoint.Failer: Failed() вернул ошибку ErrEmpty — middleware пишет событие с полем gokit.business.error и зовёт ext.LogError, крася спан как сбойный. Это тот же объединённый подход к двум каналам ошибок, что и в метриках урока 07: Failer нужен, чтобы бизнес-отказы не выглядели успехом. Шум можно погасить опцией WithIgnoreBusinessError(true).

**Вопрос:** main не вызвал SetGlobalTracer, и код трассирует через stdopentracing.GlobalTracer(). Что будет?

- Паника на первом StartSpan: трейсер не настроен
- ✅ Будет использоваться NoopTracer: спаны создаются и закрываются, но ничего не репортится
- Extract начнёт возвращать ErrSpanContextNotFound на каждом запросе
- Компилятор не пропустит вызов StartSpan без явного трейсера

До SetGlobalTracer глобальным считается NoopTracer: StartSpan возвращает заглушку, Finish мгновенно завершается, Inject/Extract честно ничего не делают. Ноль паник, ноль логов, ноль накладных расходов — и ноль трейсов в UI. Отсюда правило: конфигурация трейсера — обязанность main, как и сборка middleware из уроков 06-07; middleware же пишут против интерфейса opentracing.Tracer и не знают, кто под ним.

### Двойной спан: что окажется в Jaeger?

```go
mux.Handle("/uppercase", kithttp.NewServer(
        upper,
        decodeUppercaseRequest,
        encodeResponse,
        kithttp.ServerBefore(kitot.HTTPToContext(tracer, "HTTP /uppercase", logger)),
))

upper = kitot.TraceServer(tracer, "uppercase")(upper)
```

**Что выведет программа?**

- Один спан: HTTPToContext и TraceServer поделят один и тот же отрезок
- ✅ Два спана в одном трейсе: «uppercase» станет ChildOf спана «HTTP /uppercase»
- Два независимых корневых трейса: связь потеряется
- Паника: два спана на один ctx запрещены

HTTPToContext в ServerBefore создала server-спан «HTTP /uppercase» и положила его в ctx. TraceServer внутри endpoint-обёртки нашла его через SpanFromContext и завела дочерний спан «uppercase» с опцией ChildOf — оба спана носят один trace id и в Jaeger показываются деревом: транспорт снаружи, endpoint внутри. Это не ошибка, а штатный двухуровневый расклад: отрезки измеряют разные вещи — весь путь на транспорте и сам endpoint.

### Порядок обёрток: появится ли trace id в логе?

```go
upper = loggingMiddleware(logger)(upper)
upper = kitot.TraceServer(tracer, "uppercase")(upper)

// loggingMiddleware из урока 06 пишет только:
// logger.Log("method", "uppercase", "err", err, "took", time.Since(begin))
```

**Что выведет программа?**

- Да: loggingMiddleware автоматически достанет спан из ctx и добавит trace_id
- ✅ Нет: в лог попадут только те пары, что middleware передала явно
- Да, но только если loggingMiddleware стоит внутри TraceServer
- Лог упадёт с ошибкой: пары без trace_id запрещены

kitlog пишет ровно то, что ему передали: loggingMiddleware из урока 06 про спаны не знает и не добавит ни trace_id, ни чего-либо ещё — автоматической магии в ctx-логировании нет. Трейс и лог связываются осознанным кодом: например, traceIDMiddleware из этого урока, которая достаёт SpanContext и пишет trace id явно. Кстати, если бы loggingMiddleware стояла внутри TraceServer, спан-то в ctx был бы, но loggingMiddleware по-прежнему не читает его — порядок не меняет ничего, пока поле не добавлено руками.

### Собери инъекцию спана

Восстанови монтаж трейсинга. Пропуски — по порядку: опция транспорта для pre-декодерных функций, RequestFunc инъекции из пакета go-kit, установка глобального трейсера и endpoint-обёртка со span.kind=server.

```go
mux.Handle("/uppercase", kithttp.NewServer(
        upper,
        decodeUppercaseRequest,
        encodeResponse,
        kithttp.___(kitot.___(tracer, "HTTP /uppercase", logger)),
))

stdopentracing.___(tracer)

upper = kitot.___(tracer, "uppercase")(upper)
```

Подсказка: Первый пропуск — опция kithttp из урока 04; второй — RequestFunc из tracing/opentracing; третий — глобальный трейсер; четвёртый — middleware со спаном.

Правильные ответы в пропусках: `ServerBefore`, `HTTPToContext`, `SetGlobalTracer`, `TraceServer`

### Путь одного трейса

Расставь этапы трассированного запроса POST /uppercase в правильном порядке: от заголовка клиента до дерева в Jaeger UI.

Правильный порядок строк:

1. `Клиент прислал POST /uppercase с заголовком uber-trace-id`
2. `ServerBefore запустил HTTPToContext до декодера`
3. `tracer.Extract достал SpanContext из HTTPHeadersCarrier`
4. `HTTPToContext создала спан «HTTP /uppercase» и положила его в ctx`
5. `TraceServer нашла спан через SpanFromContext и завела дочерний «uppercase»`
6. `endpoint вызвал сервис; defer span.Finish закрыл оба спана`
7. `репортер отправил спаны батчами по UDP 6831 на jaeger-agent`
8. `Jaeger UI собрал дерево: «uppercase» внутри «HTTP /uppercase»`

### Термины урока

- **opentracing.Tracer** — Интерфейс бэкенда: StartSpan, Inject, Extract. Реализации — NoopTracer по умолчанию, Jaeger, Zipkin; подменяется одной строкой в main.
- **SpanContext** — Не спан, а travel-данные для связи процессов: trace id, span id, решение о сэмплинге. Именно он пакуется в carrier и уезжает по сети.
- **HTTPHeadersCarrier** — Адаптер http.Header под формат opentracing.HTTPHeaders: Inject пишет uber-trace-id, Extract читает; ErrSpanContextNotFound — норма для корня трейса.
- **kitot.HTTPToContext** — RequestFunc для ServerBefore: Extract из заголовков, StartSpan с ext.RPCServerOption (span.kind=server), ContextWithSpan — Span-инъекция в ctx.
- **kitot.TraceServer** — Endpoint.Middleware: дочерний спан по ChildOf, defer span.Finish(), ошибки err и Failer попадают в спан событиями (gokit.business.error).
- **jaegercfg.Configuration** — ServiceName + Sampler (const/probabilistic/ratelimiting/remote) + Reporter (UDP 6831); NewTracer возвращает tracer и closer; UI на 16686.

### Что дальше

Четвёртый слой наблюдаемости встал на место: корневой спан инъецируется на транспорте через ServerBefore, TraceServer заводит дочерние спаны на endpoint'ах, ошибки из обоих каналов попадают в трейс, а Jaeger собирает дерево по uber-trace-id. Обещание урока 07 выполнено: спаны связали логи, метрики и теперь ещё и путь запроса одним trace id. Освежить обёртки endpoint — в [уроке 02-endpoint](/courses/go-kit/lessons/02-endpoint/), опции транспорта и ServerBefore — в [уроке 04-http-transport](/courses/go-kit/lessons/04-http-transport/), структурный лог — в [уроке 06-logirovanie](/courses/go-kit/lessons/06-logirovanie/), метрики — в [уроке 07-metriki](/courses/go-kit/lessons/07-metriki/); карта курса — на [странице курса](/courses/go-kit/). Дальше — урок 09-rate-limit: расширенная механика поверх тех же обёрток. Пока весь трафик проходит в сервис свободно, но под нагрузкой или атакой это роскошь: поставим token bucket на endpoint и научимся отвечать честным 429 Too Many Requests с Retry-After.

## Коротко: что запомнить

- OpenTracing — модель: Tracer (StartSpan/Inject/Extract), Span (Finish/SetTag/LogFields), SpanContext — travel-данные, carrier — конверт для сети.
- Span-инъекция: kithttp.ServerBefore(kitot.HTTPToContext(tracer, имя, logger)) — Extract из заголовков, корневой спан span.kind=server, спан кладётся в ctx.
- TraceServer(tracer, имя) — endpoint.Middleware: дочерний спан по ChildOf, defer span.Finish, ошибки err и Failer в спане событиями.
- Jaeger в main: jaegercfg.Configuration + NewTracer + closer.Close() в defer + SetGlobalTracer; агент UDP 6831, UI 16686, заголовок uber-trace-id.
- Исходящие вызовы: ContextToHTTP в ClientBefore и TraceClient — каждая нога сети даёт пару client/server спанов, Jaeger склеивает трейс.
- Сэмплинг: const для разработки, probabilistic/ratelimiting/remote для прода; решение принимается в голове трейса и наследуется всем спанам.
- Opentracing-go и jaeger-client-go в режиме сопровождения, OpenTelemetry — будущее; модель переезжает 1-в-1, навык переносится.

## Домашнее задание

Собери трейс-контур stringsvc целиком. Первое: подними Jaeger (docker run -p 16686:16686 -p 6831:6831/udp jaegertracing/all-in-one) и объяви jaegercfg.Configuration с Sampler const/Param 1 в main; поставь SetGlobalTracer и closer.Close() в defer. Второе: подключи kitot.HTTPToContext в kithttp.ServerBefore для всех трёх endpoint'ов с именами «HTTP /uppercase», «HTTP /count», «HTTP /concat». Третье: оберни каждый endpoint в kitot.TraceServer и убедись, что в Jaeger появились деревья из двух спанов на запрос. Четвёртое: добавь traceIDMiddleware и сравни trace_id в логе с trace id в UI — они обязаны совпасть. Пятое: экспериментируй с заголовками — пришли запрос с чужим uber-trace-id (из другого трейса UI) и убедись, что твой спан джойнулся к чужому дереву; потом переключи Sampler на probabilistic с Param 0.5 и объясни по UI, что изменилось. Шестое: добавь на один endpoint WithIgnoreBusinessError(true) и опиши, чем его спаны стали отличаться от соседних при ErrEmpty.

## Вопросы и ответы по уроку

### Opentracing-go архивирован — может, сразу учить OpenTelemetry?

OpenTelemetry — правильный выбор для новых проектов, но go-kit поставляет обёртки именно под opentracing, а модель (Tracer, Span, SpanContext, Inject/Extract, carrier) в OTel практически идентична. Этот урок даёт инструменты, которые работают с текущим go-kit, и понятия, которые переезжают в otel без переучивания: инъекция в ServerBefore, спаны на endpoint, сэмплинг в main.

### Трассировать всё или сэмплировать?

Для разработки и внутренних сервисов с малым трафиком — const 1: полный трейс стоит копейки и даёт полную картину. Для нагруженного прода — probabilistic (процент от трафика) или remote (управление из UI Jaeger без передеплоя). Трюк с tail-based sampling, когда хранятся только ошибочные или медленные трейсы, требует пайплайна на стороне collector и не решается настройками клиента.

### Куда класть user_id: в baggage, в теги или в лог?

В лог из урока 06 — там поле ничего не стоит. Baggage едёт со SpanContext в каждом заголовке всей цепочки — большие или персональные значения раздут трафик и создают проблемы приватности. Теги спана полезны, но учти урок метрик: идентификаторы в тегах дробят выборку — искать по конкретному trace id или по времени с фильтром тегов низкой кардинальности (метод, исход) практичнее.
