Lightweight profiler that aggregates :telemetry span events
into a structured trace.
profile/1 runs a function while capturing every exgit
telemetry event emitted by or beneath the call. The return
value is {result, profile} where profile is a structured
breakdown: per-event totals, call counts, peak memory (when
available), and the ordered list of events for detailed
drill-down.
Designed for three audiences:
Agent developers running
Exgit.Profiler.profile(fn -> my_agent_step() end)to see where time goes in ONE call. No telemetry-handler plumbing required — attach, run, detach happens automatically.Operational monitoring where you want per-request breakdowns matched to the operation that triggered them. Attach once via
attach/1, read back viaread/1, detach viadetach/1.Test suites asserting invariants like "this operation triggered at most N transport.fetch calls" or "cache bytes never exceeded 64 MiB during this workload."
The profiler attaches to the standard [:exgit, *] event tree,
so anything the library emits is captured. Zero cost when no
profile is active.
Examples
# One-shot profile of an agent-style session.
{results, profile} =
Exgit.Profiler.profile(fn ->
{:ok, repo} = Exgit.clone(url, lazy: true)
{:ok, repo} = Exgit.FS.prefetch(repo, "HEAD", blobs: true)
Exgit.FS.grep(repo, "HEAD", "anthropic") |> Enum.to_list()
end)
profile.totals
# => %{
# "transport.fetch" => %{count: 2, us: 850_000},
# "pack.parse" => %{count: 2, us: 95_000},
# "fs.grep" => %{count: 1, us: 11_000},
# "object_store.get" => %{count: 275, us: 25_000},
# ...
# }
profile.peak_cache_bytes
# => 3_506_422
profile.events
# full event stream, ordered by start time, for drill-downAttaching manually
profile/1 is a convenience that handles attach/detach for one
call. For server processes that want to accumulate a profile
across many calls, use attach/1 + read/1 + detach/1:
{:ok, handle} = Exgit.Profiler.attach()
# ... do work ...
profile = Exgit.Profiler.read(handle)
Exgit.Profiler.detach(handle)Thread-safe: the profiler uses an ETS table and atomic counters, so concurrent calls from multiple processes accumulate into the same profile without locking.
Summary
Types
@type event_record() :: %{ event: String.t(), duration_us: non_neg_integer(), metadata: map(), started_at: integer() }
@type t() :: %{ events: [event_record()], totals: totals(), peak_cache_bytes: non_neg_integer() | :unknown, total_us: non_neg_integer() }
@type totals() :: %{ required(String.t()) => %{count: non_neg_integer(), us: non_neg_integer()} }
Functions
@spec attach() :: {:ok, handle()}
Attach a profiler to the [:exgit, *] event tree and return
a handle for later read/1 / detach/1 calls.
Returns {:ok, handle} on success. The handle is a plain map
carrying the internal ETS table; treat it as opaque.
@spec detach(handle()) :: :ok
Detach the profiler and free its ETS table.
@spec profile((-> result)) :: {result, t()} when result: var
Run fun with profiling enabled. Returns {result, profile}
where result is fun's return value and profile is a
t/0 map.
Attaches + detaches automatically; profiling is scoped to the lifetime of the call.
Read the profile accumulated so far on handle.
Only events fired from the calling process are included. This matters when multiple profilers (or async tests) are attached concurrently: each profiler sees only its own caller's events.
Caveat: child processes
Events fired in processes spawned by fun (e.g.
Task.async_stream workers from parallel FS.grep) will
NOT be captured — they fire in worker pids, not the caller.
For profiling parallel workloads, use attach/0 + read from
each worker, or emit your own spanning event at the parent
level.