Exgit.Profiler (exgit v0.1.0)

Copy Markdown View Source

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 via read/1, detach via detach/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-down

Attaching 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

Functions

Attach a profiler to the [:exgit, *] event tree and return a handle for later read/1 / detach/1 calls.

Detach the profiler and free its ETS table.

Run fun with profiling enabled. Returns {result, profile} where result is fun's return value and profile is a t/0 map.

Read the profile accumulated so far on handle.

Types

event_record()

@type event_record() :: %{
  event: String.t(),
  duration_us: non_neg_integer(),
  metadata: map(),
  started_at: integer()
}

handle()

@type handle() :: %{id: String.t(), table: :ets.tid()}

t()

@type t() :: %{
  events: [event_record()],
  totals: totals(),
  peak_cache_bytes: non_neg_integer() | :unknown,
  total_us: non_neg_integer()
}

totals()

@type totals() :: %{
  required(String.t()) => %{count: non_neg_integer(), us: non_neg_integer()}
}

Functions

attach()

@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.

detach(map)

@spec detach(handle()) :: :ok

Detach the profiler and free its ETS table.

profile(fun)

@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(map)

@spec read(handle()) :: t()

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.