Материал подготовлен в рамках нового потока курса «Observability: мониторинг, логирование, трассировка».
OpenTelemetry Collector включает широкий набор процессоров для типовых, хорошо определённых задач. Можно использовать процессор attributes для работы с парами «ключ-значение», процессор resource для изменения метаданных на уровне ресурса и ряд других – для фильтрации, пакетной обработки или маршрутизации телеметрии.
Но рано или поздно возникает задача, которая уже не укладывается ни в один из этих сценариев. Вам может понадобиться изменить структуру тела журнала, вычислить новый атрибут на основе двух существующих полей, преобразовать тип метрики или поднять атрибут с уровня записи на уровень ресурса. В этот момент специализированных процессоров уже недостаточно, и требуется нечто более выразительное.
Именно здесь и пригодится transform processor.
Transform processor работает на базе OpenTelemetry Transformation Language, или OTTL – предметно-ориентированного языка, предназначенного для преобразования телеметрии по мере её прохождения через Collector. Вы описываете декларативные инструкции, которые выполняются для спанов, записей журнала или точек метрик, а Collector вычисляет их по порядку.
В официальной документации объясняется, что умеет этот процессор, но в ней часто остаются без ответа вопросы о том, зачем вообще нужны те или иные шаблоны, что происходит при неправильной настройке инструкции и как отлаживать преобразования, которые вроде бы выполняются, но не дают видимого результата. Это руководство закрывает именно такие пробелы.
Сначала мы разберём ментальную модель, лежащую в основе работы процессора, а затем перейдём к практическим шаблонам и приёмам, пригодным для промышленной эксплуатации, чтобы вы могли уверенно использовать transform processor в реальных системах.
Начнём.
Что на самом деле делает transform processor

Прежде чем переходить к настройке, полезно сформировать ментальную модель того, как именно выполняется процессор. Без неё инструкции OTTL могут казаться непредсказуемыми, особенно когда они молча ничего не делают.
Когда пакет телеметрии поступает в процессор, обход выполняется иерархически. Для тейсов процессор проходит по каждому набору спанов ресурса, затем по каждому набору спанов области видимости и, наконец, по каждому отдельному спану и его событиям. Для логов он проходит по каждому набору логов ресурса, затем по каждому набору логов области видимости, а потом по каждой записи логов. Для метрик он обходит метрики ресурса, метрики области видимости, каждую отдельную метрику и, наконец, каждую точку данных внутри этой метрики.
Каждая инструкция по сути представляет собой вызов функции с необязательным условием where. Это условие работает как защитная проверка: функция выполняется только тогда, когда выражение в условии даёт значение true. Инструкции выполняются строго в том порядке, в котором вы их задали, и это становится особенно важным, когда вы начинаете выстраивать цепочки операций, зависящих от более ранних изменений.
Модель данных, с которой вы работаете, напрямую повторяет структуру OpenTelemetry Protocol (OTLP). Это означает, что у вас есть доступ к атрибутам ресурса, метаданным области инструментирования, а также ко всем полям спанов, журналов и метрик. Однако обращаться к ним нужно по правильному пути – в зависимости от того, с каким сигналом и на каком уровне вы работаете.
Например, использование span.attributes внутри инструкции для логов приведёт к ошибке разбора, потому что у логов нет контекста спана. Напротив, resource.attributes работает и для трейсов, и для логов, и для метрик, потому что каждый сигнал содержит сведения о ресурсе. Понимание этих границ – ключ к написанию преобразований, которые будут вести себя именно так, как вы ожидаете.
Быстрый старт: разбор и подъём полей с помощью OTTL
Одна из возможностей transform processor, недоступная другим процессорам Collector сама по себе, – разбор структурированного JSON-тела журнала и подъём его полей в полноценные атрибуты за один шаг пайплайна, без внешних инструментов.
Рассмотрим типичный сценарий, когда ваше приложение пишет в файлы журнала JSON-логи примерно такого вида:
{ "level": "error", "message": "payment gateway timeout", "order_id": "ord_8821", "duration_ms": 5023, "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "a3ce929d0e0e4736" }
Когда Collector получает эти логи через приёмник filelog, весь JSON-объект воспринимается как обычная строка и сохраняется в log.body. Его содержимое не разбирается автоматически и не поднимается в атрибуты LogRecord само по себе, если только не используется оператор json_parser из stanza.
С точки зрения вашей серверной части такая запись – всего лишь непрозрачный текст. Вы не можете фильтровать записи по уровню, не можете настраивать оповещения по duration_ms, а trace_id, встроенный в тело, не связан с реальным полем контекста трейсов, которое используется для корреляции.
Transform processor позволяет разобрать этот JSON, извлечь из него поля и сопоставить их с корректными атрибутами журнала и полями контекста OpenTelemetry в рамках одного централизованного шага.
Подготовка демонстрационного примера
Создайте каталог с тремя файлами. Сначала – файл журнала, который Collector будет читать в режиме слежения:
# app.log {"level":"info","message":"server started","port":8080} {"level":"info","message":"user login successful","user_id":"usr_4421","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736","span_id":"a3ce929d0e0e4736"} {"level":"warn","message":"high memory usage","percent":87.4,"host":"node-3","trace_id":"b7ad6b7169203331d166c7b39e74e7e3","span_id":"d166c7b39e74e7e3"} {"level":"error","message":"payment gateway timeout","order_id":"ord_8821","duration_ms":5023,"trace_id":"a3ce929d0e0e47364bf92f3577b34da6","span_id":"4bf92f3577b34da6"} {"level":"error","message":"database connection lost","retries":3,"trace_id":"d0e0e47364bf92f3577b34da6a3ce929","span_id":"77b34da6a3ce929d"}
Далее задайте конфигурацию Collector:
# otelcol.yaml receivers: filelog: include: [app.log] start_at: beginning processors: transform: error_mode: ignore log_statements: - conditions: - IsString(log.body) and IsMatch(log.body, "^\\s*\\{") statements: - merge_maps(log.cache, ParseJSON(log.body), "upsert") # Вариант 1: поднять ВСЕ разобранные поля в атрибуты журнала за один шаг. - merge_maps(log.attributes, log.cache, "upsert") # # Вариант 2 (альтернатива варианту выше): поднять только известные и безопасные поля. # - set(log.attributes["level"], log.cache["level"]) # - set(log.attributes["order_id"], log.cache["order_id"]) # - set(log.attributes["duration_ms"], log.cache["duration_ms"]) # Вариант 3 (гибридный): поднять всё, затем очистить лишние поля # - merge_maps(log.attributes, log.cache, "upsert") # - delete_key(log.attributes, "internal_debug_field") # - set(log.attributes["duration_ms"], log.attributes["dur"]) # - delete_key(log.attributes, "dur") # Структурные поля требуют отдельной обработки вне зависимости от подхода — # это НЕ обычные атрибуты, и их нужно задавать явно, потому что # merge_maps не умеет записывать в типизированные поля верхнего уровня OTLP. - set(log.trace_id.string, log.cache["trace_id"]) where log.cache["trace_id"] != nil - set(log.span_id.string, log.cache["span_id"]) where log.cache["span_id"] != nil - set(log.severity_text, log.cache["level"]) where log.cache["level"] != nil - set(log.severity_number, SEVERITY_NUMBER_ERROR) where log.cache["level"] == "error" - set(log.severity_number, SEVERITY_NUMBER_WARN) where log.cache["level"] == "warn" - set(log.severity_number, SEVERITY_NUMBER_INFO) where log.cache["level"] == "info" exporters: debug: verbosity: detailed service: pipelines: logs: receivers: [filelog] processors: [transform] exporters: [debug]
Наконец, добавьте файл Docker Compose для запуска Collector:
# docker-compose.yaml services: otelcol: image: otel/opentelemetry-collector-contrib:0.146.1 volumes: - ./otelcol.yaml:/etc/otelcol-contrib/config.yaml - ./app.log:/app.log
Запустите всё командой:
docker compose up -d
На что смотреть в выводе

Без transform processor экспортёр debug покажет каждую запись журнала с сырым строковым телом и только теми атрибутами, которые добавил приёмник filelog:
LogRecord #3 ObservedTimestamp: ... Body: Str({"level":"error","message":"payment gateway timeout","order_id":"ord_8821","duration_ms":5023,"trace_id":"a3ce929d0e0e47364bf92f3577b34da6"}) Attributes: -> log.file.name: Str(app.log) Trace ID: Span ID: Flags: 0
Если transform processor включён, та же самая запись перед экспортом будет разобрана и нормализована:
LogRecord #3 ObservedTimestamp: 2026-03-02 07:01:01.578273845 +0000 UTC Timestamp: 1970-01-01 00:00:00 +0000 UTC SeverityText: error SeverityNumber: Error(17) Body: Str({"level":"error","message":"payment gateway timeout","order_id":"ord_8821","duration_ms":5023,"trace_id":"a3ce929d0e0e47364bf92f3577b34da6","span_id":"4bf92f3577b34da6"}) Attributes: -> log.file.name: Str(app.log) -> level: Str(error) -> message: Str(payment gateway timeout) -> order_id: Str(ord_8821) -> duration_ms: Double(5023) Trace ID: a3ce929d0e0e47364bf92f3577b34da6 Span ID: 4bf92f3577b34da6 Flags: 0
В одном процессоре произошли три важных преобразования:
Тело JSON было разобрано, а его поля стали атрибутами, по которым можно выполнять запросы.
Поля уровня серьёзности были заполнены, чтобы серверные системы могли фильтровать записи и строить оповещения по уровню.
Идентификаторы трейсов были перенесены из обычной строки в теле в корректное поле OTLP, что позволяет автоматически связывать журналы с трассировками.
Здесь есть несколько приёмов, к которым вы ещё не раз вернётесь по ходу этого руководства.
Во-первых, блок conditions на уровне группы работает как шлюз. Весь набор инструкций выполняется только для тех записей, чьё тело похоже на JSON-объект. Журналы не в формате JSON проходят через процессор без изменений.
Во-вторых, ParseJSON() десериализует тело в значение-карту OTTL, после чего merge_maps() записывает эту карту в log.cache – временное рабочее пространство, доступное только во время вычисления. Затем вы извлекаете поля из этого кэша и раскладываете их по итоговым местам.
В-третьих, аксессор log.trace_id.string относится к особенностям системы типов OTTL. Поле верхнего уровня trace_id внутри ожидает байты, а суффикс .string указывает OTTL принять шестнадцатеричную строку и автоматически преобразовать её. Без этого присваивание в режиме ignore молча завершится неудачей, потому что типы не совпадают.
Теперь, когда вы увидели transform processor в действии, пора перейти к настройке, которая поможет избежать незаметной потери данных на следующих этапах: error_mode.
Понимание и настройка режимов обработки ошибок

Параметр error_mode – первое, что нужно настроить правильно, потому что его поведение по умолчанию нередко оказывается неожиданным.
На уровне процессора значением по умолчанию является propagate. Это означает, что если какая-либо инструкция OTTL сталкивается с ошибкой типа, обращается к отсутствующему полю или вызывает функцию с недопустимыми аргументами, весь пакет отклоняется, а ошибка возвращается вверх по пайплайну.
Во время разработки это полезно, потому что заставляет сразу исправлять ошибки. Однако в продакшене такой режим может превратить неидеальную реальную телеметрию в гарантированную потерю данных: достаточно одной повреждённой записи, чтобы был отброшен весь пакет.
Три доступных варианта ведут себя совершенно по-разному:
ignore: записывает ошибку в журнал и продолжает выполнение со следующей инструкции. Для продакшена это почти всегда правильный выбор.silent: пропускает проблемную инструкцию, ничего не записывая в журнал. Использовать этот режим стоит только тогда, когда вы осознанно ожидаете, что некоторые записи будут завершаться ошибкой, и хотите избежать лишнего шума в логах.propagate: возвращает ошибку вверх по цепочке и отбрасывает полезную нагрузку. Это удобно при разработке или в тех случаях, когда неудачное преобразование должно остановить пайплайн.
Можно задать безопасное значение по умолчанию на верхнем уровне, а затем переопределить его для конкретных групп инструкций, которые обязаны выполняться успешно:
transform: error_mode: ignore # безопасное значение по умолчанию для большинства инструкций log_statements: - error_mode: propagate # эта группа должна выполниться успешно, иначе пакет будет отброшен statements: - set(log.attributes["account_id"], log.attributes["required_account_id"]) - statements: # наследует верхнеуровневое значение "ignore" - set(log.attributes["region"], resource.attributes["cloud.region"])
В этой конфигурации большинство инструкций спокойно работает с несовершенными данными. Однако первая группа требует строгой корректности и отбрасывает пакет в случае сбоя.
Чтобы понять, почему это важно, добавьте в демо конфигурацию из раздела быстрого старта следующую инструкцию сразу после merge_maps():
- set(log.attributes["x"], Split(log.cache["duration_ms"], ","))
Эта инструкция пытается вызвать Split() для duration_ms. Но это поле числовое, а не строковое, и к тому же присутствует далеко не в каждой записи. Поэтому функция завершается ошибкой сразу по нескольким причинам.
Запустите демонстрационный пример командой docker compose up -d --force-recreate, и в логах Collector вы увидите сообщение failed to execute statement:
2026-03-02T07:24:36.761Z warn ottl@v0.146.0/parser.go:410 failed to execute statement { "resource": { "service.instance.id": "ea76a497-ab58-4aca-8059-46fb486781ed", "service.name": "otelcol-contrib", "service.version": "0.146.1" }, "otelcol.component.id": "transform", "otelcol.component.kind": "processor", "otelcol.pipeline.id": "logs", "otelcol.signal": "logs", "error": "expected string but got nil", "statement": "set(log.attributes[\"duration_parts\"], Split(log.cache[\"duration_ms\"], \",\"))" }
Из этого следуют два важных наблюдения:
Collector записывает ошибку в журнал и продолжает обработку. Инструкции, стоявшие до проблемной, уже выполнились, а инструкции после неё всё ещё будут выполнены для той же записи.
В сообщении об ошибке указаны точная инструкция и конкретное несоответствие типов. Поэтому режим
ignoreодновременно и безопасен, и удобен для диагностики проблем без остановки вашего пайплайна.
Теперь переключите error_mode в propagate и заново пересоздайте Collector. На этот раз ни одна запись не дойдёт до экспортёра. Вместо этого вы увидите ошибку примерно такого вида:
2026-03-02T07:37:54.301Z error logs/processor.go:62 failed processing logs { "resource": { "service.instance.id": "ad4bcf8d-101b-4f79-a6c5-7fe95cbbb51e", "service.name": "otelcol-contrib", "service.version": "0.146.1" }, "otelcol.component.id": "transform", "otelcol.component.kind": "processor", "otelcol.pipeline.id": "logs", "otelcol.signal": "logs", "error": "failed to execute statement: set(log.attributes[\"duration_parts\"], Split(log.cache[\"duration_ms\"], \",\")), expected string but got nil" }
В этом режиме ошибка поднимается вверх по цепочке, а весь пакет полностью отбрасывается. Именно такое поведение и нужно в контролируемой тестовой среде, где вы проверяете корректность преобразований.
Выбор правильного error_mode – это не просто деталь конфигурации. От него зависит, будет ли transform processor вести себя как строгий валидатор или как устойчивый нормализатор данных.
Система путей: как обращаться к данным
Каждая инструкция OTTL работает с путями. Это идентификаторы, разделённые точками, которые указывают на конкретное поле в модели данных OpenTelemetry. Если вы не понимаете, как устроена система путей, ваши инструкции либо не пройдут разбор, либо начнут молча работать не на том уровне, на котором вы рассчитывали.
Каждый тип сигнала предоставляет фиксированный набор префиксов верхнего уровня:
Сигнал | Доступные префиксы путей |
|
|
|
|
|
|
Если вы укажете span.attributes внутри блока log_statements или datapoint.attributes внутри блока trace_statements, Collector не запустится и завершится с ошибкой конфигурации, потому что transform processor проверяет пути при запуске, а не во время выполнения.
Внутри каждого префикса можно обращаться к полям в соответствии со структурой OTLP. Например:
# Распространённые пути для журналов log.body # тело журнала (любой тип значения OTTL) log.severity_number # числовой уровень серьёзности (перечисление SeverityNumber) log.severity_text # строковый уровень серьёзности log.attributes["key"] # конкретный атрибут журнала log.trace_id # байты идентификатора трассировки log.span_id # байты идентификатора спана # Распространённые пути для спанов span.name # имя спана span.kind # перечисление SpanKind span.attributes["key"] # конкретный атрибут спана span.status.code # перечисление StatusCode span.start_time # время начала span.end_time # время окончания # Распространённые пути для метрик metric.name # строка с именем метрики metric.description # строка с описанием метрики metric.unit # строка с единицей измерения метрики metric.type # перечисление MetricDataType datapoint.attributes["key"] # атрибут точки данных datapoint.value # числовое значение (gauge/sum) # Пути ресурса и области видимости (доступны для всех сигналов) resource.attributes["key"] scope.name scope.version scope.attributes["key"]
Доступ к атрибутам всегда выполняется через скобочную нотацию со строковым ключом, например log.attributes["http.status_code"]. Если такого ключа не существует, выражение даст nil, а не выбросит ошибку. Это можно сочетать с условиями where, чтобы защитить преобразование:
- set(log.attributes["status_class"], "5xx") where log.attributes["http.status_code"] >= 500
Когда вы перейдёте к более сложным преобразованиям, вам часто придётся перемещать данные между уровнями. Например, можно поднять атрибут журнала на уровень ресурса или скопировать атрибут спана в событие.
Префикс пути точно указывает OTTL, на какой слой иерархии телеметрии вы нацеливаетесь. Правильно выбранный префикс – это разница между точечным изменением и конфигурацией, которая вообще не запустится.
Чтобы увидеть полный список доступных путей для каждого сигнала OpenTelemetry, обратитесь к официальной документации по контекстам OTTL ниже. Каждый контекст определяет поля и аксессоры, допустимые в рамках конкретного префикса:
OTTL в transform processor
Если вы только начинаете работать с OTTL, это руководство подробно разбирает его синтаксис, операторы, выражения путей и библиотеку функций. Здесь же мы сосредоточимся только на двух понятиях, которые особенно важны для того, как transform processor встраивает и выполняет OTTL: поле cache и вывод контекста.
Использование cache как временного рабочего пространства
При работе с OTTL в transform processor вы часто будете видеть обращения к полю cache, например log.cache или span.cache. Это не встроенное поле OTLP, которое автоматически существует в каждой записи. Это просто устоявшийся паттерн: временная карта, которую вы создаёте и используете как рабочее пространство внутри группы statement.
Transform processor позволяет записывать данные в cache и читать их оттуда в рамках одного и того же цикла вычисления. Всё, что туда сохраняется, существует только во время обработки текущей записи и не передаётся дальше по пайплайну, если вы явно не скопируете эти данные в другое место.
Такой паттерн особенно полезен, когда преобразование нельзя чисто выразить одной инструкцией. Самый распространённый пример – разбор JSON, как это уже было показано выше:
log_statements: - merge_maps(log.cache, ParseJSON(log.body), "upsert") - set(log.attributes["level"], log.cache["level"]) - set(log.attributes["request_id"], log.cache["request_id"])
Здесь ParseJSON() возвращает карту, а merge_maps() записывает её в log.cache. Следующие инструкции затем читают значения из кэша и поднимают их в структурированные атрибуты журнала.
Тот же подход можно применять и для промежуточных вычислений. Если несколько инструкций зависят от производного значения, вычислите его один раз, сохраните в cache, а затем ссылайтесь на него в следующих инструкциях. Это делает конфигурацию более читаемой и избавляет от повторения сложных выражений в нескольких строках.
Вывод контекста
Transform processor не выполняет все инструкции на одном и том же уровне иерархии телеметрии. Вместо этого он определяет подходящий контекст OTTL по префиксам путей, которые используются в ваших инструкциях, а затем выполняет обход именно на этом уровне.
Возможные контексты включают resource, scope, span, spanevent, metric, datapoint и log. В большинстве случаев этот вывод происходит автоматически и остаётся незаметным.
Проблемы начинаются тогда, когда в одной и той же группе инструкций вы смешиваете пути, относящиеся к несовместимым контекстам.
Например, convert_sum_to_gauge() работает на уровне контекста metric, тогда как datapoint.attributes относится к контексту datapoint:
metric_statements: - convert_sum_to_gauge() where metric.name == "process.cpu.time" - limit(datapoint.attributes, 10, ["host.name"])
Такая конфигурация завершится ошибкой уже при запуске, потому что процессор не сможет вывести один корректный контекст для всей группы. Вы увидите примерно такую ошибку:
Error: invalid configuration: processors::transform: unable to infer a valid context (["resource" "scope" "metric" "datapoint"]) from statements [ "convert_sum_to_gauge() where metric.name == \"process.cpu.time\"" "limit(datapoint.attributes, 10, [\"host.name\"])" ] and conditions []: inferred context "datapoint" does not support the function "convert_sum_to_gauge"
Фикс простой: нужно разделить инструкции на отдельные группы, чтобы для каждой группы контекст выводился независимо:
metric_statements: - statements: - convert_sum_to_gauge() where metric.name == "process.cpu.time" - statements: - limit(datapoint.attributes, 10, ["host.name"])
Если Collector отказывается запускаться и сообщает об ошибке разбора, связанной с контекстом, почти всегда причина именно в этом. Разносите несовместимые контексты по отдельным группам инструкций и давайте процессору возможность корректно вывести каждый из них.
Настройка условий и групп инструкций

В самом простом варианте transform processor принимает плоский список инструкций. Для простых сценариев этого достаточно, но как только вам нужны разные режимы обработки ошибок, разные контексты или общий шлюз, который должен применяться сразу к нескольким инструкциям, такой подход начинает разваливаться.
Эту проблему решают группы инструкций. Они создают объект со своим собственным context, error_mode, conditions и списком statements. Это можно воспринимать как небольшой блок правил: сначала отобрать нужные записи, а затем выполнить над ними набор инструкций.
transform: error_mode: ignore log_statements: - conditions: - IsMatch(log.body, "^\\{") statements: - merge_maps(log.cache, ParseJSON(log.body), "upsert") - set(log.attributes["parsed.level"], log.cache["level"]) - set(log.attributes["parsed.message"], log.cache["message"]) - set(log.attributes["parsed.timestamp"], log.cache["timestamp"]) - conditions: - log.severity_number == SEVERITY_NUMBER_UNSPECIFIED statements: - set(log.severity_number, SEVERITY_NUMBER_INFO) where IsMatch(log.body, "\\sINFO[:\\s]") - set(log.severity_number, SEVERITY_NUMBER_WARN) where IsMatch(log.body, "\\sWARN(ING)?[:\\s]") - set(log.severity_number, SEVERITY_NUMBER_ERROR) where IsMatch(log.body, "\\sERROR[:\\s]")
У conditions есть две особенности, которые важнее, чем может показаться на первый взгляд.
Условия группы объединяются через OR. Если вы перечислили три условия, группа выполнится, когда истинным окажется хотя бы одно из них. Если вам нужна логика
AND, запишите её как одно выражение сandили перенесите часть логики в условияwhereна уровне отдельных инструкций.
Например, следующая группа выполняется только тогда, когда обе проверки успешны:
- conditions: - IsString(log.body) and IsMatch(log.body, "^\\{") statements: - merge_maps(log.cache, ParseJSON(log.body), "upsert")
2. Условия группы вычисляются раньше, чем защитные условия отдельных инструкций. Чтобы вообще попасть в группу, запись должна пройти проверку conditions группы. После этого для каждой инструкции её where вычисляется уже отдельно.
Иными словами, условие группы – это грубый шлюз, а условия where – более точечные проверки. Именно такая многослойность и делает группы инструкций полезными в больших конфигурациях: вы можете дёшево направить записи в нужную группу, а затем применить точную логику инструкция за инструкцией, не повторяя одни и те же верхнеуровневые проверки.
Параметр flatten_data для преобразования журналов
Чтобы понять, зачем вообще нужен этот параметр, сначала нужно разобраться, как OTLP группирует записи журналов при передаче.
OTLP не передаёт записи журнала в виде плоского списка. Вместо этого они группируются иерархически: несколько записей вкладываются в общий ScopeLogs, который, в свою очередь, вложен в общий ResourceLogs. Если сто записей пришли от одного и того же ресурса, нет смысла сто раз повторять одни и те же атрибуты ресурса.
ResourceLogs resource.attributes: {service.name: "payments", host.name: "node-3"} ScopeLogs LogRecord #1 ← shares the resource above LogRecord #2 ← shares the resource above LogRecord #3 ← shares the resource above
С точки зрения передачи данных это эффективно, но как только вы начинаете записывать в resource.attributes на основе значения, относящегося к конкретной записи, возникает риск незаметного искажения данных.
Проблема незаметной потери данных
Рассмотрим следующее преобразование, которое поднимает kubernetes.pod.name из атрибута журнала на уровень ресурса:
transform: log_statements: - set(resource.attributes["k8s.pod.name"], log.attributes["kubernetes.pod.name"]) - delete_key(log.attributes, "kubernetes.pod.name")
Теперь представим, что приходит пакет из трёх записей, которые используют один и тот же ресурс, но на самом деле происходят из разных pod’ов:
ResourceLogs resource.attributes: {service.name: "payments"} ScopeLogs LogRecord #1 log.attributes["kubernetes.pod.name"] = "payments-7d4b-xkqvp" LogRecord #2 log.attributes["kubernetes.pod.name"] = "payments-7d4b-xkqvp" LogRecord #3 log.attributes["kubernetes.pod.name"] = "payments-7d4b-mnrtz"
Без flatten_data процессор проходит записи последовательно и каждый раз записывает значение в общий ресурс. В итоге побеждает последнее присваивание:
ResourceLogs resource.attributes: { service.name: "payments", k8s.pod.name: "payments-7d4b-mnrtz" ← побеждает последнее присваивание } ScopeLogs LogRecord #1 ← имя pod удалено, теперь запись ошибочно приписана другому pod LogRecord #2 ← имя pod удалено, теперь запись ошибочно приписана другому pod LogRecord #3 ← корректно
Теперь две записи из трёх связаны не с тем pod’ом. Ошибка при этом не возникает, и по выводу Collector никак не видно, что произошла потеря корректной привязки. Именно такой режим отказа и призван предотвратить flatten_data.
Что на самом деле делает flatten_data
Когда вы включаете flatten_data: true, каждая запись журнала получает собственную приватную копию ресурса и области видимости:
LogRecord #1 + приватная копия ресурса LogRecord #2 + приватная копия ресурса LogRecord #3 + приватная копия ресурса
Теперь преобразования записывают данные в изолированные копии, поэтому конфликта больше не возникает. После выполнения всех инструкций процессор заново группирует записи по их итоговым наборам атрибутов ресурса и области видимости. Записи, которые в итоге имеют одинаковые ресурсы, снова объединяются. Записи, у которых итоговые ресурсы различаются, превращаются в отдельные элементы ResourceLogs.
Для приведённого выше примера в итоге получится две группы ресурсов:
ResourceLogs resource.attributes: {service.name: "payments", k8s.pod.name: "payments-7d4b-xkqvp"} ScopeLogs LogRecord #1 LogRecord #2 ResourceLogs resource.attributes: {service.name: "payments", k8s.pod.name: "payments-7d4b-mnrtz"} ScopeLogs LogRecord #3
Теперь каждая запись правильно отнесена к своему pod’у.
Как включить flatten_data
Эта возможность находится за мезанизмом условного включения функции (feature gate, далее фиче-гейт), то есть по умолчанию она не включена и её поведение ещё может меняться. Поэтому флаг нужно явно передать при запуске:
# docker-compose.yaml services: otelcol: image: otel/opentelemetry-collector-contrib:0.146.1 command: ["--config=/etc/otelcol-contrib/config.yaml", "--feature-gates=transform.flatten.logs"] volumes: - ./otelcol.yaml:/etc/otelcol-contrib/config.yaml
Если вы укажете flatten_data: true в конфигурации, но не передадите фиче-гейт, Collector откажется запускаться. Если вы передадите фиче-гейт, но не укажете flatten_data: true, ничего не изменится. Нужны оба условия одновременно.
Поскольку это фиче-гейт, при обновлении обязательно проверяйте список изменений Collector. Возможности, скрытые за такими гейтами, могут позже стать стабильными и перестать требовать отдельного гейта, а могут и вовсе быть удалены в одной из следующих версий. Если вы зависите от этого поведения, фиксируйте версию образа Collector и обновляйтесь осознанно.
Когда flatten_data – не лучшее решение
Прежде чем тянуться к flatten_data, убедитесь, что подъём атрибута на уровень ресурса действительно является правильной целью.
Атрибуты ресурса описывают источник телеметрии, а не содержимое отдельной записи. Поэтому имя пода должно находиться на уровне ресурса, потому что оно указывает, где именно была сгенерирована телеметрия. А вот, например, идентификатор заказа – не должно.
Если всё, что вам нужно, – это подъём атрибутов и последующая перегруппировка, то для этой задачи специально предназначен процессор groupbyattrs, и ему не нужен фиче-гейт.
processors: groupbyattrs: keys: - kubernetes.pod.name - kubernetes.namespace.name
Оставляйте flatten_data для тех случаев, когда вам нужна изоляция ресурсов на уровне отдельных записей, полная выразительность OTTL в рамках одного и того же пайплайна и когда одного groupbyattrs уже недостаточно.
Отладка инструкций OTTL
Даже если вы хорошо понимаете пути, контексты и режимы обработки ошибок, рано или поздно вы всё равно напишете инструкцию, которая как будто ничего не делает. Collector запускается без проблем, ошибок в логах нет, но ниже по пайплайну данные выглядят так, будто ничего не изменилось.
Почти всегда причина сводится к одному из трёх вариантов:
Указанные
conditionвычисляются вfalse.Путь, к которому вы обращаетесь, отсутствует в записях.
Несоответствие типов тихо пропускается, потому что вы работаете с
error_mode: ignore.
Вместо того чтобы гадать, можно заставить процессор показать, что именно он делает. Следующие приёмы дадут вам видимость выполнения инструкций и значительно упростят понимание поведения OTTL.
Включите журналирование уровня debug
Transform processor умеет выводить подробные отладочные логи, в которых показывается полный TransformContext до и после выполнения каждой инструкции. Туда входит результат вычисления условия и точные значения полей.
Вот как включить журналирование уровня debug в Collector:
service: telemetry: logs: level: debug
После этого вы увидите записи примерно такого вида:
debug ottl/parser.go TransformContext after statement execution { "statement": "set(log.attributes[\"environment\"], \"production\")", "condition matched": true, "TransformContext": { "log_record": { "attributes": { "environment": "production" } } } }
Если в поле condition matched стоит false, значит, выполнению мешает ваше условие where или условие на уровне группы. Если там true, но поле меняется не так, как ожидалось, то, скорее всего, у вас либо несоответствие типов, либо запись идёт не по тому пути, который вы имели в виду.
Журналирование уровня debug чрезвычайно многословно, поэтому в пайплайне с промышленным объёмом данных оно создаст лавину вывода. Используйте его с тестовым Collector и контролируемым источником данных, а перед выкладкой изменений обязательно отключайте.
Используйте экспортёр debug для проверки результата
Ещё один практический приём – использовать transform processor вместе с экспортёром debug и разбить пайплайн на цепочку «до и после», чтобы получить наглядное сравнение.

Сравните два вывода, и изменения должны стать заметны сразу. Если вывод до и после одинаков, значит, ваша инструкция ни к чему не применяется – а это уже сужает круг причин до условия или пути.
Практики безопасных преобразований в продакшене
Следующие практики помогут избежать самых распространённых эксплуатационных и смысловых ошибок при использовании transform processor в продакшене.
Осторожно обращайтесь с преобразованием метрик
Не все преобразования метрик безопасны с точки зрения смысла данных.
Функции вроде convert_gauge_to_sum() и convert_sum_to_gauge() переосмысляют значение данных, а модель данных OpenTelemetry не задаёт канонического соответствия между этими типами. Если Gauge отражает мгновенное измерение, то преобразование его в накопительную Sum приписывает данным свойство, которого у них может не быть.
Прежде чем менять тип метрики, убедитесь, что исходные данные действительно обладают той семантикой, которую вы им назначаете. Иначе серверные системы могут начать неправильно обрабатывать преобразованную метрику.
Столь же осторожно нужно подходить к изменению идентичности метрики. Изменение metric.name, удаление атрибутов datapoint или изменение размерностей может привести к тому, что один поток метрик с точки зрения вашей серверной системы исчезнет, а вместо него появится новый. Панели мониторинга и правила оповещений, завязанные на исходное имя или набор меток, перестанут работать.
Добавление атрибутов обычно безопасно. А вот удалять атрибуты или переименовывать метрики следует только осознанно и с полным пониманием последствий.
2. Сохраняйте связи между трейсами и логами
Поля контекста трассировки – это структурные элементы, а не косметические детали. Изменение span.trace_id, span.span_id, span.parent_span_id, log.trace_id или log.span_id может разорвать связь спанов с их родительскими элементами и сломать корреляцию между логами и трейсами.
Поднять существующий атрибут в правильное поле верхнего уровня допустимо, если само значение уже корректно. А вот произвольная генерация или перезапись идентификаторов приведёт к появлению осиротевшей телеметрии, которую будет трудно или вовсе невозможно восстановить.
3. Выбирайте правильный режим обработки ошибок
Реальная телеметрия редко бывает идеально чистой. В режиме propagate одно неожиданное значение nil может привести к отбрасыванию всего пакета. Используйте propagate при тестировании, чтобы как можно раньше выявлять ошибки, а для боевого трафика переключайтесь на ignore.
4. Делайте преобразования узконаправленными и модульными
Длинные монолитные списки инструкций трудно осмысливать и ещё труднее отлаживать. Если вы замечаете, что в одном процессоре смешиваете несвязанные задачи, такие как нормализация, обогащениеи маскирование, разделите их на несколько именованных экземпляров:
transform/normalizetransform/enrichtransform/redact
Collector поддерживает несколько экземпляров одного и того же типа процессора через суффикс /name. Небольшие, специализированные процессоры проще проверять и сопровождать.
5. Ограничивайте область действия инструкций через where
Каждая инструкция без условия применяется к каждой записи. В пайплайнах с большим объёмом данных безусловные проверки по регулярным выражениям или сложные выражения быстро начинают накапливать ощутимую нагрузку. Активно используйте where, чтобы ограничивать обработку только теми записями, которые действительно нужно преобразовать.
6. Явно обрабатывайте nil
Обращение к отсутствующему атрибуту возвращает nil, а не ошибку. Если передать nil в функцию, ожидающую конкретный тип, это либо вызовет сбой в строгих режимах обработки ошибок, либо будет молча пропущено. Поэтому необязательные поля нужно явно защищать условием:
- set(span.attributes["normalized"], ToLower(span.attributes["optional_key"])) where span.attributes["optional_key"] != nil
Такой шаблон предотвращает целый класс трудноуловимых сбоев.
7. Учитывайте порядок инструкций
Инструкции выполняются последовательно, поэтому если инструкция B зависит от результата инструкции A, она должна идти после A. Если инструкция C удаляет атрибут, который нужен инструкции D, то D должна стоять раньше. Когда преобразования разрастаются больше чем на несколько строк, полезно сначала набросать порядок зависимостей и только потом писать конфигурацию.
8. Проверяйте перед деплоем
Всегда проверяйте изменения с помощью экспортёра debug или контролируемого тестового пайплайна, прежде чем выкатывать их в продакшен. Это особенно важно для преобразований метрик, где изменение идентичности может незаметно сломать дашборды мониторинга и оставаться незамеченным до тех пор, пока кто-нибудь наконец не обратит на это внимание.
Transform processor показывает себя лучше всего тогда, когда используется осознанно и с чёткой ментальной моделью. Применяйте преобразования аккуратно, тщательно их проверяйте и прежде всего сохраняйте смысл своей телеметрии.

Если мониторинг формально есть, но в момент инцидента он либо молчит, либо засыпает шумом — значит, проблема не в данных, а в том, как система настроена и интерпретирует сигналы.
31 марта в 20:00 на открытом уроке разберемся, как диагностировать такие сбои на примере Zabbix: где искать ошибки в конфигурации, как находить узкие места и что менять, чтобы мониторинг начал реально отражать состояние системы, а не создавать иллюзию контроля. Участие бесплатное, записывайтесь.
Открытый урок пройдет в рамках курса «Observability: мониторинг, логирование, трассировка». Пройдите входной тест по курсу, чтобы оценить свои знания и навыки.
