revise September 2023 telemetry articles
Build and deploy / deploy (push) Successful in 15s

This commit is contained in:
2026-07-31 15:13:05 +03:00
parent 3d310bd605
commit 2c860caddd
7 changed files with 693 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 202 из 358 созданных материалов. Остальные 156 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
На 31 июля 2026 года строгий аудит проходит 205 из 358 созданных материалов. Остальные 153 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия
+53
View File
@@ -0,0 +1,53 @@
# П67 · 2023-09 · Логи, метрики и трассы — три прохода саморевью
## Рамка пакета
- Slug: `editorial-2023-09-practice-telemetry-signals`, `editorial-2023-09-mechanism-telemetry-signals`, `editorial-2023-09-field-telemetry-signals`.
- Голос: М6, сентябрь 2023 года. Автор работает как системный практик: начинает с несвязанного сигнала и цены ошибочного решения, строит маленький контракт данных, отделяет проверяемое от неизвестного и оставляет владельцу следующий шаг. Тон короткий и технический: «симптом → причина → проверка → действие», без вымышленного incident report, нагрузки или результата rollout.
- Главная тема: log, metric и trace — разные представления. `traceId` связывает один сценарий в trace и event/log record; metric labels отвечают на агрегирующий вопрос и имеют небольшой фиксированный словарь; event attributes объясняют конкретное событие; evidence-card хранит только допустимый вывод о synthetic модели.
- Граница: sidecar создаёт ровно пять новых файлов — этот review, один import-safe script и три локальных SVG. Registry, README, `articles.json`, очередь, документация, Git, чужие файлы, staging, commit и push не менялись.
## Проход 1 — факты, модель и границы
- Историческая граница сверена 31.07.2026 по первичным официальным материалам, доступным к сентябрю 2023: [OpenTelemetry Specification release v1.20.0 от 07.04.2023](https://github.com/open-telemetry/opentelemetry-specification/releases/tag/v1.20.0), [versioned Overview v1.20.0](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/overview.md), [Tracing API v1.20.0](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/api.md), [Metrics Data Model v1.20.0](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/metrics/data-model.md) и [Logs Data Model v1.20.0](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/logs/data-model.md). Используются только versioned URLs, а не текущая документация как будто она существовала тогда.
- Формулировки ограничены источниками. Overview разделяет signals; Tracing API описывает `SpanContext`, `TraceId` и `SpanId`; Metrics Data Model различает events, streams, time series и attributes; Logs Data Model задаёт `LogRecord`, `TraceId`, `SpanId` и attributes. Из этого не сделан ложный вывод о конкретной SDK, collector, exporter, transport, backend, schema maturity, retention, query или dashboards данного проекта.
- `assembleSyntheticTelemetryScenario()` принимает только fixed synthetic input с точным top-level shape. У valid branch общий `synthetic-trace-2023-09-A` связывает trace и log; event относится к downstream span; metric получает только `service`, `route`, `outcome`. `trace_id`, `span_id`, `request_id`, `user_id`, `order_id`, error text, любые лишние keys в metric labels и любое неизвестное top-level поле отвергаются. Это не универсальная политика для всех backend, а явно названный маленький contract.
- `runTelemetryFixture()` содержит 19 assertions. Он проверяет correlation trace/log, раздельные span IDs, fixed small labels, отсутствие `trace_id` в labels, event attributes, закрытый input shape, отрицательные ветки mismatch, high-cardinality-like keys, предел модели и rollback. Fixture создаёт records только в памяти и не читает исходники, конфигурацию, Git, часы, сеть, collector, storage или telemetry backend; не создаёт SDK provider, exporter, trace, log или metric и не передаёт данных.
- Во всех статьях и в коде явно сказано: PASS не доказывает observability, latency, cardinality, trace/log/metric export, поиск, retention, стоимость, production effect, incident, причину ошибки или совместимость SDK. `value: 1` и все identifiers — фиксированные synthetic labels, не измерение и не request identity.
- Rollback сознательно узкий: он возвращает только snapshot contract draft и ставит `telemetry=not-created-or-deleted`, `productionEffect=not-attempted`. Он не удаляет фактические записи, не выключает instrumentation, sampling, alert, dashboard, collector или backend. Реальный rollback вынесен как отдельное операционное решение с владельцем, средой и проверкой.
## Проход 2 — голос, полнота и объём
- Первые два абзаца каждого материала называют проблему и цену. Practice показывает несвязанные окна и ошибочный ремонт через identifier в metric labels; mechanism — подмену разных data model общим словом telemetry; field — решение по самой громкой витрине без evidence. Ни один пример не объявлен наблюдением реального сервиса.
- Practice отвечает на вопрос «как собрать минимальный correlation contract», mechanism — «почему trace ID не является metric label», field — «как пройти diagnostic route, не перепутав симптом и причину». У статей нет дублирующего центрального тезиса: они используют один fixture как общий предмет, но смотрят на него как на контракт, семантику и порядок диагностики.
- В каждой статье есть: таблица с доступным заголовком; один собственный SVG с осмысленными alt и caption; исполнимый marked synthetic code; упорядоченный route с буквальными словами «симптом → причина → проверка → действие»; отдельные ограничения; rollback; следующий шаг; раздел проверяемых официальных источников. Нет универсальных обещаний и фраз, запрещённых редакционным аудитом.
- Размер основного текста, посчитанный script без списка источников: practice — 9 949 знаков, mechanism — 10 222 знака, field — 10 197 знаков. Все три текста находятся внутри обязательных 5 000–15 000 знаков и внутри цели 8–11 тыс. `readingMinutes`: 11, 12 и 12 соответственно.
- Речь соответствует М6: термин не подменяет действие. После `traceId`, `SpanContext`, cardinality, labels и event attributes всегда указано, какое поле проверяется и чего оно не доказывает. Автора 2023 года не выдают за владельца всей платформы: он не приписывает себе реальные dashboards, SLO, incidents, cost data или организационный масштаб.
## Проход 3 — визуал, безопасность и выпуск
- Три SVG разделяют визуальные задачи: `correlation-map` показывает один key в trace/log и его намеренное отсутствие в metric labels; `cardinality-budget` различает маленький fixed vocabulary и per-request/free-text values; `diagnosis-route` показывает последовательность metric → trace → log/event → evidence и явно перечёркивает подмену correlation новым label. Подписи в каждом файле говорят, что это учебная схема, не production export или измерение.
- Все SVG самостоятельные: нет `<script>`, `foreignObject`, внешних URL, `data:image`, event handlers, пользовательского ввода или интерактивности. Есть `title`, `desc`, `role="img"`, `aria-labelledby`, контрастные блоки и крупные подписи. XML валиден; safety scan исключает технический `xmlns` из проверки URL.
- Перед передачей главному агенту выполнены: `node --check web/scripts/upgrade-2023-09.mjs` — PASS; `node web/scripts/upgrade-2023-09.mjs --verify-fixture` — PASS 18/18; `cd web && npm run audit:draft -- scripts/upgrade-2023-09.mjs` — PASS для трёх slug; import-safe comparison CLI/export — PASS, 3 revisions без `date`/`author`; `xmllint --noout` — PASS; SVG safety scan — clean; Sharp-render и ручной просмотр трёх схем на ширине 375 px — PASS.
- Пакет не интегрирован намеренно. Главный агент должен отдельно провести приёмочное ревью фактов, модели, SVG и затем решать вопрос registry, README, строгого архива, build и Git. Этот sidecar не меняет исходные archive metadata.
## Интеграционное ревью главного агента
Проведён независимый проход по versioned первоисточникам `v1.20.0`. Release
зафиксирован 7 апреля 2023; Tracing API определяет `SpanContext`, `TraceId` и
`SpanId`; metrics data model различает events, streams, time series и
attributes; logs data model описывает `LogRecord`, `TraceId`, `SpanId` и
attributes. Поэтому текст сохраняет раздельные вопросы к trace, metric и
log/event и не делает вывод о конкретной SDK или backend.
Model review нашёл, что исходный вход принимал неизвестное top-level поле и
молча не использовал его. `assembleSyntheticTelemetryScenario()` теперь
принимает только plain record с точным списком восьми полей; fixture добавил
отрицательную ветку `unmodeledField`. Так synthetic contract не может выглядеть
как проверка «контракта плюс неучтённые данные». После исправления fixture
прошёл `19/19` assertions.
Повторный draft audit: 10 005 / 10 249 / 10 228 знаков. Строгий archive audit
прошёл для трёх slug. Registry содержит 196 уникальных ревизий. Все три SVG
прошли XML и safety scan, затем были вручную просмотрены после Sharp-render на
375 px. `npm run build` успешно сгенерировал 374 статические страницы.
+2
View File
@@ -63,6 +63,7 @@ import { revisions as may2023Revisions } from '../scripts/upgrade-2023-05.mjs';
import { revisions as june2023Revisions } from '../scripts/upgrade-2023-06.mjs';
import { revisions as july2023Revisions } from '../scripts/upgrade-2023-07.mjs';
import { revisions as august2023Revisions } from '../scripts/upgrade-2023-08.mjs';
import { revisions as september2023Revisions } from '../scripts/upgrade-2023-09.mjs';
// This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [
@@ -131,4 +132,5 @@ export const editorialRevisions = [
...june2023Revisions,
...july2023Revisions,
...august2023Revisions,
...september2023Revisions,
];
@@ -0,0 +1,52 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Учебный budget metric labels и запрет per-request identifiers</title>
<desc id="desc">Слева три metric labels с фиксированным словарём service, route и outcome. Справа идентификаторы trace, request, user и текст ошибки перечёркнуты как неподходящие для labels и направлены в отдельный trace/log контекст.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0 0L12 6L0 12Z" fill="#138b6d"/></marker>
<style>
.bg { fill: #f8fafc; }
.panel { fill: #fff; stroke: #d9e1ed; stroke-width: 3; }
.good { fill: #e8fbf5; stroke: #138b6d; stroke-width: 3; }
.bad { fill: #fff0f1; stroke: #dc3d4b; stroke-width: 3; }
.context { fill: #e6eaff; stroke: #5b6cff; stroke-width: 3; }
.label { font: 700 35px Arial, sans-serif; fill: #17213a; }
.sub { font: 500 25px Arial, sans-serif; fill: #42516c; }
.mono { font: 600 25px Menlo, Consolas, monospace; fill: #17213a; }
.tiny { font: 500 21px Arial, sans-serif; fill: #58667d; }
.arrow { fill: none; stroke: #138b6d; stroke-width: 5; marker-end: url(#arrow); }
.cross { stroke: #dc3d4b; stroke-width: 7; stroke-linecap: round; }
</style>
</defs>
<rect class="bg" width="1200" height="720" rx="28"/>
<text x="58" y="70" class="label">Metric labels: маленький словарь, не поиск одного запроса</text>
<text x="58" y="108" class="sub">Budget — проектный договор; это не расчёт real series или backend limit</text>
<rect x="58" y="154" width="524" height="500" rx="26" class="panel"/>
<text x="88" y="208" class="label">Разрешённые dimensions</text>
<text x="88" y="245" class="sub">фиксированные synthetic values</text>
<rect x="90" y="281" width="458" height="74" rx="16" class="good"/>
<text x="118" y="327" class="mono">service = checkout-api</text>
<rect x="90" y="378" width="458" height="74" rx="16" class="good"/>
<text x="118" y="424" class="mono">route = checkout</text>
<rect x="90" y="475" width="458" height="74" rx="16" class="good"/>
<text x="118" y="521" class="mono">outcome = rejected</text>
<text x="92" y="604" class="tiny">Metric отвечает: «какой класс исхода?»</text>
<rect x="650" y="154" width="492" height="500" rx="26" class="panel"/>
<text x="680" y="208" class="label">Не класть в labels</text>
<text x="680" y="245" class="sub">per-request или свободный контекст</text>
<rect x="682" y="281" width="426" height="58" rx="14" class="bad"/>
<text x="708" y="319" class="mono">trace_id</text>
<path d="M1000 287L1087 333M1087 287L1000 333" class="cross"/>
<rect x="682" y="359" width="426" height="58" rx="14" class="bad"/>
<text x="708" y="397" class="mono">request_id / order_id</text>
<path d="M1000 365L1087 411M1087 365L1000 411" class="cross"/>
<rect x="682" y="437" width="426" height="58" rx="14" class="bad"/>
<text x="708" y="475" class="mono">user_id</text>
<path d="M1000 443L1087 489M1087 443L1000 489" class="cross"/>
<rect x="682" y="515" width="426" height="58" rx="14" class="bad"/>
<text x="708" y="553" class="mono">error text / raw URL</text>
<path d="M1000 521L1087 567M1087 521L1000 567" class="cross"/>
<path d="M583 406H645" class="arrow"/>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Учебная карта корреляции log, metric и trace</title>
<desc id="desc">Один synthetic trace ID связывает gateway span, payment span, event log и карточку evidence. Рядом metric содержит только service, route и outcome и намеренно не содержит trace ID.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0 0L12 6L0 12Z" fill="#5b6cff"/></marker>
<style>
.bg { fill: #f6f8ff; }
.panel { fill: #ffffff; stroke: #d8dff7; stroke-width: 3; }
.trace { fill: #e6eaff; stroke: #5b6cff; stroke-width: 3; }
.log { fill: #e8fbf5; stroke: #138b6d; stroke-width: 3; }
.metric { fill: #fff4df; stroke: #d77a00; stroke-width: 3; }
.evidence { fill: #f3ecff; stroke: #8b5cf6; stroke-width: 3; }
.line { fill: none; stroke: #5b6cff; stroke-width: 5; marker-end: url(#arrow); }
.small-line { fill: none; stroke: #8b5cf6; stroke-width: 4; stroke-dasharray: 10 8; marker-end: url(#arrow); }
.label { font: 700 34px Arial, sans-serif; fill: #15213d; }
.sub { font: 500 25px Arial, sans-serif; fill: #33415f; }
.mono { font: 600 24px Menlo, Consolas, monospace; fill: #17213a; }
.tiny { font: 500 21px Arial, sans-serif; fill: #495875; }
.cross { stroke: #dc3d4b; stroke-width: 8; stroke-linecap: round; }
</style>
</defs>
<rect class="bg" width="1200" height="720" rx="28"/>
<text x="58" y="70" class="label">Один correlation key, разные роли сигналов</text>
<text x="58" y="108" class="sub">Учебный сценарий: synthetic checkout authorization rejected</text>
<rect x="58" y="152" width="596" height="305" rx="24" class="panel"/>
<text x="88" y="202" class="label">Trace: путь причины</text>
<text x="88" y="238" class="mono">traceId: synthetic-trace-2023-09-A</text>
<rect x="92" y="276" width="224" height="118" rx="18" class="trace"/>
<text x="117" y="321" class="label">gateway span</text>
<text x="117" y="358" class="mono">span-gateway-A</text>
<rect x="394" y="276" width="224" height="118" rx="18" class="trace"/>
<text x="416" y="321" class="label">payment span</text>
<text x="418" y="358" class="mono">span-payment-A</text>
<path d="M317 335H385" class="line"/>
<text x="283" y="431" class="tiny">child span: gateway → payment</text>
<rect x="718" y="152" width="424" height="305" rx="24" class="metric"/>
<text x="748" y="202" class="label">Metric: класс исхода</text>
<text x="748" y="241" class="mono">rejected.total = 1</text>
<text x="748" y="290" class="sub">labels с малым словарём:</text>
<text x="748" y="328" class="mono">service = checkout-api</text>
<text x="748" y="362" class="mono">route = checkout</text>
<text x="748" y="396" class="mono">outcome = rejected</text>
<text x="748" y="432" class="tiny">не отвечает на вопрос об одном request</text>
<rect x="58" y="514" width="520" height="152" rx="24" class="log"/>
<text x="88" y="559" class="label">Log/event: контекст шага</text>
<text x="88" y="598" class="mono">traceId + spanId + event attributes</text>
<text x="88" y="632" class="tiny">payment.authorization-rejected</text>
<path d="M504 395C494 445 358 471 314 510" class="small-line"/>
<rect x="680" y="514" width="462" height="152" rx="24" class="evidence"/>
<text x="710" y="559" class="label">Evidence: граница вывода</text>
<text x="710" y="598" class="mono">same trace → same scenario</text>
<text x="710" y="632" class="tiny">not-a-production-observation</text>
<path d="M579 590H668" class="small-line"/>
<text x="790" y="480" class="mono" fill="#dc3d4b">trace ID ≠ metric label</text>
<path d="M1064 482L1124 508M1124 482L1064 508" class="cross"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 720" role="img" aria-labelledby="title desc">
<title id="title">Учебный маршрут диагностики между metric, trace и log event</title>
<desc id="desc">Сначала маленькая synthetic metric задаёт класс исхода. Затем общий trace ID ведёт к gateway и payment span, после чего log event с теми же trace и span IDs объясняет payment отказ. Карточка evidence сохраняет статус not-a-production-observation. Ветка trace ID как metric label перечёркнута.</desc>
<defs>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto"><path d="M0 0L12 6L0 12Z" fill="#5b6cff"/></marker>
<style>
.bg { fill: #f7f8fc; }
.metric { fill: #fff4df; stroke: #d77a00; stroke-width: 3; }
.trace { fill: #e6eaff; stroke: #5b6cff; stroke-width: 3; }
.log { fill: #e8fbf5; stroke: #138b6d; stroke-width: 3; }
.evidence { fill: #f3ecff; stroke: #8b5cf6; stroke-width: 3; }
.label { font: 700 33px Arial, sans-serif; fill: #17213a; }
.sub { font: 500 24px Arial, sans-serif; fill: #42516c; }
.mono { font: 600 23px Menlo, Consolas, monospace; fill: #17213a; }
.tiny { font: 500 20px Arial, sans-serif; fill: #55627b; }
.arrow { fill: none; stroke: #5b6cff; stroke-width: 5; marker-end: url(#arrow); }
.dash { fill: none; stroke: #8b5cf6; stroke-width: 4; stroke-dasharray: 10 8; marker-end: url(#arrow); }
.cross { stroke: #dc3d4b; stroke-width: 7; stroke-linecap: round; }
</style>
</defs>
<rect class="bg" width="1200" height="720" rx="28"/>
<text x="56" y="68" class="label">Маршрут: симптом → причина → проверка → действие</text>
<text x="56" y="106" class="sub">Один fixed synthetic scenario; каждая стрелка требует отдельной реальной проверки позднее</text>
<rect x="54" y="160" width="244" height="220" rx="24" class="metric"/>
<text x="82" y="207" class="label">1. Metric</text>
<text x="82" y="247" class="mono">outcome=rejected</text>
<text x="82" y="282" class="mono">route=checkout</text>
<text x="82" y="330" class="tiny">вопрос: какой класс?</text>
<rect x="392" y="160" width="340" height="220" rx="24" class="trace"/>
<text x="420" y="207" class="label">2. Trace</text>
<text x="420" y="247" class="mono">synthetic-trace-A</text>
<text x="420" y="285" class="sub">gateway span → payment span</text>
<text x="420" y="330" class="tiny">вопрос: какой путь?</text>
<rect x="826" y="160" width="320" height="220" rx="24" class="log"/>
<text x="854" y="207" class="label">3. Log/event</text>
<text x="854" y="247" class="mono">same trace + span</text>
<text x="854" y="285" class="sub">authorization-rejected</text>
<text x="854" y="330" class="tiny">вопрос: что случилось?</text>
<path d="M300 270H382" class="arrow"/>
<path d="M734 270H816" class="arrow"/>
<text x="315" y="238" class="tiny">не per-request label</text>
<text x="748" y="238" class="tiny">correlation key</text>
<rect x="345" y="482" width="510" height="142" rx="24" class="evidence"/>
<text x="378" y="530" class="label">4. Evidence card</text>
<text x="378" y="570" class="mono">not-a-production-observation</text>
<text x="378" y="606" class="tiny">записать hypothesis и нужную проверку</text>
<path d="M985 382C967 438 842 459 798 478" class="dash"/>
<text x="70" y="473" class="mono" fill="#dc3d4b">trace_id as metric label</text>
<path d="M60 493L295 529M295 493L60 529" class="cross"/>
<text x="56" y="602" class="tiny">Не заменять missing correlation новым identifier в счётчике.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+467
View File
@@ -0,0 +1,467 @@
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
function plainText(content) {
return content
.replace(/<[^>]+>/g, ' ')
.replaceAll('&nbsp;', ' ')
.replaceAll('&quot;', '"')
.replaceAll('&#039;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replace(/\s+/g, ' ')
.trim();
}
function bodyText(content) {
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
}
const sources = [
{
title: 'OpenTelemetry Specification: Release v1.20.0, 7 апреля 2023',
url: 'https://github.com/open-telemetry/opentelemetry-specification/releases/tag/v1.20.0',
note: 'официальный versioned release, доступный к сентябрю 2023. Версия фиксирует историческую рамку статьи, но не подтверждает, что конкретная система экспортирует или хранит telemetry.',
},
{
title: 'OpenTelemetry Specification v1.20.0: Overview',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/overview.md',
note: 'первичный обзор разделяет tracing, metrics, logs, resources и context propagation. Он не предписывает один backend, dashboard или retention policy.',
},
{
title: 'OpenTelemetry Specification v1.20.0: Tracing API',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/trace/api.md',
note: 'описывает SpanContext, TraceId и SpanId как данные, которые могут передаваться в distributed context. Само наличие поля в учебной записи не означает, что контекст дошёл через реальный transport.',
},
{
title: 'OpenTelemetry Specification v1.20.0: Metrics Data Model',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/metrics/data-model.md',
note: 'различает события, streams, time series и attributes. В статье слово label применяется к маленькому договору dimension values; он не измеряет фактическую cardinality какого-либо backend.',
},
{
title: 'OpenTelemetry Specification v1.20.0: Logs Data Model',
url: 'https://github.com/open-telemetry/opentelemetry-specification/blob/v1.20.0/specification/logs/data-model.md',
note: 'описывает LogRecord, включая TraceId, SpanId и Attributes. Он не доказывает, что event/log запись конкретного сервиса доставлена, индексирована или доступна в поиске.',
},
];
function sourceList() {
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
}
function revision(meta, parts) {
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
const proseLength = bodyText(contentHtml).length;
if (proseLength < 5000 || proseLength > 15000) {
throw new Error(meta.slug + ': основной текст вне диапазона 5 000–15 000 знаков: ' + proseLength);
}
return Object.freeze({ ...meta, contentHtml, proseLength });
}
const SYNTHETIC_TRACE_ID = 'synthetic-trace-2023-09-A';
const SYNTHETIC_ROOT_SPAN_ID = 'synthetic-span-gateway-A';
const SYNTHETIC_DOWNSTREAM_SPAN_ID = 'synthetic-span-payment-A';
const MODEL_LIMIT = 'in-memory-fixed-synthetic-records-no-sdk-no-export-no-network-no-backend-no-real-telemetry';
const SCENARIO_INPUT_KEYS = Object.freeze([
'synthetic',
'traceId',
'rootSpanId',
'downstreamSpanId',
'logTraceId',
'logSpanId',
'eventName',
'metricLabels',
]);
const METRIC_LABEL_KEYS = Object.freeze(['service', 'route', 'outcome']);
/**
* Учебный договор одного сценария. Он создаёт фиксированные synthetic records
* только в памяти; не читает код, конфигурацию, часы, сеть, collector, storage,
* trace/log/metric backend или production. Здесь нет настоящих latency,
* cardinality, request ID, user ID, trace, log или metric. Функция не вызывает
* OpenTelemetry SDK и не отправляет данные, поэтому её PASS не доказывает
* observability, доставку, поиск, retention, стоимость или effect в production.
*/
function rejectSyntheticScenario(reason) {
return Object.freeze({
kind: 'synthetic-telemetry-fixture-v1',
syntheticOnly: true,
accepted: false,
reason,
modelLimit: MODEL_LIMIT,
});
}
function isSyntheticText(value) {
return typeof value === 'string' && value.startsWith('synthetic-');
}
function isPlainRecord(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function hasExactOwnKeys(value, keys) {
return isPlainRecord(value)
&& Object.keys(value).length === keys.length
&& keys.every((key) => Object.hasOwn(value, key));
}
function hasOnlyLowCardinalityMetricLabels(labels) {
if (!hasExactOwnKeys(labels, METRIC_LABEL_KEYS)) return false;
const forbidden = ['trace_id', 'traceId', 'span_id', 'spanId', 'request_id', 'requestId', 'user_id', 'userId', 'order_id', 'orderId', 'error_message', 'errorMessage'];
if (forbidden.some((key) => Object.hasOwn(labels, key))) return false;
if (!METRIC_LABEL_KEYS.every((key) => typeof labels[key] === 'string' && labels[key].startsWith('synthetic-'))) return false;
return labels.service === 'synthetic-checkout-api'
&& labels.route === 'synthetic-checkout'
&& labels.outcome === 'synthetic-rejected';
}
export function assembleSyntheticTelemetryScenario(input) {
if (!hasExactOwnKeys(input, SCENARIO_INPUT_KEYS)) return rejectSyntheticScenario('synthetic-input-shape-required');
if (!input || input.synthetic !== true) return rejectSyntheticScenario('synthetic-input-required');
const required = ['traceId', 'rootSpanId', 'downstreamSpanId', 'logTraceId', 'logSpanId'];
for (const key of required) {
if (!isSyntheticText(input[key])) return rejectSyntheticScenario('missing-or-non-synthetic-' + key);
}
if (typeof input.eventName !== 'string' || !input.eventName.startsWith('synthetic.')) {
return rejectSyntheticScenario('missing-or-non-synthetic-eventName');
}
if (input.traceId !== SYNTHETIC_TRACE_ID) return rejectSyntheticScenario('unexpected-trace-id');
if (input.rootSpanId !== SYNTHETIC_ROOT_SPAN_ID || input.downstreamSpanId !== SYNTHETIC_DOWNSTREAM_SPAN_ID) return rejectSyntheticScenario('unexpected-span-id');
if (input.logTraceId !== input.traceId) return rejectSyntheticScenario('log-trace-correlation-mismatch');
if (input.logSpanId !== input.downstreamSpanId) return rejectSyntheticScenario('log-span-correlation-mismatch');
if (input.eventName !== 'synthetic.payment.authorization-rejected') return rejectSyntheticScenario('unexpected-event-name');
if (!hasOnlyLowCardinalityMetricLabels(input.metricLabels)) return rejectSyntheticScenario('metric-label-contract-rejected');
const snapshot = Object.freeze({
traceId: input.traceId,
rootSpanId: input.rootSpanId,
downstreamSpanId: input.downstreamSpanId,
metricLabels: Object.freeze({ ...input.metricLabels }),
});
return Object.freeze({
kind: 'synthetic-telemetry-fixture-v1',
syntheticOnly: true,
accepted: true,
reason: 'synthetic-correlation-contract-assembled',
modelLimit: MODEL_LIMIT,
trace: Object.freeze({
traceId: input.traceId,
rootSpan: Object.freeze({ spanId: input.rootSpanId, name: 'synthetic.checkout.request', status: 'synthetic-unset' }),
downstreamSpan: Object.freeze({ spanId: input.downstreamSpanId, parentSpanId: input.rootSpanId, name: 'synthetic.payment.authorize', status: 'synthetic-error' }),
observed: 'not-observed',
}),
metric: Object.freeze({
name: 'synthetic.checkout.authorization.rejected.total',
labels: Object.freeze({ ...input.metricLabels }),
value: 1,
traceId: 'intentionally-absent-from-labels',
observed: 'not-observed',
}),
log: Object.freeze({
eventName: input.eventName,
traceId: input.logTraceId,
spanId: input.logSpanId,
attributes: Object.freeze({
'event.domain': 'synthetic-payment',
'failure.class': 'synthetic-declined',
'retry.advice': 'synthetic-do-not-retry',
}),
observed: 'not-observed',
}),
evidence: Object.freeze({
scenario: 'synthetic-checkout-authorization-rejected',
correlationKey: input.traceId,
metricQuestion: 'synthetic-count-by-small-dimensions-only',
logQuestion: 'synthetic-event-attributes-for-one-correlated-scenario',
traceQuestion: 'synthetic-causal-route-through-two-spans',
conclusion: 'not-a-production-observation',
}),
rollback: Object.freeze({ action: 'restore-synthetic-contract-draft', snapshot }),
});
}
export function rollbackSyntheticTelemetryScenario(scenario) {
if (!scenario || scenario.accepted !== true || !scenario.rollback?.snapshot) {
return Object.freeze({ restored: false, reason: 'no-accepted-synthetic-scenario', syntheticOnly: true });
}
return Object.freeze({
restored: true,
reason: 'synthetic-contract-draft-restored',
syntheticOnly: true,
snapshot: scenario.rollback.snapshot,
telemetry: 'not-created-or-deleted',
productionEffect: 'not-attempted',
});
}
export function runTelemetryFixture() {
const validInput = Object.freeze({
synthetic: true,
traceId: SYNTHETIC_TRACE_ID,
rootSpanId: SYNTHETIC_ROOT_SPAN_ID,
downstreamSpanId: SYNTHETIC_DOWNSTREAM_SPAN_ID,
logTraceId: SYNTHETIC_TRACE_ID,
logSpanId: SYNTHETIC_DOWNSTREAM_SPAN_ID,
eventName: 'synthetic.payment.authorization-rejected',
metricLabels: Object.freeze({
service: 'synthetic-checkout-api',
route: 'synthetic-checkout',
outcome: 'synthetic-rejected',
}),
});
const valid = assembleSyntheticTelemetryScenario(validInput);
const nonSynthetic = assembleSyntheticTelemetryScenario({ ...validInput, synthetic: false });
const brokenLogTrace = assembleSyntheticTelemetryScenario({ ...validInput, logTraceId: 'synthetic-trace-2023-09-B' });
const brokenLogSpan = assembleSyntheticTelemetryScenario({ ...validInput, logSpanId: 'synthetic-span-payment-B' });
const traceAsMetricLabel = assembleSyntheticTelemetryScenario({
...validInput,
metricLabels: { ...validInput.metricLabels, trace_id: SYNTHETIC_TRACE_ID },
});
const userAsMetricLabel = assembleSyntheticTelemetryScenario({
...validInput,
metricLabels: { ...validInput.metricLabels, user_id: 'synthetic-user-A' },
});
const missingMetricDimension = assembleSyntheticTelemetryScenario({
...validInput,
metricLabels: { service: 'synthetic-checkout-api', route: 'synthetic-checkout' },
});
const unmodeledInputField = assembleSyntheticTelemetryScenario({
...validInput,
unmodeledField: 'synthetic-but-not-a-contract-field',
});
const restored = rollbackSyntheticTelemetryScenario(valid);
const rejectedRollback = rollbackSyntheticTelemetryScenario(brokenLogTrace);
return Object.freeze({
assertions: Object.freeze({
acceptsCompleteSyntheticScenario: valid.accepted === true && valid.reason === 'synthetic-correlation-contract-assembled',
keepsSingleCorrelationTraceId: valid.trace.traceId === valid.log.traceId && valid.evidence.correlationKey === SYNTHETIC_TRACE_ID,
distinguishesTraceAndSpanIdentifiers: valid.trace.rootSpan.spanId !== valid.trace.downstreamSpan.spanId && valid.log.spanId === valid.trace.downstreamSpan.spanId,
keepsMetricLabelsSmallAndFixed: Object.keys(valid.metric.labels).length === 3 && valid.metric.labels.route === 'synthetic-checkout',
excludesTraceIdFromMetricLabels: valid.metric.traceId === 'intentionally-absent-from-labels' && !Object.hasOwn(valid.metric.labels, 'trace_id'),
keepsEventAttributesOnLogRecord: valid.log.eventName === 'synthetic.payment.authorization-rejected' && valid.log.attributes['failure.class'] === 'synthetic-declined',
separatesEvidenceQuestions: valid.evidence.metricQuestion.includes('small-dimensions') && valid.evidence.logQuestion.includes('event-attributes') && valid.evidence.traceQuestion.includes('causal-route'),
doesNotClaimObservation: valid.trace.observed === 'not-observed' && valid.metric.observed === 'not-observed' && valid.log.observed === 'not-observed',
rejectsNonSyntheticInput: nonSynthetic.accepted === false && nonSynthetic.reason === 'synthetic-input-required',
rejectsBrokenLogTraceCorrelation: brokenLogTrace.accepted === false && brokenLogTrace.reason === 'log-trace-correlation-mismatch',
rejectsBrokenLogSpanCorrelation: brokenLogSpan.accepted === false && brokenLogSpan.reason === 'log-span-correlation-mismatch',
rejectsTraceIdAsMetricLabel: traceAsMetricLabel.accepted === false && traceAsMetricLabel.reason === 'metric-label-contract-rejected',
rejectsUserIdAsMetricLabel: userAsMetricLabel.accepted === false && userAsMetricLabel.reason === 'metric-label-contract-rejected',
rejectsIncompleteMetricDimensions: missingMetricDimension.accepted === false && missingMetricDimension.reason === 'metric-label-contract-rejected',
rejectsUnmodeledInputField: unmodeledInputField.accepted === false && unmodeledInputField.reason === 'synthetic-input-shape-required',
declaresNoSdkOrExport: valid.modelLimit === MODEL_LIMIT && !Object.hasOwn(valid, 'exporter') && !Object.hasOwn(valid, 'collector'),
rollbackRestoresOnlyContractDraft: restored.restored === true && restored.snapshot.traceId === SYNTHETIC_TRACE_ID && restored.telemetry === 'not-created-or-deleted',
rollbackDoesNotClaimProductionEffect: restored.productionEffect === 'not-attempted' && !Object.hasOwn(restored, 'trace'),
rejectedScenarioCannotRollback: rejectedRollback.restored === false,
}),
samples: Object.freeze({ valid, nonSynthetic, brokenLogTrace, brokenLogSpan, traceAsMetricLabel, userAsMetricLabel, missingMetricDimension, unmodeledInputField, restored, rejectedRollback }),
});
}
const syntheticExample = `import {
assembleSyntheticTelemetryScenario,
runTelemetryFixture,
} from './upgrade-2023-09.mjs';
const scenario = assembleSyntheticTelemetryScenario({
synthetic: true,
traceId: 'synthetic-trace-2023-09-A',
rootSpanId: 'synthetic-span-gateway-A',
downstreamSpanId: 'synthetic-span-payment-A',
logTraceId: 'synthetic-trace-2023-09-A',
logSpanId: 'synthetic-span-payment-A',
eventName: 'synthetic.payment.authorization-rejected',
metricLabels: {
service: 'synthetic-checkout-api',
route: 'synthetic-checkout',
outcome: 'synthetic-rejected',
},
});
const report = runTelemetryFixture();
if (!Object.values(report.assertions).every(Boolean)) throw new Error('fixture failed');
console.log(scenario.evidence.conclusion); // not-a-production-observation
// Не создаёт trace/log/metric, не запускает SDK и не отправляет данные.`;
const practice = revision({
slug: 'editorial-2023-09-practice-telemetry-signals',
title: 'Один correlation ID для лога, метрики и трассы: минимальный договор',
categories: ['Наблюдаемость', 'Архитектура'],
cover: '/assets/editorial/2023/telemetry-signals-2023-correlation-map.svg',
excerpt: 'Как договориться, что trace ID связывает один сценарий, labels метрики остаются малыми, а log attributes объясняют событие без попытки выдать учебную запись за production telemetry.',
readingMinutes: 11,
}, [
p('Проблема редко выглядит как «у нас нет observability». Обычно есть три несвязанных окна: график показывает рост ошибок, поиск показывает отдельные сообщения, а трасса либо не находится, либо не объясняет тот же запрос. В такой схеме инженер получает три правдоподобных, но несопоставимых факта. Цена — не только лишние минуты поиска. Команда может изменить retry, timeout или маршрут, не доказав, что относится к причине исходного сбоя.'),
p('Вторая ошибка появляется как быстрый ремонт: положить request ID, user ID или trace ID в labels каждой метрики, чтобы график стал поиском. Тогда граница между счётчиком и записью одного события исчезает. Она создаёт проектный риск неограниченного числа сочетаний dimensions; но эта статья не измеряет реальную cardinality, storage или цену какого-либо backend. Здесь важнее сначала назвать, какой сигнал отвечает на какой вопрос, и оставить один общий ключ только там, где он нужен.'),
h2('Один сценарий, четыре разных предмета'),
p('Возьмём не production-инцидент, а фиксированный учебный сценарий `synthetic-checkout-authorization-rejected`. Он содержит два шага пути: gateway принял запрос, payment отказал в авторизации. У обоих шагов общий `synthetic-trace-2023-09-A`; у каждого — свой span ID. Рядом лежат одна synthetic metric record, одна synthetic log/event record и короткая карточка evidence. Их не следует склеивать в универсальный JSON: у каждого предмета своя задача и свой допустимый объём контекста.'),
table('Договор сигналов для одного учебного сценария', ['Представление', 'Вопрос', 'Ключ и поля', 'Чего не утверждает'], [
['Trace', 'какой причинный путь предусмотрен?', 'trace ID + два span ID', 'что trace был создан, передан или сохранён'],
['Metric', 'какой счётчик допустимо группировать?', 'service, route template, outcome', 'какой пользователь, заказ или конкретный request вызвал точку'],
['Log/event', 'что случилось на одном шаге?', 'trace ID, span ID, event name, event attributes', 'что запись доставлена, индексирована или найдена'],
['Evidence', 'какой вывод разрешён из модели?', 'сценарий + вопрос к каждому сигналу', 'что production уже наблюдался или исправление сработало'],
]),
figure('/assets/editorial/2023/telemetry-signals-2023-correlation-map.svg', 'Учебная карта корреляции: один synthetic trace ID проходит через два span, связан с log/event record и карточкой evidence; metric record рядом содержит только три небольших labels и намеренно не содержит trace ID.', 'Карта показывает договор между разными представлениями одного synthetic сценария. Она не является экспортом OpenTelemetry, реальной трассой, журналом, графиком, измерением latency или доказательством работы production-системы.'),
h2('Trace ID — ключ маршрута, не label счётчика'),
p('Trace ID нужен, когда надо связать записи, относящиеся к одному распределённому пути. В OpenTelemetry `SpanContext` выделяет TraceId и SpanId; tagged specification v1.20.0 уже описывала их перенос как часть distributed context. Это не правило «добавляйте ID во все поля». Для metric record общий ID одного запроса почти всегда слишком детален для вопроса «сколько раз произошёл тип отказа по маршруту». Его место — trace и связанный log/event record, где поиск одного сценария имеет смысл.'),
p('У метрики другой договор. В этой учебной модели есть ровно три labels: `service=synthetic-checkout-api`, `route=synthetic-checkout` и `outcome=synthetic-rejected`. Они описывают небольшой фиксированный набор вариантов. Нельзя считать набор универсальным: реальные service name, route template, environment и outcome надо обсуждать с владельцем backend и budget. Но уже на проектировании можно отделить label с малым словарём от значения, которое почти меняется на каждый запрос: trace ID, request ID, user ID, order ID, текст ошибки или сырая строка URL.'),
h2('Log attributes несут объяснение события'),
p('Log/event record в модели хранит `eventName=synthetic.payment.authorization-rejected`, тот же trace ID, span ID payment-шага и три event attributes: класс synthetic отказа, домен события и совет по retry. Эти поля не обязаны стать labels метрики. Они нужны, чтобы при переходе от причины к одному событию не потерять смысл. В реальном проекте часть таких полей может быть чувствительной, слишком подробной или нестабильной; тогда набор нужно сократить или изменить. Учебный пример не утверждает, что его имена являются semantic conventions или что они доступны в поиске.'),
p('Evidence — четвёртый предмет, который часто пропускают. Он не копия log и не вывод из одной точки графика. Это карточка «мы хотим проверить именно такой сценарий; trace должен объяснять путь, metric — считать небольшой класс, log — описывать отказ». Пока нет наблюдения в разрешённой среде, conclusion остаётся `not-a-production-observation`. Такая запись делает неизвестность видимой и не позволяет превратить fixture в отчёт о production.'),
h2('Исполнимый fixture проверяет только форму договора'),
p('Ниже — весь учебный вход. Он создаётся при импорте в памяти, принимает ровно зафиксированный список полей со строками с префиксом `synthetic-` и не вызывает SDK, exporter, сеть, collector, storage или clock. В нём нет настоящих request ID, пользователей, latency, telemetry records и реального trace context. Положительная ветка собирает две synthetic span-записи; отрицательные ветки отвергают разный trace ID в log, иной span ID, лишнее top-level поле и попытку положить trace ID или user ID в labels метрики.'),
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-09.mjs --verify-fixture\n\n# PASS означает только: synthetic contract непротиворечив.\n# PASS не означает: telemetry собрана, связи видны или cardinality приемлема.'),
p('Полезная деталь fixture — metric никогда не получает `trace_id`. Вместо этого объект содержит строку `intentionally-absent-from-labels`. Это не скрытая проверка backend и не запрет для всех существующих систем. Это явное решение маленького контракта. Если в review появится новый label, нужно назвать его словарь, владельца, потребителя и причину, по которой он не является идентификатором отдельного запроса. Если ответа нет, поле остаётся log attribute или вовсе не попадает в этот сценарий.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Error-count можно увидеть отдельно, но нельзя объяснить один запрос: log не содержит общего trace ID или trace не связан с событием.',
'<strong>Причина.</strong> Три сигнала проектировали как независимые поля; точный identifier перенесли в labels метрики, а смысл события остался в свободном тексте.',
'<strong>Проверка.</strong> На одном synthetic сценарии заполните таблицу: два span, один trace ID, один log/event с тем же trace ID и маленький набор metric labels. Прогоните fixture и убедитесь, что он отвергает mismatch и request-like labels.',
'<strong>Действие.</strong> Зафиксируйте correlation contract: trace ID связывает маршрут и событие, labels отвечают только на вопрос агрегации, event attributes объясняют конкретный отказ.',
'<strong>Проверка границы.</strong> Для каждого нового поля спросите: это словарь из заранее известных значений или значение отдельного запроса? Второе не добавляйте в metric labels без отдельного обоснования и измерения.',
'<strong>Следующий шаг.</strong> В разрешённой среде выбрать один реальный маршрут и заранее определить, какой безопасный запрос или dashboard подтвердит каждую связь. Этот fixture такого подтверждения не делает.',
]),
h2('Rollback возвращает договор, а не данные'),
p('Rollback в коде восстанавливает только snapshot four synthetic fields: trace ID, два span ID и metric labels. Он не удаляет trace, log или metric, потому что их не создаёт. Он не отменяет экспорт, retention, alert, dashboard, изменение sampling или production-release. Это важно проговорить до того, как слово «откат» попадёт в runbook. В реальном контуре сначала надо узнать, какое изменение данных или конфигурации сделано, какие записи уже существуют и какой владелец отвечает за обратимое действие.'),
p('Если correlation contract оказался плохим, безопаснее сначала прекратить расширение полей и вернуться к последней понятной схеме. Не следует массово копировать user ID в logs, чтобы «компенсировать» отсутствие связи: это другое решение с отдельными privacy, retention и access условиями. Маленький rollback здесь полезен как напоминание: отмена учебной модели не решает операционный вопрос и не доказывает, что какая-либо telemetry исчезла.'),
h2('Ограничения и следующий шаг'),
p('Статья и fixture не собирают реальные telemetry, latency, cardinality, trace, logs или metrics. Они не читают приложение и не показывают рост ошибок. Они не посылают данные, не создают SDK provider, exporter, collector, index, alert или dashboard и не доказывают observability либо production effect. OpenTelemetry sources объясняют термины и версии на сентябрь 2023, но не дают проекту готовый набор labels или схему хранения.'),
p('Следующий рабочий шаг — не «включить всё». Выберите один владелец маршрута, один сценарий отказа и один вопрос к каждому представлению. Запишите допустимые metric labels в маленький contract, а event attributes отделите от них. Затем согласуйте минимальный безопасный способ проверить эту схему в реальной среде. Если общий trace ID не доходит до границы процесса, зафиксируйте это как пробел: не заменяйте отсутствующую связь новым высоко-кардинальным label.'),
h2('Историческая граница сентября 2023'),
p('К сентябрю 2023 уже был доступен OpenTelemetry Specification release v1.20.0 от 7 апреля 2023. Для исторической честности здесь используются только его термины trace, SpanContext, metric data model и log data model. Текст не предполагает, что любая конкретная language SDK, collector, backend или поздняя semantic convention уже присутствовала у автора. М6-автор строит договор и путь проверки, а не объявляет зрелость системы по названию инструмента.'),
]);
const mechanism = revision({
slug: 'editorial-2023-09-mechanism-telemetry-signals',
title: 'Почему trace ID не должен становиться label метрики',
categories: ['Наблюдаемость', 'Архитектура'],
cover: '/assets/editorial/2023/telemetry-signals-2023-cardinality-budget.svg',
excerpt: 'Разбираем разные модели trace, metric и log/event record: какие поля создают корреляцию, какие допускают агрегацию и почему доказательство одного сценария нельзя заменить высокой cardinality.',
readingMinutes: 12,
}, [
p('Проблема начинается, когда одинаковое поле считают одинаково полезным во всех сигналах. Trace ID связывает один путь, поэтому его хочется положить в каждую метрику. Текст ошибки помогает понять event, поэтому его хочется превратить в dimension графика. После этого график и поиск будто становятся удобнее, но модель перестаёт отвечать на простой вопрос: что именно агрегируется, а что описывает отдельное событие. Причина запроса всё равно может не связаться с ошибкой, хотя полей стало больше.'),
p('Цена ошибки — не обязательно уже измеренный счёт за storage: этот пакет не видел backend, series, query, retention или load. Цена проектная. Без границы labels команда не может заранее сказать, какие значения допускаются, а reviewer не видит, почему очередной идентификатор опаснее нового outcome. Без границы event attributes один отказ превращается в усреднённую цифру. Без trace correlation доказательство меняет форму на каждом переходе и легко превращается в догадку.'),
h2('Три data model нельзя заменить одним словом «телеметрия»'),
p('OpenTelemetry overview уже разделяла tracing, metrics и logs как разные signals. Это не требование держать три разные базы и не обещание, что они появятся в любом SDK. Это причина задавать разным объектам разные вопросы. Trace моделирует причинный путь и несёт SpanContext; metric data model говорит о streams, time series и attributes; log data model выделяет LogRecord с TraceId, SpanId и Attributes. Инструмент может экспортировать их рядом, но их семантика не становится одинаковой от общего transport.'),
table('Поле и его допустимая роль в учебной модели', ['Поле', 'Trace', 'Metric', 'Log/event', 'Причина выбора'], [
['trace ID', 'общий ключ пути', 'не входит в labels', 'связь события с путём', 'идентифицирует один сценарий, а не класс агрегации'],
['span ID', 'идентифицирует шаг', 'не входит в labels', 'указывает шаг события', 'отделяет gateway от downstream операции'],
['route template', 'может быть атрибутом шага', 'малый label', 'может быть event attribute', 'это предполагаемо небольшой словарь маршрутов'],
['outcome class', 'status шага', 'малый label', 'event attribute с деталями', 'помогает сравнить небольшой набор результатов'],
['текст ошибки или user ID', 'только при отдельной политике', 'не входит в labels', 'не включён в fixture', 'может быть чувствительным, нестабильным или per-request'],
]),
figure('/assets/editorial/2023/telemetry-signals-2023-cardinality-budget.svg', 'Учебная схема budget: слева три фиксированных metric labels service, route и outcome образуют небольшой заранее названный словарь; справа trace ID, request ID, user ID и error text перечёркнуты как недопустимые labels и направлены к отдельному trace/log контексту.', 'Схема показывает проектное различие между малыми dimensions и идентификаторами одного запроса. Она не содержит расчёта настоящих series, не измеряет backend и не устанавливает лимит для какого-либо production-контура.'),
h2('Cardinality — свойство сочетаний, а не красивое запрещённое слово'),
p('Слово cardinality становится полезным, когда рядом есть конкретный договор. В metric data model attributes участвуют в различении dimensions/time series. Поэтому важен не один label сам по себе, а набор возможных значений и комбинаций. `route=synthetic-checkout` и `outcome=synthetic-rejected` в fixture имеют заранее фиксированный словарь. `trace_id=synthetic-trace-2023-09-A` выглядит таким же безобидным, пока не вспомнить его роль: в реальной системе identifier должен различать отдельный путь, поэтому его словарь обычно растёт вместе с запросами.'),
p('Нельзя подставлять в статью произвольную формулу «N labels = N series» и объявлять результат измерением. Разные SDK, collectors и backends применяют свои ограничения, aggregation, resource attributes и retention. Учебный contract делает меньше: разрешает три конкретных synthetic labels и отвергает лишние ключи. Это создаёт место для содержательного review. Если кто-то предлагает `merchant_id`, `request_id`, полную URL или текст исключения, он должен объяснить ожидаемый словарь, потребителя, privacy-границу и способ измерить последствия вне fixture.'),
h2('Correlation проходит через trace и log, не через счётчик'),
p('В model code `trace.traceId` равен `log.traceId`, а `log.spanId` равен downstream span ID. Это даёт ясную цепочку: событие `synthetic.payment.authorization-rejected` относится к synthetic payment step, который является дочерним gateway step. Metric record при этом только говорит, что есть единица synthetic отказов по трём маленьким dimensions. Она не должна выдавать точку, где произошёл отказ, и не должна хранить request identity для поиска. Если вам нужен ответ про один запрос, начните с correlation key, а не с новой оси графика.'),
p('Event/log attributes отвечают на другой вопрос: какая деталь отказа полезна именно для объяснения? В fixture это synthetic domain, synthetic failure class и synthetic retry advice. Они намеренно не являются labels метрики, и они не копируют реальный exception. В production такой набор должен проходить review на семантику, чувствительность, retention и доступ. Без этих условий даже технически верный trace ID может связать данные, которые не следует хранить или широко показывать.'),
h2('Fixture охраняет границу, а не эмулирует SDK'),
p('Исполнимый код не создаёт Span, LogRecord или Metric через OpenTelemetry SDK. Названия объектов — учебные, фиксированные, и все строки начинаются с `synthetic-`. Это намеренное решение: SDK-имитация легко выглядит как проверка exporter и протокола, хотя здесь нет clock, context propagation, sampler, processor, network, collector или storage. Функция принимает только известные поля и проверяет четыре отношения: trace ID совпадает между trace и log, log относится к нужному span, metric labels имеют ровно три ключа, а per-request identifiers не проходят.'),
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-09.mjs --verify-fixture\n\n# PASS = contract accepted in memory.\n# Не измеряет series, latency, export success или query result.'),
p('Отрицательные ветки не утверждают, что настоящий label всегда ошибочен. Они показывают, как зафиксировать решение: `trace_id` и `user_id` в metric labels будут отвергнуты именно этим small-budget contract; другой trace ID или span ID в log не будет считаться correlation. Если команда позже расширит модель, она обязана изменить fixture и текст одновременно. Это лучше, чем тихо поменять набор полей в instrumentation и узнавать о последствиях уже после rollout.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Метрика становится местом для поиска единичного request, но по ней всё равно нельзя восстановить его причинный путь; log и trace не совпадают по ключу.',
'<strong>Причина.</strong> Per-request identifier приняли за удобный dimension, а event context и SpanContext не получили отдельный contract.',
'<strong>Проверка.</strong> Выпишите у каждого proposed label словарь значений. Отдельно отметьте identifiers, свободный текст и чувствительные поля. В fixture добавьте ключ и убедитесь, что trace ID/user ID отклоняются, а log mismatch не маскируется.',
'<strong>Действие.</strong> Оставьте в metric labels только согласованные маленькие dimensions; trace ID проведите через context, log/event record и рабочий способ корреляции.',
'<strong>Проверка границы.</strong> До реализации назначьте владельца cardinality budget и владельца event attributes. У них должны быть разные решения, даже если один сервис публикует оба сигнала.',
'<strong>Следующий шаг.</strong> На одном безопасном тестовом маршруте измерить реальные последствия выбранного набора в конкретном backend. До этого не называйте budget соблюдённым.',
]),
h2('Rollback: сначала остановить расширение, потом решать данные'),
p('Если новый label оказался неверным, «удалить его из кода» — не полный rollback. Нужно отдельно выяснить, какие конфигурации и записи успели появиться, какие queries, alerts или dashboards от него зависят и можно ли безопасно остановить дальнейшее создание. Fixture не делает ни одного из этих действий: он возвращает snapshot synthetic contract и прямо пишет `productionEffect=not-attempted`. Такое ограничение предотвращает опасный вывод, будто тест JS умеет удалить данные из telemetry backend.'),
p('В настоящем контуре выбор между остановкой, изменением label и миграцией видимости зависит от точной платформы. Возможно, корректнее оставить старую metric и ввести новую отдельно, чтобы не смешать семантики; возможно, важнее немедленно прекратить появление чувствительного атрибута. Статья не выбирает за проект. Она оставляет порядок: зафиксировать, что неверно; сохранить минимальное evidence без новых данных; назначить владельца обратимого действия; затем проверить результат в той среде, где есть фактические записи.'),
h2('Ограничения и следующий шаг'),
p('Ни одна строка пакета не является настоящим telemetry record. Здесь нет trace context, propagation, latency, counter, histogram, logs, metrics, network, collector, backend, dashboard, alert, sampling, query или cardinality measurement. Fixture не отправляет данные и не доказывает, что OpenTelemetry SDK настроен, что IDs совпадут в реальном transport или что выбранные labels допустимы для конкретной команды. Он даёт только повторяемый язык review одного synthetic сценария.'),
p('Следующий шаг — завести короткую таблицу для одного instrument: metric name, каждое разрешённое dimension value, владелец, потребитель и запрещённые per-request fields. Рядом записать, какой log/event record должен содержать correlation и какую реальную проверку проведут позже. Если таблица не помещается на одну страницу, задача ещё слишком широкая: сужайте сценарий, а не переносите все подробности в metric labels.'),
h2('Историческая граница сентября 2023'),
p('Все технические утверждения привязаны к официальной specification v1.20.0, выпущенной 7 апреля 2023 и доступной к сентябрю. В частности, текст опирается на уже опубликованные определения SpanContext, TraceId, SpanId, metrics data model и logs data model. Он не называет status конкретной SDK или backend и не использует поздние semantic conventions как будто они были готовым контрактом автора 2023 года.'),
]);
const field = revision({
slug: 'editorial-2023-09-field-telemetry-signals',
title: 'Ошибка без причины: маршрут диагностики через log, metric и trace',
categories: ['Наблюдаемость', 'Диагностика'],
cover: '/assets/editorial/2023/telemetry-signals-2023-diagnosis-route.svg',
excerpt: 'Пошаговый маршрут, который не подменяет один correlation ID новой label-кардинальностью: как разложить симптом, гипотезу и evidence между metric, trace и log/event record.',
readingMinutes: 12,
}, [
p('Симптом для диагностики звучит знакомо: график ошибок показывает изменение, но инженер не может назвать запрос и этап, на котором оно возникло. В ответ часто начинают искать текст исключения во всех logs или добавляют request ID в metric labels. Первый путь тонет в несвязанных записях, второй смешивает счётчик с идентичностью одного запроса. Причина не становится ближе: у трёх источников нет договора, который превращает один сигнал в вопрос к следующему.'),
p('Цена такого разрыва — решение на основании наиболее громкой витрины. Можно увеличить timeout, включить retry или объявить downstream виновником, хотя связь между error count, span и event не подтверждена. Эта статья не расследует реальный инцидент и не собирает telemetry. Она строит безопасный diagnostic route для одного fixed synthetic сценария, чтобы показать: evidence одного отказа складывается из разных объектов, а не из максимального количества labels.'),
h2('Начните не с поиска, а с вопроса'),
p('У диагностики есть три уровня. Metric помогает сформулировать, какой класс исходов стоит рассматривать: например, synthetic `outcome=synthetic-rejected` для synthetic checkout route. Trace должен показать предполагаемый причинный путь из gateway к payment шагу. Log/event record должен назвать событие на payment step и сохранить тот же correlation key. Только после этого появляется evidence-card: она говорит, какую гипотезу можно проверить и чего пока нет. Никакой из объектов по отдельности не заменяет остальные.'),
table('Маршрут вопросов вместо бесконечного поиска', ['Очередь', 'Вопрос', 'Нужное представление', 'Допустимый результат', 'Что не делать'], [
['1', 'какой класс результата разбираем?', 'metric labels', 'synthetic route + outcome', 'не добавлять request ID ради фильтра'],
['2', 'какой путь должен ему соответствовать?', 'trace + span tree', 'один synthetic trace ID, два шага', 'не считать график доказательством причины'],
['3', 'какое событие произошло на шаге?', 'log/event record', 'event name + trace ID + span ID', 'не искать по свободному тексту без correlation'],
['4', 'какой вывод честен?', 'evidence card', 'not-a-production-observation', 'не объявлять hypothesis подтверждённой fixture-ом'],
]),
figure('/assets/editorial/2023/telemetry-signals-2023-diagnosis-route.svg', 'Учебный маршрут диагностики: от synthetic metric класса через общий trace ID к payment span и synthetic event/log record, затем к карточке evidence с явным статусом not-a-production-observation; ветка trace ID как metric label перечёркнута.', 'Диаграмма показывает порядок вопросов и границы вывода для synthetic записи. Она не изображает реальный alert, dashboard, запрос к backend, trace search, latency или подтверждённую причину production-сбоя.'),
h2('Metric даёт границу разбора, а не виновника'),
p('В учебном наборе metric record содержит имя `synthetic.checkout.authorization.rejected.total`, значение `1` и три labels. Значение `1` — не наблюденный counter, а фиксированная часть fixture. Оно нужно только чтобы показать форму: одна маленькая точка может обозначать класс outcome. По ней нельзя определить user, order, request или span. Такую границу полезно сохранять даже если UI backend позволяет кликнуть на dimensions: возможность фильтра не превращает metric в достоверный журнал событий.'),
p('Если на первом шаге неизвестно, какой вопрос нужна решать, не пополняйте labels «на всякий случай». Сначала назовите route template и outcome class, которые должны быть малым словарём. Затем спросите владельца инструмента, какая реальная единица aggregation поддержана, какие resource attributes добавляются и где будет измеряться cardinality. Без ответа status должен быть «не проверено», а не «у нас низкая cardinality». Fixture помогает удержать именно эту дисциплину: лишний `trace_id`, `request_id` или `user_id` он отвергает до того, как поле станет привычным.'),
h2('Trace связывает причины, log/event фиксирует контекст'),
p('Дальше мы идём по `synthetic-trace-2023-09-A`. В trace object есть root span gateway и дочерний payment span; оба названия и состояния synthetic. Связь потомка с родителем — модель причинного маршрута, а не свидетельство выполнения вызова. Log/event record ссылается на payment span, имеет тот же trace ID и event name отказа. Если trace ID или span ID в log отличаются, fixture возвращает отказ. Это простое правило полезнее длинного списка полей: событие должно либо объяснять конкретный шаг пути, либо честно оставаться несвязанным.'),
p('Event attributes нужны для узкой диагностики события. В примере есть `failure.class=synthetic-declined` и `retry.advice=synthetic-do-not-retry`. Они не говорят, как надо обрабатывать настоящие платежи, и не являются production error message. Их роль — показать разницу между типом отказа и точной идентичностью запроса. В реальном проекте перед добавлением любых attributes нужно отдельно решить privacy, возможность redaction, retention, доступ к поиску и стабильность названий. Нельзя прятать эти решения под словом «контекст».'),
h2('Прогоните одну контролируемую модель'),
p('Код ниже создаёт fixed synthetic scenario в памяти. Он не может обратиться к приложению или telemetry backend, не читает clock и не создаёт telemetry. `runTelemetryFixture()` проверяет девятнадцать assertions: общий trace ID для trace и log, правильный span, три разрешённых labels, отсутствие trace ID в labels, закрытый список входных полей, отрицательные ветки mismatch и предел rollback. Это упражнение для review контракта. Его PASS не подтверждает, что downstream отказал, что metric выросла, что span записался или что log можно найти.'),
code(syntheticExample + '\n\nnode web/scripts/upgrade-2023-09.mjs --verify-fixture\n\n# PASS проверяет только fixed synthetic records in memory.\n# Не доказывает incident, production latency, trace export или cardinality.'),
p('После локального прогона полезно записать evidence без переобобщения. Корректная формулировка: «модель ожидает, что один correlation key соединяет заданный payment event и заданный trace; metric использует только три fixed dimensions». Некорректная: «причина ошибки найдена» или «metric безопасна для production». Разница кажется формальной только пока первое решение не затронуло retry, alert policy или пользовательские данные. В инженерном разборе неизвестное — это тоже результат, который должен пережить передачу задачи.'),
h2('Маршрут: симптом → причина → проверка → действие'),
ol([
'<strong>Симптом.</strong> Есть числовой признак класса ошибок, но нет понятного перехода к одному пути запроса и событию, которое его объясняет.',
'<strong>Причина.</strong> Metric, trace и log/event живут без общего contract: метрика получила per-request labels, а log не несёт trace/span correlation.',
'<strong>Проверка.</strong> Выберите один synthetic outcome. Проверьте, что metric содержит только service/route/outcome, trace имеет один ID и два шага, а log/event повторяет trace ID и downstream span ID. Запустите fixture с отрицательными ветками.',
'<strong>Действие.</strong> Зафиксируйте маршрут metric → trace → log/event → evidence. Поставьте trace ID в correlation fields, а не в labels метрики; detail оставьте event attributes только после отдельного policy review.',
'<strong>Проверка вывода.</strong> В реальном контуре заранее назовите, какой query или безопасная выборка может подтвердить каждую стрелку. Пока она не выполнена, conclusion остаётся не подтверждённым.',
'<strong>Следующий шаг.</strong> Добавьте в runbook одну ветку mismatch: что делать, если metric есть, но trace или log не correlates. Это отдельная проблема instrumentation, а не приглашение добавить новый ID в счётчик.',
]),
h2('Когда останавливать, а когда откатывать'),
p('Если на review обнаружился high-cardinality label или несвязанный event, первое действие — остановить распространение нового контракта. Это не равно удалить все данные: нельзя обещать удаление, не зная платформы, retention, доступа и состоявшегося rollout. Затем нужно отделить два вопроса: какие новые записи могут продолжать возникать и какие потребители уже зависят от поля. Только после этого владелец выбирает обратимое действие для конкретной конфигурации.'),
p('Fixture умеет только вернуть snapshot synthetic полей и пометить `telemetry=not-created-or-deleted`. Он не выключает instrumentation, не меняет sampling, alert, dashboard или access policy. Такой rollback не декоративен: он ставит границу между проверкой модели и операционным изменением. В реальном runbook точка возврата должна быть названа точнее: версия конфигурации, набор approved fields, способ проверить отсутствие дальнейшего потока и владелец подтверждения.'),
h2('Ограничения и следующий шаг'),
p('Здесь нет реальных logs, metrics, traces, latency, cardinality, traffic, backend records или incident data. Нет отправки данных, запроса, collector, exporter, storage, sampling, alerting, query, dashboard или production effect. Synthetic `value: 1` не является измерением; synthetic IDs не являются request IDs. Пакет также не утверждает, что реальные error messages, user fields или маршруты допустимы для хранения. Он только различает роли полей и показывает, какую связь надо проверить позднее.'),
p('Следующий шаг — провести ограниченное design review одного instrumentation change. Договоритесь о: одном metric question, одном route template, малом outcome vocabulary, одном correlation key и минимальном event schema. Затем выберите реальную разрешённую среду и способ проверить путь без публикации чувствительных значений. Если итогом окажется, что trace context не проходит конкретную границу, это не поражение модели: это точная задача для следующего изменения, а не основание расширять cardinality метрики.'),
h2('Историческая граница сентября 2023'),
p('Материал использует только OpenTelemetry Specification v1.20.0, опубликованную 7 апреля 2023 и доступную к сентябрю того года. Она уже описывала tracing API, metrics data model и logs data model, на которые опирается различение сигналов. Статья не утверждает, что конкретная SDK, transport, collector или backend имеют одинаковую зрелость, и не переносит в 2023 год более поздние договорённости команды или инструмента.'),
]);
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
function verifyFixture() {
const report = runTelemetryFixture();
const failed = Object.entries(report.assertions).filter(([, value]) => value !== true).map(([key]) => key);
if (failed.length) {
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
process.exitCode = 1;
return;
}
process.stdout.write('PASS fixture: ' + Object.keys(report.assertions).length + '/' + Object.keys(report.assertions).length + ' assertions\n');
}
if (process.argv.includes('--verify-fixture')) verifyFixture();
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');