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/ouroborosInstalled 2 executables: ouroboros, ouroboros-mcpThere 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.
"""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 kettleTotal: 51.80Same 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:
{"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:
{
"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 call | it was given | it answered |
|---|---|---|
subtotal | ['tea', 'mug', 'kettle'] | 52.0 |
discount | 52.0 | 5.2 |
delivery | 46.8 | 5.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
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 answeredThe 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
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
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:
- A lambda in Python, an arrow function with a short body in JavaScript, a
funcvalue in Go — none of them are wrapped. Their calls never appear. - A branch you did not run leaves nothing behind, and nothing says so. An empty trace for a function means "not called on this run", never "fine".
- Five kinds of C# member —
yield,refreturns, pointers,ref struct, expression-bodied properties — are skipped, because the instrumented form would not compile. That one is not silent: the answer names them.
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.
| language | file kinds | how the recording is put in | what has to be on the machine |
|---|---|---|---|
| Python | .py | a decorator above the function | nothing beyond Python |
| JavaScript / TypeScript | .js .mjs .cjs .jsx .ts .tsx | try/finally inside the body | node |
| C | .c .h | __attribute__((cleanup)) | gcc or clang |
| C++ | .cpp .cc .cxx .hpp .hh .hxx | a scope guard (RAII) | g++ or clang++ |
| Elixir | .ex .exs | use Ouroboros.Trace, redefining def | elixir |
| Go | .go | named returns and defer | go, for wrapping as well as building |
| Java | .java | try/catch/finally inside the body | a JDK, for wrapping as well |
| C# | .cs | try/catch/finally inside the body | the .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:
{"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}{"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.
| language | plain | instrumented | added | added per call |
|---|---|---|---|---|
| Python | 0.0201 s | 1.1350 s | 1.1148 s | 55.7 µs |
| JavaScript | 0.0380 s | 0.4209 s | 0.3829 s | 19.1 µs |
| C | 0.0017 s | 0.3242 s | 0.3225 s | 16.1 µs |
| C, short form (--minimal) | 0.0017 s | 0.1205 s | 0.1189 s | 5.9 µs |
| C++ | 0.0035 s | 0.3744 s | 0.3710 s | 18.5 µs |
| Elixir | 0.8245 s | 4.7692 s | 3.9447 s | 197.2 µs |
| Go | 0.0229 s | 0.6136 s | 0.5908 s | 29.5 µs |
| Go, without the goroutine id | 0.0229 s | 0.5631 s | 0.5402 s | 27.0 µs |
| Java | 0.0367 s | 0.4730 s | 0.4363 s | 21.8 µs |
| C# | 0.0441 s | 0.3493 s | 0.3052 s | 15.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.
| language | calls | lines | bytes | bytes per call |
|---|---|---|---|---|
| Python | 20 002 | 40 004 | 5 079 125 | 253.9 |
| JavaScript | 20 002 | 40 004 | 4 751 521 | 237.6 |
| C | 20 002 | 40 004 | 5 008 030 | 250.4 |
| C, short form (--minimal) | 20 002 | 20 002 | 760 078 | 38.0 |
| C++ | 20 003 | 40 006 | 5 408 344 | 270.4 |
| Elixir | 20 002 | 40 004 | 5 108 054 | 255.4 |
| Go | 20 002 | 40 004 | 4 888 013 | 244.4 |
| Go, without the goroutine id | 20 002 | 40 004 | 4 888 013 | 244.4 |
| Java | 20 003 | 40 006 | 5 048 314 | 252.4 |
| C# | 20 003 | 40 006 | 5 048 313 | 252.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 answered | answers | correct without the trace | with the trace | difference |
|---|---|---|---|---|
qwen3.5:4b | 600 | 44.0% | 78.3% | +34.3 |
qwen2.5:14b-instruct | 600 | 61.0% | 84.7% | +23.7 |
qwen3:32b | 600 | 66.7% | 90.3% | +23.6 |
| a Claude Opus 5 subagent | 120 | 95.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:
- It records what happened, not what should have happened. A bug that has worked for years looks exactly like correct behaviour.
- What did not run leaves nothing, and nothing warns you. Absence of records reads like "all quiet here"; it means "nobody went here".
- There is no undo command. Instrumented code stays instrumented until you revert it yourself, from version control or by hand.
- Argument names are not recorded, in any language — values only. The function's own signature is still in your source, but a tool reading the trace alone cannot recover them.
- Wrapping edits your source, so it can change behaviour. Four cases where it does are documented rather than hidden: an uncaught Go panic prints a different message, C++ does not record returned class objects, C and C++ print string pointers as addresses rather than contents, and rendering a value calls your own
__repr__/toString. - Run your own tests after wrapping, not just before. If they are green on the instrumented copy, the edit was harmless for your program. That is the check that actually settles it.
Where to go next
- Examples — the instrumented file in full, the same call on all eight languages, what every field means, and a run that crashes.
- Limits — everything the tool cannot do, everywhere it changes behaviour, and when not to use it at all.
- The repository — source, 1 257 tests, 100% statement and branch coverage over 5 583 units.
- SPEC.md — the record schema all eight backends are held to, and why each decision was made.