From 8e16858f160d58d4ea07aaa830418d097f4b7782 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Fri, 31 Jul 2026 11:44:13 +0300 Subject: [PATCH] revise August 2020 metrics articles --- editorial/production/README.md | 2 +- editorial/reviews/2020-08-draft.md | 152 ++++++ web/data/editorial-revisions.mjs | 2 + .../editorial/2020/metrics-diagnosis-2020.svg | 62 +++ .../2020/metrics-label-boundary-2020.svg | 58 +++ .../2020/metrics-signal-contract-2020.svg | 59 +++ web/scripts/upgrade-2020-08.mjs | 470 ++++++++++++++++++ 7 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 editorial/reviews/2020-08-draft.md create mode 100644 web/public/assets/editorial/2020/metrics-diagnosis-2020.svg create mode 100644 web/public/assets/editorial/2020/metrics-label-boundary-2020.svg create mode 100644 web/public/assets/editorial/2020/metrics-signal-contract-2020.svg create mode 100644 web/scripts/upgrade-2020-08.mjs diff --git a/editorial/production/README.md b/editorial/production/README.md index c26b7b6..5d897b1 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 91 из 358 созданных материалов. Остальные 267 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 94 из 358 созданных материалов. Остальные 264 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2020-08-draft.md b/editorial/reviews/2020-08-draft.md new file mode 100644 index 0000000..c26430e --- /dev/null +++ b/editorial/reviews/2020-08-draft.md @@ -0,0 +1,152 @@ +# Автономное тройное ревью П30 · август 2020 · «Метрики приложения» + +Статус: **принят независимым редактором в выпусковой набор**. В нём ровно три +revision для стабильных slug: + +- editorial-2020-08-practice-metrics-basics; +- editorial-2020-08-mechanism-metrics-basics; +- editorial-2020-08-field-metrics-basics. + +Созданы только пять разрешённых файлов П30: + +- web/scripts/upgrade-2020-08.mjs; +- web/public/assets/editorial/2020/metrics-signal-contract-2020.svg; +- web/public/assets/editorial/2020/metrics-label-boundary-2020.svg; +- web/public/assets/editorial/2020/metrics-diagnosis-2020.svg; +- этот документ. + +Module export не задаёт date или author. При будущей +интеграции registry может наложить только редакционные поля на базовые записи +архива. articles.json, registry, стандарт, очередь, package config +и Git данным пакетом не менялись. Команда --print-revisions +печатает только JSON, а --verify-fixture запускает отдельную +детерминированную проверку в памяти. + +## Проход 1. Факты и техника — пройдено + +| Утверждение | Первичный или официальный источник | Проверенная граница | +| --- | --- | --- | +| Имя метрики описывает одну величину и единицу; для counter используется суффикс _total, duration измеряется в seconds | [Prometheus: Metric and label naming](https://prometheus.io/docs/practices/naming/) | store_http_requests_total отделён от store_http_request_duration_seconds; учебные имена не смешивают запросы, ошибки и время | +| Для online-serving системы полезно считать завершённые запросы, ошибки и latency; errors нужны рядом с числом попыток | [Prometheus: Instrumentation](https://prometheus.io/docs/practices/instrumentation/) | Counter увеличивается после завершения operation; operation и outcome ограничены allowlist | +| Counter накапливает события и при рестарте может сброситься; server-side query над окном не равен ручной разности двух точек | [Prometheus: Instrumentation](https://prometheus.io/docs/practices/instrumentation/), [Prometheus: Querying basics](https://prometheus.io/docs/prometheus/latest/querying/basics/) | PromQL с increase(...[10m]) приведён как будущий вопрос к серверу; fixture проверяет только монотонную учебную серию и не моделирует reset или scrape | +| Уникальная комбинация имени и labels создаёт отдельную time series; неограниченные значения раздувают storage | [Prometheus: Metric and label naming](https://prometheus.io/docs/practices/naming/), [Prometheus: Instrumentation](https://prometheus.io/docs/practices/instrumentation/) | Учебная арифметика 3 operations × 2 outcomes и пример с 100 user ID не выданы за RAM-, disk- или workload-замер | +| Counter, gauge, histogram и summary описывают разные формы величины | [Prometheus: Metric types](https://prometheus.io/docs/concepts/metric_types/) | Counter не назван current state; fixture с sum/count не выдаёт p95 или распределение за production telemetry | + +Все четыре официальные страницы использованы для сверки смысла names, labels, +types и запросов. Они не представлены как доказательство развернутого в августе +2020 года production-контура. В тексте нет claim о реальном Prometheus server, +exporter, scrape interval, dashboard, alert delivery, нагрузке или +производственном пороге. + +### Техническая граница учебной фикстуры + +runMetricsFixture() создаёт четыре synthetic event, допускает +ровно catalog, checkout, profile и +success/error, затем формирует локальный exposition-текст. Вторая +часть fixture сравнивает две серии counter: + +- 0 → 1 → 2 за учебное окно даёт delta 2 и + пересекает упражнение >= 2; +- 0 → 1 → 1 даёт delta 1 и его не пересекает. + +Эта проверка не подменяет PromQL, target scrape, counter reset, network, +storage, client library или alerting. Её задача уже: зафиксировать, что +изменение labels или числа в учебном контракте не пройдёт незаметно. + +Вердикт прохода: **пройден**. Утверждения о Prometheus отделены от учебной +модели, а учебная модель — от запуска на реальной инфраструктуре. + +## Проход 2. Редактура, глубина и голос М3 — пройдено + +| Ревизия | Симптом и цена в первых двух абзацах | Главный вопрос | Объём основного текста | +| --- | --- | --- | --- | +| Практика | Общий график requests не показывает operation и outcome; цена — поиск не по той границе и разрастание бесполезных линий | Как выбрать один completed-request signal, bounded labels и учебный threshold | **9 182** знаков body | +| Механизм | Уникальный label размножает series и делает запрос неясным; цена — хранение и потеря агрегированного смысла | Где заканчивается полезная dimension и почему request context живёт в журнале | **10 230** знаков body | +| Полевой разбор | Одна линия requests не объясняет деградацию; цена — шумный порог и правка timeout без факта | Как прочитать controlled counter-series, окно и threshold, не выдав их за production-диагноз | **9 480** знаков body | + +- Во всех материалах начало устроено по схеме «симптом → цена → ограниченная + учебная граница», затем следует «причина → проверка → действие». +- У каждой revision больше пяти смысловых разделов, есть figure с + самостоятельным alt/figcaption, таблица с + caption/thead, code/query/fixture example, + нумерованный маршрут и четыре официальные ссылки. +- Статьи не используют общую риторику про важность наблюдаемости. Они + фиксируют completed request, counter, seconds, operation/outcome, time + series, окно, порог, log и следующий сценарий. +- Голос соответствует М3 / августу 2020 года. Автор связывает application-code + с наблюдаемым измерением, но не приписывает себе SLO, error budget, + observability platform, реальные production-значения или опыт большого + инцидента. +- Все цифры в графике, таблице cardinality и пороге явно названы учебными. + Нагрузка, допустимая ошибка и стоимость alert не придуманы вместо данных + проекта. + +Второй редакторский проход проверил, что count, duration и latency не +смешиваются. Counter не назван current state, а increase() не +выдана за ручную разность snapshots. Pseudocode над client library отмечен как +контракт labels, а не как запущенный exporter. + +Вердикт прохода: **пройден**. Тексты укладываются в 5 000–15 000 знаков, +остаются прагматичными и не перескакивают к зрелой платформенной терминологии. + +## Проход 3. Визуал, fixture и выпусковой preflight — пройдено в пределах пакета + +- metrics-signal-contract-2020.svg ведёт от операционного + вопроса к counter, двум ограниченным labels, учебному окну и действию после + пересечения порога. +- metrics-label-boundary-2020.svg отделяет allowlist + operation/outcome для series от request ID, email и полного URL, + которые остаются в журнале. +- metrics-diagnosis-2020.svg показывает три synthetic snapshots + counter 0 → 1 → 2, условие increase(...[10m]) = 2 + и следующий диагностический шаг. +- У схем есть title, desc, role="img", + вертикальные viewBox 720 px, контрастные карточки и текст не мельче 20 px в + исходном SVG. В них нет JavaScript, foreignObject, внешних URL + или raster data URI. +- Отдельный mobile preflight отрендерил все три SVG в PNG шириной 375 px + через Sharp и просмотрел результат. У всех схем крупные заголовки, короткие + строки и вертикальная композиция; clipping, наложение и горизонтальный + overflow внутри SVG не обнаружены. Это статическая проверка visual asset, не + browser-run и не test screen reader. + +### Фактически выполненные проверки + +Запущены после финальной редакторской правки 31 июля 2026 года: + +
cd web && node --check scripts/upgrade-2020-08.mjs
+cd web && npm run audit:draft -- scripts/upgrade-2020-08.mjs
+cd web && node scripts/upgrade-2020-08.mjs --verify-fixture
+cd web && xmllint --noout \
+  public/assets/editorial/2020/metrics-signal-contract-2020.svg \
+  public/assets/editorial/2020/metrics-label-boundary-2020.svg \
+  public/assets/editorial/2020/metrics-diagnosis-2020.svg
+ +| Проверка | Реальный результат | +| --- | --- | +| node --check | PASS, code 0 | +| Import-safe export и draft gate | PASS: **9 182 / 10 230 / 9 480** знаков body; три slug, tables, figures, code, routes, sources и assets найдены | +| In-memory fixture | PASS: bounded labels сохранены, counter error checkout найден, threshold пересекается на 2 и не пересекается на 1 | +| xmllint --noout | PASS, все три SVG — корректный XML | +| Sharp mobile preflight | PASS: три PNG шириной 375 px просмотрены; нет clipping, наложения или horizontal overflow внутри схем | +| Scope/self-review | PASS: в revision нет date/author; созданы только пять файлов П30; чужие незакоммиченные пакеты не редактировались и не индексировались | + +## Независимая интеграционная приёмка + +Основной редактор 31 июля 2026 года подключил три revision к +web/data/editorial-revisions.mjs, не меняя базовый +articles.json, даты или автора архивных записей. После подключения +в registry стало 85 revision. Отдельно выполнены: + +| Проверка после интеграции | Реальный результат | +| --- | --- | +| Строгий audit трёх slug | PASS: 9 182 / 10 230 / 9 480 знаков; у каждой статьи один figure, одна table и два code example | +| Production build | PASS: Next.js собрал 374 статические страницы | +| Независимый mobile visual review | PASS: основной редактор повторно просмотрел все три SVG, отрендеренные Sharp в 375 px; clipping, overlap и overflow не обнаружены | + +Ни этот отчёт, ни интеграция не утверждают, что был запущен настоящий +Prometheus, exporter, scrape, browser или assistive technology. Они фиксируют +границы автономного учебного пакета и результат статических проверок. + +Выпусковой вердикт: **ACCEPT**. Commit и push выполняются отдельной +публикационной операцией; Git остаётся источником её фактической записи. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index db9a11e..7110594 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -26,6 +26,7 @@ import { revisions as april2020Revisions } from '../scripts/upgrade-2020-04.mjs' import { revisions as may2020Revisions } from '../scripts/upgrade-2020-05.mjs'; import { revisions as june2020Revisions } from '../scripts/upgrade-2020-06.mjs'; import { revisions as july2020Revisions } from '../scripts/upgrade-2020-07.mjs'; +import { revisions as august2020Revisions } from '../scripts/upgrade-2020-08.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -57,4 +58,5 @@ export const editorialRevisions = [ ...may2020Revisions, ...june2020Revisions, ...july2020Revisions, + ...august2020Revisions, ]; diff --git a/web/public/assets/editorial/2020/metrics-diagnosis-2020.svg b/web/public/assets/editorial/2020/metrics-diagnosis-2020.svg new file mode 100644 index 0000000..5b2836c --- /dev/null +++ b/web/public/assets/editorial/2020/metrics-diagnosis-2020.svg @@ -0,0 +1,62 @@ + + Учебный разбор порога по counter + Вертикальная схема показывает: общий график не является диагнозом. Для одной series checkout error берут три синтетические точки counter ноль, один и два, проверяют прирост за десять минут против учебного порога две и затем открывают один связанный журнал. + + + + + + + + + + + Разбор учебного counter + Сигнал сужает вопрос · не называет причину + + + 1 · ВЫБРАТЬ SERIES + checkout + error + не общий requests_total + + + + + 2 · СЫРЫЕ SNAPSHOTS + синтетические точки, не production-метрика + + + + 0 + 1 + 2 + + + + 10:00 + 10:05 + 10:10 + 0 + 1 + 2 + + + + + 3 · ПРОВЕРИТЬ ОКНО + increase(...[10m]) = 2 + fixture считает прозрачную разность + не моделирует scrape и reset + + + + + 4 · УЧЕБНЫЙ ПОРОГ + 2 >= 2 → пересечён + это не alert severity и не SLO + + + + + Дальше: один связанный журнал или стендовый сценарий + diff --git a/web/public/assets/editorial/2020/metrics-label-boundary-2020.svg b/web/public/assets/editorial/2020/metrics-label-boundary-2020.svg new file mode 100644 index 0000000..024a935 --- /dev/null +++ b/web/public/assets/editorial/2020/metrics-label-boundary-2020.svg @@ -0,0 +1,58 @@ + + Граница полезных labels в метрике + Вертикальная схема показывает, что событие HTTP проходит allowlist operation и outcome в агрегированную time series. Request ID, email и полный URL уходят в журнал для поиска одного события и не становятся labels. + + + + + + + + + + + + + + Labels: граница series + Ограниченная размерность ≠ журнал события + + + СОБЫТИЕ HTTP + checkout завершился error + есть и стабильные, и уникальные поля + + + + + ALLOWLIST ДЛЯ МЕТРИКИ + operation + catalog · checkout · profile + outcome + success · error + + + + + АГРЕГИРОВАННАЯ SERIES + requests_total + operation + outcome → вопрос и query + малое число понятных комбинаций + + + + + НЕ LABEL + request ID · email · полный URL + новое значение на событие → новая series + не хранить уникальный контекст в counter + + + + + ЖУРНАЛ / REQUEST CONTEXT + поиск одного события + ID остаётся ключом расследования + + Сначала вопрос → затем label → затем series + diff --git a/web/public/assets/editorial/2020/metrics-signal-contract-2020.svg b/web/public/assets/editorial/2020/metrics-signal-contract-2020.svg new file mode 100644 index 0000000..8ee2324 --- /dev/null +++ b/web/public/assets/editorial/2020/metrics-signal-contract-2020.svg @@ -0,0 +1,59 @@ + + Контракт учебного сигнала для HTTP-операции + Вертикальная схема: один операционный вопрос про ошибки checkout ведёт к counter с ограниченными labels operation и outcome, затем к учебному окну десять минут, порогу две ошибки и следующей диагностической проверке. + + + + + + + + + + + Метрика начинается с вопроса + Учебный контракт · не production-график + + + + 1 + ОПЕРАЦИОННЫЙ ВОПРОС + Ошибки checkout + за контролируемые 10 минут? + + + + + + 2 + SIGNAL + requests_total + counter завершённых запросов + + одна величина · не диагноз + + + + + + 3 + BOUNDED LABELS + operation + outcome + catalog · checkout · profile + success · error + без user ID и request ID + + + + + + 4 + УЧЕБНОЕ УСЛОВИЕ + increase(...[10m]) + >= 2 ошибки + + + + + Пересекли порог → открыть один связанный журнал + diff --git a/web/scripts/upgrade-2020-08.mjs b/web/scripts/upgrade-2020-08.mjs new file mode 100644 index 0000000..3f2d9f5 --- /dev/null +++ b/web/scripts/upgrade-2020-08.mjs @@ -0,0 +1,470 @@ +function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function paragraph(text) { + return '

' + text + '

'; +} + +function heading(text) { + return '

' + text + '

'; +} + +function codeBlock(lines) { + return '
' + escapeHtml(lines.join('\n')) + '
'; +} + +function figure(src, alt, caption) { + return '
' + alt + '
' + caption + '
'; +} + +function orderedList(items) { + return '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +} + +function dataTable(caption, headers, rows) { + const captionHtml = '' + caption + ''; + const head = '' + headers.map((header) => '' + header + '').join('') + ''; + const body = '' + rows.map((row) => '' + row.map((cell) => '' + cell + '').join('') + '').join('') + ''; + return '
' + captionHtml + head + body + '
'; +} + +function sourceList(items) { + return ''; +} + +function visibleText(html) { + return html + .replace(/<[^>]*>/g, ' ') + .replaceAll(' ', ' ') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replace(/\s+/g, ' ') + .trim(); +} + +function proseText(html) { + return visibleText( + html + .replace(/
[\s\S]*?<\/code><\/pre>/g, '')
+      .replace(/
[\s\S]*?<\/figure>/g, '') + .replace(/
[\s\S]*?<\/div>/g, ''), + ); +} + +function createRevision(meta, bodyParts, sources) { + const bodyHtml = bodyParts.join('\n'); + const proseLength = proseText(bodyHtml).length; + + if (proseLength < 5000 || proseLength > 15000) { + throw new Error(meta.slug + ': prose length must be 5000–15000, got ' + proseLength); + } + + if (sources.length < 2) { + throw new Error(meta.slug + ': at least two primary or official sources are required'); + } + + return { + ...meta, + contentHtml: [bodyHtml, heading('Проверяемые источники'), sourceList(sources)].join('\n'), + proseLength, + }; +} + +const prometheusNaming = { + title: 'Prometheus: Metric and label naming', + url: 'https://prometheus.io/docs/practices/naming/', + note: 'официальные правила: одна величина и единица на имя метрики, базовые единицы и отдельная осторожность с label dimensions', +}; + +const prometheusInstrumentation = { + title: 'Prometheus: Instrumentation', + url: 'https://prometheus.io/docs/practices/instrumentation/', + note: 'официальные рекомендации для online-serving систем: считать завершённые запросы, ошибки и latency; не раздувать labels и держать счётчик ошибок рядом с числом попыток', +}; + +const prometheusQuerying = { + title: 'Prometheus: Querying basics', + url: 'https://prometheus.io/docs/prometheus/latest/querying/basics/', + note: 'официальная справка по selectors, range vectors, агрегации и выражениям PromQL; запросы ниже показаны как учебные формулировки', +}; + +const prometheusMetricTypes = { + title: 'Prometheus: Metric types', + url: 'https://prometheus.io/docs/concepts/metric_types/', + note: 'официальное описание counter, gauge, histogram и summary; выбор типа должен следовать форме наблюдаемой величины', +}; + +const allowedOperations = new Set(['catalog', 'checkout', 'profile']); +const allowedOutcomes = new Set(['success', 'error']); + +function assertFixtureEvent(event) { + if (!allowedOperations.has(event.operation)) { + throw new Error('Unknown operation in controlled fixture: ' + event.operation); + } + if (!allowedOutcomes.has(event.outcome)) { + throw new Error('Unknown outcome in controlled fixture: ' + event.outcome); + } + if (typeof event.durationSeconds !== 'number' || event.durationSeconds < 0) { + throw new Error('durationSeconds must be a non-negative number'); + } +} + +function metricLine(name, labels, value) { + const encodedLabels = Object.entries(labels) + .map(([key, label]) => key + '="' + String(label).replaceAll('"', '\\"') + '"') + .join(','); + return name + '{' + encodedLabels + '} ' + value; +} + +function renderFixtureExposition(events) { + const totals = new Map(); + const durationSums = new Map(); + const durationCounts = new Map(); + + for (const event of events) { + assertFixtureEvent(event); + const key = event.operation + '|' + event.outcome; + totals.set(key, (totals.get(key) || 0) + 1); + durationSums.set(event.operation, (durationSums.get(event.operation) || 0) + event.durationSeconds); + durationCounts.set(event.operation, (durationCounts.get(event.operation) || 0) + 1); + } + + const lines = [ + '# HELP store_http_requests_total Completed HTTP requests in the controlled fixture.', + '# TYPE store_http_requests_total counter', + ]; + + for (const [key, value] of [...totals.entries()].sort()) { + const [operation, outcome] = key.split('|'); + lines.push(metricLine('store_http_requests_total', { operation, outcome }, value)); + } + + lines.push('# HELP store_http_request_duration_seconds Duration samples in the controlled fixture.'); + lines.push('# TYPE store_http_request_duration_seconds summary'); + + for (const operation of [...durationCounts.keys()].sort()) { + lines.push(metricLine('store_http_request_duration_seconds_sum', { operation }, durationSums.get(operation))); + lines.push(metricLine('store_http_request_duration_seconds_count', { operation }, durationCounts.get(operation))); + } + + return lines.join('\n'); +} + +function errorDelta(samples) { + if (!Array.isArray(samples) || samples.length < 2) { + throw new Error('At least two counter samples are required'); + } + + const values = samples.map((sample) => sample.value); + if (values.some((value) => typeof value !== 'number' || value < 0)) { + throw new Error('Counter samples must be non-negative numbers'); + } + if (values.some((value, index) => index > 0 && value < values[index - 1])) { + throw new Error('Controlled fixture does not model counter resets'); + } + + return values.at(-1) - values[0]; +} + +export function runMetricsFixture() { + const events = [ + { operation: 'catalog', outcome: 'success', durationSeconds: 0.03 }, + { operation: 'checkout', outcome: 'success', durationSeconds: 0.08 }, + { operation: 'checkout', outcome: 'error', durationSeconds: 0.11 }, + { operation: 'profile', outcome: 'success', durationSeconds: 0.05 }, + ]; + const controlledSamples = [ + { at: '2020-08-01T10:00:00Z', value: 0 }, + { at: '2020-08-01T10:05:00Z', value: 1 }, + { at: '2020-08-01T10:10:00Z', value: 2 }, + ]; + const oneErrorSamples = [ + { at: '2020-08-01T10:00:00Z', value: 0 }, + { at: '2020-08-01T10:05:00Z', value: 1 }, + { at: '2020-08-01T10:10:00Z', value: 1 }, + ]; + const deltaAtTwoErrors = errorDelta(controlledSamples); + const deltaAtOneError = errorDelta(oneErrorSamples); + const exposition = renderFixtureExposition(events); + + return { + exposition, + controlledSamples, + oneErrorSamples, + deltaAtTwoErrors, + deltaAtOneError, + assertions: { + keepsOnlyBoundedLabels: !exposition.includes('user_id=') && !exposition.includes('request_id='), + includesCompletedErrorCounter: exposition.includes('store_http_requests_total{operation="checkout",outcome="error"} 1'), + thresholdTriggersAtTwoErrors: deltaAtTwoErrors >= 2, + thresholdDoesNotTriggerAtOneError: deltaAtOneError < 2, + }, + }; +} + +const controlledExpositionCode = [ + '// Учебная фикстура из этого revision-модуля. Не подключена к Prometheus server.', + 'const events = [', + " { operation: 'catalog', outcome: 'success', durationSeconds: 0.03 },", + " { operation: 'checkout', outcome: 'success', durationSeconds: 0.08 },", + " { operation: 'checkout', outcome: 'error', durationSeconds: 0.11 },", + '];', + '', + '// Функция проверяет allowlist и рендерит только два bounded label:', + 'const exposition = renderFixtureExposition(events);', + 'console.log(exposition);', + '', + '# TYPE store_http_requests_total counter', + 'store_http_requests_total{operation="checkout",outcome="error"} 1', + 'store_http_request_duration_seconds_count{operation="checkout"} 2', +]; + +const thresholdQueryCode = [ + '# Учебный PromQL-вопрос, а не production-alert:', + '# «В контролируемом десятиминутном окне стало две или больше ошибок checkout?»', + 'sum(increase(store_http_requests_total{', + ' operation="checkout",', + ' outcome="error"', + '}[10m])) >= 2', + '', + '# В in-memory fixture вместо PromQL проверяется та же простая граница:', + 'errorDelta(controlledSamples) >= 2 // true', + 'errorDelta(oneErrorSamples) >= 2 // false', +]; + +const labelBoundaryCode = [ + 'const allowedOperations = new Set(["catalog", "checkout", "profile"]);', + 'const allowedOutcomes = new Set(["success", "error"]);', + '', + 'function recordFinishedRequest(event) {', + ' if (!allowedOperations.has(event.operation)) throw new Error("unknown operation");', + ' if (!allowedOutcomes.has(event.outcome)) throw new Error("unknown outcome");', + '', + ' // Нет user_id, email, request_id, полного URL или текста ошибки.', + ' requestTotal.inc({ operation: event.operation, outcome: event.outcome });', + ' requestDuration.observe({ operation: event.operation }, event.durationSeconds);', + '}', + '', + '// requestTotal и requestDuration — объекты выбранной client library.', + '// Псевдокод задаёт контракт labels; library и server не запускаются в статье.', +]; + +const labelQueryCode = [ + '# Раскладываем завершённые запросы только по двум заранее ограниченным labels.', + 'sum by (operation, outcome) (', + ' increase(store_http_requests_total[10m])', + ')', + '', + '# Не делаем так: один request_id создаёт новую series для каждого запроса.', + 'store_http_requests_total{request_id=""}', +]; + +const diagnosisSamplesCode = [ + '# Синтетические snapshots одного counter, не production-метрика.', + '# metric: store_http_requests_total{operation="checkout",outcome="error"}', + '2020-08-01T10:00:00Z 0', + '2020-08-01T10:05:00Z 1', + '2020-08-01T10:10:00Z 2', + '', + '# Для упражнения: delta = 2, поэтому порог >= 2 пересечён.', + '# Фикстура намеренно не моделирует scrape delay, counter reset или PromQL extrapolation.', +]; + +const practiceArticle = createRevision( + { + slug: 'editorial-2020-08-practice-metrics-basics', + title: 'Метрики приложения: начинаем с одного операционного вопроса', + categories: ['Наблюдаемость', 'Prometheus', 'Практика'], + cover: '/assets/editorial/2020/metrics-signal-contract-2020.svg', + excerpt: 'График количества запросов не объясняет деградацию сам по себе. Выбираем одну операцию, bounded labels, учебный запрос и порог, который можно проверить на контролируемой серии.', + readingMinutes: 14, + }, + [ + paragraph('Симптом знакомый: на графике есть общее число HTTP-запросов, а ответить на вопрос «в какой операции появились ошибки?» всё равно нельзя. Цена такой метрики проявляется во время разбора. Инженер сравнивает несвязанные пики, добавляет ещё одну линию или начинает искать проблему по логам без точки входа. Если в название метрики положить всё подряд, следующий шаг становится ещё дороже: график растёт, а причина по-прежнему не отделена от фонового шума.'), + paragraph('В августе 2020 года я бы не начинал с набора дашбордов и не называл это готовой observability-платформой. Достаточно выбрать один повторяемый вопрос для HTTP-операции: «появились ли в учебном окне ошибки завершения checkout?» Ниже только контролируемая фикстура с выдуманными значениями. Она показывает форму сигнала, labels, запрос и порог; ни Prometheus server, ни реальные нагрузки, ни production-alert в этом материале не запускались.'), + heading('Сначала формулируем вопрос, потом имя метрики'), + paragraph('Общее число запросов отвечает лишь на вопрос «сколько завершений увидел обработчик». Оно не различает каталог, checkout и профиль, не отделяет успешный исход от ошибки и не говорит, что делать дальше. Поэтому вопрос надо писать до кода. Для этой заметки он ограничен одной границей: обработчик уже закончил HTTP-операцию и знает её стабильное имя и исход. Вход в счётчик ставится после завершения, чтобы число попыток, ошибок и длительностей относилось к одному и тому же моменту.'), + paragraph('Такое ограничение полезнее, чем попытка измерить всё на первом шаге. Путь до приложения, ответ базы, очередь, клиентский браузер и ручная работа оператора остаются отдельными границами. Если их смешать в одну величину, название получится широким, а действие по ней — неясным. Сигнал ниже не доказывает доступность для пользователя и не заменяет трассу или журнал. Он лишь даёт проверяемую развилку: в выбранной операции были успехи, ошибки или не было завершений вовсе.'), + dataTable( + 'Малый контракт учебного сигнала: каждая строка отвечает на один вопрос', + ['Величина', 'Тип и единица', 'Разрешённые labels', 'Операционный вопрос', 'Не доказывает'], + [ + ['store_http_requests_total', 'counter, завершённые запросы', 'operation: 3 имени; outcome: success/error', 'В какой операции и с каким исходом завершилась работа?', 'почему произошла ошибка и что видел браузер'], + ['store_http_request_duration_seconds', 'учёт длительностей, seconds', 'operation: 3 имени', 'Есть ли данные о длительности выбранной операции?', 'конкретную причину медленного запроса'], + ['Учебный порог >= 2', 'условие над counter за 10 минут', 'только checkout/error', 'Пересекла ли контролируемая серия границу упражнения?', 'production-порог, SLO или допустимую нагрузку'], + ], + ), + paragraph('В названии есть доменная часть store, сущность http_requests и суффикс _total для накапливаемого счётчика. Это не косметика: из имени видно, что значение не является миллисекундами или текущим числом подключений. Для длительности используем seconds, а не смешиваем миллисекунды в одном месте и секунды в другом. Официальные рекомендации Prometheus предлагают одну величину и одну единицу на имя; это делает запросы и ревью понятнее даже без готового dashboard.'), + heading('Выбираем тип по форме состояния'), + paragraph('Counter растёт на каждое завершение и может обнулиться при перезапуске процесса. Он подходит для числа запросов и ошибок, но сам по себе редко отвечает на вопрос «что происходило за последние пять минут». Для окна используют функцию над изменением counter, например increase() или rate(); в этой статье нужен именно count ошибок в контролируемом окне, поэтому выбираю increase(). Gauge меняется в обе стороны и здесь не подходит: «ошибки прямо сейчас» не являются состоянием, которое приложение должно произвольно выставлять в ноль.'), + paragraph('Для длительности нужен не один усреднённый number на всю систему, а набор наблюдений. Клиентская библиотека может экспортировать histogram или summary; выбор зависит от задачи и версии библиотеки. В первом упражнении я не строю p95, не сравниваю SLO и не объявляю latency-контракт. Достаточно сохранить seconds и стабильное имя операции, чтобы следующая проверка могла сравнить один и тот же вход. Если операции не ограничены словарём, сначала надо договориться о словаре, а не добавлять route или произвольный URL в label.'), + heading('Контролируемая фикстура вместо обещания реального графика'), + paragraph('Ниже находится исполняемый фрагмент из этого revision-модуля. Он принимает четыре учебных события, разрешает только три операции и два исхода, затем печатает текстовую exposition с counter и суммой/количеством длительностей. В нём нет сетевого вызова, метрики не отправляются в Pushgateway и нет зависимости от конкретной client library. Именно поэтому пример можно проверить как контракт имён и labels, не выдавая его за снятый с production endpoint результат.'), + codeBlock(controlledExpositionCode), + paragraph('В примере специально нет user_id, email, полного URL, request ID, текста исключения или IP-адреса. Эти значения помогают найти один случай в журнале, но почти никогда не являются ограниченной размерностью метрики. У operation есть короткий allowlist, у outcome — два допустимых значения. Если операция неизвестна, фикстура завершается ошибкой. Это лучше, чем незаметно породить новую series из имени нового endpoint-а или строки, пришедшей из запроса.'), + figure( + '/assets/editorial/2020/metrics-signal-contract-2020.svg', + 'Вертикальная схема выбора метрики: сначала один вопрос про завершённую HTTP-операцию, затем counter с operation и outcome, отдельно duration в seconds, ограниченный учебный запрос и действие после его результата', + 'Схема отделяет сигнал от диагноза: counter показывает ветку для расследования, но не объясняет источник ошибки без следующей проверки.', + ), + heading('Порог — это граница решения, а не украшение графика'), + paragraph('Порог имеет смысл только вместе с действием. Для контролируемой серии вопрос звучит так: «если за десять минут fixture получила две или больше ошибок checkout, надо ли открыть один журнал или повторить изолированный сценарий?» Число 2 здесь выбрано для упражнения: одна ошибка проверяет, что счётчик и label существуют, две — что условие меняет ветку. Оно не выводится из пользовательского трафика, не описывает допустимый процент ошибок и не должно копироваться в alert rule.'), + paragraph('PromQL-выражение ниже суммирует изменение одного counter в окне. В реальном Prometheus counter reset и фактические точки scrape обрабатываются механизмом самого движка; in-memory fixture ниже проверяет только прозрачную арифметику двух snapshots. Это важное различие. Нельзя сказать «локальный delta равен результату production PromQL» и пропустить проверку на выбранной версии сервера. Но можно сначала договориться, какой именно label и какое пересечение должно поменять следующее действие.'), + codeBlock(thresholdQueryCode), + heading('Короткий маршрут первой метрики'), + orderedList([ + 'Записать один вопрос в форме «операция, исход, окно, следующее действие». Не начинать с имени dashboard или общей фразы «нужны метрики».', + 'Выбрать момент завершения операции и определить, что считается success и error. Если исход ещё не известен, не увеличивать окончательный counter раньше времени.', + 'Составить allowlist labels. Для этого примера оставить только operation и outcome; отдельно записать, где живут request ID и текст ошибки.', + 'Назвать metric одной величиной и одной единицей: counter получает _total, длительность хранится в seconds. Проверить название по документации Prometheus до добавления в код.', + 'Прогнать контролируемые события и убедиться, что неизвестная operation отвергается, а строка exposition не содержит user-specific labels.', + 'Сформулировать учебный запрос и порог, затем записать действие по обе стороны границы. Перед реальным alert отдельно проверить scrape interval, reset, нагрузку и владельца реакции.', + ]), + heading('Граница первой проверки'), + paragraph('После этого шага у проекта не появляется полный мониторинг. Нет данных о клиентах, сетевых hop-ах, базе, очереди, релизе или фактической нагрузке. Нет также SLO, error budget и обещания, что две ошибки одинаково важны для любого продукта. Это нормально для первой метрики: она должна сделать один вопрос проверяемым, а не создать видимость знания о всей системе. Если counter пересекает учебную границу, следующий артефакт — один связанный запрос или журнал, а не бесконечное добавление labels.'), + paragraph('Числа, длительности, операции и окно в тексте учебные. Реальный порог выбирают после того, как есть согласованный смысл ошибки, период наблюдения, известная стоимость ложного срабатывания и возможность проверить контекст. В августе 2020 года автор только начинает связывать приложение с наблюдаемым сигналом: он умеет поставить маленький договор над кодом, но не приписывает себе опыт эксплуатации общей платформы или реальные production-значения.'), + ], + [prometheusNaming, prometheusInstrumentation, prometheusMetricTypes, prometheusQuerying], +); + +const mechanismArticle = createRevision( + { + slug: 'editorial-2020-08-mechanism-metrics-basics', + title: 'Лейблы Prometheus: где заканчивается полезная размерность', + categories: ['Наблюдаемость', 'Prometheus', 'Разбор механизма'], + cover: '/assets/editorial/2020/metrics-label-boundary-2020.svg', + excerpt: 'Label помогает разложить один сигнал, пока его значения ограничены. Разбираем, почему request ID и полный URL не становятся диагностикой, как посчитать учебную cardinality и где поставить контракт в коде.', + readingMinutes: 14, + }, + [ + paragraph('Симптом появляется не в коде обработчика, а после добавления «ещё одного полезного label». Метрика по-прежнему показывает запросы, но одинаковый график превращается в множество почти уникальных series, а простой запрос по операции начинает возвращать лишние строки. Цена — не только память и диск сервера метрик. В разборе становится неясно, какое измерение является частью вопроса, а какое случайно сохранило одну пользовательскую историю вместо агрегированного сигнала.'), + paragraph('Для августа 2020 года мне достаточно разобрать один механизм: каждая уникальная комбинация name и labels образует отдельную time series. Ниже нет фактического размера Prometheus, нагрузки или claim о production инциденте. Есть учебный расчёт, короткий allowlist и fixture, который отвергает неограниченные значения. Цель не в том, чтобы запретить labels, а в том, чтобы различать размерность вопроса и идентификатор конкретного события.'), + heading('Series — результат выбора, а не побочный эффект строки'), + paragraph('Представим один counter store_http_requests_total. Без labels у него один ряд для target. С operation="catalog" и outcome="success" появляется ряд для этой пары. Если добавить request_id, новый запрос почти наверняка принесёт новое значение. Такая строка может выглядеть информативно, но это уже не ответ на вопрос «как меняется число ошибок checkout», а попытка поместить журнал события в storage метрик. Для поиска единичного случая есть correlation ID в логе; для агрегирования — короткий набор измерений.'), + paragraph('Проверка здесь простая: можно ли заранее перечислить значения label и останется ли осмысленным sum by (...), если часть dimensions убрать? Для operation ответ обычно да: проект заранее знает небольшой набор логических обработчиков. Для outcome тоже да, если договор ограничен success и error. Для email, UUID, сырого path с ID товара, stack trace и текста ошибки ответ нет: новые значения приходят извне или растут вместе с числом запросов.'), + dataTable( + 'Граница labels в учебном HTTP-сигнале', + ['Кандидат', 'Значения известны заранее?', 'Какой вопрос поддерживает', 'Решение в этом контракте'], + [ + ['operation', 'да: catalog, checkout, profile', 'В какой логической операции выросло число завершений или ошибок?', 'оставить; проверять allowlist'], + ['outcome', 'да: success/error', 'Есть ли различие между удачными и ошибочными завершениями?', 'оставить; не хранить текст ошибки'], + ['status_code', 'почти ограничен, но для первого вопроса избыточен', 'Нужны ли отдельные HTTP-классы?', 'добавить только после нового вопроса; пока outcome достаточно'], + ['request_id, user ID, email', 'нет: новое значение почти на каждую операцию', 'Найти один конкретный запрос', 'не добавлять; оставить в журнале'], + ['Полный URL /orders/12345', 'нет: ID и query string растут', 'Понять маршрут', 'нормализовать в operation или route template до метрики'], + ], + ), + paragraph('Эта таблица не означает, что status_code всегда плох. Он может быть полезным, когда действительно нужен вопрос о 404 и 500. Но нельзя добавлять его по привычке, если следующий шаг всё равно одинаков для всех error. Контракт должен быть небольшим: одна добавленная dimension меняет не только текст exposition, но и число рядов, агрегации, правила и стоимость хранения. Если ей не соответствует отдельное действие, она пока не проходит ревью.'), + heading('Кардинальность можно оценить до первого scrape'), + paragraph('У учебного counter есть три операции и два исхода. Для одного target верхняя граница — шесть комбинаций, если все они встретятся. Если такой же application exporter запускается на двух target, Prometheus добавляет target labels на стороне scrape, и в простом мысленном расчёте получится до двенадцати наблюдаемых рядов. Это не замер сервера и не предел всех связанных metric families: histogram создаёт дополнительные bucket, sum и count series. Расчёт нужен для другого — увидеть мультипликацию до того, как в неё попадёт неограниченный input.'), + paragraph('Добавим в этот же контракт user ID со ста учебными значениями. Уже для counter получаем не шесть, а до шестисот комбинаций на target; с двумя target — до тысячи двухсот. Цифры специально синтетические. Они не описывают настоящих пользователей, RAM или пропускную способность Prometheus, но показывают форму ошибки: значение, которое растёт вместе с пользователями, умножает каждый уже выбранный label. Поэтому полезнее спросить «можно ли получить это из лога по request ID?» до того, как переносить поле в метрику.'), + heading('Проверяем boundary в коде, а не глазами на dashboard'), + paragraph('Ниже псевдокод обёртки над client library. Его можно реализовать на выбранном клиенте, но в этой статье не создаётся HTTP endpoint и не вызывается внешняя библиотека. Важна граница перед инструментированием: operation и outcome проходят allowlist, а произвольные поля не имеют места в label object. Такое правило полезнее комментария «не использовать high cardinality», потому что новый endpoint или ошибка перестают незаметно менять форму metric family.'), + codeBlock(labelBoundaryCode), + paragraph('Слово «псевдокод» здесь важно. Имена методов inc и observe часто похожи в client libraries, но конкретный API, регистрация и exposition зависят от выбранной версии. Нельзя копировать фрагмент и считать, что он создал metrics endpoint. Однако контракт входа проверяем без инфраструктуры: функция renderFixtureExposition в этом module принимает только тот же набор bounded values. Unknown operation завершает fixture ошибкой, а выходной текст не может получить request_id или user_id.'), + figure( + '/assets/editorial/2020/metrics-label-boundary-2020.svg', + 'Вертикальная схема границы labels: событие запроса проходит через allowlist operation и outcome, попадает в агрегированную time series; request ID, email и полный URL направлены в журнал и не становятся labels', + 'Один signal хранит ограниченные dimensions. Идентификатор отдельного события остаётся ключом поиска в журнале, а не генератором новой series.', + ), + heading('Запрос должен агрегировать тот же договор'), + paragraph('Если metric family содержит operation и outcome, запрос может явно сохранить оба измерения. В примере ниже sum by группирует изменение counter за учебные десять минут. Это полезно именно потому, что labels заранее ограничены: результат имеет шесть или меньше понятных строк, а не одну строку на пользователя. Если выражение приходится постоянно фильтровать по уникальным идентификаторам, проблема не в синтаксисе PromQL, а в том, что журнал и метрика получили одну и ту же работу.'), + codeBlock(labelQueryCode), + paragraph('У counter есть ещё одна граница: процесс может перезапуститься и значение станет меньше. Prometheus предназначен для работы с такими series, но вручную вычитать две точки и называть результатом increase() нельзя. Контролируемая fixture ниже намеренно выбрасывает ошибку на убывающем наборе, потому что она тестирует лишь договор порога без модели reset. После подключения реального сервера нужно проверить scrape interval, фактическую экспозицию и поведение выражения на используемой версии Prometheus.'), + heading('Когда новый label всё-таки оправдан'), + paragraph('Новый label появляется не потому, что поле уже есть в request object. Сначала появляется новый вопрос и другое действие. Например, команда действительно готова разбирать 4xx отдельно от 5xx; тогда можно договориться о bounded status_class и добавить его после теста, что все значения нормализуются в известные классы. Или один exporter измеряет две заранее перечисленные внешние базы; тогда dependency может быть допустимой размерностью. Но строка SQL, hostname от пользователя и путь с UUID остаются данными для лога или отдельного хранилища.'), + paragraph('Перед добавлением полезно сделать маленький расчёт: сколько значений есть сейчас, какое верхнее значение допускает код, с чем оно перемножится и какой запрос станет возможным. Если на любой строке ответ «не знаю, значения приходят из входа», действие откладывают. Нельзя компенсировать неопределённость надеждой, что график потом подскажет. График уже создан из series, и потерянная граница будет дорого стоить следующему расследованию.'), + heading('Маршрут ревью labels'), + orderedList([ + 'Записать один operational question и назвать действие после его ответа. Вопрос «собрать всё на будущее» для labels не подходит.', + 'Выписать каждый candidate label, источник его значения и максимальное число значений. Отдельно отметить поля из URL, пользователя, исключения и request context.', + 'Оставить только измерения, которые можно заранее перечислить и по которым действительно будет агрегация. Для первого контракта выбрать operation и outcome.', + 'Посчитать учебную верхнюю границу combinations с уже существующими labels и target. Для histogram отдельно учесть, что buckets создают дополнительные series.', + 'Поставить allowlist или нормализацию на границе instrumentation и прогнать controlled fixture с допустимым и недопустимым событием.', + 'Сформировать PromQL-запрос с явным sum by. Перед выпуском на реальный сервер отдельно проверить scrape, reset, storage и ответственность за действие.', + ]), + heading('Что этот механизм не решает'), + paragraph('Bounded labels не заменяют нормальный журнал, trace context или доступ к исходному событию. Они также не дают ответ на вопрос, почему запрос завершился ошибкой: для этого после срабатывания нужны связанный request ID и контекст кода. В статье не оценивается память Prometheus, не запускается exporter и не сравниваются latency на живой системе. Упомянутые числа — простая арифметика учебной модели, а не production telemetry.'), + paragraph('Это и есть уровень М3 для 2020 года: автор начинает видеть, что наблюдаемый сигнал зависит от границы данных, а не от цвета dashboard. Он умеет сказать «эта dimension принадлежит журналу, а эта — ограниченному metric contract», но не заявляет опыт управления общей платформой наблюдаемости, SLO или error budget. Следующий проверяемый шаг после такого текста — один изолированный endpoint и один запрос, а не массовое тиражирование labels по приложению.'), + ], + [prometheusNaming, prometheusInstrumentation, prometheusQuerying, prometheusMetricTypes], +); + +const fieldArticle = createRevision( + { + slug: 'editorial-2020-08-field-metrics-basics', + title: 'Разбор метрик: как проверить учебный порог по сырой серии', + categories: ['Наблюдаемость', 'Prometheus', 'Отладка'], + cover: '/assets/editorial/2020/metrics-diagnosis-2020.svg', + excerpt: 'Одна линия «requests» не даёт причины для действия. Разбираем синтетическую серию counter, выбор окна и порога, чтобы отличить факт пересечения от выдуманного production-диагноза.', + readingMinutes: 14, + }, + [ + paragraph('Симптом в разборе обычно звучит слишком широко: «на графике есть запросы, но непонятно, что ухудшилось». Цена поспешного ответа — шумный порог, который игнорируют, или изменение timeout без доказательства, что проблема вообще в этом endpoint. Одна точка counter не показывает, сколько ошибок появилось за окно; общий request count не показывает операцию; отдельный stack trace не показывает, повторяется ли случай. Пока эти три вещи смешаны, график украшает разбор, но не направляет действие.'), + paragraph('Ниже — не отчёт о реальном инциденте. Это контролируемая серия для одной метрики store_http_requests_total{operation="checkout",outcome="error"} и один учебный вопрос: «пересекло ли число новых ошибок checkout за десять минут границу двух?» Значения, timestamps, threshold и графическая форма выдуманы для проверки порядка рассуждений. Нет настоящего Prometheus server, scrape, нагрузки, alerting, SLO или production-значений.'), + heading('Начинаем не с графика, а с различимой ветки'), + paragraph('Первое решение — выбрать сигнал. В нашем случае это counter завершённых ошибок, потому что операция имеет чёткий конец, а интересует число событий, накопленных за окно. Причина выбора не в том, что counter «самый популярный». Он позволяет сравнить два момента и узнать, появлялись ли новые error outcomes. Если задача была бы про число активных соединений в конкретную секунду, подошёл бы gauge. Если задача была бы про распределение длительностей, нужна отдельная metric family с seconds и выбранным типом распределения. Один тип не должен притворяться ответом на все вопросы.'), + paragraph('Второе решение — оставить labels достаточными, но не уникальными. operation="checkout" говорит, о какой логической работе идёт речь. outcome="error" отделяет ошибки от успешных завершений. Они не содержат пользователя, URL с ID или request ID. Поэтому при пересечении порога можно открыть журнал по времени и операции, а не искать одну уникальную series. Если нужен конкретный запрос, его корреляционный идентификатор берут из лога; попытка добавить его в counter разрушила бы агрегирование ещё до расследования.'), + dataTable( + 'Учебная диагностика: сигнал ведёт к следующей проверке, но не заменяет её', + ['Наблюдение', 'Что оно действительно говорит', 'Чего из него нельзя вывести', 'Следующее ограниченное действие'], + [ + ['increase(...error...[10m]) = 0', 'в выбранной серии нет новых error increments в окне', 'что все пользователи получили успех и что scrape не пропущен', 'сверить, соответствует ли operation исходной жалобе; не объявлять систему здоровой'], + ['increase(...error...[10m]) = 1', 'в учебной серии появилась одна новая ошибка', 'масштаб, причину и необходимость paging', 'проверить один связанный лог или повторить изолированный сценарий'], + ['increase(...error...[10m]) = 2', 'учебный порог пересечён', 'production severity, SLO или допустимую долю ошибок', 'открыть ветку диагностики checkout и зафиксировать контекст'], + ['Общий requests_total растёт', 'обработчик завершал какие-то запросы', 'какая operation или outcome изменилась', 'добавить bounded group-by, не менять timeout по одной общей линии'], + ], + ), + paragraph('Таблица намеренно отделяет факт от решения. Ноль в одном counter не доказывает отсутствие проблемы: metric может не покрывать нужный путь, scrape может отсутствовать, а браузер может отвалиться до приложения. Две ошибки не доказывают, что нужно будить человека ночью: это всего лишь порог учебной серии. Чтобы порог стал реальным правилом, проект должен отдельно назвать окно, владельца, стоимость ложного срабатывания и связь с фактическим пользователем. В этом материале мы останавливаемся раньше и проверяем форму логики.'), + heading('Сырые snapshots полезнее легенды о «пике»'), + paragraph('Для counter важен прирост, а не последняя цифра. В синтетической записи ниже значение равно нулю, затем единице и двум. За десять минут разница между первой и последней точкой равна двум. Такая арифметика подходит для controlled fixture, где мы заранее запретили reset и знаем все точки. Она не заменяет реализацию increase() в Prometheus: настоящий движок работает с range vector, точками scrape и правилами обработки counter. Поэтому в документе рядом существуют две вещи — PromQL-формулировка вопроса и более простая in-memory проверка того же порога.'), + codeBlock(diagnosisSamplesCode), + paragraph('Ошибка здесь часто начинается с фразы «на графике был пик». Пик без имени series, окна и сравниваемой величины не даёт следующего действия. Правильная запись короче: «для operation=checkout и outcome=error в учебных snapshots прирост за 10 минут равен двум; в упражнении это открывает проверку одного лога». В ней не содержится догадки о базе, сетевом hop-е или пользователе. Эти причины проверяются после того, как signal сузил вход в разбор.'), + figure( + '/assets/editorial/2020/metrics-diagnosis-2020.svg', + 'Вертикальная схема разборa метрики: общий график не отвечает на вопрос, поэтому выбирается counter ошибок checkout, проверяются две крайние точки учебного окна, сравнивается порог два и затем открывается один связанный журнал или изолированный сценарий', + 'Порог не называет причину. Он только переводит разбор от общей линии к одной ограниченной операции и следующей проверке.', + ), + heading('Запрос и fixture проверяют разные части договора'), + paragraph('PromQL ниже выражает желаемую форму запроса к серверу метрик: выбрать operation и outcome, взять изменение counter за десять минут и сравнить с границей. В реальном проекте его нужно выполнить на той же версии Prometheus, с известным scrape interval и реальной конфигурацией target. Только тогда можно читать ответ и обсуждать график. В автономном пакете запрос не выполняется: вместо этого runMetricsFixture() возвращает два прозрачных набора snapshots и проверяет, что простая разность даёт true на двух ошибках и false на одной.'), + codeBlock(thresholdQueryCode), + paragraph('Эта разница между expression и fixture не является недостатком. Она защищает от ложного отчёта «PromQL проверен», когда запущен был только Node. Fixture доказывает ограниченный контракт: labels bounded, counter не убывает в модели, две ошибки пересекают учебный threshold, одна — нет. Она не доказывает scrape, storage, alert delivery, restart process или поведение любого exporter. Такой маленький тест полезен для ревью, потому что будущая правка не сможет тихо превратить порог в >= 1 или добавить поле пользователя в labels.'), + heading('Почему окно и порог выбирают вместе'), + paragraph('Окно в десять минут и число два не существуют по отдельности. В маленькой controlled fixture десять минут дают три понятных snapshots, а две ошибки создают ветку, отличную от одной. Если выбрать минуту, но scrape происходит реже, результат может быть пустым или зависеть от случайной точки. Если выбрать сутки, краткий отказ растворится в общей сумме. Эти рассуждения не дают готовое число для production: они требуют сверить частоту scrape, тип операции, возможный burst и того, кто умеет реагировать на сигнал.'), + paragraph('Также нельзя заменить counter ошибок общей долей без определения знаменателя. Соотношение error/attempt полезно только тогда, когда оба числа считаются в одном месте и за одно окно. Если success заканчивается на proxy, а error пишется внутри приложения, отношение будет смесью разных границ. Поэтому первая версия текста не рисует процент и не объявляет «норму ошибок». Она оставляет более узкий, проверяемый вопрос о числе завершённых error в одной operation. Следующий metric contract может добавить attempts и проверить их общую точку увеличения.'), + heading('Маршрут учебного разбора'), + orderedList([ + 'Записать исходный симптом без диагноза: какой пользовательский путь подозревается и какая цена ошибки, если она повторится.', + 'Выбрать одну metric family и момент увеличения. Для этого упражнения counter увеличивается только после завершения checkout с известным outcome.', + 'Проверить labels: operation и outcome должны быть bounded; request ID и полный URL остаются в журнале, а не в series.', + 'Собрать три синтетические snapshots, явно пометить их учебными и посчитать только ту величину, которую fixture умеет моделировать.', + 'Сформулировать PromQL-вопрос с тем же окном и labels, но не объявлять его выполненным до запуска на реальном сервере метрик.', + 'После пересечения учебного threshold открыть один связанный журнал или изолированный сценарий. Изменять timeout, retry или код только после нового наблюдаемого факта.', + ]), + heading('Какие ошибки этот порядок останавливает'), + paragraph('Такой разбор останавливает четыре распространённые подмены. Он не позволяет принять общую линию запросов за доказательство ошибки checkout. Он не позволяет считать последнюю цифру counter скоростью изменения. Он не даёт уникальному request ID стать label только потому, что его удобно видеть на графике. И он не превращает учебное число два в обещание о production alert. Всё это звучит скромно, но именно эти подмены делают первую метрику бессмысленной: сигнал начинает жить отдельно от вопроса и действия.'), + paragraph('В реальном продолжении после такого упражнения понадобится отдельный стенд: endpoint с bounded operation, Prometheus scrape, одно известное изменение в тестовой нагрузке и сохранённый результат запроса. Потом можно обсуждать более широкую систему. Здесь автор августа 2020 года осваивает только связку «операционный вопрос → signal → labels → проверка → действие». Он не выдумывает реальную деградацию, не приписывает себе SLO/error budget и не подменяет контрольную серию производственным измерением.'), + ], + [prometheusInstrumentation, prometheusQuerying, prometheusNaming, prometheusMetricTypes], +); + +export const revisions = [practiceArticle, mechanismArticle, fieldArticle] + .map(({ proseLength, ...revision }) => revision); + +if (process.argv.includes('--print-revisions')) { + process.stdout.write(JSON.stringify(revisions)); +} else if (process.argv.includes('--verify-fixture')) { + const fixture = runMetricsFixture(); + if (!Object.values(fixture.assertions).every(Boolean)) { + throw new Error('Metrics fixture assertions failed'); + } + process.stdout.write(JSON.stringify(fixture, null, 2) + '\n'); +}