Guide

Getting started

Install, adopt a project, collect a trace, read the report — without editing a single line of the project's source.

Requirements

Install

$ uv tool install callsight             # core CLI
$ uv tool install 'callsight[ui]'       # + the web UI
$ uv tool install 'callsight[stream]'   # + the remote streaming server

Both extras can be combined: 'callsight[ui,stream]'. From a source checkout, uv tool install . does the same.

Adopt a project

callsight init copies the hook runtime and the build wiring into a callsight/ directory inside your project, writes a starter trace.config (an existing one is never overwritten), and prints the exact snippet to paste into your build file.

$ cd /path/to/your/project
$ callsight init .
wrote /path/to/your/project/trace.config
copied runtime + make integration into /path/to/your/project/callsight/ (35 sources found)

The build system is auto-detected — CMakeLists.txt means CMake, otherwise Make. Force it with --build make or --build cmake, and add --stream to also copy the on-device streaming client.

Wire it into your build

After your SRCS/OBJS/CFLAGS_SYMBOLS are defined:

CALLSIGHT_DIR ?= callsight
include $(CALLSIGHT_DIR)/Makefile.callsight

instrument: CFLAGS = $(CFLAGS_INSTRUMENT)
instrument: $(BINDIR)/$(TARGET).instr
$(BINDIR)/$(TARGET).instr: $(OBJS) $(TRACE_OBJ) | $(BINDIR)
	$(CC) $(CFLAGS_INSTRUMENT) -o $@ $(OBJS) $(TRACE_OBJ) $(LDFLAGS)

The fragment expects SRCS, BUILDDIR, BINDIR, TARGET, CC, CFLAGS_SYMBOLS and LDFLAGS, and defines CFLAGS_INSTRUMENT (your flags + hooks + exclude lists) and TRACE_OBJ (the runtime object, compiled without instrumentation).

After the target is defined:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/callsight")
include(CallSight)
callsight_instrument(<your-target>)

Normal builds are completely untouched — instrumentation is opt-in per configure:

$ cmake -DCALLSIGHT_INSTRUMENT=ON -B build-instr
$ cmake --build build-instr

Full details for both, including every overridable variable, are on the reference page.

Link the way you ship. Earlier versions required -no-pie so that recorded addresses matched link addresses. The runtime now records the load bias in every trace header and the analyzer subtracts it, so position-independent executables work unchanged — which matters, because forcing -no-pie meant profiling a binary built differently from the real one.

Build the instrumented profile

$ make clean && make instrument       # clean when switching profiles

The instrumented binary lives beside your normal one (bin/yourapp.instr by convention with the Make integration), so a normal make still produces an uninstrumented build with zero hooks in it.

Run it

The hooks are compiled in but inert until you ask for a trace, so an instrumented binary is safe to run normally. One command runs it with tracing on and reports on what it recorded:

$ callsight run --timeout 10 --top 20 -- ./bin/yourapp.instr

run clears trace files from any earlier run first — reporting on a stale traces/ directory silently mixes two runs into one set of numbers, which is the easiest way to draw a wrong conclusion here.

Or drive it by hand, which is what run does for you:

$ TRACE_ENABLE=1 ./bin/yourapp.instr
$ callsight analyze traces/ --exe ./bin/yourapp.instr --top 20

Each thread writes traces/trace.<pid>.<tid>.<seq>.bin. A clean exit flushes everything; a killed process loses each thread's buffered tail.

Read the report

events=850059 threads=24 functions=84 span=6.6ms unmatched_exits=0 unclosed_enters=127

== TOP BY SELF TIME ==
     calls      incl_ms      self_ms       p50       p99       max  function (first location)
        12       13.170       13.170  983.04us    1.97ms    1.97ms  timer_sleep_us (src/utils/timer.c:38)
     16111       11.098        6.518      71ns    3.84us    1.59ms  qs_partition (src/sort/quicksort.c:23)
    358383        4.738        4.738       8ns      14ns  352.74us  qs_swap (src/sort/quicksort.c:5)

unmatched_exits=0 means every exit was matched to an enter — a clean trace. Every call is timed, so p50, p99 and max are measurements rather than estimates: qs_swap above normally finishes in 8 ns, and once took 352 µs. Read the columns, export a flame graph, and interpret the summary line on the analysis page.

If something looks wrong

callsight doctor checks the pieces this depends on — the compiler and whether it is GCC, addr2line, that your config selects something, and that the trace directory is writable with room to spare:

$ callsight doctor
[ok  ] compiler: cc is GCC — selective instrumentation available
[ok  ] addr2line: /usr/bin/addr2line
[ok  ] trace.config: 35 sources, 34 instrumented, 1 excluded
[ok  ] free space: 28187 MB available
[ok  ] -finstrument-functions: accepted by the compiler

Narrow the selection

Your first wide run is a survey. Sort it by calls, find the chatty leaf helpers, and exclude them — they usually account for most of the volume and none of the insight.

# trace.config
exclude src/utils/rng.c        # RNG helpers: pure noise
exclude-func crc32_update     # one hot function, by name

Then rebuild. Or go the other way and name a single entry point — callsight resolves its whole call subtree from the sources and instruments exactly that:

$ callsight select src/ --function handle_request
$ callsight scan . --config trace.config     # preview without building

The whole selection model is on the configuration page.

Try it without a project

The repository ships tests/matrixlab, a multi-threaded C11 workload used as the end-to-end fixture — the traces in these docs come from it.

$ git clone https://github.com/harshithsunku/callsight && cd callsight/tests/matrixlab
$ make clean && make instrument
$ TRACE_ENABLE=1 TRACE_MAX=1000000 timeout 5 ./bin/matrixlab.instr
$ uv run callsight analyze traces/ --exe bin/matrixlab.instr --top 20