Наблюдаемость: спаны, метрики, логи
Руководство пользователя к ТЗ Р7 и [С §15.5]. Решения: D-5240..D-5259 в docs/decisions.md; устройство — docs/ARCHITECTURE.md, раздел «Наблюдаемость».
Без настройки ничего не экспортируется и не открывается: спаны не записываются (проверка одного атомарного флага), порта метрик нет. Внутрипроцессный вызов rpc остаётся в пределах 0.2–0.3 мкс.
Быстрый старт#
# спаны, метрики и логи в OpenTelemetry Collector по gRPC
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4317 OTEL_SERVICE_NAME=shop ./shop
# Prometheus: страница http://127.0.0.1:9464/metrics
./shop --metrics-listen 127.0.0.1:9464| Что | Флаг | Переменная окружения | По умолчанию |
|---|---|---|---|
| Адрес OTLP/gRPC | --otlp-endpoint url | OTEL_EXPORTER_OTLP_ENDPOINT (или …_TRACES_ENDPOINT) | выкл. |
| Спаны | OTEL_TRACES_EXPORTER=none выключает | вкл., если задан адрес | |
| Метрики по OTLP | OTEL_METRICS_EXPORTER=otlp | выкл. | |
| Логи по OTLP | OTEL_LOGS_EXPORTER=otlp | выкл. | |
| Страница Prometheus | --metrics-listen addr | OXL_METRICS_LISTEN | выкл. |
| Доля выборки | --trace-sample p | OXL_TRACE_SAMPLE, OTEL_TRACES_SAMPLER_ARG | 1.0 |
| Имя сервиса | OTEL_SERVICE_NAME | oxl | |
| Атрибуты ресурса | OTEL_RESOURCE_ATTRIBUTES=k=v,k=v | ||
| Пауза между пакетами | OTEL_BSP_SCHEDULE_DELAY (мс) | 2000 | |
| Очередь / размер пакета | OTEL_BSP_MAX_QUEUE_SIZE / OTEL_BSP_MAX_EXPORT_BATCH_SIZE | 8192 / 512 | |
| Период метрик | OTEL_METRIC_EXPORT_INTERVAL (мс) | 10000 |
Адрес: http://host:4317 (без TLS, h2c), https://host:4317 (TLS, системные корневые сертификаты) или host:port; порт по умолчанию 4317. Бездельничающий --metrics-listen 9464 слушает только 127.0.0.1; чтобы открыть наружу, укажите 0.0.0.0:9464 (страница не защищена — ставьте за фаервол).
Спаны#
Автоматически создаются спаны:
| Где | Имя | Вид | Атрибуты |
|---|---|---|---|
| обработчик rpc | pkg.Service/method | server (если вызов пришёл по сети; иначе internal) | rpc.system, rpc.service, rpc.method, rpc.grpc.status_code |
| вызов rpc по сети | то же | client | rpc.system, rpc.method, server.address, rpc.grpc.status_code |
операция std.orm | db.select, db.insert, db.update, db.upsert, db.delete, db.count, db.exec, db.begin, db.commit, db.rollback | client | db.system, db.operation, db.collection.name (текст SQL и значения параметров в спан не попадают) |
HTTP-сервер (std.http) | HTTP GET | server | http.request.method, url.path, http.response.status_code |
HTTP-клиент (Client) | HTTP GET | client | http.request.method, url.full (без query), http.response.status_code |
Статус ERROR ставится при любом исходе rpc, кроме ok (в том числе при доменной ошибке; сообщение — имя исхода), у HTTP-сервера — при коде 5xx, у HTTP-клиента — при 4xx/5xx и при отсутствии ответа.
Распространение контекста#
- gRPC: заголовок метаданных
traceparent(W3C Trace Context, версия00); совместимо со всеми OpenTelemetry-SDK. Прежнийx-oxl-traceпродолжает отправляться и читаться. - HTTP (
std.http): сервер читаетtraceparentзапроса, клиент добавляет его в исходящие запросы. ctx.traceId(32 hex-цифры, одинаков во всех процессах) иctx.spanId(16 hex-цифр, спан, в котором выполняется код) доступны в обработчиках rpc.- Дочерние задачи (
spawn,scope) наследуют текущий спан.
Выборка#
Решение принимается один раз, у корня трассы, по хэшу traceId: все процессы, увидевшие один идентификатор, решают одинаково; дальше решение родителя (флаг 01/00 в traceparent) уважается всеми. Вызов с отброшенной трассой всё равно несёт traceId и ctx.spanId, но спаны не записываются.
@trace(sample: 0.1)на rpc — доля выборки для трасс, корнем которых является этот rpc;0.0— не трассировать,1.0— всегда.- Иначе — доля процесса:
--trace-sample/OXL_TRACE_SAMPLE. Значение «кластера» из §15.5 сегодня это значение процесса (см. D-5241, вопрос заказчику).
Свои спаны#
use std.obs { span, metric, log }
fn charge(order: Order) io {
span.with("charge") { // закрывается при выходе, в том числе при панике
let s = span.start("antifraud") // или вручную
s.set("order", "{order.id}")
s.setInt("amount", order.cents)
if s.recording() { /* дорогое вычисление атрибута */ }
s.setError("score too high") // статус ERROR
s.end()
}
}Закрывайте спаны в обратном порядке открытия и в той же задаче. Если трассировка выключена или трасса не выбрана, span.start ничего не стоит.
Метрики#
let paid = metric.counter("orders_paid_total", help: "Paid orders.", labels: {"shop": "eu"})
paid.inc()
let queue = metric.gauge("queue_length")
queue.set(12.0); queue.dec()
let size = metric.histogram("payload_bytes", buckets: [64.0, 1024.0, 65536.0])
size.observe(300.0)- Имя — имя Prometheus (
[a-zA-Z_:][a-zA-Z0-9_:]*), счётчикам принято окончание_total. Неверное имя или метка, а также имя, уже занятое метрикой другого вида, сообщается один раз на stderr, а обработчик ничего не делает. - Одинаковые имя и метки — одна и та же серия. Создавайте обработчик один раз и храните его: поиск идёт под мьютексом, увеличение — атомарная операция.
- Без
bucketsгистограмма имеет корзины задержек в секундах: 0.0001 … 10.
Встроенные метрики (/metrics и OTLP):
| Метрика | Вид | Метки |
|---|---|---|
oxl_rpc_requests_total | counter | method, status (ok timeout unavailable cancelled denied internal invalid app_error) |
oxl_rpc_duration_seconds | histogram | method, status |
oxl_rpc_inflight | gauge | |
oxl_http_requests_total, oxl_http_request_duration_seconds | counter, histogram | method, code (HTTP-сервер) |
oxl_http_client_requests_total, oxl_http_client_request_duration_seconds | counter, histogram | method, code (error — ответа нет) |
oxl_orm_queries_total, oxl_orm_query_duration_seconds | counter, histogram | op |
oxl_tls_handshakes_total | counter | result (ok, failed) |
oxl_db_tx_retries_total | counter | reason (leader_change, conflict) — повторы тела atomic(db) (D-7282) и одиночных операторов ORM вне atomic(db) (db.insert/update/upsert/delete, find, запросы; D-7300) |
oxl_db_tx_conflict_total | counter | — блоки atomic(db) и одиночные операторы вне него (D-7300), завершившиеся TxConflict (и строка oxl: warning: TxConflict … в stderr, D-7282) |
oxl_scheduler_workers, …_sleeping_workers, …_run_queue_length, …_background_tasks, …_context_switches_total | gauge, counter | |
oxl_telemetry_spans_exported_total, …_dropped_total, …_export_failures_total | counter | работа экспортёра |
Задержка rpc измеряется для первых 256 вызовов метода и далее для каждого 16-го (с весом 16), счётчики вызовов точны (D-5254).
Формат страницы — Prometheus text 0.0.4. GET /metrics — 200, остальные пути — 404, не-GET — 405.
Логи#
log.info("order {id} paid") внутри обработчика rpc или спана несёт идентификаторы:
2026-11-03T15:04:05.250Z INFO order 42 paid id=42 trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7
{"ts":"…","level":"info","msg":"order 42 paid","trace_id":"4bf9…","span_id":"00f0…","fields":{"id":"42"}}С OTEL_LOGS_EXPORTER=otlp записи уходят и по OTLP (с trace_id/span_id в самой записи, поля — атрибутами).
Экспорт: гарантии и потери#
- Очередь ограничена (
OTEL_BSP_MAX_QUEUE_SIZE); при переполнении новые спаны отбрасываются и учитываются вoxl_telemetry_spans_dropped_total. Горячий путь никогда не ждёт сети. - Пакеты уходят отдельной задачей раз в
OTEL_BSP_SCHEDULE_DELAYили сразу, когда набрался пакет. Недоступный сборщик не мешает программе: пакет отбрасывается и считается, первые три сбоя пишутся в stderr. - При нормальном завершении (и после SIGTERM, после дренажа) остаток очередей отправляется синхронно, не дольше секунды на запрос.
Проверка#
scripts/bench.py --rpc— задержка внутрипроцессного вызова (с выключенной трассировкой и с включённой, но отброшенной выборкой).- ctest
telemetry_otlp: два процесса, свой mock-сборщик OTLP, проверка общегоtraceId, связи «родитель–потомок» клиент → сервер,/metrics, логов с идентификаторами и выборки.
Инспектор#
--inspect-ui[=addr] (по умолчанию 127.0.0.1:8090) открывает в узле веб-интерфейс состояния кластера [С III.8]: одна встроенная страница и JSON-API GET /api/<раздел> (nodes roles config dynconf services actors sagas streams traces logs rights), POST /api/action (заголовок X-Oxl-Inspect: 1). Любой узел показывает весь кластер: локальные данные других узлов запрашиваются по межузловому каналу. Тот же API — gRPC-сервис oxl.Inspect (runtime/proto/oxl/inspect.proto) на порту узла; oxl inspect --cluster host:port поднимает страницу локально. Буферы трасс и логов — 64 МБ / 15 мин на узел с UI (или на всех узлах с OXL_INSPECT_BUFFERS=1). Доступ — роль Admin или inspect (JWT), действия — отдельные роли, всё пишется в oxl.audit; в профиле dev с --insecure-dev локальный пользователь — оператор. Решения: D-6400..D-6410.
Трассы из командной строки, журнал запросов, A/B#
oxl trace <traceId> --cluster host:portпечатает трассу из буферов узлов (разделtracesинспектора): дерево спанов по узлам и граф вызовов между ними.oxl trace --record f.oxlrec --cluster host:port --for 60s [--service S]записывает входящие unary-rpc всех узлов (метод, запрос, метаданные без учётных данных, пользователь, трасса, время, ответ или ошибка) в журнал.oxlrec(формат —docs/oxlrec.md);oxl replay f.oxlrec|<traceId> --cluster host:portотправляет их снова и сравнивает ответы.- A/B-эксперименты ([С §18.5]): код узнаёт вариант через
ab.variant("имя")(std.ab);oxl test --kind abпрогоняет записанный трафик,oxl ab start|status|stopработает в кластере. Встроенные метрики вариантов —latency.p50/p95/p99/mean(время обработчика, мс) иerror_rate; пользовательские — любые метрикиstd.obsпо имени. Значимость — t-тест Уэлча для средних и z-тест для долей, p < 0,05 при ≥ 100 запросах на вариант. Решения: D-6850..D-6866.
Исходник: docs/observability.md