Наблюдаемость с 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, чтобы агент читал трейс раньше, чем код.
1. Tool-сервис
Заголовок раздела «1. Tool-сервис»# tool: бэкенд OpenTelemetry (grafana/otel-lgtm). Опциональный, по умолчанию ВЫКЛЮЧЕН.# Бэкенд лежит в compose:; патч app-сервисов, из-за которого они начинают# экспортировать сюда, лежит в compose_after:, поэтому он побеждает# собственный оверлей каждого приложения, — один переключатель включает всю# фичу целиком (см. compose/otel.yml и compose/otel-apps.yml).type: toolcontainer: "otel"compose: - compose/otel.ymlcompose_after: - compose/otel-apps.ymlhosts: web: otel.myproject.localhosticon: "📊"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-машины нормально, но знать об этом стоит.
2. Оверлей
Заголовок раздела «2. Оверлей»# 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 и направьте второй провайдер на каталог в вашем репозитории:
apiVersion: 1providers: - 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: falseUID источников данных в образе — 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).
3. Инструментирование сервисов
Заголовок раздела «3. Инструментирование сервисов»Патч приложений (compose/otel-apps.yml) несёт окружение OTEL_*; что каждый сервис с ним делает, зависит от языка. Цель у всех рецептов одна: в репозитории приложения нет никакого диффа, пока инструмент выключен, и как можно меньше — когда включён.
Python — без диффа
Заголовок раздела «Python — без диффа»Автоинструментирование через opentelemetry-instrument, накладываемое поверх venv проекта при старте контейнера. Патч приложений заменяет command приложения:
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 — небольшой дифф, включаемый через env
Заголовок раздела «Go — небольшой дифф, включаемый через env»Рантайм-инъекции для Go нет; приложение линкует SDK. Держите его инертным, пока эндпоинт не задан, — по образцу «пустой DSN — нет SDK», которым пользуется большинство трекеров ошибок:
// 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 — без диффа через NODE_OPTIONS
Заголовок раздела «Node — без диффа через NODE_OPTIONS»Node загружает инструментирование раньше приложения, если попросить об этом через --import; в репозитории приложения ничего не меняется. Пакеты лежат в каталоге воркспейса со своим package.json, монтируются в контейнер и устанавливаются при первом старте:
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-nodeworkspace/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 processorregister.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.2RUN 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-fpmclear_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.
4. Трейсы текстом для агента
Заголовок раздела «4. Трейсы текстом для агента»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:rogroup: 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 сам называет интерпретатор:
otel-lookup: image: python:3.13-alpine profiles: ["tools"] # никогда не поднимается `compose up` working_dir: /opt/otel volumes: - ./workspace/otel:/opt/otel:ro 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_idcurl -H 'traceparent: 00-<id>-<span>-01' http://api.myproject.localhost/api/v1/thingsdwe cmd otel.traces -- show <trace_id> # ровно этот запрос, без поискаdwe cmd otel.traces -- summary --last 15m # когда вы ещё не знаете, ГДЕ искатьshow сворачивает повторяющиеся соседние спаны (×25 SELECT … <- possible N+1) и печатает исключения со стек-фреймами самого приложения. У запроса, отправленного с traceparent, созданным вызывающей стороной, есть удалённый родитель, поэтому Tempo не может назвать его корень; он попадает в строку - в summary.
5. Расскажите агенту
Заголовок раздела «5. Расскажите агенту»Добавьте короткий раздел в AGENTS.md (или на страницу, на которую он ссылается). Формулировка, которая сработала:
Only while the optional
oteltool 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 15mwhen you do not know where yet, thetraceparent → curl → showloop 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-спан назван по своему глаголу; сам запрос виден только внутри трейса.
См. также
Заголовок раздела «См. также»- Добавление сервиса — форма tool-сервиса, на которой строится это руководство.
- Авторство проектных команд —
service_exec,hide:,${args}. ../reference/config/services/index.md—type: tool, оверлеиcompose:иcompose_after:,hosts:.