Что это. Контракт, по которому соседний инструмент читает журнал прогонов грейдера: состав записи, гарантии и границы. Команда —
stepik-grader usage.Чего здесь нет. Описания самого журнала (это
core/stats.pyи configuration.md) и рассуждений о том, куда данные отправлять: экспорт пишет файл, и на этом его работа кончается.
Журнал прогонов ведётся давно и отдавался только человеку — сводкой на экран
(stepik-grader stats). Соседнему инструменту нужен тот же материал
записями, а не сводкой, и в формате, на который можно опереться: сводка
меняется вместе с тем, что интересно показать человеку, и опираться на неё
значит ломаться от каждой правки вывода.
Ровно то, что уже лежит в журнале, — ни одного нового измерения. Список
полей закрытый и живёт константой _FIELDS в
core/usage_export.py; тест
test_export_carries_only_declared_fields падает, если наружу поедет что-то
сверх него.
| Поле | Тип | Что означает |
|---|---|---|
schema |
str |
stepik-grader/usage/1 — имя и версия формата |
ts |
float |
момент прогона, epoch-секунды |
mode |
int |
режим проверки: 1–4 |
os |
str |
platform.system() — Linux, Windows, Darwin |
verdicts |
object |
счётчики вердиктов прогона: {"OK": 3, "WA": 1} |
total_time |
float |
суммарное время прогона, секунды |
isolation |
str |
backend песочницы, если прогон шёл под --sandbox |
Поля, которых в записи журнала не было, в событие не попадают вовсе — вместо
null. Потребитель отличает «не измеряли» от «измерили ноль» по наличию ключа.
Чего в выгрузке нет и не будет: путей к решениям, имён файлов и задач, кода,
текстов ошибок, идентификаторов пользователя или машины. Журнал их и не хранит —
это свойство core/stats.py, а не фильтр на выходе.
JSON Lines: одна запись — одна строка, порядок от старых к новым.
{"mode": 1, "os": "Linux", "schema": "stepik-grader/usage/1", "total_time": 1.5, "ts": 1756000000.0, "verdicts": {"OK": 3}}
{"isolation": "bwrap", "mode": 4, "os": "Linux", "schema": "stepik-grader/usage/1", "total_time": 0.7, "ts": 1756000100.0, "verdicts": {"WA": 1}}Строки независимы: обрыв записи теряет одну строку, а не файл. Ключи отсортированы — diff двух выгрузок читается глазами.
Версия стоит в каждой строке, а не в имени файла и не в заголовке. Потребитель читает построчно, и файл, собранный из двух выгрузок разного возраста, остаётся разбираемым.
stepik-grader usage # JSON Lines в стандартный вывод
stepik-grader usage --usage-out usage.jsonl # в файл (каталог создаётся)Счётчик записанного при выводе в stdout уходит в stderr: иначе он попал бы в конвейер вместе с данными. При записи в файл он печатается обычным образом.
Три исхода различаются:
- журнал пуст — сообщение о том, что статистика выключена (она opt-in), код 0;
- часть записей не разобралась — число пропущенных и путь к журналу, код 0: остальные записи выгружены, а покалеченный журнал чинится удалением;
- записать не удалось — причина целиком и код 1: команду звали ради файла, и молчаливый успех без файла хуже отказа.
- Сети нет. Экспорт читает файл и пишет файл. Отправку наружу, если она
кому-то нужна, делает тот, кто читает выгрузку, — осознанно и своими руками.
Проверяется тестом:
socketв прогоне экспорта подменён на отказ. - Ничего не собирается сверх журнала. Включение экспорта не включает сбор:
журнал ведётся, только когда включена статистика (
--statsилиstats = trueвpyproject.toml). - Выгрузка не меняет журнал. Чтение и только чтение: ротация, очистка и
запись остаются за
core/stats.py.
Формат назван версией в каждой записи (schema) именно затем, чтобы его можно
было менять, не ломая читателя молча.
- Стабильны имя и смысл перечисленных полей. Переименование
ts,mode,os,verdicts,total_time,isolationили смена их семантики — ломающее изменение: оно поднимает версию доstepik-grader/usage/2и объявляется в CHANGELOG.md. - Расширение аддитивно, и незнакомое поле потребитель игнорирует. Новое поле добавляется как необязательное, версия при этом не меняется — читатель, который падает на незнакомом ключе, написан неверно. Отсутствие ключа по-прежнему значит «не измеряли», а не «ноль».
- Как добавляют новое. Поле входит в
_FIELDS(core/usage_export.py), в таблицу выше и в тест закрытого списка — тремя правками сразу, иначе гейтtest_export_carries_only_declared_fieldsпокажет расхождение. Новое поле допустимо, только если оно уже лежит в журнале: экспорт ничего не собирает сам, и обратное означало бы новый сбор под видом нового формата. - Удаление поля — тоже смена версии. Читатель вправе рассчитывать на объявленный состав; молча пропавший ключ неотличим от «не измеряли».
Формат описывает прогоны, а не сессии обучения: связать записи в «сессию» по ним нельзя — в журнале нет ни идентификатора запуска, ни ключа задачи, и добавление такого поля означало бы новый сбор, а не новый формат. Если потребителю нужна сессия, это отдельное решение с отдельным разбором приватности.
Прогресс по задачам живёт в другом месте (история обучения, SQLite) и сюда не попадает: у него другой контракт и другая чувствительность — там есть имена задач.
- SECURITY.md — что покидает машину и что нет;
- configuration.md — как включается статистика;
- api.md — HTTP-контракты веб-слоя (выгрузка в них не входит).
Тот же материал отдаётся локальным сервером — для инструмента, которому удобнее спросить, чем читать файл. Эндпоинт выключен по умолчанию и включается явным флагом вместе с сервером:
stepik-grader --serve --expose-usage
curl 'http://127.0.0.1:8000/api/v1/usage?since=1735689600'
Ответ — {schema, events, skipped}; поля записей те же, что у файлового
экспорта, и список их закрыт той же константой. Без флага эндпоинт отвечает
404: выключенное снаружи должно выглядеть отсутствующим, а не запертым.
Почему отдельный флаг, а не часть --serve: поднять интерфейс для себя и
открыть накопленное соседнему инструменту — разные решения. Первое человек
принимает, чтобы работать; второе — чтобы поделиться, и оно не должно
случаться заодно.
Полный контракт эндпоинта — api.md.