跳到主要内容

Ouroboros Tracing

Записывает, как код на самом деле исполнялся — вызовы, доводы, возвращённые значения, исключения, длительности. Обмазывает исходник на восьми языках — Python, JavaScript/TypeScript, C, C++, Elixir, Go, Java и C# — так, что запуск обмазанного кода дописывает по две строки JSONL на вызов в файл debug.info, и даёт средства читать этот файл с отбором и постранично. Берите, когда надо понять поведение работающей программы: разобрать чужой код, найти ошибку, которой не видно по итоговому выводу, снять снимок «было так» перед правкой, найти медленные вызовы или зависшие. Для C и C++ есть шесть средств навигации по дереву на clangd и clang-tidy, чтобы выбрать, что обмазывать. Не берите, чтобы узнать, как код ДОЛЖЕН работать, и не считайте записи доказательством правильности.

Skill metadata​

SourceBundled (installed by default)
Pathskills/software-development/ouroboros-tracing
Version2.1.0
AuthorDigitable
LicenseBSD-2-Clause
Platformslinux, macos
Tagsouroboros, tracing, instrumentation, mcp, debugging

Reference: full SKILL.md​

信息

The following is the complete skill definition that Digit loads when this skill is triggered. This is what the agent sees as instructions when the skill is active.

Уроборос: записи о вызовах

О чём это​

Инструмент правит исходник так, что каждая функция при вызове дописывает две строки в файл debug.info: одну на входе, одну на выходе. По ним видно, что звали, с чем, что вернулось, что упало и сколько это заняло.

Работает на восьми языках: Python, JavaScript/TypeScript, C, C++, Elixir, Go, Java, C#.

Есть два входа: командная строка ouroboros (17 подкоманд) и сервер MCP ouroboros-mcp (17 средств). Это одна и та же машина, просто с двух сторон.

Все пути вида docs/…, bench/…, scripts/… и SPEC.md ниже — это файлы в хранилище инструмента: <https://github.com/digitable-lol/ouroboros>. Там же лежит и подлинник этого навыка, skill/SKILL.md, из которого сделана эта копия: правки вносят туда, а не сюда.

Читайте раздел «Границы» до того, как делать выводы из записей. Записи говорят, как код себя вёл, а не как он должен себя вести. Программа с ошибкой даёт записи, в которых ошибка выглядит нормой.

Когда брать​

  • Надо понять, как работает незнакомый код, — а читать его целиком дорого.
  • Ошибка не видна ни по итоговому выводу, ни по отслеживанию стека: неверное значение рождается в середине цепочки и до вывода не доходит.
  • Программа идёт долго и молчит, и непонятно, где она сейчас.
  • Нужен снимок «до правки» — чтобы после правки сверить, что поведение то же.
  • Нужно найти медленные вызовы или те, что вошли и не вернулись.

Когда не брать​

  • Надо узнать, что код должен делать. Спросите человека или спецификацию.
  • Надо доказать, что код правильный. Записи этого не могут по устройству.
  • Ошибка уже видна по выводу или по отслеживанию стека — дешевле посмотреть туда.
  • Горячий путь, где важна каждая микросекунда, и обмазать надо всё.

Что нужно поставить​

Обязательно: Python 3.12 или новее. Всё остальное — только под тот язык, который собираетесь обмазывать и запускать.

зачемчто
обмазать и читать записи на любом языкеPython ≥ 3.12
запустить обмазанный JavaScript/TypeScriptNode
собрать и запустить обмазанный C/C++gcc или clang
собрать и запустить обмазанный ElixirElixir и Erlang/OTP
обмазать, собрать и запустить обмазанный GoGo (разбор идёт его же средствами)
собрать и запустить обмазанный JavaJDK (javac и java)
обмазать, собрать и запустить обмазанный C#.NET SDK (разбор берёт Roslyn из самого SDK)
шесть средств навигации по C/C++clangd и clang-tidy на PATH
черновик и чистовикgit

libclang (нужен разбору C и C++) и @babel/parser (нужен разбору JavaScript) приезжают вместе с пакетом. Отдельно ставить не надо.

Установка​

uv tool install git+https://github.com/digitable-lol/ouroboros
Installed 2 executables: ouroboros, ouroboros-mcp

Через Homebrew — одной строкой, Python доставится сам:

brew install digitable-lol/tap/ouroboros

Через asdf — когда рядом нужно держать несколько выпусков и переключать их:

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

Эти три способа — Homebrew, asdf и uv tool install — основные. Всё описанное здесь снято с выпуска 0.5.0.

На PyPI пакета нет, поэтому pip install ouroboros-logger не сработает. Из локального хранилища ставится обычным pip install <путь к хранилищу>. Остальные способы (один файл-программа, образ) — docs/install.md.

Проверка, что встало:

ouroboros languages
{"languages": ["python", "javascript", "c", "cpp", "elixir", "go", "java", "csharp"]}

Ставятся две команды: ouroboros и ouroboros-mcp. Других нет.

Для C и C++ нужен ещё компилятор C. Разговор с libclang идёт через маленькую родную программу, которая собирается один раз на машине при первом обмазывании файла на C или C++. Заголовки llvm для этого не нужны, компилятор — нужен. На машине, где обмазывают C, он и так есть; на машине, где обмазывают только Python или JavaScript, ничего доставлять не надо.

Подключить сервер MCP​

{ "mcpServers": { "ouroboros": { "type": "stdio", "command": "ouroboros-mcp" } } }

Из исходников, без установки:

{ "mcpServers": { "ouroboros": {
"type": "stdio", "command": "uv",
"args": ["run", "--directory", "<путь к хранилищу>", "ouroboros-mcp"] } } }

Проверка: спросите у агента список средств. Должно прийти семнадцать имён.

Порядок работы​

обмазать -> запустить -> прочитать

Обмазать. Три способа, по нарастанию осторожности:

средствочто делает
wrap_code_snippet(code, language)обмазывает строку в памяти, на диск не пишет
wrap_file(path)обмазывает файл на месте, все функции
wrap_functions(path, functions)обмазывает только названные функции

На большом или горячем файле берите wrap_functions: обмазка всего файла затопит файл записей, и нужное в нём утонет.

Повторная обмазка безопасна: уже обмазанная функция пропускается, новая добавляется.

Запустить. Как обычно — своей командой, своими проверками. Файл записей задаётся переменной OUROBOROS_DEBUG_INFO; если не задана, записи идут в ./debug.info рядом с рабочим каталогом.

Обмазка кладёт рядом с файлом помощника — его имя приходит в ответе полем runtime_header (ouroboros_runtime.py, .go, .hpp и так далее). Его надо собирать и запускать вместе с программой. У Python и C++ это выходит само; у Go и Java — нет.

  • Go: go run main.go не найдёт помощника и упадёт на undefined: _ouroEnter. Запускайте go run . в модуле (тогда помощник подхватывается сам) либо перечислите его явно: go run main.go ouroboros_runtime.go.
  • Java: помощник — класс ouroboros.OuroborosRuntime, то есть он объявляет пакет ouroboros. Простое javac App.java OuroborosRuntime.java соберётся, но при запуске упадёт на NoClassDefFoundError: ouroboros/OuroborosRuntime: без -d класс лёг рядом с исходником, а не в папку своего пакета. Собирайте javac -d . App.java OuroborosRuntime.java.
  • C#: ничего делать не надо — SDK сам подбирает все .cs в проекте.

Прочитать.

средствочто делает
read_trace(path, …)отбирает записи: по имени функции, по содержимому, по исходу, по длительности, по потоку; выдаёт постранично
trace_stats(path, …)сводка: сколько раз какую функцию звали, настоящие длительности, что вошло и не вернулось

Начинайте с trace_stats — он даёт карту. read_trace уже по ней.

Полезные отборы: min_duration находит медленные вызовы, outcome: "raised" — упавшие, in_flight в сводке — те, что вошли и не вернулись (зависание или падение).

Черновик и чистовик​

Отдельный способ работы, когда код пишете вы (или агент), а не берёте готовый.

create_project -> write_file -> execute -> finish
черновик обмазка при запуск копия в
с git сохранении чистовик
  • create_project(base) заводит <base>/черновик/ — обычное хранилище git.
  • write_file(base, rel_path, content) обмазывает содержимое перед записью на диск и делает свой оборот. Код, который не разбирается, не сохраняется — средство отвечает отказом с сообщением разборщика.
  • execute(base, command) запускает команду внутри черновика, подставив OUROBOROS_DEBUG_INFO, и дописывает в тот же файл строку о самом запуске.
  • finish(base) копирует черновик в <base>/чистовик/.

finish не снимает обмазку. Копия в чистовике обмазана ровно так же, как черновик. Необмазанного текста нигде нет: write_file обмазывает буфер до того, как байты попадут на диск, так что ни в черновике, ни в его истории git подлинника автора никогда не было. Средство честно называется копированием, и в ответе стоит "instrumentation_removed": false.

Что копия не уносит: .git, debug.info, кеши средств и всё, что выглядит собранным (двоичные файлы, объектные файлы, дампы памяти). Каждое такое имя перечислено в skipped с причиной — если там оказалось нужное, перенесите сами.

Выбрать, что обмазывать в дереве C и C++​

Шесть средств поверх clangd и clang-tidy. Они нужны до wrap_functions, чтобы выбрать функции, которые стоит обмазать, вместо обмазки целого горячего файла.

средствовопрос, на который отвечает
symbol_search(query, root)где в дереве это имя
document_symbols(path)что определяет вот этот файл
references(path, symbol)кто это использует
call_hierarchy(path, symbol)кто это зовёт (или кого зовёт оно)
describe_symbol(path, symbol)где определено и с какой подписью
lint_file(path)что говорит clang-tidy про настоящие ошибки

Три вещи, на которых здесь спотыкаются:

  1. Пяти средствам из шести нужен полный путь. Относительный путь даёт отказ unresolvable URI at (root).textDocument.uri — это ответ clangd, не наш. Исключение — lint_file: ему относительный путь годится.
  2. Всему, что смотрит за пределы одного файла, нужен compile_commands.json. Путь к каталогу с ним передаётся доводом compile_commands_dir.
  3. Первый вызов на свежем дереве платит за построение указателя. Дальше он лежит на диске. На большом дереве поднимайте index_timeout. В ответе есть index_complete: если там false, ответ неполный.

Нет clangd или clang-tidy — средства отвечают понятным отказом, а не падают:

{"ok": false, "error": "clangd not found on PATH"}

Все семнадцать средств​

средствообязательные доводы
wrap_code_snippetcode, language
wrap_filepath
wrap_functionspath, functions
read_tracepath
trace_statspath
create_projectbase
write_filebase, rel_path, content
read_filebase, rel_path
list_filesbase
executebase, command
finishbase
lint_filepath
symbol_searchquery, root
document_symbolspath
referencespath, symbol
call_hierarchypath, symbol
describe_symbolpath, symbol

Все семнадцать — обычные средства MCP с обычными схемами. Никакого указателя возможностей, никакого разбора схем по запросу, никакой подачи операций через одно средство здесь нет. Полный справочник со всеми необязательными доводами — docs/mcp-tools.md в хранилище; он снят разговором с живым сервером, а не написан руками.

У командной строки тоже семнадцать подкоманд: wrap-file, wrap-functions, wrap-snippet, trace, trace-stats, lint, symbols, doc-symbols, refs, callers, describe, create, write, execute, finish, languages, mcp.

Но это не те же самые семнадцать — совпадение чисел случайно. Общих пятнадцать, а по краям расходится:

  • только у MCP: read_file и list_files — прочитать файл в черновике и перечислить его файлы. Из командной строки для этого берут обычные cat и ls, отдельных подкоманд нет;
  • только у командной строки: languages (какие языки поддержаны) и mcp (запустить сам сервер MCP). Средствами MCP их вызывать неоткуда.

Что лежит в записи​

Две строки на вызов, связанные полем id:

{"p":"in","t":"2026-08-29T08:30:14.227","id":"7b0bd3b0-…","ci":-1,"th":"1175211.128643830919680","fn":"add","a":"0, 0","k":""}
{"p":"out","id":"7b0bd3b0-…","fn":"add","r":"0","d":2e-06}
{"p":"out","id":"e81a5517-…","fn":"div","x":"ZeroDivisionError: division by zero","d":5e-06}
полегдечто значит
pобеin — вошли, out — вышли
tinвремя входа
idобеномер вызова; связывает вход с выходом
fnобеимя функции
ainзначения доводов по позиции, через запятую
kinименованные доводы как имя=значение
routвозвращённое значение
xoutисключение как Вид: сообщение
doutдлительность в секундах
ciinномер ядра
thinметка потока

Строка входа без пары — вызов, который вошёл и не вернулся. Зависание, падение, жёсткий выход. Это то, чего однострочная запись «по завершении» не могла бы показать вовсе.

Границы​

Каждый пункт ниже проверен прогоном.

Чего в записях нет по устройству​

  • Того, что не выполнялось. Ветвь, в которую не зашли, записей не даст — и не пожалуется. Отсутствие записей читается как «тут всё спокойно», а значит оно «тут никто не был».
  • Намерения. Функция вернула -1 — это код ошибки или ответ? В записи этого нет.
  • Вызовов через функцию-значение в Go. func(x int) int { ... } в переменной или доводе не обмазывается, и её вызовы в записях не появятся. У Python вложенная def обмазывается и видна (outer.<locals>.inner), у Go такой же по смыслу код молчит. Обмазываются только объявленные функции и методы.
  • Имён позиционных доводов — ни у одного из восьми языков. В поле a везде одни значения: 2, 3. У C, C++ и Elixir подпись при обмазке разобрана и имена известны, но раньше эти три писали a=2, b=3, и тогда поле значило у трёх языков одно, а у двух другое. Сейчас имён нет нигде, и по одной записи их не восстановить.
  • Номера ядра — ни у одного из восьми языков. Поле ci в обычной программе всегда -1 (в разборе — null). У Python потому, что функции os.sched_getcpu в CPython нет вовсе; у остальных — потому, что переносимого способа узнать ядро в их средах нет. Настоящий номер бывает только в сборке C внутри ядра операционной системы.
  • Значений длиннее 200 знаков. Обрезаются, хвост не восстанавливается. Списки и словари вдобавок обрезаются по числу элементов — первые десять.
  • Настоящей цены вызова. Поле d меряет обмазанный прогон, который идёт дольше обычного. Профилировщик записи не заменяют.

Что записывается не полностью​

  • C++ не записывает возвращённый объект классового типа — в поле r стоит (no value). Провести такой возврат через помощник значило бы отменить пропуск копирования, который C++17 гарантирует: программа, считающая свои конструкторы, начала бы печатать перемещение, которого без обмазки не было, а тип с удалёнными копированием и перемещением перестал бы собираться. Доводы, длительность и факт исключения записываются как обычно.
  • C не записывает возвращённую структуру — то же (no value), по той же причине: вида для printf у структуры нет.
  • У C не бывает поля x — в языке нет исключений.
  • Длительность у C округлена до микросекунды, поэтому вызовы короче микросекунды показывают 0.000000.
  • C# оставляет нетронутыми пять видов частей, потому что обмазанный вариант не собрался бы: с yield, возврат по ссылке, указатели, ссылочные структуры и свойства с телом-выражением. Про каждую такую часть обмазка возвращает предупреждение с причиной — молча они не пропадают. Довод out не попадает в снимок доводов на входе: там он ещё не присвоен.
  • Java пропускает то, у чего нет тела — отвлечённые и родные методы, объявления в договоре. Замыкания и безымянные классы сами не обмазываются.
  • Короткий вид записи для C (--minimal) пишет только строку входа — без доводов, без возвращённого значения, без номера вызова и без длительности. Обычные средства чтения такие строки вызовами не считают: каждая попадает в список незавершённых. Он годится, чтобы увидеть дерево вызовов и глубину, и ни для чего другого.

Где обмазка меняет поведение программы​

  • Go: непойманная паника печатает не то же самое. Единственное место во всех восьми языках, где меняется вывод программы. Чтобы записать вид и текст паники, замыкание вызывает recover() и паникует заново, поэтому программа, чью панику никто не ловит, печатает в поток ошибок panic: bad [recovered, repanicked] вместо panic: bad. Код возврата тот же, обычный вывод тот же, всякая пойманная паника приходит тем же значением.
  • Python: плюс кадр стека на каждый вызов. Замерено: при пределе рекурсии 200 достижимая глубина 199 без обмазки и 95 с обмазкой; при пределе 1000 — 999 и 495. Примерно вдвое мельче. Программа с тесно подогнанным пределом рекурсии может упереться там, где не упиралась. Для глубокой рекурсии обмазывайте не саму рекурсивную функцию, а тех, кто её зовёт.
  • Изображение значения зовёт код самой программы. Чтобы записать довод, надо спросить у него, как он выглядит, — а это ваш код: __repr__ в Python, toJSON в JavaScript, ToString в Java и C#. Если такой метод что-то считает или меняет, обмазанная программа поведёт себя иначе. Проверено прогоном: программа, печатавшая «вызовов repr в самой программе: 0», после обмазки печатает «>0». Починить это нечем — записать значение, не спросив у значения, невозможно. Смягчено одно: бросок внутри такого метода не роняет программу, в запись идёт <Тип toString threw ...>.
  • Обмазка остаётся в файле, пока вы её не уберёте. Обратной команды нет, и finish её не делает.
  • В отслеживании стека появляются кадры обёртки между вашими.

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

Чего стоит по времени и по месту​

Замерено на одной функции add(a, b), 20 002 вызова, Linux, ext4. Все восемь строк сняты одним прогоном scripts/measure/run.sh:

языкдобавка на вызовбайт на вызов
C#15,6 мкс251
C19,9 мкс248
JavaScript20,4 мкс237
Java20,9 мкс251
C++22,2 мкс269
Go28,9 мкс243
Python58,2 мкс253
Elixirоколо 190 мкс254

Не смешивайте эти числа с числами прошлых прогонов. Они заметно гуляют: при неизменном коде Python дал 63,5 микросекунды в прошлый раз и 58,2 в этот. Сравнивать языки между собой можно только внутри одной таблицы, снятой одним прогоном.

Что отсюда стоит унести:

  • Дороже всего сама запись, а не язык. Четыре языка из восьми — C, JavaScript, Java и C++ — уложились в 20–22 микросекунды, хотя устроены совершенно по-разному. Столько стоит дописать в файл две строки на вызов.
  • C# дешевле всех — 15,6 мкс, и это не про язык. Его помощник, как и у Java, держит файл открытым, тогда как помощники Python, C и C++ открывают и закрывают его на каждую запись.
  • Go — 28,9 мкс, из них 3,3 стоит поле th (номер горутины). Цена эта растёт вместе с глубиной стека: на глубине 100 один такой опрос обходится уже в 67 микросекунд. В глубоко вложенной программе добавка будет заметно больше.
  • Python дороже втрое (58 мкс): обёртка написана на самом Python, и каждая запись собирается через json.dumps и reprlib.
  • Elixir дороже всех, и замер у него самый неустойчивый — отдельные прогоны дали от 161 до 202 микросекунд. Здесь верен порядок величины, а не третий знак.
  • Миллион вызовов — примерно четверть гигабайта. От 237 до 269 байт на вызов при коротких доводах. Java и C# разошлись на один байт за пять мегабайт.

Считать по этим числам, «во сколько раз медленнее», нельзя: подопытная программа не делает ничего, кроме вызовов, и отношение выходит от 6 до 313 в зависимости только от того, насколько быстрым был пустой цикл на этом языке. Осмысленное число — добавка на вызов, и у семи языков из восьми она лежит в пределах 16–58 микросекунд.

Как повторить замер: scripts/measure/run.sh в хранилище.

Сколько от этого пользы — по замеру​

Спрашивали так: дать отвечающему чужую программу и спросить, что она сделала на этом запуске, — много ли добавляет трасса. Двенадцать программ, по пять вопросов на программу, две группы. Мера и разбор ответов записаны до первого прогона. Обе группы получали исходник целиком, команду запуска и то, что программа напечатала; опытная вдобавок — debug.info того же запуска. То есть контрольной группе хватало всего, чтобы вывести ответ, прочитав код.

Опыт лежит в scripts/measure/trace-help/, пересдаётся его run.sh. Снят он на шести языках из восьми — Java и C# в него пока не входят, и про них эти числа ничего не говорят.

Чем слабее отвечающий, тем больше польза​

кто отвечалбез трассыс трассойразница и её промежуток
qwen3.5:4b (600 ответов)44,0 %78,3 %+34,3 (19,7 … 47,7)
qwen2.5:14b-instruct (600)61,0 %84,7 %+23,7 (12,3 … 35,0)
qwen3:32b (600)66,7 %90,3 %+23,6 (10,3 … 36,3)
агент Claude Opus 5 (120)95,0 %98,3 %+3,3 (0,0 … 8,3)

На сильной модели польза не подтвердилась. У агента ноль попадает в промежуток, а порог был объявлен до прогона: если промежуток накрывает ноль — это не находка. Больше того, все четыре его ошибки из ста двадцати — «нет» вместо false: верное значение в неверном виде. На этих двенадцати коротких программах сильная модель и так считает в уме, и трасса ей не нужна.

Это главное, что нужно унести о пользе, и звучит оно не в пользу инструмента.

Польза узкая даже там, где она есть​

На средней модели прибавка сидит не везде, а только на вопросах о значениях:

о чём вопросбез трассыс трассой
вызывалась ли функция вообще100 %100 %
кто бросил исключение100 %100 %
сколько раз вызвана функция58,9 %68,9 %
что вернул такой-то вызов46,4 %87,1 %
с чем позвали функцию50,0 %100 %

На вопросе «вызывалась ли функция вообще» трасса не даёт ровно ничего — сто процентов в обеих группах. Берите записи ради значений: с чем позвали и что вернулось.

Когда запись не влезает в запрос​

Пятый прогон — те же программы на входе в сотни раз большем: запись выходит 1,62 миллиона знаков против 4 670 знаков исходника, то есть в 347 раз больше самой программы. В запрос кладут обрезок в 8 000 знаков.

ответ лежитбез трассыс урезанной трассой
вызов уцелел в обрезке9,1 %65,5 %
вызов вырезан0,0 %10,8 %
нужна вся запись целиком0,0 %10,0 %

Обрезка спасает только то, что в неё попало. Общий счёт вырос с 13,9 до 37,2 %, но по разбивке видно, что вся прибавка — на уцелевших вызовах. На длинной программе не рассчитывайте прочитать трассу целиком: отбирайте read_trace и trace_stats, а не кладите файл в запрос.

Отдельно: окупается ли она агенту по деньгам​

Два других опыта (bench/RESULTS.md, bench/RESULTS_debug.md, по три прогона на руку) мерили не понимание, а цену работы. Там, где ошибка видна по итоговому выводу, инструмент не взяли ни разу из трёх — и это был верный выбор. Там, где не видна, он побил ручную расстановку печати вдвое по длине ответа и на треть по времени, но простое чтение кода оказалось не хуже.

Коротко​

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

Что проверить, прежде чем считать работу сделанной​

  • Проверки программы прогнаны после обмазки и зелёные.
  • Записи прочитаны средствами (trace_stats, read_trace), а не глазами по файлу: файл на миллион вызовов читать глазами нечем.
  • Незавершённые вызовы (in_flight) просмотрены — там зависания и падения.
  • Никто не считает «записи есть» доказательством «код правильный».
  • Никто не считает «записей нет» доказательством «сюда не ходят»: возможно, сюда просто не зашли в этом прогоне.
  • Если обмазан чужой код — решено, что с обмазкой делать дальше: она остаётся в файлах, пока её не уберут.

Где что лежит​

страницао чём
docs/install.mdвсе способы установки, подключение сервера MCP, переменные среды
docs/getting-started.mdвсе команды и порядок работы
docs/trace-existing-code.mdпо шагам: прологировать чужой код
docs/languages.mdчем восемь языков отличаются в записи
docs/limits.mdграницы — самая важная страница
docs/measurements.mdзамеры по восьми языкам и как их повторить
docs/mcp-tools.mdсправочник всех семнадцати средств
scripts/measure/trace-help/опыт на 600 ответах: помогает ли трасса понять чужой код
bench/RESULTS.md, bench/RESULTS_debug.mdдва опыта про цену работы агента
SPEC.mdсхема записи