Packages
A code comprehension tool that traces user-defined function calls as a nested call tree with named arguments and return values.
Current section
Files
Jump to
Current section
Files
code_story
README.md
README.md
# CodeStory
Every codebase has a story. CodeStory lets you read it. Drop `CodeStory.tell()` into a function and see the narrative unfold: which functions are called, with what arguments (by name and value), and what they return — rendered as a nested call tree.
**Use cases:**
- Joining a new codebase and understanding how it actually works
- Tracing call flow before refactoring
- Spotting redundant or unexpected function calls
- Exposing your code's vocabulary and catching ubiquitous language mismatches
- Debugging by seeing exactly where data goes wrong
## Requirements
- **Elixir 1.15+**
- **OTP 27+** — CodeStory uses the session-based `:trace` module introduced in
OTP 27. On older OTP releases, starting a trace will fail.
## Installation
Add `code_story` to your dependencies in `mix.exs`:
```elixir
defp deps do
[{:code_story, "~> 0.2.0", only: :dev}]
end
```
Install it as `only: :dev` so that any `CodeStory.tell()` calls you forget to
remove fail to compile in production rather than shipping.
To track the development version instead, point at the repository:
```elixir
defp deps do
[{:code_story, github: "angeleah/code_story", only: :dev}]
end
```
Or if you've cloned it locally, point to the path on disk:
```elixir
defp deps do
[{:code_story, path: "../code_story", only: :dev}]
end
```
Then fetch the dependency:
```bash
mix deps.get
```
No `require`, no `use`, no macros. Just `CodeStory.tell()` and `CodeStory.stop()`.
## Usage
The quickest way is to **wrap the call you want to understand**. The trace prints,
tracing cleans up on its own (no `stop()`), and your result flows through unchanged:
```elixir
invoice = CodeStory.tell(fn -> process_order(params) end)
```
This is ideal for unfamiliar code: you know the entry point even when you don't
know where the flow ends. Wrapping is always safe — if tracing can't run for any
reason, your function still runs and returns normally.
Prefer to bracket a region by hand — a LiveView handler, or a span across several
statements? Use the manual pair: `CodeStory.tell()` before, `CodeStory.stop()`
after (`stop()` is what prints):
```elixir
def handle_request(params) do
CodeStory.tell()
result = process_order(params)
CodeStory.stop()
result
end
```
Need the tree as data instead of a printed trace? See `CodeStory.narrate/2`.
This outputs a nested call tree to the terminal:
```
--- CodeStory Trace ---
MyApp.Orders.process_order(params: %{customer_id: 7, items: [...]})
MyApp.Orders.validate_item(item: %{sku: "A1", qty: 2}) => :ok ×3 (varies)
MyApp.Orders.calculate_total(items: [...]) => 29.97
MyApp.Repo.insert!(changeset: #Ecto.Changeset<...>) => %MyApp.Invoice{id: 42}
=> MyApp.Orders.process_order returned %MyApp.Invoice{id: 42}
--- End Trace ---
```
Each call renders as a compact **inline signature** — `Mod.fun(name: value, …) => return`
on one line — so the trace reads as one dense indented tree, with indentation showing call
depth. A call whose assembled line would exceed the `:width` budget (default `100`) falls
back to a **stacked layout** instead: the function name, one argument per line, then the
return. So a trace naturally mixes inline (small calls) and stacked (struct-heavy ones).
A few things happen by default to keep the story readable:
- **Repeated calls fold.** The three `validate_item` calls collapse into one node marked `×3` — or `×3 (varies)` when the calls share a function but differ in their arguments (a single representative call is shown). Turn this off with `fold_repeats: false`.
- **Infrastructure stays at the boundary.** A call into your Ecto repo appears as a single node rendered with its **known Ecto parameter names** — `Repo.insert!(changeset: …)`, `Repo.get!(queryable, id)`, `Repo.aggregate(queryable, aggregate)` — while the repo's internal Ecto plumbing is hidden. Turn this off with `auto_boundary: false`.
- **Ecto values read cleanly.** A schema struct drops its `__meta__`/`NotLoaded` bookkeeping (so it reads `%Order{id: 12, status: "paid", …}`), and an `Ecto.Query` argument shows as `#Ecto.Query<Schema>` rather than the full query dump. No Ecto dependency — detection is purely string-shaped.
Only your project's own functions appear in the trace. Standard library calls, dependency code, framework-generated functions (like `__struct__/0`, `__changeset__/0`), and CodeStory itself are filtered out automatically.
## Options
All options are passed to `CodeStory.tell/1`:
```elixir
CodeStory.tell(detail: :outline)
CodeStory.tell(detail: :novel, output: :file)
CodeStory.tell(show_args: false, output: :both)
```
- **`detail`** — how much of the story to tell. Default: `:short_story`.
- `:outline` — function names and argument names only. No values, no returns. Great for seeing the shape of a call flow, spotting boundary crossings, and finding redundant calls.
- `:short_story` — names, truncated values, and returns. The default — enough detail to follow the plot without getting lost in the data.
- `:novel` — names with complete, untruncated values and returns. Every detail, nothing elided. Use when you need to see the full picture.
- **`show_args`** — show argument names alongside values. Default: `true`.
Set to `false` to show values only.
- **`output`** — where to write the trace. Default: `:terminal`.
`:file` writes to `code_story_trace.log` in your project root (ANSI codes stripped).
`:both` writes to terminal and file.
> **Add `code_story_trace.log` to your `.gitignore` before using `:file`.**
> A trace records real argument and return values, so tracing code that
> handles passwords, API keys, tokens, or personal data writes those values
> to the log in plaintext. Treat the file as sensitive and delete it when
> you're done reading it.
- **`auto_boundary`** — treat Ecto repos as _boundary modules_: a repo call
(e.g. `Repo.get!`) shows as a single node with its arguments and return, but the
repo's internal Ecto plumbing is hidden. Default: `true`. Set to `false` to
trace repo internals.
- **`fold_repeats`** — collapse consecutive sibling calls to the same function
into one node marked `×N` (or `×N (varies)` when the calls share a function but
differ). Default: `true`. Set to `false` to show every call.
- **`depth`** — cap how many levels the trace nests. A positive integer
(`depth: 1` shows the entry call only; `depth: 2` adds its direct children, and
so on); below the cap a node's interior is replaced by a `… (N more levels)`
marker. Default: `:infinity` (no limit).
- **`width`** — the line-width budget for the compact inline signature
`Mod.fun(name: value, …) => return`. A call whose assembled line fits within
`width` renders inline; a longer one falls back to the stacked layout (name,
one argument per line, return). Default: `100`. Raise it for a wide terminal,
lower it for a strict slide, or pass `:infinity` to force every call inline.
## How It Differs from `dbg/2`
| | `dbg/2` | CodeStory |
| ----------------- | -------------------------------------- | ---------------------------------------------------------- |
| **Scope** | Single expression or pipeline | Span of execution between tell/stop |
| **What it shows** | Every intermediate value in a pipeline | Only user-defined function calls (filters out stdlib/deps) |
| **Identity** | Shows code expressions | Shows function names with named arguments |
| **Purpose** | Debug a specific value | Hear the story — understand call flow |
| **Output** | Per-expression, inline | Buffered, dumped as one cohesive block |
## How It Works
1. **Module detection** — reads your `mix.exs` app name and finds all your project's modules
2. **Argument name extraction** — reads Elixir debug info from BEAM files to recover original parameter names, scanning across all function clauses to find the best names
3. **Erlang tracing** — sets up trace sessions on the calling process for your modules
4. **Tree building** — a collector process receives trace events and builds a nested call tree
5. **Formatted output** — the tree is rendered with indentation and ANSI colors, then dumped as one block
## Color Scheme
Terminal output uses ANSI colors for readability:
- **Header/footer** (`--- CodeStory Trace ---`): cyan
- **Function names**: blue
- **Argument names**: yellow
- **Argument values**: default terminal color
- **Return values**: green
- **Fold marker** (`×N`): magenta
The semantic colors render **bold**, so the trace stays legible on both light and dark
terminals (and projectors). Meaning never rests on color alone — structure, `=>`, `name:`,
`×N`, and indentation carry it — so bold is purely a readability bump, not a dependency.
## Limitations (v1)
- **Single process only** — traces the calling process. Calls in spawned Tasks, GenServers, etc. are not captured.
- **Modules detected at tell time** — hot-reloaded modules mid-trace won't be traced.
- **Dev only** — installed with `only: :dev`, so leftover `CodeStory.tell()` calls fail to compile in prod.
- **One trace per process** — calling `tell()` while a trace is already active warns and returns an error.
## License
Copyright 2026 Angeleah Daidone
Licensed under the [Apache License, Version 2.0](LICENSE). You may not use this
project except in compliance with the License.