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
:tracemodule introduced in OTP 27. On older OTP releases, starting a trace will fail.
Installation
Add code_story to your dependencies in mix.exs:
defp deps do
[{:code_story, "~> 0.1.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:
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:
defp deps do
[{:code_story, path: "../code_story", only: :dev}]
end
Then fetch the dependency:
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:
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):
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 ---
process_order
params: %{items: [...], customer_id: 7}
validate_item ×3 (varies)
item: %{sku: "A1", qty: 2}
=> :ok
calculate_total
items: [...]
=> 29.97
Repo.insert!
changeset: #Ecto.Changeset<...>
=> %Invoice{id: 42}
=> process_order returned %Invoice{id: 42}
--- End Trace ---
Each function name appears on its own line, with arguments and return values indented below it. Functions with children are visually separated by blank lines.
A few things happen by default to keep the story readable:
- Repeated calls fold. The three
validate_itemcalls 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 withfold_repeats: false. - Infrastructure stays at the boundary. A call into your Ecto repo (
Repo.insert!) appears as a single node with its arguments and return; the repo's internal Ecto plumbing is hidden. Turn this off withauto_boundary: false.
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:
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 tofalseto show values only.output— where to write the trace. Default::terminal.:filewrites tocode_story_trace.login your project root (ANSI codes stripped).:bothwrites to terminal and file.Add
code_story_trace.logto your.gitignorebefore 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 tofalseto 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 tofalseto show every call.depth— cap how many levels the trace nests. A positive integer (depth: 1shows the entry call only;depth: 2adds 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).
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
- Module detection — reads your
mix.exsapp name and finds all your project's modules - 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
- Erlang tracing — sets up trace sessions on the calling process for your modules
- Tree building — a collector process receives trace events and builds a nested call tree
- 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
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 leftoverCodeStory.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. You may not use this project except in compliance with the License.