Отслеживание: логи, метрики, трейсинг, профилировщик

Система наблюдаемости языка — постепенно наращиваемые модули поверх одной идеи (см. ИСТОРИЯ_РАЗРАБОТКИ.md, где подробно описан замысел): логи, метрики, трейсинг, профилировщик.

Логи (стандартная_библиотека/лог.код)

подключить лог;

функция главная(): целое
    Логгер логгер = создать_логгер(уровень_инфо(), да, "приложение.log");

    лог_инфо(логгер, "сервер запущен");
    лог_предупреждение(логгер, "конфиг не найден, используются значения по умолчанию");
    лог_ошибка(логгер, "не удалось подключиться к базе данных");

    вернуть 0;
конец

создать_логгер(минимальный_уровень, в_консоль, путь_к_файлу):

  • минимальный_уровень — сообщения строже этого уровня отбрасываются

молча (обычная модель "что видно в проде" / "что видно при отладке"); задаётся одной из именованных функций уровень_отладка() / уровень_инфо() / уровень_предупреждение() / уровень_ошибка() / уровень_критическая(), а не магическим числом напрямую.

  • в_консоль — печатать ли через вывести(...).
  • путь_к_файлу — куда дописывать строки лога; "" отключает запись

в файл (дописывается через дописать_в_файл_бинарно, то есть безопасно для одновременной записи из нескольких запусков программы — каждая запись это отдельный write() в конец файла).

Пять функций записи — лог_отладка/лог_инфо/лог_предупреждение/ лог_ошибка/лог_критическая(логгер, сообщение) — каждая строка получает метку времени и уровень автоматически:

[2026-09-29 19:42:46] ОШИБКА: не удалось подключиться к базе данных

Метка времени — собственная реализация (без вызова C strftime/localtime — тот же принцип "без внешней зависимости", что и у хранилище.код): раскладывает unix-время на год/месяц/день/ час/минуту/секунду напрямую, алгоритмом Говарда Хиннанта "civil_from_days" — тот же алгоритм, что лежит в основе <chrono> из C++20. Функции время_из_unix(unix_секунды): ДатаВремя и дата_время_в_текст(ДатаВремя): текст доступны и отдельно, не только внутри логгера.

⚠️ Честно, узко (v1):

  • Время всегда UTC, не местный часовой пояс — учёт часового пояса

и перехода на летнее/зимнее время конкретного региона отдельная, более сложная задача.

  • Поддержаны только даты после 1970-01-01 (неотрицательные

unix-времена).

  • Пока нет структурированных полей (ключ=значение) сверх самого

текста сообщения, и нет вывода в JSON — только человекочитаемая строка. Запланировано следующим шагом.

  • Как и у любой строки, построенной через +/интерполяцию и не

сохранённой в переменную, промежуточные буферы форматирования одной записи лога честно утекают (тот же принятый во всём языке компромисс "утечка, не крах" — см. раздел про ARC выше) — при активном логировании это стоит иметь в виду до тех пор, пока не будет отдельного решения для этого класса утечек в масштабах всего языка.

Метрики (стандартная_библиотека/метрики.код)

Второй кирпич системы наблюдаемости — счётчики и измерители, та же модель, что в Prometheus/StatsD/большинстве промышленных систем:

  • счётчик (counter) — только растёт (запросов обработано, ошибок

случилось). Сбрасывается в ноль только перезапуском программы.

  • измеритель (gauge) — текущее значение, может расти и падать

(открытых соединений сейчас, занято памяти, температура датчика).

подключить метрики;

функция главная(): целое
    Метрики м = создать_метрики();

    счётчик_увеличить_на_единицу(м, "запросы_всего");
    счётчик_увеличить(м, "ошибок_всего", 2);

    измеритель_установить(м, "открытых_соединений", 5.0);
    измеритель_увеличить(м, "открытых_соединений", 2.0);
    измеритель_уменьшить(м, "открытых_соединений", 1.0);

    метрики_напечатать(м);

    вернуть 0;
конец

API:

  • создать_метрики(): Метрики
  • счётчик_увеличить(м, имя, на) / счётчик_увеличить_на_единицу(м, имя)

/ счётчик_значение(м, имя): целое — счётчик заводится с нуля при первом обращении, дальше только прибавляет; не может убывать.

  • измеритель_установить(м, имя, значение) (устанавливает РОВНО в

значение) / измеритель_увеличить(м, имя, на) / измеритель_уменьшить(м, имя, на) / измеритель_значение(м, имя): дробное

  • метрики_снимок(м): текст — снимок ВСЕХ метрик в текстовом формате

Prometheus exposition format (# TYPE имя counter\nимя значение\n) — тот же формат, который отдаёт любой настоящий /metrics-эндпоинт, значит его понимает реальный Prometheus-сервер без переделки. Строки идут в порядке РЕГИСТРАЦИИ метрики (первого обращения к её имени).

  • метрики_напечатать(м) — печатает снимок через вывести(...).
  • метрики_записать_файл(м, путь): результат из верного — ПЕРЕЗАПИСЫВАЕТ

файл целиком (не дописывает, в отличие от лог.код) — снимок метрик это текущее состояние на момент вызова, а не история событий.

⚠️ Честно, узко (v1):

  • Нет гистограмм (распределение величины — например, "сколько

запросов уложилось в 10мс, сколько в 100мс, сколько дольше" — нужно для процентилей задержки) — задумано отдельным следующим шагом.

  • Нет меток (labels — Prometheus умеет

запросы{путь="/логин", код="200"}, здесь только плоское имя метрики).

  • ✅ Отдача снимка по HTTP — рабочий пример

examples/веб/наблюдаемость_http.код: /metrics отдаёт метрики_снимок(...) с заголовком `Content-Type: text/plain; version=0.0.4; charset=utf-8` — тем же, по которому настоящий Prometheus-сервер узнаёт формат и может скрейпить эндпоинт без дополнительной настройки. См. подробности (и честное упрощение — обработка запросов строго последовательная, не параллельная, из-за отсутствия потокобезопасности у Метрики/Трейсер) в разделе про трейсинг ниже.

Трейсинг (стандартная_библиотека/трейсинг.код)

Третий и последний из первоначально задуманных кирпичей — "спаны" (span, именованные, размеченные по времени участки выполнения, вложенные друг в друга, как скобки), с человекочитаемым деревом длительностей на выходе. Та же модель, что в реальных системах трассировки (OpenTelemetry, Jaeger, Zipkin, "flame graph").

подключить трейсинг;

функция обработать_запрос(Трейсер т)
    спан_начать(т, "обработать_запрос");

    спан_начать(т, "прочитать_бд");
    // ... работа с базой данных ...
    спан_завершить(т);

    спан_начать(т, "сформировать_ответ");
    // ... формирование ответа ...
    спан_завершить(т);

    спан_завершить(т);
конец

функция главная(): целое
    Трейсер т = создать_трейсер();
    обработать_запрос(т);
    трейс_напечатать(т);
    вернуть 0;
конец

Вывод (отступ — вложенность, длительности посчитаны по монотонным часам, монотонное_время_нс()):

  прочитать_бд: 5.096 мс
  сформировать_ответ: 4.949 мс
обработать_запрос: 10.064 мс

API:

  • создать_трейсер(): Трейсер
  • спан_начать(т, имя) — отмечает вход в именованный участок кода.
  • спан_завершить(т) — закрывает самый последний из открытых

спанов (не принимает имя — как закрывающая скобка не указывает, какой открывающей она соответствует, порядок сам это определяет).

  • трейс_снимок(т): текст — накопленный текст всех уже ЗАВЕРШЁННЫХ

спанов, деревом, в порядке закрытия. Спаны, которые ещё не дошли до спан_завершить(), в снимок не попадают.

  • трейс_напечатать(т) — печатает снимок через вывести(...).
  • трейс_очистить(т) — сбрасывает накопленный журнал (например, между

запросами HTTP-сервера); не трогает ещё открытые спаны.

  • трейс_записать_файл(т, путь): результат из верного — перезаписывает

файл целиком накопленным журналом (та же причина, что у метрики_записать_файл — см. выше).

⚠️ Честно, узко (v1):

  • Спаны обязаны закрываться в строго обратном порядке открытия ("(а(б)в)"

законно, перекрёстное "(а[б)в]" — нет) — спан_завершить() не проверяет, ЧТО именно закрывается, только "самый последний из открытых". Настоящий span_id/родитель (как в OpenTelemetry, для произвольного/асинхронного/межпоточного порядка) — не реализован.

  • Один Трейсер — один линейный синхронный поток выполнения; для

трассировки нескольких потоков параллельно нужен отдельный Трейсер на поток (Трейсер, как Список/Словарь, не потокобезопасен).

  • спан_завершить() без соответствующего спан_начать() не крашит

программу — в журнал попадает явная строка-предупреждение (⚠️ спан_завершить() без соответствующего спан_начать()).

  • Нет отправки трейсов по сети (распределённая трассировка между

сервисами, протокол передачи контекста между процессами) — только локальный снимок одного процесса, хоть и уже доступный по HTTP (см. ниже).

Метрики и трейсинг по HTTP (examples/веб/наблюдаемость_http.код)

Рабочий пример, объединяющий сеть.код + метрики.код + трейсинг.код: HTTP-сервер на порту 9090 с двумя эндпоинтами —

  • GET /metrics — снимок метрики_снимок(...) с заголовком

Content-Type: text/plain; version=0.0.4; charset=utf-8 (тот самый, по которому настоящий Prometheus-сервер узнаёт формат — эндпоинт можно напрямую указать в scrape_configs без дополнительной настройки).

  • GET /trace — снимок трейс_снимок(...), дерево уже обработанных

запросов (сам HTTP-запрос к /trace — тоже спан, но попадёт в снимок только со СЛЕДУЮЩЕГО обращения — снимок это история уже случившегося на момент вызова, см. честные ограничения выше).

$ curl http://localhost:9090/metrics
# TYPE http_запросов_всего counter
http_запросов_всего 3
...

$ curl http://localhost:9090/trace
  маршрутизация: 0.009 мс
http_запрос: 2.182 мс

⚠️ Честное, сознательное упрощение: в отличие от examples/веб/http_сервер.код (каждое подключение — свой поток параллельно), здесь подключения обрабатываются строго ПОСЛЕДОВАТЕЛЬНО, одно за другим. Причина — прямое следствие уже указанного выше ограничения: Метрики/Трейсер не потокобезопасны, параллельная запись из нескольких потоков в общий экземпляр была бы гонкой данных. Сделать это безопасным (например, один поток-владелец метрик и канал для запросов на обновление, тем же приёмом, что уже применён в http_сервер.код для передачи сокета клиента в поток) — отдельный, более сложный следующий шаг, сознательно не взятый здесь ради ясности примера.

Профилировщик (стандартная_библиотека/профилировщик.код)

В отличие от трейсинг.код (дерево ОДНОГО прохода выполнения — "что происходило и в каком порядке"), профилировщик агрегирует: сколько раз был вызван именованный участок кода и сколько суммарно/в среднем времени он занял за ВЕСЬ прогон программы — вопрос "куда уходит время", на который дерево одного запроса не отвечает. Ручной API — тот же дух, что у начать_замер/закончить_замер, расставляемых вручную вокруг измеряемого кода (а не автоматическая инструментация каждой функции компилятором — тот путь требует поддержки в кодогенераторе и накладных расходов на каждый вызов каждой функции, отдельный, гораздо больший шаг на будущее).

подключить профилировщик;

функция главная()
    Профилировщик п = создать_профилировщик();

    начать_замер(п, "обработка_запроса");
    // ... код ...
    закончить_замер(п, "обработка_запроса");

    профиль_напечатать(п);
    // обработка_запроса: вызовов=1, всего=1.156 мс, среднее=1.156 мс
конец
  • создать_профилировщик(): Профилировщик
  • начать_замер(п, имя) — отмечает начало измеряемого участка.
  • закончить_замер(п, имя) — отмечает конец и прибавляет прошедшее

время к накопленной статистике этого имени (вызовов, всего, среднее считается из вызовов/всего).

  • замер_вызовов(п, имя): целое, замер_всего_нс(п, имя): целое —

текущая статистика одного имени напрямую (без отчёта целиком).

  • профиль_отчёт(п): текст / профиль_напечатать(п) — сводка по

всем измеренным участкам, в порядке их ПЕРВОГО измерения (сортировки по убыванию времени в языке пока нет — самый затратный участок приходится искать глазами по колонке "всего").

⚠️ Честно, узко (v1):

  • начать_замер/закончить_замер для ОДНОГО И ТОГО ЖЕ имени не

поддерживают вложенность/рекурсию — повторный начать_замер(п, "а") без закончить_замер между вызовами молча перезаписывает время начала, первый вызов "теряется" (засчитается только внешний интервал). Для вложенных участков с РАЗНЫМИ именами всё работает нормально. Если нужна именно вложенность как таковая — для этого существует трейсинг.код (спаны); разумно использовать оба модуля вместе: трейсинг — "что происходило и в каком порядке в этом запуске", профилировщик — "куда суммарно ушло время за всю программу".

  • закончить_замер() без предшествующего начать_замер() того же

имени молча ничего не делает (не крашит программу, но и не пишет предупреждение, в отличие от спан_завершить() в трейсинге — писать было бы некуда без отдельного текстового журнала; для диагностики таких ошибок используйте лог.код рядом).

  • Та же причина, что и в метрики.код/трейсинг.код: Словарь не

поддерживает перечисление ключей, поэтому имена измеряемых участков хранятся дважды — в Словаре (быстрый доступ) и параллельно в Списке (порядок перебора при отчёте).

  • Не потокобезопасен — как и Список/Словарь/Метрики/Трейсер,

один Профилировщик на один поток параллельно.