Ouroboros what your code actually did, one line in and one line out 0.6.1 GitHub

Your program printed a number. Ouroboros tells you where that number came from.

A shop program was asked for the price of an order and answered Total: 51.80. The customer had been promised free delivery over 50.00, and their goods came to 52.00. They were charged for delivery anyway.

The printed line does not say why. The source code says what the program may do — every branch, every case, including the ones that did not happen on this run. Neither of them says what the program did on this run, with these numbers.

Ouroboros answers that, and only that. It adds recording to your own source file. You then run the program the ordinary way, and every call leaves two lines behind: one when it is entered, one when it comes back.

Not a debugger. Nothing to attach to, nothing to step through. The program runs at full speed, unattended, on a server if that is where the problem is.

Not a profiler. It answers what was this called with and what did it answer, not where did the time go — though every line carries its own duration.

Not a log statement. You do not decide in advance what is worth printing, and you do not go back and delete it later from forty places.

The four steps, on the shop program

This is the whole tool. Four commands, and the output below each one is the real output of running it — not an illustration.

1. Install it

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

There is a Homebrew formula too — brew install digitable-lol/tap/ouroboros — and an asdf plugin, a single-file build and a container image. All of them are on the install page.

2. Point it at the file

Here is the program. It is twenty-nine lines and it has no logging in it at all.

site/examples/shop.py, before
"""What one order costs: goods, the regular-customer discount, delivery."""

import sys

PRICES = {"tea": 18.00, "mug": 12.50, "kettle": 21.50}


def subtotal(order):
    return sum(PRICES[name] for name in order)


def discount(amount):
    """Regular customers get 10% off."""
    return round(amount * 0.10, 2)


def delivery(amount):
    """Delivery is free from 50.00."""
    return 0.00 if amount >= 50.00 else 5.00


def total(order):
    goods = subtotal(order)
    goods = goods - discount(goods)
    return round(goods + delivery(goods), 2)


if __name__ == "__main__":
    print(f"Total: {total(sys.argv[1:]):.2f}")
ouroboros wrap-file shop.py
{"ok": true, "path": "shop.py", "language": "python", "functions_wrapped": 4, "runtime_header": "ouroboros_runtime.py"}

It found 4 functions and put recording into each of them. The file is the same file — same lines, same order, same comments, same docstring — plus one import at the top and one marker line above each function. Nothing else moved. A helper file, ouroboros_runtime.py, is dropped next to it; that is the piece that does the actual writing, and it imports nothing but the standard library.

The whole instrumented file is on the examples page.

3. Run the program the way you always run it

python3 shop.py tea mug kettle
Total: 51.80

Same answer, same command, no flags, no wrapper process. That matters more than it sounds: whatever you already do to run the thing — a test suite, a container, a cron job, a server — keeps working, and the recording happens inside it.

4. Read what happened

Beside the program there is now a file called debug.info. It has 8 lines in it, two for each of the 4 calls that happened:

debug.info, exactly as written
{"p":"in","t":"2026-09-08T15:50:09.581","id":"0deff01d-9ae2-4b24-b7bc-448f9eea6b21","ci":-1,"th":"4102424.138269349349248","fn":"total","a":"['tea', 'mug', 'kettle']","k":""}
{"p":"in","t":"2026-09-08T15:50:09.581","id":"b898115d-2023-4299-9c21-3f580f686691","ci":-1,"th":"4102424.138269349349248","fn":"subtotal","a":"['tea', 'mug', 'kettle']","k":""}
{"p":"out","id":"b898115d-2023-4299-9c21-3f580f686691","fn":"subtotal","r":"52.0","d":3e-06}
{"p":"in","t":"2026-09-08T15:50:09.581","id":"bd13922b-8aa2-4751-a7db-101c332fb16b","ci":-1,"th":"4102424.138269349349248","fn":"discount","a":"52.0","k":""}
{"p":"out","id":"bd13922b-8aa2-4751-a7db-101c332fb16b","fn":"discount","r":"5.2","d":3e-06}
{"p":"in","t":"2026-09-08T15:50:09.581","id":"a61fc823-5c12-4c6d-bd3a-99c370d35c12","ci":-1,"th":"4102424.138269349349248","fn":"delivery","a":"46.8","k":""}
{"p":"out","id":"a61fc823-5c12-4c6d-bd3a-99c370d35c12","fn":"delivery","r":"5.0","d":1e-06}
{"p":"out","id":"0deff01d-9ae2-4b24-b7bc-448f9eea6b21","fn":"total","r":"51.8","d":0.000252}

You do not have to read that shape. ouroboros trace turns it into something plainer, and can filter it — by function, by argument, by how long a call took, by whether it blew up:

the first two of the four calls
{
  "ok": true,
  "path": "debug.info",
  "calls_parsed": 4,
  "malformed": 0,
  "matched": 4,
  "returned": 4,
  "next_cursor": null,
  "in_flight": [],
  "in_flight_truncated": false,
  "records": [
    {
      "index": 0,
      "started": "2026-09-08T15:50:09.581",
      "call_id": "b898115d-2023-4299-9c21-3f580f686691",
      "name": "subtotal",
      "args": "['tea', 'mug', 'kettle']",
      "kwargs": "",
      "outcome_kind": "result",
      "outcome": "52.0",
      "duration": 3e-06,
      "cpu": null,
      "thread": "4102424.138269349349248"
    },
    {
      "index": 1,
      "started": "2026-09-08T15:50:09.581",
      "call_id": "bd13922b-8aa2-4751-a7db-101c332fb16b",
      "name": "discount",
      "args": "52.0",
      "kwargs": "",
      "outcome_kind": "result",
      "outcome": "5.2",
      "duration": 3e-06,
      "cpu": null,
      "thread": "4102424.138269349349248"
    },
    {
      "index": 2,
      "started": "2026-09-08T15:50:09.581",
      "call_id": "a61fc823-5c12-4c6d-bd3a-99c370d35c12",
      "name": "delivery",
      "args": "46.8",
      "kwargs": "",
      "outcome_kind": "result",
      "outcome": "5.0",
      "duration": 1e-06,
      "cpu": null,
      "thread": "4102424.138269349349248"
    },
    {
      "index": 3,
      "started": "2026-09-08T15:50:09.581",
      "call_id": "0deff01d-9ae2-4b24-b7bc-448f9eea6b21",
      "name": "total",
      "args": "['tea', 'mug', 'kettle']",
      "kwargs": "",
      "outcome_kind": "result",
      "outcome": "51.8",
      "duration": 0.000252,
      "cpu": null,
      "thread": "4102424.138269349349248"
    }
  ]
}

What the four calls say

Read the r values — what each call answered — in the order they happened:

the callit was givenit answered
subtotal['tea', 'mug', 'kettle']52.0
discount52.05.2
delivery46.85.0
total['tea', 'mug', 'kettle']51.8

The goods came to 52.00. The discount was worked out from 52.00, correctly. And then the delivery rule — free from 50.00 — was asked about 46.80, the amount left after the discount. Under 50, so delivery was charged.

Nobody wrote that down anywhere. It is not in the printed total, and you would have to read the program in exactly the right order to see it. It is simply what the run did, and the run wrote it down.

What the trace does not do is tell you this is a bug. Maybe delivery is supposed to be charged on the discounted amount; plenty of shops work that way. A trace shows behaviour, never intent — a rule that has been wrong for three years looks exactly like a rule that is right. The judgement stays with the person who knows the business. This is the most important sentence on the site, and it is the first line of the limits page too.

How it works

Three views of the same mechanism: what happens step by step, where the tool sits among the things you already have, and how it decides what to record.

From your command to the written line

Wrapping happens once, at your keyboard. Recording happens on every call, inside your own process. Nothing watches from outside.Wrapping happens once, at your keyboard. Recording happens on every call, inside your own process. Nothing watches from outside.
Wrapping happens once, at your keyboard. Recording happens on every call, inside your own process. Nothing watches from outside.
The mermaid source of this diagram
sequenceDiagram
    autonumber
    actor You
    participant CLI as ouroboros
    participant File as Your source file
    participant Run as Your program, running
    participant Sink as debug.info

    You->>CLI: wrap-file shop.py
    Note over CLI: asks the language's own parser where<br/>each function begins and ends
    CLI->>File: splices the recording in at those offsets,<br/>and drops a helper file beside it
    CLI-->>You: {"ok": true, "functions_wrapped": 4}

    You->>Run: python3 shop.py tea mug kettle
    loop once per wrapped call
        Run->>Sink: {"p":"in", "fn":"delivery", "a":"46.8", ...}
        Note over Run: your function body runs, untouched
        Run->>Sink: {"p":"out", "r":"5.0", "d":1e-06}
    end
    Run-->>You: Total: 51.80

    You->>Sink: ouroboros trace debug.info
    Sink-->>You: 4 calls: what each was given, what each answered

The important part of that picture is the middle: the recording runs inside your program, as ordinary code in your own file. There is no agent, no daemon, no port, no ptrace, no permissions to grant. That is also the reason for the whole of the limits page: code that is spliced into your source is code that can change how your source behaves.

Where the tool sits

Ouroboros talks to two things: your source tree and the toolchain that already builds it. The trace file is the only thing it produces.Ouroboros talks to two things: your source tree and the toolchain that already builds it. The trace file is the only thing it produces.
Ouroboros talks to two things: your source tree and the toolchain that already builds it. The trace file is the only thing it produces.
The mermaid source of this diagram
C4Context
    title Ouroboros in the system it is used in

    Person(dev, "Developer", "Wants to know what the code did on this run, not what it should do")
    Person(agent, "AI coding agent", "Asks the same questions over MCP")

    System_Boundary(machine, "Your machine") {
        System(ouro, "Ouroboros", "CLI and MCP server. Rewrites sources, then reads the trace back")
        System_Ext(src, "Your source tree", "Python, JS/TS, C, C++, Elixir, Go, Java, C#")
        System_Ext(chain, "The toolchain you already have", "python3, node, gcc, g++, elixir, go, javac, dotnet")
        SystemDb(sink, "debug.info", "One append-only JSONL file. Two lines per call")
    }

    Rel(dev, ouro, "wrap-file, trace", "shell")
    Rel(agent, ouro, "17 tools", "MCP over stdio")
    Rel(ouro, src, "reads it, and writes it back in place")
    Rel(ouro, chain, "asks it to parse, then to build and run")
    Rel(chain, sink, "the running program appends to it")
    Rel(ouro, sink, "reads and filters")

    UpdateRelStyle(dev, ouro, $offsetX="-25", $offsetY="-30")
    UpdateRelStyle(agent, ouro, $offsetX="20", $offsetY="-40")
    UpdateRelStyle(ouro, src, $offsetX="-70", $offsetY="-22")
    UpdateRelStyle(ouro, chain, $offsetX="-95", $offsetY="-10")
    UpdateRelStyle(chain, sink, $offsetX="-80", $offsetY="22")
    UpdateRelStyle(ouro, sink, $offsetX="30", $offsetY="40")
    UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="1")

The toolchain in that picture is yours, unchanged. Ouroboros does not carry its own compilers. It asks the language's own parser where each function begins and ends — libclang for C and C++, Babel for JavaScript, go/parser for Go, the JDK's own parser for Java, Roslyn for C# — and then edits bytes at those exact offsets. The two parsers that are not already on your machine, libclang and Babel, ship inside the package.

An AI coding agent can drive the same thing over MCP: 17 tools, the same wrap-run-read loop, so the agent reads what its code did rather than guessing.

What gets recorded, and what quietly does not

Every "skipped" branch on the left is a real gap in the trace. Most of them are silent, which is why they are worth knowing before you rely on a trace being complete.Every "skipped" branch on the left is a real gap in the trace. Most of them are silent, which is why they are worth knowing before you rely on a trace being complete.
Every "skipped" branch on the left is a real gap in the trace. Most of them are silent, which is why they are worth knowing before you rely on a trace being complete.
The mermaid source of this diagram
flowchart TD
    A([A file you point at]) --> B{Is the extension one<br/>of the eight languages?}
    B -- no --> X1[/Refused: no backend for this file/]
    B -- yes --> C{Does that language's own<br/>parser accept the file?}
    C -- no --> X2[/Refused: CorruptedSourceError.<br/>Nothing is written/]
    C -- yes --> D{For each declaration the parser found:<br/>a named function or method, with a body?}
    D -- "no — a lambda, a short arrow function,<br/>a Go func value, an abstract declaration" --> S1[Skipped, and silent.<br/>Its calls never appear anywhere]
    D -- yes --> E{Would the instrumented<br/>form still compile?}
    E -- "no — C# yield, ref return,<br/>pointer, ref struct" --> S2[Skipped, and the answer<br/>says which and why]
    E -- yes --> F[Recording spliced in.<br/>Line numbers unchanged]
    F --> G[[Every call now writes two lines:<br/>one on entry, one on exit]]
    G --> H{Did the call come back?}
    H -- "no — hang, crash, hard exit" --> I[The entry line stands alone.<br/>trace lists it under in_flight]
    H -- yes --> J[The two lines join on the call id]

Three of those branches deserve saying out loud, because they produce a trace that looks complete and is not:

Eight languages, one shape of record

The point of supporting eight languages is that the record is the same in all eight, so one reader answers questions across a system built out of several.

languagefile kindshow the recording is put inwhat has to be on the machine
Python.pya decorator above the functionnothing beyond Python
JavaScript / TypeScript.js .mjs .cjs .jsx .ts .tsxtry/finally inside the bodynode
C.c .h__attribute__((cleanup))gcc or clang
C++.cpp .cc .cxx .hpp .hh .hxxa scope guard (RAII)g++ or clang++
Elixir.ex .exsuse Ouroboros.Trace, redefining defelixir
Go.gonamed returns and defergo, for wrapping as well as building
Java.javatry/catch/finally inside the bodya JDK, for wrapping as well
C#.cstry/catch/finally inside the bodythe .NET SDK, for wrapping as well

Here is the same call, add(2, 3), recorded on the two ends of that list. All eight are on the examples page, taken in one run:

Python
{"p":"in","t":"2026-09-08T15:50:11.186","id":"4b394991-8721-4cdb-bcaf-fbac0fdc4f92","ci":-1,"th":"4102502.133342617835392","fn":"add","a":"2, 3","k":""}
{"p":"out","id":"4b394991-8721-4cdb-bcaf-fbac0fdc4f92","fn":"add","r":"5","d":1e-06}
C#
{"p":"in","t":"2026-09-08T15:50:17.879","id":"9402c372-d8ab-4425-b9b9-b62561237971","ci":-1,"th":"4105161.1","fn":"Prog.add","a":"2, 3","k":""}
{"p":"out","id":"9402c372-d8ab-4425-b9b9-b62561237971","fn":"Prog.add","r":"5","d":0.000254}

Same keys, same order, same meaning. What differs is deliberate: C# writes the class into the name (Prog.add), Python does not; each language renders values and durations its own way. The record schema is fixed; the dialect is each language's own.

flang is not supported, in case you came from there: no extension maps to a backend, and the tool does not mention it anywhere. There is an idea for it, and an idea is not a feature.

What it costs

Two lines per call, written to one file. That is cheap per call and not free in bulk, so here are both halves, measured on this machine today by scripts/measure/run.sh — the same program, twenty thousand calls, run seven times with and seven times without the recording.

languageplaininstrumentedaddedadded per call
Python0.0201 s1.1350 s1.1148 s55.7 µs
JavaScript0.0380 s0.4209 s0.3829 s19.1 µs
C0.0017 s0.3242 s0.3225 s16.1 µs
C, short form (--minimal)0.0017 s0.1205 s0.1189 s5.9 µs
C++0.0035 s0.3744 s0.3710 s18.5 µs
Elixir0.8245 s4.7692 s3.9447 s197.2 µs
Go0.0229 s0.6136 s0.5908 s29.5 µs
Go, without the goroutine id0.0229 s0.5631 s0.5402 s27.0 µs
Java0.0367 s0.4730 s0.4363 s21.8 µs
C#0.0441 s0.3493 s0.3052 s15.3 µs

The number that means something is the last column. The ratio "how many times slower" does not: the program being measured does nothing except call a function, so the ratio mostly measures how fast an empty loop is in that language. Seven of the eight land between 15.3 and 55.7 microseconds of extra time per call, and Elixir is the outlier at 197.2.

Most of that is not the language. It is opening a file, appending a line and closing it, twice per call — which is why C, C++, JavaScript and Java land within a few microseconds of each other despite being nothing alike.

languagecallslinesbytesbytes per call
Python20 00240 0045 079 125253.9
JavaScript20 00240 0044 751 521237.6
C20 00240 0045 008 030250.4
C, short form (--minimal)20 00220 002760 07838.0
C++20 00340 0065 408 344270.4
Elixir20 00240 0045 108 054255.4
Go20 00240 0044 888 013244.4
Go, without the goroutine id20 00240 0044 888 013244.4
Java20 00340 0065 048 314252.4
C#20 00340 0065 048 313252.4

Between 237.6 and 270.4 bytes per call, with two short arguments. In round numbers: a million calls is about a quarter of a gigabyte. Wrap a hot loop and you will notice; wrap the twenty functions you actually have a question about, and you will not.

The one dial is C's --minimal form: one line instead of two, no call frame, 5.9 microseconds and 38 bytes per call. It exists for kernel builds, and it gives up the completion line — so you lose return values, durations and exceptions.

The full machine those numbers came from is on the limits page.

Does it help a model read code?

Measured, and the measure was declared before the first run. Twelve programs in six languages, written the way other people's code looks — branching depends on the data, some functions never run, somewhere an exception appears. Five questions each about what happened in one particular run, every question asked twice: once with the source, the command and the output alone, once with the trace of that same run added. The control group gets everything needed to work the answer out unaided, or the comparison would be rigged; a separate grader sees only the question number and the answer.

who answeredanswerscorrect without the tracewith the tracedifference
qwen3.5:4b60044.0%78.3%+34.3
qwen2.5:14b-instruct60061.0%84.7%+23.7
qwen3:32b60066.7%90.3%+23.6
a Claude Opus 5 subagent12095.0%98.3%+3.3

The weaker the reader, the more the trace buys. On the strong one the interval starts at zero — no gain shown. The honest reading is not "an agent does not need this": twelve fifty-line programs are simply too easy for it, and it answers from the source.

Two things matter more than the averages. The gain is uneven — questions about values that would have to be computed in the head go from 46% to 87%, while "was this function ever called" is 100% either way: the trace does not make a reader smarter, it removes the need to calculate. And on the smallest model the trace removed refusals rather than errors — "don't know" fell from 29.4% to 4.0% while confidently wrong answers stayed where they were.

The setup, the per-question breakdown, what happens when the trace no longer fits in the request, and what this experiment does not show — in docs/measurements.md. Re-take it with one command: scripts/measure/trace-help/run.sh.

Before you decide to use it

The honest version of this list is a whole page — limits — and it is the page worth reading before the others. The short form:

Where to go next