Уроборос: запись вызовов работающей программы Первый прогон: поставить, обмазать, запустить, прочитать
0%

Первый прогон: поставить, обмазать, запустить, прочитать

Первый прогон: поставить, обмазать, запустить, прочитать

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

Что должно стоять на машине

нужно когда
Python 3.12 или новее всегда
gcc или clang, g++ или clang++ только чтобы обмазать и собрать C и C++
Node только для JavaScript и TypeScript
elixir только чтобы собрать и запустить Elixir

Разбор C и C++ идёт через libclang, но доставлять его отдельно не надо: он приезжает вместе с пакетом. Разбор JavaScript и TypeScript уложен внутрь пакета тем же образом, поэтому npm install не нужен — нужен только сам node. Больше ничего: ни базы, ни отдельной службы, ни ключа.

Поставить

Выпуск 0.6.1 выложен, и обычных способов установки три. Любой из них выносит наружу две команды — ouroboros и ouroboros-mcp.

Homebrew — хранилище формул digitable-lol/tap подключается само, отдельная команда brew tap не нужна:

brew install digitable-lol/tap/ouroboros

asdf — держит рядом несколько версий и переключает их по .tool-versions. Адрес хранилища пишется явно: в общем списке плагинов asdf инструмента пока нет, и без адреса plugin add его не найдёт.

asdf plugin add ouroboros https://github.com/digitable-lol/ouroboros.git
asdf install ouroboros latest
asdf set ouroboros latest

uv — самый короткий путь:

uv tool install git+https://github.com/digitable-lol/ouroboros

Из исходников тоже можно, и отдельной сборки не требуется — uv сам заводит окружение и ставит зависимости:

git clone https://github.com/digitable-lol/ouroboros
cd ouroboros
uv sync

Проверка, что всё встало, — одна и та же при любом способе:

ouroboros languages          # после brew, asdf или uv tool install
uv run ouroboros languages   # если работаете из исходников
{"languages": ["python", "javascript", "c", "cpp", "elixir", "go", "java", "csharp"]}

Дальше в главе команды пишутся коротко — ouroboros …. Если ставили из исходников, перед каждой нужен запускатель: uv run ouroboros ….

Файл, на котором всё показано

Обычный расчёт скидки. В нём три функции и одна беда: последний вызов передаёт строку туда, где ждут число.

def discount_rate(total, member):
    if member:
        return 0.15
    if total >= 10000:
        return 0.10
    return 0.0


def apply_discount(total, member=False):
    rate = discount_rate(total, member)
    return round(total * (1 - rate), 2)


def main():
    for total, member in [(9999, False), (10000, False), (500, True)]:
        print(total, member, apply_discount(total, member=member))
    apply_discount("free")


main()

Шаг 1. Обмазать

ouroboros wrap-file discount.py
{"ok": true, "path": "discount.py", "language": "python", "functions_wrapped": 3, "runtime_header": "ouroboros_runtime.py"}

Три функции обмазаны. Рядом с файлом положен ouroboros_runtime.py — маленький помощник, который и пишет записи; он нужен обмазанному файлу, чтобы работать.

Посмотрим, что случилось с исходником:

from ouroboros_runtime import log as _ouro_log
@_ouro_log
def discount_rate(total, member):
    if member:
        return 0.15
    if total >= 10000:
        return 0.10
    return 0.0

Добавились ровно две вещи: строка ввоза наверху и по одной строке @_ouro_log перед каждым def. Наверху — значит выше первой функции, но ниже #!, объявления кодировки, строки описания модуля и from __future__: у этих строк место в файле особое, и обмазка его не занимает.

Тело функции не тронуто. Поэтому ранние выходы, вложенные функции и уже написанные try/finally продолжают работать как раньше — обмазка перехватывает вызов снаружи, а не переписывает его изнутри.

У других языков вставка выглядит иначе, но правило одно: находим границы функции и вставляем по краям, а не перепечатываем текст. Пятая глава показывает все восемь.

Файл переписан на месте. wrap-file меняет ваш файл, а не создаёт копию. Работайте под системой контроля версий или на копии. Если менять исходное дерево нельзя вовсе — в инструменте есть отдельная песочница, она разобрана в главе про работу с агентом.

Шаг 2. Запустить

Обмазанная программа запускается обычным способом. Единственное добавление — переменная среды, которая говорит, куда писать:

OUROBOROS_DEBUG_INFO=./debug.info python3 discount.py

Переменную можно и не задавать: тогда записи лягут в debug.info в текущем каталоге. Задавать её удобно, когда прогонов несколько и путать их не хочется.

Программа отработала как всегда: напечатала три строки и упала на четвёртом вызове с TypeError. Обмазка ничего в поведении не изменила.

Шаг 3. Посмотреть, что записалось

Файл читается и глазами — это обычный текст:

{"p":"in","t":"2026-09-10T19:59:29.262","id":"36c9ba0c-e827-4e03-9020-3f9745d1a37c","ci":-1,"th":"7.129140652823040","fn":"discount_rate","a":"9999, False","k":""}
{"p":"out","id":"36c9ba0c-e827-4e03-9020-3f9745d1a37c","fn":"discount_rate","r":"0.0","d":2e-06}

Первая строка: вошли в discount_rate с доводами 9999, False. Вторая: вышли, вернули 0.0, заняло две миллионных секунды. Номер id у них общий — это и есть один вызов.

Читать глазами хорошо на девяти записях и плохо на девяти тысячах. Для второго есть две команды.

Шаг 4. Спросить у трассы

trace — выбрать нужные вызовы

Самый частый вопрос: что упало и с чем его позвали.

ouroboros trace ./debug.info --outcome raised --function discount_rate
{
  "ok": true,
  "path": "debug.info",
  "calls_parsed": 9,
  "malformed": 0,
  "matched": 1,
  "returned": 1,
  "next_cursor": null,
  "in_flight": [],
  "in_flight_truncated": false,
  "records": [
    {
      "index": 6,
      "started": "2026-09-10T19:59:29.262",
      "call_id": "0bec3caf-a258-4571-85f8-0b3313bf3a50",
      "name": "discount_rate",
      "args": "'free', False",
      "kwargs": "",
      "outcome_kind": "raised",
      "outcome": "TypeError: '>=' not supported between instances of 'str' and 'int'",
      "duration": 3e-06,
      "cpu": null,
      "thread": "7.129140652823040"
    }
  ]
}

Вот ради этой строки всё и делалось:

"args": "'free', False"

Стек вызовов показывает, что сравнение упало. Он не показывает, что в функцию приехала строка 'free'. Трасса показывает.

Заодно в ответе видно, сколько всего разобрано (calls_parsed), сколько строк оказалось битыми (malformed) и сколько подошло под условие (matched).

trace-stats — свести весь прогон

ouroboros trace-stats ./debug.info
{
  "ok": true,
  "path": "debug.info",
  "calls_parsed": 9,
  "malformed": 0,
  "total_calls": 9,
  "in_flight": [],
  "by_function": [
    {
      "name": "apply_discount",
      "count": 4,
      "result": 3,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 7.8e-05,
        "max": 0.000106,
        "mean": 9.3e-05,
        "total": 0.000372,
        "count": 4
      }
    },
    {
      "name": "discount_rate",
      "count": 4,
      "result": 3,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 1e-06,
        "max": 3e-06,
        "mean": 2e-06,
        "total": 7e-06,
        "count": 4
      }
    },
    {
      "name": "main",
      "count": 1,
      "result": 0,
      "raised": 1,
      "unknown": 0,
      "duration_seconds": {
        "min": 0.000746,
        "max": 0.000746,
        "mean": 0.000746,
        "total": 0.000746,
        "count": 1
      }
    }
  ],
  "by_thread": [
    {
      "thread": "7.129140652823040",
      "count": 9,
      "functions": 3,
      "cpus": []
    }
  ],
  "duration_seconds": {
    "min": 1e-06,
    "max": 0.000746,
    "mean": 0.000125,
    "total": 0.001125,
    "count": 9
  },
  "timespan": {
    "first": "2026-09-10T19:59:29.262",
    "last": "2026-09-10T19:59:29.262",
    "seconds": 0.0,
    "timestamps_parsed": 9,
    "timestamps_unparsed": 0
  },
  "note": "counts/durations are over completed calls; `duration_seconds` are REAL per-call durations (exit−entry) from each call's `d`. `by_thread` groups calls by the `th` token (CPUs each thread ran on); empty for traces with no thread field. `in_flight` = entered (`p:in`) but never completed. `timespan` is first→last entry time."
}

Девять вызовов, три функции. У каждой видно, сколько раз она вернула значение (result) и сколько раз бросила (raised), и сколько заняли её вызовы. Поле in_flight пустое — значит все вызовы, которые вошли, вышли. Когда оно непустое, это интересно, и про это следующая глава.

Последним полем идёт note — сам инструмент говорит, что именно посчитано: только завершённые вызовы, длительности настоящие, in_flight — вошедшие и не вышедшие.

Что проверить после первого прогона

  • В ответе wrap-file число functions_wrapped больше нуля. Ноль означает, что обмазывать было нечего или файл уже обмазан.
  • Файл debug.info появился и непустой. Пустой — значит обмазанный код не исполнялся: например, запустили не тот файл.
  • malformed равно нулю. Не ноль — в трассу попало что-то, кроме записей.
  • in_flight пуст. Не пуст — какие-то вызовы не вернулись.
  • Обмазанный файл не уехал в общую ветку. Он полезен ровно на время разбора.

Дальше — про отказы: что инструмент делать откажется и почему это его лучшее свойство.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше