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:

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_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 (Repo.insert!) appears as a single node with its arguments and return; the repo's internal Ecto plumbing is hidden. Turn this off with auto_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 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).

How It Differs from dbg/2

dbg/2CodeStory
ScopeSingle expression or pipelineSpan of execution between tell/stop
What it showsEvery intermediate value in a pipelineOnly user-defined function calls (filters out stdlib/deps)
IdentityShows code expressionsShows function names with named arguments
PurposeDebug a specific valueHear the story — understand call flow
OutputPer-expression, inlineBuffered, 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

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. You may not use this project except in compliance with the License.