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

Наблюдаемость с OpenTelemetry

Вам нужны трейсы и метрики dev-стека — для себя и для кодового агента, который в нём работает, — без правок в продакшен-образах сервисов и без того, чтобы включать это всем подряд. Это руководство строит опциональный tool-сервис otel, который любой разработчик включает одной командой:

Окно терминала
dwe services enable otel --apply # бэкенд поднят, сервисы экспортируют в него
dwe services disable otel --apply # его нет; сервисы снова работают на своём обычном конфиге

Механизма «паков» в DWE нет, и он здесь не нужен: вся фича — это tool-сервис, чей compose:-оверлей поднимает бэкенд, а compose_after:-оверлей патчит соседние app-сервисы. Когда сервис включён, оба файла передаются в Compose; когда выключен, ни один из них не читается, и app-сервисы работают ровно по базовому определению без каких-либо накладных расходов.

Что получится в итоге:

  • grafana/otel-lgtm — OTel Collector + Tempo (трейсы) + Loki (логи) + Prometheus (метрики) + Grafana в одном контейнере, доступный по http://otel.<project>.localhost. Loki в образе есть, но рецепты здесь не экспортируют логи приложений (OTEL_LOGS_EXPORTER: "none"): логи остаются в собственном выводе контейнера и коррелируют с трейсами там, где приложение пишет trace_id/span_id в свои строки логов (обёртка slog для Go ниже). Включить OTLP-логи можно (например, OTEL_LOGS_EXPORTER: "otlp" с дистрибутивом Python), но здесь это не описано и не проверено.
  • App-сервисы, экспортирующие OTLP в него и инструментированные под свой язык (ниже рецепты для Python, Go, Node и PHP).
  • Текстовый поиск трейсов для агента (dwe cmd otel.traces) и раздел для вашего AGENTS.md, чтобы агент читал трейс раньше, чем код.
workspace/services/otel/service.yml
# tool: бэкенд OpenTelemetry (grafana/otel-lgtm). Опциональный, по умолчанию ВЫКЛЮЧЕН.
# Бэкенд лежит в compose:; патч app-сервисов, из-за которого они начинают
# экспортировать сюда, лежит в compose_after:, поэтому он побеждает
# собственный оверлей каждого приложения, — один переключатель включает всю
# фичу целиком (см. compose/otel.yml и compose/otel-apps.yml).
type: tool
container: "otel"
compose:
- compose/otel.yml
compose_after:
- compose/otel-apps.yml
hosts:
web: otel.myproject.localhost
icon: "📊"
info:
title: "Grafana · traces, metrics (OpenTelemetry)"
notes:
disable: "Optional dev tool; the stack runs without it."

Без ports: — Grafana доступна через vhost прокси, а приём OTLP (otel:4318) и query API Tempo (otel:3200) используются только внутри compose-сети. Сервис type: tool не может объявлять depends_on, и ему это не нужно: dev-инструмент никогда не блокирует стек.

Выключите его по умолчанию в workspace/defaults.yml (тяжёлый образ, шесть процессов и инструментированное приложение, о котором никто не просил):

services:
"otel":
enabled: false

Добавьте vhost в Caddyfile. Caddy разрешает апстрим лениво, так что блок безвреден, пока контейнера нет:

http://otel.myproject.localhost {
reverse_proxy otel:3000
}

nginx не ленив: буквальный proxy_pass http://otel:3000 разрешается один раз при старте, и nginx отказывается стартовать, если имени ещё нет, — контейнер otel создаётся одновременно с nginx и без depends_on. Вместо этого разрешайте имя на каждый запрос через DNS Docker, а шаблон vhost пусть монтирует в (всегда включённый) прокси-сервис сам оверлей otel — тогда nginx видит его, только пока инструмент включён:

# configs/nginx/templates/tools/otel.conf.template — envsubst официального образа
# подставляет только переменные, заданные в его окружении, так что $host остаётся.
server {
listen 80;
server_name ${OTEL_HOST};
resolver 127.0.0.11 valid=10s;
set $otel_upstream http://otel:3000;
location / {
proxy_pass $otel_upstream;
proxy_set_header Host $host;
proxy_set_header Upgrade $http_upgrade; # вебсокет Grafana Live
proxy_set_header Connection "upgrade";
}
}
# compose/otel.yml — рядом с сервисом otel
nginx:
volumes:
- ./configs/nginx/templates/tools/otel.conf.template:/etc/nginx/templates/otel.conf.template:ro
environment:
OTEL_HOST: "${OTEL_HOST:-otel.myproject.localhost}"
# workspace/defaults.yml — .env несёт хост, только пока инструмент включён
exports:
env:
- name: OTEL_HOST
from: services.otel.hosts.web
when: services.otel.enabled

Запустите dwe validate перед первым dwe run: собственные валидаторы проекта могут пинить каждый опциональный сервис где-то ещё — например, сценарий интеграционного теста, перечисляющий, какие сервисы он включает и выключает, — и новый tool-сервис нужно добавить и туда.

Больше для Grafana за прокси ничего не нужно — собственный vhost в корне означает, что не нужны ни GF_SERVER_ROOT_URL, ни подпуть. Анонимный доступ в образе — Admin; для dev-машины нормально, но знать об этом стоит.

# compose/otel.yml — бэкенд otel.
# Относительные пути bind-маунтов разрешаются от каталога первого -f файла
# (compose.base — корень проекта при обычной раскладке), а не от compose/.
services:
otel:
# Пиньте версию: `:latest` залипает в локальном кэше образов. 0.33.1 — multi-arch.
image: grafana/otel-lgtm:0.33.1
# Без container_name: фиксированное имя обходит скоупинг compose-проекта и
# конфликтует с копиями, которые запускает `dwe test` (`dwe validate` предупреждает).
restart: unless-stopped
# Образ не объявляет VOLUME: всё состояние (Grafana, Loki, Prometheus,
# Tempo, Pyroscope) лежит в /data в записываемом слое, поэтому без тома каждое
# пересоздание — правка оверлея + `dwe run`, enable/disable --apply,
# `dwe reset` — теряет всю телеметрию. Именованный том в рамках проекта её
# сохраняет. Дашборды берутся из репозитория (ниже).
volumes:
- otel_data:/data
- ./configs/otel/provisioning/dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/grafana-dashboards.yaml:ro
- ./configs/otel/dashboards:/otel-lgtm/grafana-dashboards-myproject:ro
# В образе есть собственный HEALTHCHECK (`/otel-lgtm/docker/healthcheck.sh`,
# 30s / 5s / 3 retries), но БЕЗ start_period. `dwe run` использует `compose up
# --wait`; холодный старт шести процессов занимает больше 90 с, так что без этого
# блока контейнер становится unhealthy и весь `dwe run` падает.
healthcheck:
test: ["CMD", "/otel-lgtm/docker/healthcheck.sh"]
interval: 30s
timeout: 5s
retries: 3
start_period: 120s
start_interval: 5s
volumes:
otel_data:
# compose/otel-apps.yml — патч app-сервисов, перечисленный в compose_after:,
# поэтому он эмитится после каждой группы сервисов (tool → infra → app) и
# побеждает собственный оверлей приложения, а не проигрывает ему.
# Compose сливает это с базовым определением: `command` ЗАМЕНЯЕТСЯ,
# `environment` и `volumes` СЛИВАЮТСЯ. Никакого `depends_on: otel` ни в одну
# сторону — спаны, отправленные до того, как коллектор ответит, отбрасываются
# с предупреждением в логе приложения, и это задуманное поведение.
services:
app:
environment:
OTEL_SERVICE_NAME: "app"
OTEL_RESOURCE_ATTRIBUTES: "service.namespace=${COMPOSE_PROJECT_NAME},deployment.environment.name=dev"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318"
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
OTEL_TRACES_EXPORTER: "otlp"
OTEL_METRICS_EXPORTER: "otlp"
OTEL_LOGS_EXPORTER: "none"
# В dev сценарий «сделал запрос — посмотрел»: сбрасываем спаны каждую 1 с и
# отправляем метрики каждые 10 с (панелям с rate() нужно два сэмпла).
# Это ручки конкретного языка для долгоживущего процесса: под php-fpm они
# ничего не делают — там каждый запрос собирает свежий SDK и сбрасывает всё при завершении.
OTEL_BSP_SCHEDULE_DELAY: "1000"
OTEL_METRIC_EXPORT_INTERVAL: "10000"

Ловушки, о которых стоит знать до первого dwe run:

  • dwe validate не видит собственный HEALTHCHECK образа. Заметка config.healthcheck_start_period покрывает только блок healthcheck:, записанный в compose, поэтому медленный дефолт образа вроде этого проявляется как зависший или упавший dwe run, а не как находка.
  • enable|disable --apply перезапускает весь стек. Сервис без on_enable / on_disable получает requires: restart по умолчанию, то есть полный dwe restart — вместе с базой данных и сетью (~11 с на небольшом стеке Laravel), — и приложение отвечает 502 через прокси, пока его контейнер снова не станет healthy. Ждите его health-эндпоинт, а не возврата из команды dwe. Путь полегче — без --apply: dwe services enable otel записывает переключатель, затем dwe run (compose up --wait с --remove-orphans по умолчанию) пересоздаёт только контейнеры, чьё определение изменилось, — otel, пропатченные приложения, прокси, если оверлей его трогает, — и снимает ожидающий рестарт.
  • Первые минуты все панели с rate() показывают «No data». Это интервал экспорта, а не сломанный пайплайн; сделайте несколько запросов и сначала загляните в Explore → Tempo.
  • depends_on на опциональный сервис в базовом compose.yaml ломает стек, пока этот сервис выключен. Держите связку внутри оверлея.
  • Патчите только сервисы, которые существуют всегда, когда включён инструмент. В блоке патча нет ни image:, ни build:, поэтому он валиден только поверх определения из более раннего файла. Патчите сервис из базового compose.yaml или всегда включённый. Если пропатченное приложение живёт в собственном оверлее (например, compose/services/site.yml) и разработчик выключает его, оставив otel включённым, compose/otel-apps.yml всё равно объявляет site: только с environment: / volumes:, и Compose отвергает весь проект: у сервиса нет ни образа, ни контекста сборки. compose_after: чинит порядок, в котором эмитится патч, а не то, существует ли пропатченный сервис, — эта ловушка всё ещё актуальна.
  • Варианты, собранные через extends:, не инструментированы. Отладочный вариант, объявленный как extends: {file: compose.yaml, service: app}, читает базовый файл, а не патч из compose_after:, и молча работает без окружения OTEL_*. Если вместо этого пропатчить его в compose/otel-apps.yml, вы попадёте в предыдущую ловушку всякий раз, когда вариант выключен.
  • compose_after: побеждает собственный оверлей приложения. DWE эмитит файл из compose_after: после каждой группы сервисов (tool → infra → app; dwe compose files печатает цепочку), поэтому compose/otel-apps.yml идёт после compose/<app>.yml и выигрывает при каждом слиянии целым значением: command:, healthcheck: и любой ключ environment:, который задают оба файла. Новые env-ключи и дополнительные volumes: сливаются нормально из любого яруса. Переопределение целым значением кладите в compose_after: — обычный оверлей compose: по-прежнему проигрывает такое слияние более позднему оверлею приложения.

Образ провижинит три дашборда: RED-метрики (классическая гистограмма), RED-метрики (нативная гистограмма — нужна фича Prometheus, которую этот бандл не включает) и дашборд для JVM. Замените его файл провайдера своим, оставьте классический RED и направьте второй провайдер на каталог в вашем репозитории:

configs/otel/provisioning/dashboards.yaml
apiVersion: 1
providers:
- name: "RED Metrics (classic histogram)"
type: file
options:
path: /otel-lgtm/grafana-dashboard-red-metrics-classic.json
foldersFromFilesStructure: false
- name: "myproject"
type: file
folder: "myproject"
allowUiUpdates: true
disableDeletion: false
options:
path: /otel-lgtm/grafana-dashboards-myproject
foldersFromFilesStructure: false

UID источников данных в образе — prometheus, tempo, loki, pyroscope. Обратной записи из контейнера в репозиторий нет: правьте в UI, копируйте JSON-модель, вставляйте её поверх файла в configs/otel/dashboards/, коммитьте.

Не рассчитывайте, что дашборд переедет между языками. Имена метрик следуют инструментированию: Python экспортирует http_server_duration_milliseconds_* с http_target плюс метрики процесса/GC, а рецепт для Go ниже только трейсовый и никаких метрик приложения не экспортирует. Проверить, что реально приходит, можно через curl http://otel:9090/api/v1/label/__name__/values из любого контейнера. Единственное, что переносится без изменений, — панели на span-метриках Tempo (traces_spanmetrics_*: span_name, span_kind, status_code, service).

Патч приложений (compose/otel-apps.yml) несёт окружение OTEL_*; что каждый сервис с ним делает, зависит от языка. Цель у всех рецептов одна: в репозитории приложения нет никакого диффа, пока инструмент выключен, и как можно меньше — когда включён.

Автоинструментирование через opentelemetry-instrument, накладываемое поверх venv проекта при старте контейнера. Патч приложений заменяет command приложения:

compose/otel-apps.yml
app:
command:
- uv
- run
- --frozen
- --with
- opentelemetry-distro==0.65b0
- --with
- opentelemetry-exporter-otlp-proto-http==1.44.0
- --with
- opentelemetry-instrumentation-fastapi==0.65b0
- --with
- opentelemetry-instrumentation-sqlalchemy==0.65b0
- --with
- opentelemetry-instrumentation-redis==0.65b0
- watchfiles
- --filter
- python
- opentelemetry-instrument python -m app
- app
environment:
OTEL_PYTHON_FASTAPI_EXCLUDED_URLS: "healthz"

watchfiles — не пакет OpenTelemetry: это раннер горячей перезагрузки, которым уже пользовался базовый compose, поэтому он должен быть dev-зависимостью проекта — иначе добавьте --with watchfiles. Пины относятся к одному релизному поезду (SDK 1.44.0 ↔ contrib 0.65b0); поднимайте их вместе. Инструментируйте только один слой БД (SQLAlchemy или asyncpg), иначе каждый запрос появится дважды. Дочерний процесс, который запускает вотчер, наследует наложенный PATH, так что горячая перезагрузка продолжает работать. --with разрешается мимо uv.lock; если фича остаётся надолго, перенесите пакеты в недефолтную группу зависимостей и используйте uv run --frozen --group otel. Корневые спаны для фоновых воркеров и обработчиков ботов автоматически не появляются: несколько строк на opentelemetry-api (без SDK это no-op) вокруг каждого тика дают трейсу имя. OTLP по HTTP (4318), а не gRPC — не нужно собирать wheel grpcio на свежем Python.

Рантайм-инъекции для Go нет; приложение линкует SDK. Держите его инертным, пока эндпоинт не задан, — по образцу «пустой DSN — нет SDK», которым пользуется большинство трекеров ошибок:

internal/telemetry/telemetry.go
// Init устанавливает OTel SDK, когда задан OTEL_EXPORTER_OTLP_ENDPOINT, и
// возвращает функцию завершения. При пустой переменной ничего не делает, и
// обёртки otelhttp / otelpgx ниже проваливаются в глобальный no-op провайдер.
func Init(ctx context.Context) (shutdown func(context.Context) error, err error) {
if os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") == "" {
return func(context.Context) error { return nil }, nil
}
exp, err := otlptracehttp.New(ctx) // эндпоинт, протокол, заголовки: всё из OTEL_* env
if err != nil {
return nil, err
}
res, _ := resource.New(ctx,
resource.WithFromEnv(),
// Не WithProcess(): он включает WithProcessCommandArgs(), и argv — DSN,
// токены, переданные флагами, — попал бы в каждый экспортируемый ресурс.
resource.WithProcessPID(),
resource.WithProcessExecutableName(),
resource.WithProcessRuntimeName(),
resource.WithProcessRuntimeVersion(),
)
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(time.Second)),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{}) // читает/пишет `traceparent`
return tp.Shutdown, nil
}
// HTTP-сервер: otelhttp самым внешним, health-пробы отфильтрованы. chi знает
// шаблон маршрута только ПОСЛЕ роутинга, поэтому внешний хендлер не может назвать
// спан; второй, внутренний middleware переименовывает его, когда запрос уже смаршрутизирован.
r.Use(func(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "http", otelhttp.WithFilter(func(r *http.Request) bool {
switch r.URL.Path {
case "/healthz", "/readyz", "/version":
return false
}
return true
}))
})
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
if pattern := chi.RouteContext(r.Context()).RoutePattern(); pattern != "" {
trace.SpanFromContext(r.Context()).SetName(r.Method + " " + pattern)
}
})
})
// Пул pgx: подключаем трейсер к конфигу пула. Запросы, сгенерированные sqlc,
// начинаются с `-- name: GetEntries :many`; без функции имени эта строка-комментарий
// становится именем спана, поэтому выводим его из заголовка.
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, fmt.Errorf("parse database DSN: %w", err)
}
cfg.ConnConfig.Tracer = otelpgx.NewTracer(
// otelpgx.WithIncludeQueryParameters(), // по желанию: буквальные значения параметров в спанах
otelpgx.WithSpanNameFunc(sqlcSpanName),
)
pool, err := pgxpool.NewWithConfig(ctx, cfg)

WithIncludeQueryParameters закомментирован намеренно: он пишет буквальные значения параметров в спаны, а Grafana здесь даёт анонимный Admin. Включайте его только на сугубо локальном стеке с нечувствительными данными.

Рецепт только трейсовый: Init не ставит MeterProvider, поэтому otelhttp пишет свои метрики в глобальный no-op meter, и ничего не экспортируется. Задайте OTEL_METRICS_EXPORTER: "none" в патче Go-сервиса, а не копируйте otlp из примера выше. RED-панели для Go-сервиса строятся на span-метриках Tempo (traces_spanmetrics_*), которые выводятся из самих трейсов.

Модули: go.opentelemetry.io/otel, go.opentelemetry.io/otel/sdk, go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp, go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp, github.com/exaring/otelpgx. Ожидайте, что go get поднимет всё семейство go.opentelemetry.io/otel, которое SDK трекера ошибок уже подтянул транзитивно, — держите их на одной версии. Исходящим HTTP-вызовам нужен otelhttp.NewTransport(http.DefaultTransport). Строки логов коррелируют с трейсами через небольшую обёртку slog.Handler, которая добавляет trace_id/span_id из trace.SpanContextFromContext(ctx), когда спан валиден, — иначе это no-op, так что её можно ставить безусловно.

otelpgx ≥ 0.12 говорит на актуальных семантических конвенциях: db.system.name, db.query.text, db.operation.name, db.namespace, db.collection.name, плюс собственный pgx.query.parameters, когда включён WithIncludeQueryParameters. Всё, что читает спаны, должно знать и старый, и новый набор ключей — см. раздел 4.

Node загружает инструментирование раньше приложения, если попросить об этом через --import; в репозитории приложения ничего не меняется. Пакеты лежат в каталоге воркспейса со своим package.json, монтируются в контейнер и устанавливаются при первом старте:

compose/otel-apps.yml
site:
environment:
NODE_OPTIONS: "--import /opt/otel-node/register.mjs"
OTEL_SERVICE_NAME: "site"
OTEL_RESOURCE_ATTRIBUTES: "service.namespace=${COMPOSE_PROJECT_NAME},deployment.environment.name=dev"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318"
# exporter-trace-otlp-http умеет только JSON; protobuf-вариант — это
# другой пакет. Пишите то, что правда, а не копируйте значение из Go.
OTEL_EXPORTER_OTLP_PROTOCOL: "http/json"
OTEL_TRACES_EXPORTER: "otlp"
OTEL_METRICS_EXPORTER: "none"
OTEL_LOGS_EXPORTER: "none"
OTEL_NODE_ENABLED_INSTRUMENTATIONS: "http,undici"
OTEL_BSP_SCHEDULE_DELAY: "1000"
# Выключает SSR-половину Sentry SDK в приложении — см. ловушки ниже.
SENTRY_DSN: ""
volumes:
- ./workspace/otel/node:/opt/otel-node
workspace/otel/node/
package.json # @opentelemetry/api (та же 1.x, что и собственная копия приложения!),
package-lock.json # auto-instrumentations-node, sdk-node, sdk-trace-base,
# exporter-trace-otlp-http, instrumentation — запиненные
ensure.sh # `npm ci` с проверкой хэша, не фатальный, запускается из register.mjs
register.mjs # точка входа для --import
instrumentation.mjs # NodeSDK + span processor

register.mjs — единственное, что называет NODE_OPTIONS, и он обязан быть защитным, потому что NODE_OPTIONS действует на весь контейнер: он доходит до самого npm, до синхронизации зависимостей в образе и до node -e в compose-хелсчеке. Поэтому он (1) сразу возвращается, когда process.argv[1] пуст (это проба хелсчека), (2) синхронно запускает ensure.sh с очищенным NODE_OPTIONS для дочернего npm ci (иначе установка рекурсивно зайдёт в саму себя), (3) вызывает module.register('@opentelemetry/instrumentation/hook.mjs', import.meta.url) — ESM-хук загрузчика, который позволяет instrumentation-http пропатчить сервер фреймворка, — и (4) импортирует instrumentation.mjs, который запускает NodeSDK с getNodeAutoInstrumentations() и OTLP-экспортером, настроенным из окружения.

Undici (глобальный fetch) перехватывается через diagnostics_channel, так что SSR-фреймворк, вызывающий бэкенд, передаёт traceparent без кода. Что нарушает обещание «без диффа» или добавляет шум:

  • Sentry SDK v8+ в приложении. Sentry.init запускает собственную настройку OpenTelemetry. Глобальный реестр @opentelemetry/api работает по принципу «кто первый, тот и прав», поэтому SDK, зарегистрированный из --import, сохраняет провайдер, но предзагруженные инструментирования http/undici от Sentry всё равно работают и дают второй серверный спан на каждый запрос через ваш провайдер. Патч site: в compose/otel-apps.yml задаёт SENTRY_DSN: "" — такой ключ environment: целым значением побеждает собственный оверлей приложения, — так что SSR-половина Sentry выключена, пока инструмент включён, для любого приложения, которое трактует ложный SENTRY_DSN как «выключено». Приложение, которое откатывается на DSN по умолчанию при любом ложном значении (dsn: process.env.SENTRY_DSN || defaultDsn) или захардкодило DSN, этим не покрыто: и пустая, и неустановленная переменная приводят к значению по умолчанию, поэтому исправлять нужно собственный код инициализации приложения. Браузерная половина (PUBLIC_*, встраивается при сборке) не затронута. Скажите об этом в комментариях оверлея.
  • Health-пробы. Собственный процесс compose-хелсчека пропускается проверкой argv[1], но его запрос всё равно доходит до инструментированного сервера. Фильтрация в конфиге http-инструментирования пропускает спаны, которые создают другие инструментирования, поэтому отбрасывайте их в обёртке SpanProcessor вокруг BatchSpanProcessor (onEnd: путь / без user-agent, /@vite/, /@fs/, /node_modules/, /__astro*) — она стоит после всех инструментирований. Учтите, что хелсчек, рендерящий полную страницу, всё равно создаёт реальный трафик к бэкенду на каждом интервале, и бэкенд его трейсит.
  • Нет http.route на стороне сайта. Фреймворк не передаёт шаблон маршрута в инструментирование, так что серверные спаны именуются по пути. Группируйте по префиксу пути в инструменте поиска.

PHP (php-fpm, Laravel) — без диффа в приложении, одна строка в образе

Заголовок раздела «PHP (php-fpm, Laravel) — без диффа в приложении, одна строка в образе»

У автоинструментирования PHP две половины: C-расширение opentelemetry (движок хуков, который нельзя поставить при старте контейнера) и SDK с пакетами инструментирования (Composer). Обе остаются вне репозитория приложения. Расширение собирается в образ, но не включается; пакеты ставятся в отдельный vendor-каталог внутри воркспейса; оверлей монтирует ini, который загружает и то и другое, так что при выключенном инструменте PHP работает ровно как раньше.

# Образ приложения. pecl нужны phpize и компилятор; Debian-образы php:*-fpm сохраняют и то и другое.
# Без docker-php-ext-enable: ini в conf.d нет, поэтому .so никогда не загружается (паттерн xdebug).
ARG OTEL_EXT_VER=1.4.2
RUN pecl install -o -f opentelemetry-${OTEL_EXT_VER} && rm -rf /tmp/pear

Почему бы просто не включить его: загруженное расширение ставит observer API даже без единого зарегистрированного хука и стоит ~25% на микробенчмарке вызовов пользовательских функций. Если же гейтить его build-аргументом или вторым образом, каждое переключение потребует пересборки. Сама строка в образе требует одной пересборки — dwe docker build <service>, — потому что ни dwe run, ни dwe services enable --apply образы не пересобирают.

# compose/otel-apps.yml — command: сливается целым значением, отсюда compose_after:
app:
command: ["/opt/otel-php/ensure.sh", "php-fpm", "-F"] # ENTRYPOINT образа по-прежнему выполняется первым
volumes:
- ./workspace/otel/php:/opt/otel-php # rw: ensure.sh пишет build/
- ./workspace/otel/php/otel.ini:/usr/local/etc/php/conf.d/zz-otel.ini:ro
environment:
OTEL_PHP_AUTOLOAD_ENABLED: "true"
OTEL_SERVICE_NAME: "app"
OTEL_RESOURCE_ATTRIBUTES: "service.namespace=${COMPOSE_PROJECT_NAME},deployment.environment.name=dev"
OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318"
OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf"
OTEL_TRACES_EXPORTER: "otlp"
OTEL_METRICS_EXPORTER: "none" # периодический reader никогда не тикает внутри одного запроса
OTEL_LOGS_EXPORTER: "none"
OTEL_PROPAGATORS: "tracecontext,baggage"
OTEL_PHP_DISABLED_INSTRUMENTATIONS: "pdo" # один слой БД — см. ниже
workspace/otel/php/
composer.base.json # open-telemetry/sdk, exporter-otlp, opentelemetry-auto-laravel, opentelemetry-auto-pdo;
# allow-plugins: tbachert/spi (через него SDK находит экспортер и инструментирования)
ensure.sh # composer update в build/ с проверкой хэша, не фатальный, затем exec "$@"
prepend.php # auto_prepend_file: сначала автозагрузчик приложения, затем build/vendor
otel.ini # extension=opentelemetry + auto_prepend_file=/opt/otel-php/prepend.php
.gitignore # build/

Отдельный vendor не должен тянуть второй Laravel. Если разрешать его самостоятельно, opentelemetry-auto-laravel требует laravel/framework, и Composer ставит второй полный фреймворк (~75 пакетов) рядом с фреймворком приложения, часть — в других версиях: две копии одного класса за двумя автозагрузчиками. Поэтому ensure.sh пишет build/composer.json из composer.base.json и composer.lock приложения: каждый залоченный пакет попадает в replace ровно в своей залоченной версии, а всё, что эти пакеты сами заменяют или предоставляют (illuminate/*, psr/http-client-implementation, …), — в provide:

Окно терминала
jq --slurpfile lock "$app_lock" '
($lock[0].packages + ($lock[0]["packages-dev"] // [])) as $pkgs
| .replace = ($pkgs | map({(.name): .version}) | add)
| .provide = ($pkgs | map(. as $p | ((.replace // {}) + (.provide // {}))
| to_entries | map({(.key): (if .value == "self.version" then $p.version else .value end)}))
| flatten | add // {})
' composer.base.json > build/composer.json
(cd build && composer update --no-dev --no-interaction --quiet)

После этого Composer проверяет ограничения OTel-пакетов против того, что реально есть у приложения, и ставит только пакеты, нужные одному OTel (13 в тестовом проекте). Хэш покрывает composer.base.json и lock приложения, так что рестарт без изменений пропускает Composer, а composer update в приложении приводит к повторному разрешению при следующем старте; цена — доступ к Packagist на этом старте и отсутствие закоммиченного lock-файла у сайдкара.

prepend.php подключает vendor/autoload.php приложения раньше, чем свой, — bootstrap-файлам SDK сразу нужны классы psr/guzzle/polyfill из приложения, а getLoader() Composer возвращает закэшированный загрузчик, когда public/index.php подключает его повторно. Кроме того, он ограничен по области действия: ini доходит до каждого PHP-процесса в контейнере, а загрузка vendor приложения в собственный процесс composer смешивает две версии Symfony Console:

<?php
(static function (): void {
$sapi = PHP_SAPI;
$script = (string) ($_SERVER['SCRIPT_FILENAME'] ?? '');
if ($sapi !== 'fpm-fcgi' && !($sapi === 'cli' && basename($script) === 'artisan')) {
return;
}
$app = '/workspace/src/vendor/autoload.php';
$otel = __DIR__ . '/build/vendor/autoload.php';
if (!extension_loaded('opentelemetry') || !is_file($app) || !is_file($otel)) {
return;
}
// Воркеры очереди: корневые спаны отбрасываем, джобы оставляем (они несут traceparent диспетчера).
if ($sapi === 'cli' && in_array($_SERVER['argv'][1] ?? '', ['queue:work', 'queue:listen'], true)
&& getenv('OTEL_TRACES_SAMPLER') === false) {
putenv('OTEL_TRACES_SAMPLER=parentbased_always_off');
}
require $app;
require $otel;
})();

Что получается: серверный спан на запрос с http.route, по одному спану sql <VERB> на запрос к БД с новыми ключами (db.system.name, db.query.text, плейсхолдеры, а не значения), корневой Command <name> для php artisan <cmd> и джоба из очереди как спан process внутри трейса, который её отправил, — Laravel передаёт traceparent в payload джобы. Весь стек добавил ~4,5 мс по p50 к приветственной странице на 11 мс. Ловушки:

  • Один слой БД. Наблюдатель запросов opentelemetry-auto-laravel уже даёт по спану на запрос; если активен ещё и auto-pdo, у каждого запроса появляются соседи PDO::prepare / PDOStatement::execute / fetchAll. Держите pdo в OTEL_PHP_DISABLED_INSTRUMENTATIONS, если вам не нужны PDO::connect или сырые PDO-вызовы в обход query builder.
  • Шум от опроса очереди. queue:listen каждые ~3 с запускает queue:work --once; без правила parentbased_always_off каждый опрос — это 3-секундный трейс примерно с 20 SQL-спанами, даже на пустой очереди. Обратная сторона: джоба, отправленная без контекста трейса (из процесса, который prepend.php пропускает, или поставленная в очередь, пока инструмент был выключен), не трейсится вовсе.
  • clear_env. По умолчанию в php-fpm clear_env = yes вычищает все переменные OTEL_* из воркеров, и ничего не трейсится, без единой ошибки. Официальный образ задаёт clear_env = no; собственный конфиг пула в проекте должен это сохранить.
  • Экспорт всё равно занимает воркер. Каждый запрос собирает свежий SDK и сбрасывает спаны в своём shutdown-обработчике, поэтому OTEL_BSP_SCHEDULE_DELAY здесь ничего не делает. Laravel сначала вызывает fastcgi_finish_request() — клиент не ждёт экспорта, а FPM-воркер ждёт.
  • Коллектор лежит, оверлей включён (dwe stop otel): запросы по-прежнему отвечают 200, но каждый пишет стек-трейс экспорта в лог FPM. Присмотритесь к OTEL_PHP_LOG_DESTINATION.

Grafana — для людей. Агенту нужна команда с компактным выводом, и для этого достаточно HTTP API Tempo; основную работу делают два эндпоинта: GET :3200/api/search?q=<TraceQL>&start=&end=&limit= и GET :3200/api/v2/traces/<hex>. Трейсы скрипт читает через v2, потому что он возвращает стандартную форму OTLP ({"trace": {"resourceSpans": […]}}), а v1 (/api/traces/<hex>) отдаёт те же спаны под устаревшим ключом batches. На неизвестный id v2 отвечает пустым 200, а не 404, поэтому пустой resourceSpans означает «не найден». Поисковый индекс отстаёт на 10–60 с (наблюдалось ~45 с); трейс по id читается сразу, и именно это делает цикл поиска ниже надёжным. Запросам значений тегов (/api/v2/search/tag/resource.service.name/values) тоже нужны start/end: без них Tempo отвечает только из live store, и сервис, чьи спаны уже лежат в завершённых блоках — например, после рестарта otel, — выпадает из списка.

DWE поставляет для этого Python-скрипт только на stdlib в examples/otel/ (traces.py вместе с юнит-тестами): подкоманды summary, list, show <id>, traceparent, services и selftest. Скопируйте его в workspace/otel/. Он работает внутри любого контейнера стека, где есть python3, — в самом образе otel есть только curl и bash, и в официальных образах php:* его тоже нет, включая Debian; проверьте кандидата через docker exec <container> python3 --version, — и монтируется оверлеем, так что команда существует ровно тогда, когда существует бэкенд:

# compose/otel-apps.yml, пропатченный app-сервис
volumes:
- ./workspace/otel:/opt/otel:ro
workspace/commands/otel.yml
group:
title: Otel
description: "Trace lookup against the otel tool service (Tempo), for triage without opening Grafana"
# Скрыта, пока инструмент выключен: маунта выше тогда нет.
hide: '{{ not (index .Raw "services" "otel" "enabled") }}'
commands:
traces:
type: service_exec
description: "Traces as text (SQL, slow spans, N+1, exceptions): -- summary | list | show <id> | traceparent | -h"
service: app
mode: exec-or-fail # никогда не поднимать одноразовый контейнер без трафика
workdir: /workspace/src
argv: [python3, /opt/otel/traces.py, "${args}"]
messages:
error: "otel.traces failed — see the message above (exit 2: backend unreachable, is otel enabled? dwe services enable otel --apply; exit 3: Tempo rejected the query)"

Если ни в одном контейнере нет Python (стек на PHP), не заводите второй tool-сервис: объявите одноразовый compose-сервис в том же compose/otel.yml, за профилем, чтобы up никогда его не поднимал, и нацельте на него service_run. service: принимает имя compose-сервиса, так что каталог в workspace/services/ ему не нужен; service_run всегда передаёт --entrypoint "", поэтому argv сам называет интерпретатор:

compose/otel.yml
otel-lookup:
image: python:3.13-alpine
profiles: ["tools"] # никогда не поднимается `compose up`
working_dir: /opt/otel
volumes:
- ./workspace/otel:/opt/otel:ro
workspace/commands/otel.yml
traces:
type: service_run # compose run --rm, ~0.7 с на вызов
service: otel-lookup
argv: [python3, /opt/otel/traces.py, "${args}"]

Семантические конвенции сдвинулись: новые инструментирования (Go, Node) пишут db.query.text / db.system.name там, где старые пишут db.statement / db.system, и http.request.method / url.path вместо http.method / http.target. Инструмент поиска должен читать оба варианта.

Цикл, которым пользуется агент:

Окно терминала
dwe cmd otel.traces -- traceparent # печатает значение заголовка + trace_id
curl -H 'traceparent: 00-<id>-<span>-01' http://api.myproject.localhost/api/v1/things
dwe cmd otel.traces -- show <trace_id> # ровно этот запрос, без поиска
dwe cmd otel.traces -- summary --last 15m # когда вы ещё не знаете, ГДЕ искать

show сворачивает повторяющиеся соседние спаны (×25 SELECT … <- possible N+1) и печатает исключения со стек-фреймами самого приложения. У запроса, отправленного с traceparent, созданным вызывающей стороной, есть удалённый родитель, поэтому Tempo не может назвать его корень; он попадает в строку - в summary.

Добавьте короткий раздел в AGENTS.md (или на страницу, на которую он ссылается). Формулировка, которая сработала:

Only while the optional otel tool is enabled (dwe services list). When the question is “what did this request actually do” — too many queries, a slow endpoint, a 500, a misbehaving worker — look at the trace BEFORE reading code: dwe cmd otel.traces -- summary --last 15m when you do not know where yet, the traceparent → curl → show loop when you do. Some periodic jobs are chatty by design; compare against what the code says the job should do before calling it a regression.

Без такого указателя агент обходит dwe целиком (docker logs, docker exec … psql), а объявленная read-only команда db.query экономит ему больше ходов, чем трейсы.

  • Без тома otel_data каждое пересоздание контейнера теряет всю телеметрию: правка оверлея и dwe run, dwe services enable|disable --apply, dwe reset. Остановка и повторный запуск того же контейнера (dwe restart otel, dwe stop, затем dwe run) её сохраняют в любом случае.
  • На уровне логирования debug собственные HTTP-вызовы OTLP-экспортера появляются в логе приложения примерно раз в секунду (Python: urllib3.connectionpool … POST /v1/traces). Увеличьте OTEL_BSP_SCHEDULE_DELAY или заглушите этот логгер.
  • Health-пробы, исключённые из HTTP-инструментирования, всё равно дают трейсы без родителя — PING / SELECT / connect — от своих проверок БД. Фильтруйте через --exclude-root / --kind server в инструменте поиска или на коллекторе.
  • Span-метрики Tempo несут только span_name, так что SQL-спан назван по своему глаголу; сам запрос виден только внутри трейса.