Логи — самый старый сигнал наблюдаемости и самый недооценённый. Свободный текст «что-то пошло не так в 14:32» бесполезен в проде: по нему нельзя ни отфильтровать, ни связать с конкретным запросом. Структурный лог с trace_id — совсем другое дело: он превращается из строки в стек в точку, из которой видно и весь трейс запроса, и метрики вокруг него.
Это третья статья серии «Наблюдаемость на практике». Трейсинг дал скелет запроса — здесь подвешиваем к каждому спану детали того, что реально произошло внутри. И заодно выясняем, что «структурный лог» и «коррелируемый лог» — не одно и то же.
В статье
- Структурный лог не значит коррелируемый
- trace_id в записи: два языка, два способа
- Потеря связи: опыт, а не предупреждение
- Модель Loki: что попадает в индекс
- Замер кардинальности: 2 стрима против 1202
- Три попытки испортить индекс
- LogQL на OTLP-логах: не то, к чему вы привыкли
- Корреляция в обе стороны и ловушка ведущего нуля
- Итог
- Документация и первоисточники
Структурный лог не значит коррелируемый
Начнём с того, что легко принять за успех. Вот запись Go-сервиса из стенда — структурная, с полями, машиночитаемая:
{"time":"...","level":"INFO","msg":"заказ создан","order_id":"ord-SKU-0001-1",
"sku":"SKU-0001","quantity":1,"reserved":true}По ней можно фильтровать, агрегировать, строить графики. Одного нельзя: связать её с трейсом. trace_id здесь нет.
А вот запись Java-сервиса того же стенда:
{"@timestamp":"...","log":{"level":"INFO","logger":"...InventoryController"},
"service":{"name":"java-backend","version":"1.0.0","environment":"stand"},
"message":"найден товар sku=SKU-0001 quantity=1000",
"trace_id":"a6b86467c24ec17a3c8c7a6653a5b72d","span_id":"122dfa15b7202df6",
"trace_flags":"03","ecs":{"version":"8.11"}}Здесь trace_id есть, и это заслуга не разработчика: агент положил его в MDC, а Spring Boot вывел ECS-формат сам. Никаких сторонних библиотек не понадобилось — начиная с версии 3.4 Boot умеет структурные логи из коробки:
structured:
format:
console: ecs
ecs:
service:
name: java-backend
version: 1.0.0
environment: standАсимметрия между языками объясняется просто: в Go нет байткод-инструментирования, и то, что агент делает незаметно, приходится делать явно.
trace_id в записи: два языка, два способа
В Go есть мост otelslog, который отправляет записи slog в OTLP-поток и контекст туда прикладывает. Но stdout он не трогает — а значит конвейер, читающий файл (Vector, Fluent Bit), корреляции не получит.
Лечится хендлером на десяток строк:
func (h traceContextHandler) Handle(ctx context.Context, rec slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
rec.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, rec)
}В стенде логи идут двумя маршрутами одновременно — и это не избыточность, а то, как обычно и выходит в проде: stdout читает конвейер, OTLP уходит в Collector напрямую.
func newLogger(lp *sdklog.LoggerProvider) *slog.Logger {
var stdout slog.Handler = traceContextHandler{
Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
}
if lp == nil {
return slog.New(stdout)
}
bridge := otelslog.NewHandler("go-frontend", otelslog.WithLoggerProvider(lp))
return slog.New(fanoutHandler{handlers: []slog.Handler{stdout, bridge}})
}Второе, о чём легко забыть в Go: контекст надо передавать. slog.Info вместо slog.InfoContext — и запись уходит без trace_id, потому что откуда бы хендлер его взял.
// ErrorContext, а не Error: без контекста запроса запись не получит trace_id,
// и потом по ошибочному трейсу не найти её лог.
s.Log.ErrorContext(s.logCtx(r.Context()), "запрос отклонён",
"status", code, "msg", msg, "err", errText(err), "path", r.URL.Path)Потеря связи: опыт, а не предупреждение
В стенде эта ошибка воспроизводится одной командой — переменная заставляет сервис логировать без контекста запроса:
./scripts/up.sh all otel logctx # LOG_WITHOUT_CONTEXT=trueПрогон 100 запросов и запрос к Loki:
записей «заказ создан» без trace_id: 86 ← все записи этого прогона
записей «заказ создан» с trace_id: 276 ← от предыдущих прогонов
логов java-backend с trace_id: 778 ← Java не пострадалОбратите внимание на третью строку. Ошибка в Go-коде не задела Java-сервис: агент прикладывает контекст независимо от того, как логирует сосед. То есть корреляция может быть частичной — по трейсу найдутся логи одного сервиса и не найдутся логи другого, и выглядеть это будет как «логи иногда пропадают».
Приложение при этом полностью работоспособно: все 100 запросов обслужены, факт совпал с планом. Ломается только возможность отладки.
Модель Loki: что попадает в индекс
Loki индексирует лейблы, а не тело записи. Отсюда его дешевизна и главное правило: лейблы должны быть низкой кардинальности.
При приёме по OTLP лейблы Loki строит не из чего попало, а из атрибутов ресурса, и не из всех — только из перечисленных в его настройках. Вот полный список по умолчанию, снятый с работающего Loki 3.7.5:
otlp_config:
default_resource_attributes_as_index_labels:
- service.name
- service.namespace
- service.instance.id
- deployment.environment
- deployment.environment.name
- cloud.region
- cloud.availability_zone
- k8s.cluster.name
- k8s.namespace.name
- k8s.pod.name
- k8s.container.name
- container.name
- k8s.replicaset.name
- k8s.deployment.name
- k8s.statefulset.name
- k8s.daemonset.name
- k8s.cronjob.name
- k8s.job.nameВосемнадцать атрибутов — и список стоит просмотреть глазами до того, как полагаться на него. Разложим по устойчивости значения:
| Атрибут | Насколько устойчив |
|---|---|
service.name, service.namespace |
меняется при переименовании сервиса, то есть почти никогда |
deployment.environment, cloud.region, cloud.availability_zone, k8s.cluster.name, k8s.namespace.name |
меняется при смене окружения или кластера — редко |
k8s.deployment.name, k8s.statefulset.name, k8s.daemonset.name, k8s.cronjob.name, k8s.container.name |
берутся из спецификации; стабильны между репликами и перезапусками |
service.instance.id |
у Java-агента в стенде — новый UUID при каждом старте процесса; спецификация допускает и стабильный идентификатор, если его задать явно |
k8s.pod.name |
меняется у пода Deployment; у StatefulSet имя стабильно (app-0, app-1) |
k8s.replicaset.name |
новый при изменении шаблона пода; при откате на прежний шаблон возвращается прежний |
k8s.job.name |
у Job из CronJob каждый запуск свой; у отдельно созданного Job — фиксированный |
container.name |
зависит от источника: в Kubernetes обычно совпадает с k8s.container.name и стабилен, но значение проставляет то, что собирает телеметрию, и в других средах оно может быть уникальным на экземпляр |
Надёжно проблемны две верхние выделенные строки: service.instance.id — всегда, k8s.pod.name — в самом частом случае, когда поды создаёт Deployment.
Остальные лучше считать потенциально текучими, а не заведомо опасными: у replicaset, job и container.name поведение зависит от контроллера, окружения и того, кто именно проставляет атрибут. Поэтому правильное действие — не заучивать список, а посмотреть фактические значения у себя.
Смотреть надо не запросом LogQL, а двумя служебными ручками Loki:
# сколько значений у конкретного лейбла
curl -sG "$LOKI/loki/api/v1/label/k8s_pod_name/values" | jq '.data | length'
# сколько всего потоков у сервиса — число реально встретившихся сочетаний лейблов
# (не больше произведения их кардинальностей, но обычно меньше)
curl -sG "$LOKI/loki/api/v1/series" --data-urlencode 'match[]={service_name="ваш-сервис"}' | jq '.data | length'Если число потоков близко к количеству реплик — набор лейблов в порядке. Если оно растёт вместе с числом деплоев за сутки, лейбл пора убирать из индекса.
Соблазнительно посчитать то же самое агрегацией вида count(count by (k8s_pod_name) (count_over_time({...}[24h]))) — но на реальном окне она упирается в собственный лимит:
maximum number of series (500) reached for a single query; consider reducing query
cardinality by adding more specific stream selectors, reducing the time range, or
aggregating results with functions like sum(), count() or topk()Причём упирается даже при низкой кардинальности индексных лейблов — я получил этот ответ и на стенде с двумя потоками. Ловушка в том, что лимит считает не индексные потоки, а серии результата. Structured metadata (о ней — следующий абзац) автоматически попадает в лейблы результата запроса, и count_over_time по двум потокам даёт отдельную серию на каждое уникальное сочетание trace_id, span_id и прочих полей записи — то есть практически на каждую запись. За [5m] записей мало, и серий меньше 500; за [24h] их тысячи. Сообщение говорит про «query cardinality», и его легко принять за подтверждение проблемы с лейблами индекса, а дело в лейблах результата.
Лечится это отбрасыванием лишнего до диапазонной агрегации — keep оставляет в результате только нужные лейблы:
count(count by (k8s_pod_name) (
count_over_time({service_name="ваш-сервис"} | keep service_name, k8s_pod_name [24h])
))Для вопроса «сколько потоков» надёжнее всё же /loki/api/v1/series выше: он отвечает по индексу и не зависит от объёма записей.
Всё остальное — trace_id, span_id, severity_text, прикладные поля вроде order_id и sku — попадает в structured metadata. Это отдельное хранилище рядом с записью: по нему можно фильтровать, но оно не раздувает индекс.
Соблазнительно прочитать этот список как встроенную защиту: что бы ни прислали в атрибутах, в индекс попадёт только знакомое. Список действительно ограничивает набор атрибутов — но низкой кардинальности он не гарантирует, и виновники в нём видны невооружённым глазом.
service.instance.id — высокая кардинальность по определению
service.instance.id уникален для экземпляра. Спецификация OTel не требует, чтобы он менялся: можно задать стабильный идентификатор (например, через OTEL_RESOURCE_ATTRIBUTES), и тогда при перезапуске он сохранится. Но если его не задать, OTel Java agent в стенде генерирует новый UUID при каждом старте процесса — и тогда это единственный атрибут из списка, который меняется при каждом рестарте. В Kubernetes к нему в большинстве установок присоединяется k8s.pod.name, а k8s.replicaset.name добавляется при изменении шаблона пода — и все они по умолчанию индексные: каждое новое сочетание их значений — новый поток.
Дальше меряем на service.instance.id: он воспроизводится в локальном стенде без Kubernetes и потому годится для опыта, который читатель может повторить у себя за пару минут.
Проверяется это прямо на стенде одной командой — ./scripts/bench-instance-id.sh поднимает Loki сначала с дефолтным набором лейблов, потом с безопасным, между рестартами Java-сервиса даёт нагрузку и считает потоки:
КОНФИГ РЕСТАРТОВ СТРИМОВ У JAVA-BACKEND ЛЕЙБЛЫ ИНДЕКСА
defaults 0 3 2 deployment_environment,service_instance_id,service_name
defaults 1 4 3 deployment_environment,service_instance_id,service_name
defaults 2 5 4 deployment_environment,service_instance_id,service_name
defaults 3 6 5 deployment_environment,service_instance_id,service_name
safe 0 2 1 deployment_environment,service_name
safe 1 2 1 deployment_environment,service_name
safe 2 2 1 deployment_environment,service_name
safe 3 2 1 deployment_environment,service_nameВерхняя половина таблицы — дефолтный набор: каждый перезапуск добавляет поток одному и тому же сервису. На десяти подах с регулярными раскатками это уже десятки лишних потоков на сервис, и рост не прекращается всё время жизни индекса.
Нижняя половина — тот же сценарий после исправления. Лечится явным набором лейблов с отключением дефолтного:
otlp_config:
resource_attributes:
ignore_defaults: true
attributes_config:
- action: index_label
attributes:
- service.name
- service.namespace
- deployment.environmentВажно, что service_instance_id при этом не потерян — он ушёл в structured metadata, и по нему по-прежнему можно фильтровать:
{service_name="java-backend"} | service_instance_id != ""
→ 388 записейРовно то, что нужно: значение доступно для отладки, а индекс от него не растёт.
Отдельное наблюдение, которое чуть не увело меня к противоположному выводу. Первая версия опыта перезапускала Go-сервис — и потоки не росли вовсе. Причина: у go-frontend атрибута service.instance.id нет совсем, Go SDK его не выставляет, в отличие от Java-агента. Если бы я остановился на этом, в статье стояло бы «дефолтный список безопасен», и это было бы неверно.
Замер кардинальности: 2 стрима против 1202
Правило «не кладите высококардинальные значения в лейблы» есть в любой документации. Пересказывать его неинтересно — лучше измерить.
Два одинаковых прогона по 600 запросов. Разница только в том, уезжает ли trace_id в лейбл индекса.
ПРОГОН СТРИМОВ ОЗУ LOKI, МиБ ЗАПРОС, мс ЛЕЙБЛЫ ИНДЕКСА
normal 2 81,1 46 service_name, deployment_environment
highcard 1202 86,5 46 те же + trace_id_labelСтримов стало в 600 раз больше. Число 1202 при 600 запросах объясняется просто: по два стрима на каждый trace_id — запись Go и запись Java с одинаковым trace_id, но разными service_name.
Обратите внимание на базовую линию: два лейбла, а не три. Это тот самый явный безопасный набор из предыдущего раздела — без service.instance.id. Оба прогона идут с ним, чтобы разница между ними объяснялась ровно одной добавленной строкой, а не заодно и дефолтными настройками.
А теперь то, что приятнее было бы не писать. Из трёх колонок доказательна одна.
Число потоков воспроизводится в каждом прогоне: 2 против 1202, всегда. А память и время запроса на таком объёме — шум. Вот четыре прогона подряд, время в миллисекундах, normal против highcard:
прогон 1 51 / 79 похоже на замедление
прогон 2 64 / 48 «раздутый» вариант БЫСТРЕЕ
прогон 3 31 / 62 снова замедление
прогон 4 46 / 46 разницы нетНаправление меняется от прогона к прогону — значит эффекта на этом объёме просто не видно. То же с памятью: базовая линия гуляла 80,0–86,8 МиБ, что шире разницы с highcard.
Первая редакция этого раздела утверждала «запрос замедлился примерно в полтора раза» — по одному прогону. Утверждение не пережило повторов, и правильный вывод здесь такой: высокая кардинальность надёжно бьёт по числу потоков, а деградацию запросов и памяти на игрушечных объёмах измерить нельзя. Ждать её стоит на проде, где потоков миллионы, а не тысяча.
Чтобы время запроса хотя бы не прыгало от случайного всплеска, скрипт берёт медиану пяти замеров — но и это не делает величину доказательной, о чём в отчёте написано прямо.
Стримы, кстати, взяты из метрики самого Loki (loki_ingester_memory_streams), а не посчитаны по числу уникальных значений: считать надо то, что считает сам движок.
Три попытки испортить индекс
Замер получился с третьей попытки, и две неудачные попытки полезнее результата — потому что каждая объясняет устройство системы.
Попытка первая. Положить trace_id в атрибут ресурса через OTTL в Collector и ждать взрыва:
transform/blow_up_cardinality:
log_statements:
- context: log
statements:
- set(resource.attributes["trace_id_label"], trace_id.string)Результат: 2 стрима против 2, ровно никакого эффекта. Причина — список индексируемых атрибутов: trace_id_label в нём не значится, поэтому Loki положил его в structured metadata и индекс не тронул.
Попытка вторая. Разрешить атрибут в конфиге Loki:
otlp_config:
resource_attributes:
ignore_defaults: true
attributes_config:
- action: index_label
attributes:
- service.name
- service.namespace
- deployment.environment
- trace_id_labelНабор здесь — тот же безопасный, что в обычном loki.yaml, плюс одна добавленная строка: так разница между прогонами объясняется ровно ею, а не заодно дефолтными настройками.
Лейбл появился — но стримов стало 9, а уникальных значений лейбла всего 6 на 600 запросов. Причина тоньше и стоит того, чтобы её запомнить: в OTTL set(resource.attributes[...]) в контексте log меняет ресурс всей группы записей, а не отдельной записи. Ресурс у ResourceLogs общий, поэтому в атрибут попадал trace_id какой-то одной записи из пачки.
Попытка третья, рабочая. Скопировать trace_id в атрибут записи, а затем перенести его в ресурс процессором, который умеет перегруппировывать поток:
transform/copy_trace_id:
error_mode: ignore
log_statements:
- context: log
statements:
- set(attributes["trace_id_label"], trace_id.string)
groupbyattrs:
keys: [trace_id_label]Только теперь получается по стриму на trace_id — 1202.
Практический вывод двойной. Положить своё высококардинальное значение в лейбл Loki через OTLP случайно трудно: нужно и явно разрешить атрибут, и правильно его доставить. Но расслабляться рано — одно высококардинальное значение уже есть в дефолтном наборе, и это service.instance.id из предыдущего раздела. То есть опасность приходит не столько от неосторожного trace_id, сколько от настроек по умолчанию. Отдельно остаётся риск у тех, кто пишет в Loki напрямую через push API, где лейблы задаёт клиент.
LogQL на OTLP-логах: не то, к чему вы привыкли
Первое, что делает человек, знакомый с LogQL, — пишет | json и фильтрует по полю. На логах, пришедших по OTLP, это не работает — но не так, как можно подумать.
Сам | json пустоту не возвращает. Записи приходят, и в каждой появляется диагностическая пара полей:
запись: заказ создан
__error__: JSONParserErr
__error_details__: Value looks like object, but can't find closing '}' symbolLoki честно сообщает, что парсить нечего: тело записи — не JSON, а обычная строка. Пустой результат появляется только на следующем шаге, когда вы фильтруете по полю, которого парсер не создал:
{service_name="go-frontend"} | json → записи есть, у каждой __error__
{service_name="go-frontend"} | json | level="ERROR" → 0 стримовРазница практическая: __error__="JSONParserErr" — это подсказка, по которой видно, что запрос построен неверно. Если сразу писать фильтр и смотреть на пустой результат, подсказку вы не увидите и будете искать проблему в данных, а не в запросе. Кстати, отфильтровать такие записи можно и специально — | __error__="" оставит только те, где парсер сработал.
Причина же в структуре записи. Тело — само сообщение, а поля уже разобраны и лежат в метаданных:
тело записи: заказ создан
метаданные: deployment_environment, detected_level, flags, order_id, quantity,
reserved, scope_name, service_name, service_version,
severity_number, severity_text, sku, span_id, trace_id,
telemetry_sdk_*, observed_timestampПарсить нечего — фильтровать надо прямо по метаданным. Вот проверенный набор с фактическими результатами на данных стенда:
sum by (severity_text) (count_over_time({service_name="go-frontend"}[1h]))
→ ERROR 26, INFO 283
sum by (detected_level) (count_over_time({service_name=~"go-frontend|java-backend"}[1h]))
→ error 39, info 851, warn 13
sum by (service_name) (count_over_time({service_name=~"go-frontend|java-backend"} | severity_text="ERROR" [1h]))
→ go-frontend 26, java-backend 13
sum(rate({service_name="go-frontend"} | severity_text="ERROR" [5m]))
→ 0,0867 записей в секунду
sum(count_over_time({service_name="go-frontend"} | reserved="false" [1h]))
→ 32 заказы, где товара не хватило
sum(count_over_time({service_name="go-frontend"} | sku="SKU-0004" [1h]))
→ 22 записи по медленному SKUДве последние строки стоит рассмотреть отдельно. reserved и sku — прикладные поля, и по ним считается бизнес-показатель прямо из логов, без отдельной метрики. Это удобно и до определённого объёма дешевле, чем заводить счётчик. Граница проходит там, где запрос начинает сканировать слишком много: метрика в Prometheus отвечает мгновенно на любом окне, LogQL — сканирует.
И важное следствие про два маршрута доставки: | json остаётся нужен для файлового маршрута, где Vector или Fluent Bit читают JSON-строку целиком и кладут её в тело как есть. Один и тот же лог, доставленный двумя путями, требует разных запросов. Если у вас сосуществуют оба конвейера — а это частый случай, — дашборды придётся писать с учётом того, откуда приехала запись.
Корреляция в обе стороны и ловушка ведущего нуля
Связку принято проверять в одну сторону: есть ли в логе ссылка на трейс. Этого мало. Может оказаться, что из лога в трейс переход работает, а обратно — нет.
В стенде проверка делает два независимых перехода: scripts/correlation-probe.sh берёт trace_id из Tempo и ищет по нему записи в Loki, сверяя, что это записи того же запроса (по order_id из атрибутов спана), а затем наоборот — берёт trace_id из записи и запрашивает трейс.
И вот на первом переходе всё сначала не работало вовсе:
1) трейс -> логи
trace_id из Tempo: 646d64b5bf7cabe7f3c83e27d711aec
записей в Loki по этому trace_id: 0
тот же order_id встречается в логах: НЕТ
2) лог -> трейс
trace_id, взятый ИЗ ЗАПИСИ лога: 0646d64b5bf7cabe7f3c83e27d711aec
трейс по этому id найден в Tempo: да, спанов 9Сравните два идентификатора. В первом 31 символ, во втором 32. Tempo отдаёт trace id без ведущих нулей, а в логах он полный, как определяет спецификация OTLP.
Следствие практическое и коварное: переход «из трейса в логи» подстановкой значения из API Tempo не находит ничего у каждого шестнадцатого трейса — ровно у тех, чей идентификатор начинается с нуля. Ошибка проявляется в шести процентах случаев и выглядит как «иногда логи не находятся», то есть как случайность, а не как дефект.
Лечится дополнением до 32 символов:
trace_id = raw_trace_id.rjust(32, "0")После этого связка работает в обе стороны:
1) трейс -> логи
trace_id из Tempo: 3e27ea5aee6e8fe069ba880695f6167 (31 символов)
после дополнения нулями: 03e27ea5aee6e8fe069ba880695f6167 (32)
записей в Loki по этому trace_id: 3
сервисы в найденных записях: ['go-frontend', 'java-backend']
java-backend найден товар sku=SKU-0001 quantity=1000
java-backend заказ сохранён order_id=ord-SKU-0001-2 sku=SKU-0001
go-frontend заказ создан
order.id из атрибутов спана: ord-SKU-0001-2
тот же order_id встречается в логах: да
2) лог -> трейс
трейс по этому id найден в Tempo: да, спанов 9Три записи, оба сервиса, и order_id совпал — то есть найдены логи именно этого запроса, а не «какие-нибудь логи за то же время».
В Grafana те же два перехода настраиваются через provisioning, а не руками — иначе они не переживут пересоздание контейнера:
derivedFields:
- name: TraceID
matcherType: label
matcherRegex: trace_id
# $$ — экранирование для provisioning Grafana: одиночный $ она пытается
# раскрыть как переменную окружения.
url: "$${__value.raw}"
datasourceUid: tempo
urlDisplayLabel: Открыть трейсОбратите внимание на matcherType: label. Обычно derived fields показывают с разбором тела регулярным выражением — но для OTLP-логов это не нужно: trace_id уже лежит в метаданных отдельным полем.
Итог
- Структурный лог не значит коррелируемый. У Go в stdout
trace_idне появится сам: мостotelslogкладёт контекст только в OTLP-поток. Хендлер на десять строк решает вопрос — но его надо написать. - В Java это делает агент. Он кладёт контекст в MDC, а Spring Boot с версии 3.4 выводит структурный лог без сторонних библиотек.
- Ошибка корреляции бывает частичной. Логи без контекста в одном сервисе не задевают другой: по трейсу найдутся записи Java и не найдутся записи Go. Выглядит как «логи иногда пропадают».
- Список индексируемых атрибутов ограничивает набор, но не гарантирует низкую кардинальность. В дефолтных восемнадцати есть
service.instance.id— он меняется при каждом перезапуске процесса, и три рестарта Java-сервиса дали 2, 3, 4, 5 потоков одному сервису. Рядом с нимk8s.pod.name(текучий у Deployment, стабильный у StatefulSet) и несколько атрибутов, поведение которых зависит от окружения. Вывод не «список плохой», а «набор надо задавать явно, сignore_defaults: true, и проверять фактическую кардинальность своих лейблов». - Замер вместо пересказа: 2 потока против 1202 на 600 запросах — и это единственная величина, которая воспроизводится. Время запроса в четырёх прогонах меняло направление (51/79, 64/48, 31/62, 46/46), память гуляла шире эффекта. Деградацию запросов на игрушечном объёме измерить нельзя, и лучше это сказать, чем показать красивую цифру из одного прогона.
- На OTLP-логах
| jsonне работает, но и пустоту не возвращает — записи приходят с__error__="JSONParserErr", и это подсказка, что запрос построен неверно. Пусто становится на следующем шаге, при фильтре по несуществующему полю. Для файлового маршрута парсер по-прежнему нужен: один лог, два пути доставки, два разных запроса. - Бизнес-показатели считаются прямо из логов по прикладным полям в метаданных — до объёма, на котором сканирование становится дороже отдельной метрики.
- Tempo отдаёт trace id без ведущих нулей. Переход «из трейса в логи» ломается у каждого шестнадцатого трейса, если не дополнить идентификатор до 32 символов.
На этом первая волна серии закончена: сигналы порождаются (статья 1), сшиваются в трейс (статья 2) и связываются с логами. Дальше — метрики, SLO и профилирование; отдельно про выбор хранилища логов будет сравнение Loki, OpenSearch и VictoriaLogs, где Loki перестаёт быть данностью и становится одним из вариантов.
Документация и первоисточники
- Grafana Loki: LogQL — селекторы, фильтры, парсеры, агрегации.
- Grafana Loki: structured metadata — что хранится рядом с записью и не попадает в индекс.
- Grafana Loki: OTLP ingestion — какие ресурсные атрибуты становятся лейблами и как это настраивается.
- OpenTelemetry: logs data model — тело, атрибуты, severity, связь с трейсом.
- Spring Boot: structured logging — форматы ECS, Logstash, GELF без сторонних библиотек.
- OpenTelemetry Collector: groupbyattrs processor — перегруппировка записей по атрибутам.
- Стенд, конфиги, скрипты проверок и сырые выводы —
digital-cookbook/observability/practice-stack.
Комментарии