My Honest Review of execsnoop

Necco Ceresani
Necco Ceresani··24 min read

Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.

The verdict. execsnoop is a good tool that hands you a firehose. It traces every execve() on the host and prints one line each, which is exactly right when the next thing to read that output is grep, and unreadable when the next thing to read it is you. A single npm install emits several hundred lines, a busy host emits thousands, and none of it is scoped to the thing you actually care about. Two further limits show up once you are relying on it: it watches execve() only, so its own documentation says a process that forks without exec never appears, and it caps arguments at 20 by default. I built exectop because of that gap: same kernel events, folded into one row per kind of command, with the handful that do not fit ranked on top.

I build eBPF tools for a living, and execsnoop is the one I have reached for most over the years. I am reviewing it here as someone who ended up writing an alternative, which is a bias worth declaring up front: I hit a wall with it often enough to spend months on the other side of that wall. What I do not have is a decade of running someone else's production fleet, so read this as an argument about output shape rather than a claim about your environment. The wall is always the same one. Watching a build, I would get the answer I asked for and be unable to see it, because four hundred correct lines had scrolled past and the one that mattered went with them.

Why does execsnoop miss processes that my application clearly started?

Because execsnoop traces the execve() system call, and not every new process calls it. The bcc documentation is explicit: applications that fork without exec "won't be included in the execsnoop output." A process that forks a copy of itself and keeps running the same program image is a real process doing real work, and it never appears in the stream, because no new program was ever loaded.

This is not a bug, it is the definition of what the tool traces, and for most questions it is the right definition. You usually want to know which programs ran, and execve() is exactly where a program starts running. The gap opens when you are trying to follow a tree rather than list programs, because membership in that tree propagates through fork, which is the event execsnoop is not watching.

exectop traces three tracepoints instead of one, and the second exists specifically to close this gap:

TracepointWhat it does
sched_process_execOne record per exec: pid, ppid, comm, argv, depth, and the fork-to-exec time
sched_process_forkAdds the child to the traced set, one generation deeper
sched_process_exitRemoves the task from the traced set

The fork hook does not print anything. Its whole job is bookkeeping: when a traced process forks, the child is added to a kernel hash map of traced process groups, so anything that child later execs is in scope without anyone having to predict it. A grandchild three levels down is captured because its parent was captured, not because it matched a name filter.

How do I limit execsnoop to one container or pid instead of the whole host?

Use --cgroupmap for a container and -P PPID for a process, both documented bcc flags. The man page describes -P PPID as "Trace this parent PID only", and --cgroupmap MAPPATH and --mntnsmap MAPPATH as tracing "cgroups in this BPF map only" and "mount namespaces in this BPF map only", both filtered in-kernel. All three scope the stream without you having to filter it afterward.

The cgroup and mount-namespace filters run in-kernel, which is the efficient place to filter, and they are the right mechanism for a container. The cost is that you populate the BPF map yourself before the tool is useful for a given target.

-P is the one to be careful with, and the man page's "Trace this parent PID only" undersells the problem. It matches one generation. Run make under execsnoop -P <make's pid> and you see the recipe shells that make forked and nothing they went on to run, so cc1 and as fall outside the filter entirely as grandchildren of the shell. The failure is silent: no error, no warning, just a plausible-looking list with the thing you were hunting absent from it.

exectop takes the target as an argument and resolves it for you, which is a convenience difference rather than a capability one:

./bin/exectop -- npm ci        # launch a command and watch its whole tree
yeet run . -- --container api   # everything a running container starts
yeet run . -- --pid 4242        # a process and its descendants
yeet run .                      # whole host, unscoped

Launch mode has a property the attach modes cannot have, and it is worth understanding before you pick one. The launcher starts your command stopped, hands the pid to the probe, waits for the tracepoints to attach, and only then lets the process go. Nothing runs before the probe is watching. When you attach to something already running with --pid or --container, membership propagates at fork, so a child that forked before you attached is outside the traced set until it forks again. If you can launch the thing yourself, launch it.

My npm install printed hundreds of exec lines. How do I make sense of them?

Group them by kind of command, so repetition becomes a count instead of scrollback. A line-per-event stream gives every event the same visual weight, and a build does not distribute its interest evenly: in a real sample of a small C build, 425 execs landed in 42 seconds. Two commands accounted for half of them: echo ran 124 times and date ran 90. Folded by kind of command, the whole run reads as a handful of rows.

  command                              count   share          fork→exec  parent
▸ echo ⟨1 arg⟩                          ×124  ██······  29.2%     149µs  bash
▸ date ⟨1 arg⟩                           ×90  █·······  21.2%     137µs  bash
▸ sleep ⟨1 arg⟩                          ×46  █·······  10.8%     145µs  bash
▸ as -EL -mabi=lp64 -o ⟨2 args⟩          ×27  ········   6.4%     128µs  gcc
▸ gcc -Wall -O2 -c -o ⟨2 args⟩           ×26  ········   6.1%     243µs  bash

The compression is what makes an outlier visible. At 425 lines, an unexpected curl is one line among 425 and you find it by knowing to search for it. At six rows, a seventh row that says curl is the thing your eye lands on first. This is the argument for a folded view in one sentence: you cannot grep for the command you did not know to suspect.

What makes folding work is the key it folds on. Two cc1 invocations compiling different files are the same kind of work and should be one row; the same compiler with a different optimization flag is not. So the key is the command plus its flag names, with positional paths dropped and repeated flags deduplicated. cc1 -quiet a.c and cc1 -quiet b.c merge, cc1 -O2 stays separate. Two carve-outs came from real data rather than from design: subcommands count as part of the identity, so git add and git gc do not merge, and sh -c folds on the first command inside the script rather than on the script text, because a generated Makefile emits a different script per target and folding on the text shatters one recipe into dozens of single-use rows.

Why does execsnoop cut my gcc command line off after 20 arguments?

Because 20 is the default value of its --max-args flag, and you can raise it. bcc's documentation states the limit plainly: "we are looking only into first 20 arguments of the command". That cap is a count of arguments rather than a count of bytes, which is why a gcc line with a dozen flags survives and a linker line naming three hundred object files does not. exectop caps differently, copying a fixed 1024-byte window of the argument blob and marking the record truncated when it does not fit.

The practical difference shows on long command lines. A normal gcc -Wall -O2 -c -o foo.o foo.c fits comfortably under both caps. A linker line naming three hundred object files blows through 20 arguments almost immediately and through a kilobyte of bytes somewhat later, and in both cases you lose the tail of the arguments while the program name, the flags and the timing stay correct. Neither approach is better in general. A byte window suits a folded view, because the fold key is built from the command and its flag names, which live at the front of the line.

The way exectop reads those bytes is worth one paragraph, because it is the part that surprised me. The obvious approach is to walk the userspace argv pointer array, and the verifier hates it: a bounded loop over indexed userspace pointers, each read fallible, is the shape it is built to reject. The kernel already stores the arguments contiguously at mm->arg_start..arg_end as a NUL-separated blob, so one bounded bpf_probe_read_user copies the lot and JavaScript splits it. No loop, no pointer chasing. That constraint is not specific to argv: the verifier pushes parsing into userspace in most BPF programs worth writing, which is part of why the toolkit you start with matters.

When should I pipe execsnoop into grep instead of reading a table?

Whenever the next thing that reads the output is a program rather than a person. This is where execsnoop wins outright, and the list is longer than people expect:

  • Piping into anything. grep, awk, sort | uniq -c, a file for later. A line-oriented stream is a Unix citizen and a full-screen terminal interface is not. exectop needs a real TTY and refuses to be redirected.
  • Watching a whole host with no target in mind. When you do not yet know which application is interesting, a host-wide stream is the correct shape, and scoping to a tree presupposes an answer you do not have.
  • Scripting and alerting. Feeding execs into a log pipeline, matching a pattern and firing on it. execsnoop is a stream you can build on.
  • Failed execs. execsnoop -x includes exec calls that failed, which is how you catch a build looking for a tool that is not installed. exectop traces sched_process_exec, which fires after the new program is installed, so a failed exec never reaches it at all.
  • Filtering by name or argument content. -n NAME and -l LINE take regexes and filter in-kernel. When you know what you are looking for, naming it is faster than reading a folded table.

That last point deserves a caveat that runs the other way. Filtering by process name catches unrelated processes that happen to share the name and misses the ones you did not predict, which is exactly the case where following a tree is worth its setup. The two approaches fail in opposite directions, and neither failure is hypothetical.

Does execsnoop drop events during a parallel make?

Both tools can, because a fork storm outruns the buffer that carries events to userspace. Delivery is bound by bytes moved rather than by event count, and each exectop record carries a kilobyte argument window: in a deliberate stress test, 96,000 execs fired across 24 workers produced 36,000 captured and 60,000 dropped, against a ceiling around 2,700 execs a second.

What matters more than the ceiling is that the drops are counted rather than silently lost. The verdict line reports 59,431 dropped and the headless report states that the numbers below it are a floor. A monitoring tool that quietly under-reports is worse than one that admits it, because a fold count you cannot trust is a fold count you have to re-derive by hand. Normal workloads are nowhere near this: a real npm install runs tens of execs per second, and a heavily parallel make -j24 on a big tree is the case that reaches the ceiling.

execsnoop has its own version of this limit through the perf buffer, and I am not going to put a number on it, because I have not measured it and bcc's documentation does not state one. If you are tracing a fork storm and the counts have to be exact, verify against a known event count on your own kernel rather than trusting either tool's defaults.

How do I spot the one unexpected curl in an npm install?

Let the tool rank it for you: it surfaces a command only when that command is both rare and did something a build step has no business doing. Rarity alone does not work, and the measurement that proves it is the reason the rule has two halves: against a real npm install, 11 of 34 distinct commands ran exactly once. A rule that flagged anything running once would flag a third of an ordinary build, which is the same as flagging nothing.

So a row is surfaced only when it ran three times or fewer and matched one of a named set of behaviors: fetching from the network, piping a download into a shell, evaluating constructed input, widening permissions, changing privileges, touching credential paths, or unpacking into /tmp. Every reason names something observed in the arguments rather than a guess about intent. A build that legitimately fetches six times stays silent, because six is that build's normal.

── doesn't fit ──────────────────────────────────────────────────────────────
  ▲ curl -fsS -o ⟨2 args⟩              ran once, fetches from the network
  ▲ base64 -d                          ran once, evaluates constructed input
  ▲ ls ⟨1 arg⟩                         ran once, touches credential paths
  ▲ chmod ⟨2 args⟩                     ran once, widens permissions

Be clear about what this is not. These are pattern matches on observed command lines, so anything that renames a binary, builds its argument string at runtime, or avoids launching a process at all will not be flagged. It is a way to see what happened, not a control that something can be prevented from evading. execsnoop makes no claim here at all, which is a defensible position: it prints what ran and leaves the judgment to you. For how this plays out across a whole build in CI rather than one install, capturing every command a build runs covers the volume problem in more depth than a review of one tool should.

Why does strace -f struggle where an eBPF tracer doesn't?

Because strace -f attaches to every child through ptrace and stops the process on each syscall it reports. Across a tree of hundreds of short-lived processes, that per-syscall stop dominates, and the thing you are measuring runs materially slower than it does unobserved. An eBPF tracepoint observes from inside the kernel with no stop, so the traced application runs at close to normal speed.

The trade is what you see. strace shows every syscall for one process, with arguments and return values, which is far more information than either exec tracer provides. When the question is "what did this one process do", strace is the better tool and it is not close. When the question is "what did this tree of processes launch", strace -f is paying an enormous cost to deliver an answer buried in output you then have to filter, and the exec tracers answer it directly.

Why does a package using fetch() show up as nothing at all?

Because fetch() never launches a program, and both tools only see programs being launched. This is the boundary that matters most, and it applies identically to execsnoop and exectop because it is a property of what exec means rather than of either implementation. A dependency that does its damage inside Node, Python, or the JVM produces no rows at all. Fetching a URL with fetch() looks like nothing; fetching it with curl is a row.

The consequence is worth stating in the strongest terms, because it is the way process tracing gets misread: a quiet screen is not evidence that nothing happened. It is evidence that nothing was launched. Those are very different claims, and only one of them is supported. Around the edges of that boundary sit other things neither tool covers: which files a process opened, what it sent over a socket, and what it read. Those need agent-lock for file access, container-traffic for HTTP, and pktscope for raw packets, and the reason they are separate tools is that each one needs a different hook.

If what you are auditing is an AI coding agent rather than a build, the process-launch view is one of several you want, and auditing what an agent actually ran covers that case directly rather than through a build-shaped lens.

Can I read a folded view without a terminal, in CI?

Yes, through a headless mode that aggregates for a fixed window and prints a text report. This matters because the interactive view is the half of exectop that will not survive a pipe, and a CI job has no TTY at all.

yeet run src/probes/capture.js -- 4242 30

That seeds the same traced set from pid 4242, aggregates for 30 seconds, then prints the totals, the bucket breakdown, the findings and the top folds as plain text before exiting. What that unlocks in a pipeline, including diffing two fold lists to find the step that ran locally and not in CI, is its own subject and out of scope for a review of execsnoop.

execsnoop needs nothing special for this, which is its structural advantage: it was always a stream, so redirecting it to a file in CI is the ordinary way to use it. If your CI need is "keep a log of every exec in this job", reach for execsnoop and a redirect and stop reading here.

The bottom line: keep execsnoop for pipes, reach for something folded when you have to read it yourself

execsnoop is not a tool with a flaw in it. It is a correct, fast, host-wide stream, and every limit in this review follows from that being the thing it set out to be. The question is what happens next to the output. Send it to grep, a file or a pattern match and the shape is right and nothing beats it. Read it with your eyes, during an install or a build, and the same shape buries the one line that mattered in four hundred that did not. Being right about every event and being readable at four hundred of them are different properties, and execsnoop only ever promised the first.

So use execsnoop when the output goes into a pipe, a file, or a pattern match, when you are watching a whole host without a specific target, or when you need failed execs. Use execsnoop -n or -l when you already know the command you are hunting, because a regex beats reading any table. Use exectop when the answer is more than a screenful, when you want repetition folded into one row per kind of command, and when you want the unusual ranked rather than found by searching. Use strace -f when the question is about one process and you need every syscall, and accept the slowdown as the price. Use bcc's --cgroupmap when you need in-kernel cgroup filtering and are willing to populate the map, and exectop --container when you would rather name the container. Reach for neither when the thing you are chasing never launches a process, because the tool will be silent and you will read the silence as an answer.

Frequently asked questions

What does execsnoop do on Linux?

execsnoop traces new process execution and prints one line per event as it happens. The bcc version traces the execve() system call and prints COMM, PID, RET and ARGS columns, with optional time, timestamp, UID and PPID columns behind flags. It ships in the bcc toolkit and is one of the most widely used eBPF tools on Linux, because a line-oriented stream of every program launch is immediately greppable and needs no setup beyond installing bcc.

Why does execsnoop not show some processes?

Because it traces execve() rather than process creation. The bcc documentation states that applications that fork without exec will not be included in the execsnoop output. A process that forks a copy of itself and never replaces its program image, which is how many worker pools and some shells behave, creates a real process that execsnoop does not print. Tracing the sched_process_fork tracepoint alongside exec is what closes that gap.

Does execsnoop truncate command arguments?

Yes, at 20 arguments by default. The bcc documentation states that the limitation is that we are looking only into the first 20 arguments of the command, and exposes a --max-args flag to raise it. Tools that capture a fixed byte window instead of a fixed argument count trade a different way: a long compiler invocation gets cut off mid-string rather than after a set number of arguments, and the record is marked truncated.

How do I trace only the child processes of one command on Linux?

Launch the command under a tracer that follows process creation, rather than filtering a host-wide stream after the fact. Filtering by process name catches unrelated processes with the same name and misses the ones you did not predict. Following the tree means adding each new child to a traced set in the kernel at fork time, so a grandchild three levels down stays in scope without anyone having to name it in advance.

Can I run eBPF process tracing inside a container?

You run it on the host rather than inside the workload container, because BPF programs attach to the kernel and a host has one kernel shared by every container on it. A probe attached from the host sees execs from all of them, and narrowing to one workload means filtering by cgroup id or pid namespace inside the program. Running the tracer inside an unprivileged application container generally fails at load.

Do I need root to trace process execution with eBPF?

Loading a BPF program requires privilege: root, or CAP_BPF and CAP_PERFMON on a modern kernel. That does not have to be your shell. bcc tools including execsnoop are conventionally run with sudo, so the tracing process itself holds those capabilities. Some runtimes split it, performing the privileged load in a daemon so the command you type stays unprivileged, which matters on a shared box where people should be able to trace without holding root.

What is the difference between fork and exec on Linux?

fork creates a new process as a copy of the caller, and exec replaces the program running inside a process with a different one. Running a command from a shell is normally both: fork makes the process, exec loads the program into it. They are separable, which is why a tracer watching only exec misses a process that forked and never loaded a new program, and a tracer watching only fork never learns which program ended up running.

What is the lowest-overhead way to see what a build is launching?

An in-kernel filter that drops events before they are copied to userspace. The cost of eBPF tracing scales with matched events rather than total host activity, so a probe that checks membership in a traced set with one hash lookup and returns costs almost nothing for every exec outside the scope. The expensive pattern is a probe on a hot path that copies data unconditionally and filters in userspace afterward.

Can eBPF tell me why a process was slow to start?

It can separate a slow program from a slow launch, which is usually the question behind it. Recording a timestamp at fork and another at exec gives the gap between a process being created and its program starting, and that gap is dominated by the kernel's own work rather than by anything the program does. A large fork-to-exec time points at the machine, at memory pressure or at a very large binary, rather than at the code.

Is strace a better way to see what a command runs?

For one process, strace shows far more: every syscall, with arguments and return values. For a tree of processes it is the wrong tool, because strace -f attaches to each child through ptrace and stops it on every syscall, which is a heavy per-syscall cost across hundreds of short-lived processes. eBPF tracepoints observe from the kernel side with no ptrace stop, so the traced application runs at close to normal speed.

What can process-launch tracing not see?

Anything that never launches a program. Work done inside a language runtime, an HTTP request made with a library call rather than by shelling out to curl, a file read by the process itself, all of it is invisible to a tracer watching exec. This is the single most important boundary when reasoning about what process tracing proves, and it is why a quiet screen is not evidence that nothing happened.

Does bcc need a compiler installed on every host?

The Python tools do, because bcc compiles the BPF program on the machine where you run it and needs a working LLVM, clang and kernel headers matching the running kernel. The CO-RE libbpf-tools in the same repository avoid this by compiling ahead of time and relocating against BTF at load, which is most of why they exist. A box that had headers installed and then took a kernel upgrade without a matching headers package will fail where an identical box succeeds.

What are the limitations of execsnoop?

Three worth knowing, all documented by bcc. It traces execve() only, so its own documentation states that applications which fork without exec will not be included in the output. It captures the first 20 arguments by default, raisable with --max-args. And it prints one line per event, which is ideal for a pipe and hard to read when a single npm install emits several hundred lines. None of these are defects; they are the consequences of building a fast, greppable, host-wide stream.

What are the alternatives to execsnoop?

For a flat host-wide stream, the closest alternatives are bpftrace with a tracepoint on sched_process_exec, or the CO-RE execsnoop in bcc's libbpf-tools, which ships as a static binary and drops the LLVM dependency. For a scoped view of one application's process tree with repetition grouped, exectop follows fork as well as exec. For every syscall from a small number of processes rather than launches from many, strace -f is the tool, at a much higher runtime cost.

Sources

  • bcc execsnoop example output (IO Visor bcc, 2026) — states that execsnoop traces "the execve() system call (commonly used exec() variant)", that "applications that fork without exec" will not be included in the output, and that "the limitation is that we are looking only into first 20 arguments of the command", with --max-args defaulting to 20; documents the COMM, PID, RET and ARGS columns plus the -T, -t, -x, -n, -l, -U, -u, -P and -q flags.
  • execsnoop-bpfcc man page (Ubuntu manpages, noble) — documents -P PPID as "Trace this parent PID only" without stating whether it follows all descendants, and --cgroupmap MAPPATH and --mntnsmap MAPPATH as tracing "cgroups in this BPF map only (filtered in-kernel)" and "mount namespaces in this BPF map only", which is the in-kernel scoping path for a container.
  • exectop README (yeet-src, 2026) — documents the three sched tracepoints and their roles, the traced hash of tgid to depth grown at every fork, the 4 MiB ring buffer, the mm->arg_start..arg_end argv read that avoids a verifier fight, and the fold key built from the command plus flag names with the git subcommand and sh -c carve-outs.
  • exectop: what it can't see (yeet-src, 2026) — states the measured ring-buffer ceiling of roughly 2,700 execs per second, with 96,000 execs fired across 24 workers producing 36,000 captured and 60,000 dropped; the 1024-byte argv window; the attach-mode gap where children that already existed are outside the traced set; and that anything which never execs is invisible.
  • exectop: what gets flagged (yeet-src, 2026) — records that 11 of 34 distinct commands in a real npm install ran exactly once, which is why rarity alone is not the flag rule, and states the two-part rule: three runs or fewer plus a match against a named behavior such as fetching from the network, evaluating constructed input, widening permissions or touching credential paths.
  • bcc INSTALL guide (IO Visor bcc, 2026) — states a kernel 4.1 or newer built with CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y and CONFIG_BPF_EVENTS=y, plus CONFIG_IKHEADERS=y to reach kernel headers, and the LLVM and clang dependencies that the Python tools carry onto every host where they run.
  • libbpf-tools README (IO Visor bcc, 2026) — explains the CO-RE rewrite of the bcc tools, which compile ahead of time and relocate against BTF at load, shipping as static binaries and removing the LLVM, clang and kernel-headers requirement that the Python versions impose on each target host.
  • sched tracepoints in the kernel tree (kernel.org documentation, 2026) — the tracepoint infrastructure behind sched_process_exec, sched_process_fork and sched_process_exit; tracepoints are a stable instrumentation interface rather than a syscall ABI, which is why one BPF object built against them works across architectures without a per-arch entry point.
  • strace man page (man7.org, 2026) — documents -f as tracing child processes as they are created by currently traced processes, and the ptrace-based attach mechanism that stops the tracee on each traced event, which is the per-syscall cost that makes it expensive across a large process tree.

Related resources