Metadata-Version: 2.4
Name: s-telemetrykit
Version: 0.1.3
Summary: SDK учёта вызовов навыков: track_skill (декоратор/контекст-менеджер) + общий локальный outbox исходящих сущностей. 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. Доставкой занимается
CLI-воркер — кит в сеть не ходит вообще.

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

## Зачем

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

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

```python
from telemetrykit import track_skill


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


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

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

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

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

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

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

| # | Источник | Почему он |
|---|----------|-----------|
| 1 | env `SKILLERY_SKILL_ID` / `SKILLERY_SKILL_VERSION` | явная воля запускающего: тесты, headless, нестандартные раскладки. Половинчатый override допустим — задан только id, версия ищется дальше |
| 2 | `_skill_meta.json` → `_skill_meta.toml` → `SKILL.md` (frontmatter) | канон стора навыков: их пишет `skillkit` при установке, там лежат slug и version. Ищем **вверх по дереву каталогов от файла навыка**, максимум 10 уровней |
| 3 | `importlib.metadata.version(dist)` | метаданные УСТАНОВЛЕННОГО дистрибутива, а не константа в коде: их проставляет сборка, разъехаться с колесом они не могут |
| 4 | имя top-level пакета + `"unknown"` | неопределённость не должна ронять навык |

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

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

## Контракт события (`kind="skill_run"`)

```json
{
  "event_id": "uuid4-hex",
  "skill_id": "vk",
  "skill_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 в `~/.skillery/installation_id`, не
  выводится из имени пользователя, hostname или MAC.

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

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

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

```json
{"id": "…", "kind": "skill_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`.

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

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

- **Запись** — одна строка за один системный вызов. На 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 МБ (`SKILLERY_OUTBOX_MAX_BYTES`). Сверху него самая старая половина
строк выбрасывается, а на их месте остаётся конверт `kind="outbox.rotated"` со
счётчиком `dropped` — потеря становится ВИДИМОЙ на бэке, а не молча случившейся.
Ротирует ровно один процесс (lock-файл), остальные в этот момент просто
дописывают.

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

| Переменная | Что делает |
|---|---|
| `SKILLERY_TELEMETRY_DISABLED=1` | выключает учёт вызовов навыков |
| `SKILLERY_OUTBOX_DISABLED=1` | выключает исходящий транспорт целиком |
| `SKILLERY_OUTBOX_PATH` | полный путь к файлу outbox'а |
| `SKILLERY_TELEMETRY_DIR` | каталог outbox'а (историческое имя) |
| `SKILLERY_OUTBOX_MAX_BYTES` | порог ротации (по умолчанию 5 МБ) |
| `SKILLERY_HOME` | каталог-дом (по умолчанию `~/.skillery`; учитывается и `SKILLERY_CONFIG_DIR`, чтобы не появилось второго понятия «дома») |
| `SKILLERY_SKILL_ID` / `SKILLERY_SKILL_VERSION` | override идентичности навыка |
| `SKILLERY_SESSION_ID` | общий id сессии для нескольких процессов |

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

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

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

Остальное — `ErrorDetails`, `SysInfo`, `SkillTracker`, `STATUS_*`,
`KIND_SKILL_RUN`, `telemetry_disabled` и под-модули `args` / `detect` /
`identity` / `sanitize` / `paths`.

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

```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.
