Skip to main content

eBPF

Yeet scripts load a compiled eBPF object (.bpf.o), attach its programs, and talk to its maps from JavaScript — ring buffers, hash maps, arrays, LPM tries, bloom filters, per-CPU maps, and .data/.rodata/.bss globals — with keys and values typed end-to-end from the object's BTF.

The flow is always the same:

import probe from './probe.bpf.o';
import { RingBuf } from 'yeet:bpf';

// 1. declare the maps you want to reach (and any attach overrides), then start
const control = await probe
.bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 4096 })
.start(); // every program in the object auto-attaches by its ELF section

// 2. open a typed handle to a bound map and use it
const events = new RingBuf(control, "events");
// ringbuf records are decoded into an object keyed by the BTF struct name
await events.subscribe(({ event }) => console.log(event));

Importing​

Any path ending in .bpf.o resolves to a default-exported BpfObject bound to that path:

import probe from './probe.bpf.o';

The ELF is not read at import time — it is loaded, verified, and attached when start() is called.

The map handle classes and builder are exported from yeet:bpf:

import {
BpfObject, BpfControl,
RingBuf, BpfSubscription, DataSec,
HashMap, LruHashMap, LpmTrie, ArrayMap, BloomFilter,
PercpuHashMap, LruPercpuHashMap, PercpuArrayMap,
} from 'yeet:bpf';

O BpfObject​

A chainable builder representing a compiled eBPF object. Declare the maps you want to reach and any attach overrides, then call start() to load it. bind and attach both return this, so they chain.

M BpfObject.bind​

bind(name: string, spec: { kind: string, ...opts }): BpfObject

Declares that the named map should be exposed to JavaScript. Every map you open a handle to must be bound before start() — a handle on an unbound map rejects when you call it, because no daemon-side service was created for it.

FieldTypeDescription
namestringMap name as it appears in the ELF.
kindstringRequired. The map type (see table below).
btf_structstring(RingBuf) Name of the record struct in the object's BTF; drives event decoding.
capacitynumber(RingBuf) In-process broadcast buffer size in entries, for fanning events out to subscribers. Defaults to a built-in value.
any other fieldany(data) Written into the global of that name before the program first runs — see seeding.

kind selects the map type and must match the map's actual kernel type. Each accepts several spellings:

Map typeAccepted kind values
Ring buffer"ringbuf" (canonical), "ring_buf"
Data section (.data/.rodata/.bss)"data"
Hash"hashmap", "hash_map", "hash"
LRU hash"lru_hashmap", "lru_hash_map", "lru_hash"
LPM trie"lpm_trie", "lpm"
Array"array"
Per-CPU hash"percpu_hashmap", "percpu_hash_map", "percpu_hash"
LRU per-CPU hash"lru_percpu_hashmap", "lru_percpu_hash_map", "lru_percpu_hash"
Per-CPU array"percpu_array"
Bloom filter"bloom_filter", "bloom"

Ring buffer records carry no __type annotation, so you name their struct explicitly with btf_struct. Key/value maps instead lift their types from the __type(key, …) / __type(value, …) annotations on the BPF C side — no btf_struct needed. Any field other than kind is passed through as an option; binding the same map twice throws.

Data sections are maps too, named <object>.<section> by libbpf — probe.bpf.o gets probe.rodata, probe.data, and probe.bss (see DataSec for the exact naming rule).

probe
.bind("events", { kind: "ringbuf", btf_struct: "event", capacity: 8192 })
.bind("flows", { kind: "hashmap" })
.bind("probe.rodata", { kind: "data" });

M BpfObject.attach​

attach(progName: string, spec: object | null): BpfObject

Declares how the named program attaches. A non-null spec.kind selects one of five lanes; the program's own BPF type must match the kind you pass, or start() rejects. Pass null for the section-name auto-attach path.

You do not need to call attach for every program. On start() the daemon attaches all programs in the object (verified: a tracepoint program with no attach() call still fires). What attach() controls is the spec for programs that need or accept one:

  • kprobe / kretprobe, tracepoint, fentry / fexit, cgroup hooks, … auto-attach from their ELF section name. No attach() call is needed; if you list one anyway, pass null.
  • xdp and tcx attach with sane defaults (host namespace, all interfaces) — call attach() only to override the target.
  • perf, uprobe, and usdt require an attach() spec — without one, start() rejects with an invalid-opts error.

Declaring the same program twice throws.

kind: "uprobe"​

Attaches a uprobe/uretprobe to a user-space binary. Entry vs. return is taken from the program's section name (uprobe/uretprobe), not the spec.

FieldTypeRequiredDescription
binarystringyesBare name resolved via ld.so.cache (e.g. libssl.so.1.1) or an absolute path.
symbolstringnoSymbol to resolve (e.g. SSL_read). If omitted, offset is a raw ELF offset.
offsetnumbernoAdded to the resolved symbol offset (default 0).
pidnumbernoAttach only to this PID. If omitted, attaches to every process mapping the binary.
probe.attach("trace_ssl", { kind: "uprobe", binary: "libssl.so.1.1", symbol: "SSL_read" });

kind: "usdt"​

Attaches to a USDT (userspace statically defined tracepoint) — the .note.stapsdt probes baked into binaries built with sys/sdt.h, such as libjvm.so, libc, PostgreSQL, Node, and Python. The program lives in a SEC("usdt/<provider>/<name>") section; a single attach covers every place the probe was compiled in, inlined copies included.

FieldTypeRequiredDescription
binarystringyesBare name resolved via ld.so.cache (e.g. libc.so.6) or an absolute path to any ELF carrying the probe's notes.
providerstringyesProbe provider, the first half of the provider:name pair in the note (e.g. hotspot).
namestringyesProbe name, the second half (e.g. gc__begin).
pidnumbernoAttach only to this PID. If omitted, attaches to every process mapping the binary.
// Count JVM garbage collections as they happen.
probe.attach("on_gc", { kind: "usdt", binary: libjvm, provider: "hotspot", name: "gc__begin" });

You can enumerate a binary's probes with readelf -n <file> (each note prints its Provider: and Name:).

No return variant

A USDT probe is a point marker, not a function boundary — there is no entry/return duality as with uprobe/uretprobe. Paired regions like gc__begin/gc__end or mem__pool__gc__begin/…__end are two separately-named probes the instrumenter placed; attach a program to each and correlate them yourself (typically by thread id and timestamp).

Semaphore-gated probes

Some probes (the JVM's hotspot:* among them) are guarded by a semaphore and only fire while a tracer is attached — the attach bumps the semaphore in the target's memory. For these, scope to a pid so the semaphore can be bumped reliably in that process. Argument-free lifecycle probes (gc__begin, safepoint__begin, thread__start, compiled__method__load) need no special JVM flags; method- and monitor-level probes require -XX:+ExtendedDTraceProbes / -XX:+DTraceMonitorProbes and carry real overhead.

Reading arguments​

Probe arguments are read inside the BPF program with libbpf's bpf_usdt_arg (directly, or via the BPF_USDT(...) wrapper from usdt.bpf.h), then handed to JavaScript through a map like any other value:

#include <bpf/usdt.bpf.h>

SEC("usdt/hotspot/thread__start")
int BPF_USDT(on_thread_start, char *name, u64 name_len, u64 tid) {
// …read name/tid, emit on a ring buffer…
}

The char * / u64 values arrive in JavaScript with their usual BTF types — char[N] copied out with bpf_probe_read_user_str decodes to a string, u64 to a BigInt, and so on.

Containers​

USDT attaches by the binary's on-disk inode, so a containerized process is reachable through its proc-root path — the daemon runs in the host namespaces and can open into any container's mount namespace:

// host-visible PID of the containerized process (not its in-container PID)
const pid = 4242;
probe.attach("on_gc", {
kind: "usdt",
binary: `/proc/${pid}/root/opt/jdk/lib/server/libjvm.so`,
provider: "hotspot",
name: "gc__begin",
pid,
});

The two rules that make it work: address the binary by its absolute /proc/<host-pid>/root/… path (a bare name resolves against the host's ld.so.cache, not the container's), and scope with the process's host PID (the one /proc shows, not the container-internal PID). This is the same mechanism bcc/bpftrace use for containers.

kind: "perf"​

Arms a perf event and runs the program on each sample. One program fans out to one perf event per requested CPU.

FieldTypeRequiredDescription
eventobjectyes{ kind: "software", name } or { kind: "hardware", name }.
cpunumber[]noCPU indices to arm. Omit for every online CPU.
targetobjectnoWhich tasks to observe (see below). Defaults to all PIDs in the host namespace.
sampleobjectno{ freq: N } (≈N samples/sec) or { period: N } (every N events). Omit for a non-sampling counter.

Software event names: cpu_clock, task_clock, page_faults, context_switches, cpu_migrations, page_faults_min, page_faults_maj, alignment_faults, emulation_faults.

Hardware event names: cpu_cycles, instructions, cache_references, cache_misses, branch_instructions, branch_misses, bus_cycles, stalled_cycles_frontend, stalled_cycles_backend, ref_cpu_cycles.

target is one of:

  • { kind: "pids", ns?, pids? } — pids is an array (omit for all); ns is a namespace scope.
  • { kind: "cgroup", path } — observe every task in the cgroup at path.
probe.attach("on_tick", {
kind: "perf",
event: { kind: "software", name: "cpu_clock" },
sample: { freq: 99 },
target: { kind: "pids", ns: { pid: 1234 } },
});

kind: "xdp" / kind: "tcx"​

Network attachments. Both take a namespace scope via ns/ifindex. tcx additionally takes order.

FieldTypeRequiredDescription
ns"host" | { pid } | { path }noNetwork namespace to attach in. Defaults to the host.
ifindexnumber[]noInterface indices. Omit for all interfaces.
order"default" | "before" | "after"no(tcx only) Placement in the TCX chain. Default lets the kernel choose.
probe.attach("ingress", { kind: "tcx", ifindex: [2], order: "before" });

Namespace scope​

ns (and the perf target's ns) names a PID or network namespace by:

  • "host" — the host namespace (the default when ns is omitted).
  • { pid: 1234 } — the namespace that PID 1234 lives in.
  • { path: "/proc/1234/ns/net" } — a namespace by path.

(Resolving a namespace by container name arrives with yeet:container.) Under the hood these desugar to the daemon's { handle, ifindex } / { handle, pids } wire shapes; you can pass those explicitly instead, but not alongside the sugar.

M BpfObject.start​

start(): Promise<BpfControl>

Loads the ELF, runs the verifier, binds the declared maps, and attaches every program. Resolves to a BpfControl handle. Rejects on failure — common causes: bad ELF path, verifier rejection, a missing attach spec for a perf/uprobe program, a kind/program mismatch, or missing CAP_BPF.

O BpfControl​

The handle returned by start(). Pass it to a map handle constructor, and call stop() to tear the object down.

P BpfControl.id​

id: string

Identifier of the running instance. Map handles capture it to route their operations.

M BpfControl.stop​

stop(): Promise<void>

Detaches all programs and tears down the loaded object and its map services.

Map handles​

Open a typed handle to a bound map by constructing the class for its kind with (control, name):

import { HashMap, RingBuf } from 'yeet:bpf';

const flows = new HashMap(control, "flows");
const events = new RingBuf(control, "events");

Keys and values are typed end-to-end from the map's BTF: a struct becomes a plain object whose fields keep their C types — __u64 → BigInt, __u8[N] → Uint8Array, and scalars / strings / nested structs / arrays map to their JS equivalents. The __type(key, …) / __type(value, …) annotations on the BPF C side drive the lift.

The constructor never validates the kind — it just records (control.id, name). The native bindings reject with InvalidMapKind the first time you call a method whose map isn't the kind you constructed.

O RingBuf​

Streams BPF_MAP_TYPE_RINGBUF events into JavaScript.

M RingBuf.subscribe​

subscribe(cb: (event: any) => void, onError?: (err: Error) => void): Promise<BpfSubscription>

Subscribes cb to events. Each record is decoded via the map's btf_struct and delivered as an object keyed by that struct name — a record of struct event { … } arrives as { event: { …fields } }, so destructure it: subscribe(({ event }) => …). Fields keep their BTF types (__u64 → BigInt, __u8[N] → Uint8Array, char[N] → a NUL-terminated string, …).

Resolves to a BpfSubscription once the daemon-side pump is wired up; setup failures reject the Promise. Runtime errors (e.g. a slow consumer lagging the broadcast and dropping events) flow to onError, which defaults to logging the message via console.error.

Example​

Every nanosleep(2) on the box becomes one record carrying the caller and the duration it asked for. The BPF side reads the timespec out of the syscall argument, reserves a slot, fills it, and submits:

struct sleep_event {
__u32 pid;
__u64 ns;
char comm[16];
};

// Only referenced through a local pointer, so clang would drop it from
// the object's BTF; a global keeps it there for `btf_struct` to find.
const struct sleep_event *unused_sleep_event __attribute__((unused));

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
struct __kernel_timespec ts;
if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
return 0;

struct sleep_event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;

e->pid = bpf_get_current_pid_tgid() >> 32;
e->ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
bpf_get_current_comm(e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
return 0;
}

The JS side binds the map with the struct name, subscribes, and takes the first five records:

import probe from "./ringbuf.bpf.o";
import { RingBuf } from "yeet:bpf";

const control = await probe
.bind("events", { kind: "ringbuf", btf_struct: "sleep_event", capacity: 4096 })
.start();

const events = new RingBuf(control, "events");

let seen = 0;
let done;
const finished = new Promise((resolve) => { done = resolve; });

const sub = await events.subscribe(({ sleep_event: e }) => {
if (seen >= 5) return;
console.log(`${e.comm} pid=${e.pid} sleeps for ${e.ns / 1000n}µs`);
if (++seen === 5) done();
});

await finished;
await sub.unsubscribe();
await control.stop();
containerd pid=4054 sleeps for 10000µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs
lima-guestagent pid=1237 sleeps for 20µs

comm arrives as a string, pid as a number, and the __u64 ns as a BigInt.

Who calls nanosleep

Go runtimes (containerd, dockerd, most Kubernetes agents) call nanosleep steadily, so the examples on this page fill up on their own on any box running one. A shell sleep and Python's time.sleep go through the clock_nanosleep syscall instead — swap the section name to tracepoint/syscalls/sys_enter_clock_nanosleep (the timespec is then args[2]) to see those.

The struct has to be in the object's BTF

btf_struct is looked up in the .bpf.o's own BTF, and clang only emits BTF for types reachable from globals, map definitions, and function signatures. A record struct that is only ever touched through a local pointer — the usual reserve/submit pattern — gets dropped. Keep it reachable with a throwaway global as above.

O BpfSubscription​

Returned by RingBuf.subscribe.

M BpfSubscription.unsubscribe​

unsubscribe(): Promise<void>

Stops delivery and releases the daemon-side subscription.

O HashMap / LruHashMap​

HashMap wraps BPF_MAP_TYPE_HASH; LruHashMap wraps BPF_MAP_TYPE_LRU_HASH. The JS surface is identical — the choice of class is the choice of kernel semantics: on a full map update() rejects with E2BIG for HashMap but silently evicts the least-recently-used entry for LruHashMap (whose lookup/iteration also refresh recency).

MethodSignatureDescription
lookuplookup(key): Promise<value | undefined>Point lookup; undefined on a clean miss.
updateupdate(key, value): Promise<void>BPF_ANY — insert or overwrite.
deletedelete(key): Promise<void>Rejects with NotFound if the key is absent.
entriesentries({ batchSize? }): AsyncIterator<[key, value]>Cursor-paged async iteration over all pairs.
updateBatchupdateBatch(pairs): Promise<void>Bulk insert of [key, value] pairs in one syscall (kernel ≥5.6); atomic per batch.
deleteBatchdeleteBatch(keys): Promise<void>Bulk delete (kernel ≥5.6).
lookupBatchlookupBatch(count?): Promise<[key, value][]>Kernel-batched page fetch — fast, not resumable across calls (kernel ≥5.6).
drainBatchdrainBatch(count?): Promise<[key, value][]>Atomic "fetch and delete up to count" (kernel ≥5.6).
infoinfo(): { map_type, key_size, value_size, max_entries }Cached kernel map metadata.
Iteration under churn

Kernel iteration order is unstable across concurrent modification — entries inserted mid-walk may or may not appear, and entries deleted between the scan and a follow-up lookup are silently skipped. For correctness under churn, collect stale keys during entries() and delete them in a separate pass.

Example​

Total requested sleep time per process. The __type annotations are what give JS its typed __u32 keys and __u64 values:

struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, __u32);
__type(value, __u64);
} sleep_ns_by_pid SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
struct __kernel_timespec ts;
if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
return 0;

__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;

__u64 *total = bpf_map_lookup_elem(&sleep_ns_by_pid, &pid);
if (total)
__sync_fetch_and_add(total, ns);
else
bpf_map_update_elem(&sleep_ns_by_pid, &pid, &ns, BPF_NOEXIST);
return 0;
}
import probe from "./hashmap.bpf.o";
import { HashMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleep_ns_by_pid", { kind: "hashmap" }).start();
const totals = new HashMap(control, "sleep_ns_by_pid");

await sleep(1000);
console.log(await totals.info());

for await (const [pid, ns] of totals.entries()) {
console.log(`pid ${pid} asked for ${ns / 1_000_000n}ms of sleep`);
}

await totals.update(0, 42n);
console.log("lookup(0) =", await totals.lookup(0));
await totals.delete(0);
console.log("after delete =", await totals.lookup(0));

await totals.updateBatch([[1_000_001, 1n], [1_000_002, 2n]]);
console.log("page:", await totals.lookupBatch(4));
console.log("drained:", (await totals.drainBatch(1024)).length, "entries");
console.log("left:", (await totals.lookupBatch(1024)).length);

await control.stop();
{ map_type: 'Hash', key_size: 4, value_size: 8, max_entries: 1024 }
pid 4004 asked for 10ms of sleep
pid 4054 asked for 50ms of sleep
pid 1237 asked for 0ms of sleep
lookup(0) = 42n
after delete = undefined
page: [ [ 1000002, 2n ], [ 1000001, 1n ], [ 4004, 10000000n ], [ 4054, 50000000n ] ]
drained: 5 entries
left: 0

__u64 values are BigInts in both directions — pass 42n, not 42. drainBatch fetches and deletes in one syscall, so the map is empty afterwards apart from anything the tracepoint re-inserted in between.

LruHashMap is a drop-in swap. Declare the map as BPF_MAP_TYPE_LRU_HASH with a small max_entries, bind with kind: "lru_hashmap", and overfill it:

import probe from "./lru_hashmap.bpf.o";
import { LruHashMap } from "yeet:bpf";

const control = await probe.bind("sleep_ns_by_pid", { kind: "lru_hashmap" }).start();
const totals = new LruHashMap(control, "sleep_ns_by_pid");

const { max_entries } = await totals.info();
const pairs = Array.from({ length: max_entries * 2 }, (_, i) => [i + 1, BigInt(i)]);
await totals.updateBatch(pairs);

let n = 0;
for await (const _ of totals.entries()) n++;
console.log(`inserted ${pairs.length}, map holds ${n} (max_entries ${max_entries})`);
console.log("oldest key survived?", (await totals.lookup(1)) !== undefined);
console.log("newest key survived?", (await totals.lookup(pairs.length)) !== undefined);

await control.stop();
inserted 128, map holds 64 (max_entries 64)
oldest key survived? false
newest key survived? true

A plain HashMap would have rejected the batch with E2BIG. The kernel keeps a little per-CPU slack in LRU maps, so the live count can sit a few entries under max_entries.

O ArrayMap​

Wraps BPF_MAP_TYPE_ARRAY. The key is always a __u32 index and the slot count is fixed at max_entries. The kernel rejects deletes on arrays, so there is no delete/deleteBatch/drainBatch — clear a slot by writing a zero value.

MethodSignatureDescription
lookuplookup(index): Promise<value | undefined>Lookup by __u32 index; undefined past max_entries.
updateupdate(index, value): Promise<void>Write a slot; an index past max_entries rejects with E2BIG.
entriesentries({ batchSize? }): AsyncIterator<[index, value]>Async iteration over all slots.
updateBatchupdateBatch(pairs): Promise<void>Bulk write (kernel ≥5.6).
lookupBatchlookupBatch(count?): Promise<[index, value][]>Kernel-batched page fetch (kernel ≥5.6).
infoinfo(): { map_type, key_size, value_size, max_entries }Kernel map metadata.

This is distinct from the .data/.rodata/.bss arrays that back BPF globals — those use DataSec.

Example​

A log2 histogram of requested sleep durations. Slots are fixed at build time, so an array is the natural shape:

// Slot b counts sleeps of 2^b .. 2^(b+1) microseconds; slot 23 is 8s+.
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 24);
__type(key, __u32);
__type(value, __u64);
} sleep_hist SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
struct __kernel_timespec ts;
if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
return 0;

__u64 us = ts.tv_sec * 1000000ULL + (__u64)ts.tv_nsec / 1000;
__u32 slot = 0;
while (us > 1 && slot < 23) {
us >>= 1;
slot++;
}

__u64 *n = bpf_map_lookup_elem(&sleep_hist, &slot);
if (n)
__sync_fetch_and_add(n, 1);
return 0;
}
import probe from "./array.bpf.o";
import { ArrayMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleep_hist", { kind: "array" }).start();
const hist = new ArrayMap(control, "sleep_hist");

await sleep(1000);
console.log(await hist.info());

for await (const [slot, n] of hist.entries()) {
if (n === 0n) continue;
console.log(`${2 ** slot}µs..${2 ** (slot + 1)}µs: ${n}`);
}

console.log("slot 13 =", await hist.lookup(13));
await hist.update(13, 0n);
await hist.updateBatch([[0, 0n], [1, 0n]]);
console.log("page:", (await hist.lookupBatch(24)).length, "slots");

console.log("lookup(24) =", await hist.lookup(24));
try {
await hist.update(24, 0n);
} catch (e) {
console.log("update(24):", e.message);
}

await control.stop();
{ map_type: 'Array', key_size: 4, value_size: 8, max_entries: 24 }
2µs..4µs: 4
16µs..32µs: 36
8192µs..16384µs: 6
slot 13 = 6n
page: 24 slots
lookup(24) = undefined
update(24): Map operation failed: Argument list too long (os error 7)

Every slot exists from map creation, so entries() always yields exactly max_entries pairs and untouched slots read as 0n — reset one by writing zero, since arrays can't delete.

O LpmTrie​

Wraps BPF_MAP_TYPE_LPM_TRIE — keys are (prefixlen, data) pairs and the kernel matches the longest prefix on lookup. Methods mirror HashMap (lookup, update, delete, entries, updateBatch, deleteBatch, lookupBatch, info) except drainBatch, which the kernel's trie ops don't implement — use entries() + deleteBatch() to drain.

Keys accept two shapes:

  • A string — a full-length byte-prefix match. Each charCodeAt(i) becomes one byte, so input is interpreted as ASCII / Latin-1; multi-byte UTF-8 is not supported here.
  • An explicit { prefixlen, data } object — prefixlen in bits, data the byte array. Use this for non-ASCII keys.

Example​

Count nanosleep calls from processes whose name starts with a given prefix. The key is the standard LPM shape — prefixlen in bits, then the bytes — and the program looks its own comm up as a full-length key, so any stored prefix of it matches:

struct comm_key {
__u32 prefixlen;
__u8 data[16];
};

struct {
__uint(type, BPF_MAP_TYPE_LPM_TRIE);
__uint(max_entries, 256);
__type(key, struct comm_key);
__type(value, __u64);
__uint(map_flags, BPF_F_NO_PREALLOC);
} watched SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
struct comm_key key = { .prefixlen = 8 * sizeof(key.data) };
bpf_get_current_comm(key.data, sizeof(key.data));

__u64 *calls = bpf_map_lookup_elem(&watched, &key);
if (calls)
__sync_fetch_and_add(calls, 1);
return 0;
}
import probe from "./lpm_trie.bpf.o";
import { LpmTrie } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("watched", { kind: "lpm_trie" }).start();
const watched = new LpmTrie(control, "watched");

// A string key is a byte prefix: every comm starting with "contain" matches.
await watched.update("contain", 0n);
await watched.update("docker", 0n);
// The explicit form — prefixlen in bits, data as bytes — spells "co".
await watched.update({ prefixlen: 16, data: [0x63, 0x6f] }, 0n);

await sleep(1000);
for await (const [key, calls] of watched.entries()) {
const name = String.fromCharCode(...key.data.subarray(0, key.prefixlen / 8));
console.log(`${name}/${key.prefixlen}: ${calls} nanosleeps`);
}

console.log("containerd ->", await watched.lookup("containerd"));
console.log("coredns ->", await watched.lookup("coredns"));
console.log("bash ->", await watched.lookup("bash"));

await watched.deleteBatch(["docker"]);
console.log("after delete ->", await watched.lookup("dockerd"));

await control.stop();
contain/56: 5 nanosleeps
co/16: 0 nanosleeps
docker/48: 0 nanosleeps
containerd -> 5n
coredns -> 0n
bash -> undefined
after delete -> undefined

containerd matched the longer contain entry rather than co; coredns fell through to co. Keys come back from entries() in the explicit shape: data is a Uint8Array padded to the slot width, so slice it by prefixlen / 8 to recover the string.

For IP prefixes use the explicit form — a string like "10.0.0.0" would be matched as eight ASCII bytes, not an address:

struct ipv4_key {
__u32 prefixlen;
__u8 data[4];
};
await acl.update({ prefixlen: 8,  data: [10, 0, 0, 0] }, 1);
await acl.update({ prefixlen: 24, data: [10, 1, 2, 0] }, 2);

await acl.lookup({ prefixlen: 32, data: [10, 1, 2, 3] }); // 2 — the /24 wins
await acl.lookup({ prefixlen: 32, data: [10, 9, 9, 9] }); // 1 — falls back to the /8
await acl.lookup({ prefixlen: 32, data: [192, 168, 0, 1] }); // undefined

O BloomFilter​

Wraps BPF_MAP_TYPE_BLOOM_FILTER — a probabilistic set with no keys and no removal. contains may report false positives but never false negatives.

MethodSignatureDescription
insertinsert(value): Promise<void>Set the bits for value. Idempotent.
containscontains(value): Promise<boolean>true if value was likely inserted, false if definitely not.
infoinfo(): { map_type, value_size, max_entries }Metadata (no key_size — bloom is keyless).

Example​

Remember which PIDs have ever called nanosleep. The BPF side inserts with bpf_map_push_elem (and would test with bpf_map_peek_elem); map_extra sets the number of hash functions:

struct {
__uint(type, BPF_MAP_TYPE_BLOOM_FILTER);
__uint(max_entries, 1024);
__type(value, __u32);
__uint(map_extra, 3);
} sleepers SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;

bpf_map_push_elem(&sleepers, &pid, BPF_ANY);
return 0;
}
import probe from "./bloom_filter.bpf.o";
import { BloomFilter } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleepers", { kind: "bloom_filter" }).start();
const sleepers = new BloomFilter(control, "sleepers");

await sleep(1000);
console.log(await sleepers.info());

console.log("pid 1 slept?", await sleepers.contains(1));
console.log("pid 4000000 slept?", await sleepers.contains(4_000_000));

await sleepers.insert(4_000_000);
console.log("after insert:", await sleepers.contains(4_000_000));

await control.stop();
{ map_type: 'BloomFilter', value_size: 4, max_entries: 1024 }
pid 1 slept? false
pid 4000000 slept? false
after insert: true

There is no way to list or remove members; false is definitive, true means "probably".

O Per-CPU maps​

PercpuHashMap (BPF_MAP_TYPE_PERCPU_HASH), LruPercpuHashMap (…_LRU_PERCPU_HASH), and PercpuArrayMap (…_PERCPU_ARRAY) split values across CPUs:

  • lookup(key) resolves to an array of values, one per possible CPU in CPU-index order (undefined on a miss). Aggregate on the caller side — e.g. vals.reduce((a, b) => a + b, 0n) for a per-CPU counter.
  • update(key, valuesPercpu) takes an array whose length must equal info().num_cpus.
  • entries() yields [key, [v_cpu0, v_cpu1, …]].
  • info() additionally reports num_cpus.

Kernel-batched paths (lookupBatch/updateBatch/deleteBatch/drainBatch) are not available on per-CPU maps — use entries() for full scans. The two hash variants also have delete(key); the array variant does not (arrays can't delete). LruPercpuHashMap evicts on a full update like its non-percpu sibling, with per-CPU LRU bookkeeping.

Example: PercpuArrayMap​

A single global counter with no cache-line contention. Because each CPU owns its slot, the program can bump it without an atomic:

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, __u32);
__type(value, __u64);
} sleeps SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
__u32 zero = 0;

__u64 *n = bpf_map_lookup_elem(&sleeps, &zero);
if (n)
*n += 1;
return 0;
}
import probe from "./percpu_array.bpf.o";
import { PercpuArrayMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleeps", { kind: "percpu_array" }).start();
const sleeps = new PercpuArrayMap(control, "sleeps");

await sleep(1000);
const info = await sleeps.info();
console.log(info);

const perCpu = await sleeps.lookup(0);
console.log("per-cpu:", perCpu);
console.log("total:", perCpu.reduce((a, b) => a + b, 0n));

await sleeps.update(0, new Array(info.num_cpus).fill(0n));
for await (const [slot, values] of sleeps.entries()) {
console.log(`slot ${slot} reset, sum now ${values.reduce((a, b) => a + b, 0n)}`);
}

try {
await sleeps.update(0, [0n]);
} catch (e) {
console.log("wrong length:", e.message);
}

await control.stop();
{ map_type: 'PercpuArray', key_size: 4, value_size: 8, max_entries: 1, num_cpus: 16 }
per-cpu: [ 0n, 0n, 0n, 0n, 0n, 0n, 0n, 0n, 5n, 0n, 1n, 0n, 0n, 0n, 0n, 0n ]
total: 6n
slot 0 reset, sum now 0
wrong length: Map operation failed: Expected 16 per-CPU values, got 1

Example: PercpuHashMap / LruPercpuHashMap​

Per-process call counts where each CPU keeps its own tally. Same shape as the HashMap example, minus the atomic:

struct {
__uint(type, BPF_MAP_TYPE_PERCPU_HASH);
__uint(max_entries, 1024);
__type(key, __u32);
__type(value, __u64);
} sleeps_by_pid SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(void *ctx)
{
__u32 pid = bpf_get_current_pid_tgid() >> 32;
__u64 one = 1;

__u64 *n = bpf_map_lookup_elem(&sleeps_by_pid, &pid);
if (n)
*n += 1;
else
bpf_map_update_elem(&sleeps_by_pid, &pid, &one, BPF_NOEXIST);
return 0;
}
import probe from "./percpu_hashmap.bpf.o";
import { PercpuHashMap } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe.bind("sleeps_by_pid", { kind: "percpu_hashmap" }).start();
const counts = new PercpuHashMap(control, "sleeps_by_pid");

await sleep(1000);
const { num_cpus } = await counts.info();

for await (const [pid, perCpu] of counts.entries()) {
const total = perCpu.reduce((a, b) => a + b, 0n);
const busy = perCpu.filter((n) => n > 0n).length;
console.log(`pid ${pid}: ${total} nanosleeps across ${busy}/${num_cpus} cpus`);
}

await counts.update(0, new Array(num_cpus).fill(1n));
console.log("lookup(0) =", await counts.lookup(0));
await counts.delete(0);
console.log("after delete =", await counts.lookup(0));

await control.stop();
pid 4054: 5 nanosleeps across 1/16 cpus
pid 1237: 14 nanosleeps across 1/16 cpus
lookup(0) = [ 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n, 1n ]
after delete = undefined

For the LRU variant declare BPF_MAP_TYPE_LRU_PERCPU_HASH, bind with kind: "lru_percpu_hashmap", and construct LruPercpuHashMap — the script is otherwise identical.

O DataSec​

Reads and writes the .data / .rodata / .bss sections that back the object's BPF globals.

libbpf exposes each section as a map named <object>.<section>, where <object> is the file name up to its first ., truncated to eight characters: probe.bpf.o yields probe.data / probe.rodata / probe.bss, while execsnoop.bpf.o yields execsnoo.bss. Bind and open the handle with that name:

const control = await probe.bind("probe.bss", { kind: "data" }).start();
const bss = new DataSec(control, "probe.bss");

Which section a global lands in follows the usual C rules: initialised globals go to .data, zero-initialised ones to .bss, and const volatile ones to .rodata.

Seeding at start​

Any field on the bind spec other than kind is written into the global of that name before the program first runs — the daemon-side equivalent of setting a knob before load:

await probe.bind("probe.data", { kind: "data", target_fd: 2 }).start();

Seed .data or .bss globals only. The kernel freezes .rodata when the object loads, so a const volatile global is readable but never writable from JS — seeding it fails start() with Write Access Denied, and patch rejects with Symbol … is not writable. Use a plain (non-const) global for anything you want to set from JS.

M DataSec.read​

read(sym?: string): Promise<any>

With a symbol name, reads that global; with no argument, reads the whole section as a typed composite (BigInt for __u64, Uint8Array for __u8[N], numbers / strings / nested objects / arrays for the rest). Returns undefined for a genuine miss.

M DataSec.patch​

patch(sym: string, value: any): Promise<void>
patch(values: object): Promise<void>

Partial update — only the leaves you name are written; unspecified bytes are left as-is. Atomic per call: if any field fails to lift, nothing changes. Pass a single (sym, value) to write one symbol (scalar or sub-tree), or an object to write several at once.

const cfg = new DataSec(control, "probe.data");
await cfg.patch("target_pid", 1234);
await cfg.patch({ sample_rate: 100, enabled: 1 });

Example​

Three globals in three sections: a read-only version stamp, a threshold the program reads, and counters it writes:

const volatile __u32 version = 1;
__u64 long_sleep_ns = 1000000000;
__u64 calls = 0;
__u64 long_sleeps = 0;

SEC("tracepoint/syscalls/sys_enter_nanosleep")
int on_nanosleep(struct trace_event_raw_sys_enter *ctx)
{
struct __kernel_timespec ts;
if (bpf_probe_read_user(&ts, sizeof(ts), (void *)ctx->args[0]))
return 0;

__u64 ns = ts.tv_sec * 1000000000ULL + ts.tv_nsec;
__sync_fetch_and_add(&calls, 1);
if (ns >= long_sleep_ns)
__sync_fetch_and_add(&long_sleeps, 1);
return 0;
}
import probe from "./data.bpf.o";
import { DataSec } from "yeet:bpf";

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const control = await probe
.bind("data.rodata", { kind: "data" })
.bind("data.data", { kind: "data", long_sleep_ns: 10_000_000n }) // seeded before the first run
.bind("data.bss", { kind: "data" })
.start();

const rodata = new DataSec(control, "data.rodata");
const data = new DataSec(control, "data.data");
const bss = new DataSec(control, "data.bss");

console.log(await rodata.read(), await data.read());

await sleep(1000);
console.log("long sleeps:", await bss.read("long_sleeps"));
console.log(await bss.read());

await data.patch("long_sleep_ns", 1_000_000_000n);
await bss.patch({ calls: 0n, long_sleeps: 0n });
await sleep(1000);
console.log(await data.read(), await bss.read());

try {
await rodata.patch("version", 2);
} catch (e) {
console.log("rodata is frozen:", e.message);
}

await control.stop();
{ version: 1 } { long_sleep_ns: 10000000n }
long sleeps: 6n
{ calls: 8n, long_sleeps: 6n }
{ long_sleep_ns: 1000000000n } { calls: 45n, long_sleeps: 0n }
rodata is frozen: Failed to patch map: Symbol `version` is not writable

The seed lowered the threshold to 10ms before the program ever ran, so the first second counted six long sleeps; after the patch raised it back to a full second, none qualified. Reads and writes go straight to the section's memory, so a patch takes effect on the very next program run — no reload.

Full example​

import probe from './execsnoop.bpf.o';
import { RingBuf, HashMap } from 'yeet:bpf';

const control = await probe
.bind("events", { kind: "ringbuf", btf_struct: "exec_event", capacity: 8192 })
.bind("counts", { kind: "hashmap" }) // per-pid exec counter
.start(); // tracepoint auto-attaches by section

const events = new RingBuf(control, "events");
const counts = new HashMap(control, "counts");

// records arrive keyed by the BTF struct name; fields keep their C types
const sub = await events.subscribe(({ exec_event: e }) => {
console.log(`${e.comm} (pid ${e.pid}) exec'd ${e.filename}`);
});

// the isolate exits once the top-level module settles, so hold it open —
// here, until the first SIGINT-equivalent teardown you wire up. A bare
// setTimeout does NOT keep it alive; await a promise instead.
await new Promise(resolve => {
setTimeout(async () => {
for await (const [pid, n] of counts.entries()) console.log(pid, n);
await sub.unsubscribe();
await control.stop();
resolve();
}, 10_000);
});