Metadata-Version: 2.4
Name: s-telemetrykit
Version: 0.2.0
Summary: Продуктово-нейтральный SDK учёта запусков: track_run (декоратор/контекст-менеджер) + общий локальный outbox исходящих сущностей. Специфика продукта — одним configure(). Stdlib-only, ноль зависимостей.
Author: Dmitry
License: MIT
License-File: LICENSE
Requires-Python: >=3.11
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# s-telemetrykit

Продуктово-нейтральный SDK учёта запусков: фиксирует **факт и результат**
каждого запуска компонента и кладёт событие в локальный outbox. Доставкой
занимается воркер на стороне продукта — кит в сеть не ходит вообще.

**Кит не знает ни одного конкретного продукта.** Ни бренда, ни каталога-дома, ни
имён env-переменных, ни канона файлов меты, ни доменного термина потребителя.
Единица учёта называется **компонентом** (component) — это то, чей запуск
измеряется: инструмент, скрипт, подключаемый модуль. Специфика продукта задаётся
одним вызовом `configure()` (см. ниже). Сторож против регресса слоёв —
`tests/test_layering.py`.

**Ноль зависимостей, только stdlib.** Это требование, а не текущее состояние:
кит импортирует каждый компонент-потребитель, поэтому цена его импорта — часть
цены любого вызова. Для сравнения на одной машине: `import telemetrykit` — 68 мс,
`import librarykit` — 750 мс (корень китов тянет httpx, cryptography, keyring,
браузерный слой). Отсюда же собственный минимальный санитайзер секретов вместо
`librarykit.redaction` — см. докстринг `telemetrykit/sanitize.py`.

## Зачем

Событий «этим инструментом воспользовались» в системе обычно не эмитится вообще
— мы не знаем, пользуются им или нет. Просить LLM «отчитаться о вызове»
ненадёжно: отчёт зависит от того, вспомнит ли модель про него. Факт запуска
должен фиксировать КОД.

## Подключение

```python
from telemetrykit import track_run


@track_run()                      # id и версия определяются САМИ
def main() -> int:
    ...


# либо как контекст-менеджер
with track_run():
    do_work()
```

Аргументы не нужны. `track_run("vk", "1.2.3")` тоже работает, но это **запасной
путь**: захардкоженная в десятках мест версия гарантированно разъедется с
реальной (в одном из проектов версия CLI уже жила в двух местах и дала
бесконечную петлю самообновления).

Три обещания перед вызывающим:

1. **исключение проходит насквозь** — кит его записывает, но не глотает: код
   возврата принадлежит вызывающему;
2. **кит молчит** — ни строки в stdout/stderr, ни настроенного `logging`;
3. **кит не падает** — нет прав, диск полон, битый файл, сломанный резолв:
   любая внутренняя ошибка гасится и наружу не выходит.

> `track_skill()` и `SkillTracker` — **устаревшие** имена версии 0.1.x. Они
> работают (импорты уже опубликованной версии ломать нельзя), но канон —
> `track_run()` / `RunTracker`.

## Настройка под продукт — один `configure()`

```python
from pathlib import Path
from telemetrykit import TelemetryConfig, configure

configure(TelemetryConfig(
    home=Path.home() / ".myproduct",            # каталог outbox'а и installation_id
    env_prefix="MYPRODUCT",                     # MYPRODUCT_DISABLED, MYPRODUCT_HOME, …
    meta_files=("_myproduct_meta.json", "MANIFEST.md"),
    meta_id_keys=("slug", "myproduct_id", "name"),
    default_kind="tool_run",                    # kind конверта в outbox'е
))
```

| Поле | Что задаёт | Дефолт |
|---|---|---|
| `home` / `home_provider` | каталог-дом (провайдер — если он зависит от рантайма) | `~/.telemetrykit` |
| `env_prefix` | префикс ВСЕХ env-переменных кита | `TELEMETRYKIT` |
| `extra_env_names` | доп. имена переменных по логическому ключу (исторические алиасы продукта) | `{}` |
| `meta_files` | файлы меты, которые ищутся вверх по дереву | `("component.json", "pyproject.toml")` |
| `meta_id_keys` | ключи идентификатора внутри меты | `("component_id", "id", "slug", "name")` |
| `meta_version_keys` | ключи версии внутри меты | `("component_version", "version")` |
| `default_kind` | `kind` конверта для событий запуска | `"run"` |
| `component_id` / `component_version` | явная идентичность, если каскад не нужен | `None` |

Свойства: вызов **идемпотентен**, повторный **перезаписывает конфигурацию
целиком** (а не сливает с прежней — иначе состояние кита перестало бы быть
выводимым из одного вызова), и сбрасывает кэши, зависящие от конфигурации.
`configure()` принимает и отдельные поля: `configure(default_kind="tool_run")`.
`reset_to_defaults()` возвращает нейтральные дефолты (нужно тестам потребителя).

Место такого вызова — **тонкий модуль-адаптер на стороне продукта**, а не кит.

## Как определяются id и версия

Каскад, первый сработавший побеждает (`telemetrykit/detect.py`):

| # | Источник | Почему он |
|---|----------|-----------|
| 1 | env `<PREFIX>_COMPONENT_ID` / `<PREFIX>_COMPONENT_VERSION` | явная воля запускающего: тесты, headless, нестандартные раскладки. Половинчатый override допустим — задан только id, версия ищется дальше |
| 2 | `configure(component_id=…, component_version=…)` | воля продукта, встроившего кит, когда идентичность ему известна |
| 3 | файлы меты из `meta_files` | канон продукта (их обычно пишет установщик). Ищем **вверх по дереву каталогов от файла вызывающего**, максимум 10 уровней. Обработчик выбирается по расширению: `.json` / `.toml` (верхний уровень, затем `[project]`) / `.md` (YAML-frontmatter) |
| 4 | `importlib.metadata.version(dist)` | метаданные УСТАНОВЛЕННОГО дистрибутива, а не константа в коде: их проставляет сборка, разъехаться с колесом они не могут |
| 5 | имя top-level пакета + `"unknown"` | неопределённость не должна ронять вызывающего |

**Вызывающий модуль определяется по стеку, а не по `cwd`.** В момент применения
декоратора кит идёт вверх по кадрам (`sys._getframe`) до первого кадра, чей
модуль не наш и не служебная обёртка (`contextlib`/`functools`), и берёт его
`__file__` и `__name__`. Рабочий каталог у процесса произвольный и про компонент
не знает ничего, а `sys.argv[0]` — это интерпретатор или трамплин. `inspect` не
используется намеренно: он дороже, чем весь остальной кит.

**Запуск из исходников (компонент не установлен).** Обычно срабатывает шаг 3 —
рядом с кодом лежит файл меты его репозитория. Если и его нет, шаг 4 не найдёт
дистрибутива, и мы честно отдадим `<имя пакета>` + `"unknown"`: события всё
равно попадут в приёмник, просто без версии.

## Контракт события (`kind` = `default_kind`, по умолчанию `"run"`)

```json
{
  "event_id": "uuid4-hex",
  "component_id": "vk",
  "component_version": "1.2.3",
  "installation_id": "uuid4-hex",
  "session_id": "uuid4-hex",
  "timestamp": "2026-07-28T10:00:00.123456+00:00",
  "subcommand": "post create",
  "duration_ms": 42,
  "status": "OK",
  "error_details": null,
  "sys_info": {"os": "Windows", "arch": "AMD64", "python_version": "3.13.5"},
  "arg_names": ["--text", "--dry-run"]
}
```

- `status` — `OK` | `ERROR` | `INTERRUPTED`. `KeyboardInterrupt` — отдельный
  статус (иначе прерванные запуски раздуют долю ERROR и спрячут настоящие
  поломки); `SystemExit(0)` — это `OK`, `SystemExit(2)` — `ERROR` с кодом.
- `error_details` (или `null`):
  `{error_type, error_code, message_template, sanitized_stack_trace}`.
  `error_code` берётся из атрибута исключения (`code` / `error_code` /
  `exit_code` / `errno` / `status_code`) — по коду, а не по тексту, строят
  алерты.
- `event_id` — ключ дедупликации на приёмнике (доставка at-least-once).

### Приватность

- **Значения аргументов не логируются никогда** — только имена флагов
  (`--phone`, `-v`); у `--name=Иван` берётся левая часть. Подкомандой считается
  только ведущий позиционный токен, похожий на имя команды (строчная латиница,
  2..32 символа) — это отсекает телефоны, пути, e-mail и имена собственные.
  Разбор останавливается на первом флаге: всё после флага — его значение.
- **Стек и сообщение проходят санитайзер**: `Bearer …`, `token=`/`password=`/
  `api_key=`, префиксные токены (`ghp_`, `github_pat_`, `glpat-`, `xoxb-`, `sk-`,
  `AKIA`), JWT, длинные hex, e-mail, `user:pass@host`. Абсолютные пути →
  `~/…`, причём не только текущего пользователя (трейс может прийти из чужого
  venv или CI).
- `message_template` — шаблон: длинные числа → `<num>`, содержимое кавычек →
  `<str>` (короткий идентификатор вроде `KeyError: 'phone'` сохраняется — он
  нужен для агрегации и значением не является).
- `installation_id` — анонимный uuid4 в `<home>/installation_id`, не выводится
  из имени пользователя, hostname или MAC.

## Outbox — общий транспорт (не «очередь телеметрии»)

`telemetrykit/outbox.py` — **публичная библиотека** для любых исходящих
сущностей: сегодня запуски компонентов и логи (`kind="log"`), завтра что-то
ещё. Второй такой механизм заводить нельзя: разъехавшиеся очереди — это
разъехавшиеся гарантии доставки.

Файл один — `<home>/outbox.jsonl`. Конверт:

```json
{"id": "…", "kind": "run", "ts": "…", "schema_version": 1, "payload": {…}}
```

`kind` — обычная строка: новый тип не требует правки модуля, валидация
`payload` лежит на продюсере, который один знает форму своих данных.
`schema_version` — версия КОНВЕРТА, не payload'а.

```python
from telemetrykit import outbox

ident = outbox.append("log", {"level": "ERROR", "message": "boom"})  # id или None
batch = outbox.read_batch(100)      # читаем, НЕ удаляя
...                                  # отправили
outbox.remove([e["id"] for e in batch])   # ack
outbox.path()                        # где лежит файл
```

Порядок «прочитал → отправил → подтвердил» даёт at-least-once: перезапуск между
отправкой и ack приведёт к повтору, поэтому в конверте и есть `id`.

> Читатель outbox'а (воркер) обязан применить ТУ ЖЕ конфигурацию, что и
> писатели, — иначе он будет смотреть в другой каталог. На практике это значит:
> импортировать общий модуль-адаптер продукта перед обращением к `outbox`.

### Параллельная запись

Пишущих процессов много (компоненты запускаются одновременно), читающий один
(воркер).

- **Запись** — одна строка за один системный вызов. На POSIX достаточно
  `O_APPEND` (стандарт требует неделимости «сдвиг в конец + запись»). **На
  Windows `O_APPEND` этого не даёт**: CRT реализует его как «seek, потом write»
  двумя вызовами, и между ними вклинивается другой процесс. Замерено на живой
  машине: 6 процессов × 200 строк дали **1048 строк из 1200** — 13% событий
  пропали молча. Настоящий атомарный append в Windows — файл, открытый с правом
  `FILE_APPEND_DATA` (и без `FILE_WRITE_DATA`), тогда позицию двигает ядро; тот
  же замер с ним — **1200 из 1200**. Биндинги через `ctypes` собираются лениво,
  при первой записи; если не сложилось — откат на обычный `os.write`.
- **Перезапись** (ротация и ack) — под lock-файлом и через `os.replace`, а
  дозаписанный конкурентами хвост переносится в новый файл по смещению: событие,
  приехавшее во время ack, не теряется.

### Ротация

Порог 5 МБ (`<PREFIX>_OUTBOX_MAX_BYTES`). Сверху него самая старая половина
строк выбрасывается, а на их месте остаётся конверт `kind="outbox.rotated"` со
счётчиком `dropped` — потеря становится ВИДИМОЙ на приёмнике, а не молча
случившейся. Ротирует ровно один процесс (lock-файл), остальные в этот момент
просто дописывают.

## Переменные окружения

Префикс `TELEMETRYKIT` — дефолтный; после `configure(env_prefix="MYPRODUCT")`
те же имена читаются как `MYPRODUCT_…`. Исторические имена продукта добавляются
через `extra_env_names` и проверяются ПОСЛЕ основного.

| Переменная | Что делает |
|---|---|
| `TELEMETRYKIT_DISABLED=1` | выключает учёт запусков |
| `TELEMETRYKIT_OUTBOX_DISABLED=1` | выключает исходящий транспорт целиком |
| `TELEMETRYKIT_OUTBOX_PATH` | полный путь к файлу outbox'а |
| `TELEMETRYKIT_OUTBOX_DIR` | каталог outbox'а |
| `TELEMETRYKIT_OUTBOX_MAX_BYTES` | порог ротации (по умолчанию 5 МБ) |
| `TELEMETRYKIT_HOME` | каталог-дом (по умолчанию `~/.telemetrykit`) |
| `TELEMETRYKIT_COMPONENT_ID` / `TELEMETRYKIT_COMPONENT_VERSION` | override идентичности компонента |
| `TELEMETRYKIT_SESSION_ID` | общий id сессии для нескольких процессов |

Env сильнее конфигурации: это воля ЗАПУСКАЮЩЕГО, а `configure` — воля продукта.

Выключателя два намеренно: пользователь вправе отказаться от статистики
запусков, не отключая доставку остального (например логов, которые сам же
попросил собрать для разбора инцидента).

## Публичный API

```python
from telemetrykit import track_run           # главное
from telemetrykit import TelemetryConfig, configure, current, reset_to_defaults
from telemetrykit import TelemetryEvent      # контракт события
from telemetrykit import outbox              # общий транспорт (нужен воркеру)
from telemetrykit import outbox_path         # алиас outbox.path
```

Остальное — `ErrorDetails`, `SysInfo`, `RunTracker`, `STATUS_*`, `KIND_RUN`,
`telemetry_disabled` и под-модули `args` / `config` / `detect` / `identity` /
`sanitize` / `paths`. Устаревшие имена 0.1.x: `track_skill`, `SkillTracker`.

## Разработка

```bash
uv venv && uv pip install pytest ruff
python -m pytest -q
python -m ruff check telemetrykit tests
```

Публикация на PyPI — по семвер-тегу `vX.Y.Z` через GitLab Trusted Publishing
(OIDC, токены нигде не хранятся), см. `.gitlab-ci.yml`. Версии поднимаются
**только патчами** от PyPI-latest.
