@fallow-cli/beacon
v0.4.3
Published
Lightweight runtime coverage beacon for fallow cloud
Maintainers
Readme
@fallow-cli/beacon
Lightweight runtime coverage beacon for fallow cloud. Collects V8 (Node.js) or Istanbul (Node / Bun / Deno / browser) coverage data and sends it to the fallow cloud API for analysis.
Install
npm install @fallow-cli/beaconRuntime compatibility
| Runtime | Mode | How to enable |
| ------- | -------- | -------------------------------------------------------------------------------- |
| Node.js | V8 | Set NODE_V8_COVERAGE=<dir> before process start. Zero build change. |
| Node.js | Istanbul | Instrument your build with oxc-coverage-instrument or babel-plugin-istanbul. |
| Bun | Istanbul | Bun does not implement v8.takeCoverage. Instrument at build time. |
| Deno | Istanbul | Instrument at build time. |
| Browser | Istanbul | Unchanged. The beacon reads window.__coverage__. |
The beacon auto-detects the active source. It prefers Istanbul when
globalThis.__coverage__ is populated (build-time opt-in signals intent
and gives higher fidelity than V8's best-effort offsets), and falls back
to V8 when NODE_V8_COVERAGE is set. Override with
coverageSource: "v8" | "istanbul".
Node.js (V8 coverage)
import { createNodeBeacon } from "@fallow-cli/beacon";
// Must be set before the Node process starts.
// Example shell usage:
// NODE_V8_COVERAGE=/var/tmp/my-app-v8 node server.js
const beacon = createNodeBeacon({
apiKey: "fallow_live_k1_...",
projectId: "my-app",
endpoint: "https://api.fallow.cloud",
});
beacon.start();
process.on("SIGTERM", async () => {
await beacon.flush();
await beacon.stop();
process.exit(0);
});V8 mode uses best-effort coverage (function-level). Zero CPU overhead.
NODE_V8_COVERAGE must be present when the Node process starts; the
beacon cannot enable V8 collection retroactively at runtime.
Bun / Deno / Node (Istanbul via build-time instrumentation)
Any Istanbul-compatible instrumenter that writes to globalThis.__coverage__
works. The simplest path for TypeScript projects is
oxc-coverage-instrument.
Bun example
// scripts/bun-preload-coverage.ts
import { plugin } from "bun";
import { instrument } from "oxc-coverage-instrument";
plugin({
name: "oxc-coverage-instrument",
setup(build) {
build.onLoad({ filter: /\.(ts|tsx)$/ }, async ({ path }) => {
const source = await Bun.file(path).text();
const instrumented = await instrument(source, path);
return { loader: "ts", contents: instrumented };
});
},
});Preload at startup:
bun --preload ./scripts/bun-preload-coverage.ts run src/index.tsThe beacon then auto-detects Istanbul mode — no code change in your beacon setup:
import { createNodeBeacon } from "@fallow-cli/beacon";
const beacon = createNodeBeacon({
apiKey: "fallow_live_k1_...",
projectId: "my-app",
endpoint: "https://api.fallow.cloud",
onRuntimeMismatch: (detail) => {
console.error("beacon cannot capture:", detail);
},
});
beacon.start();Instrumented code costs ~1–5% CPU. Gate the preload behind an env var
(FALLOW_SERVER_COVERAGE=true) so production deploys without the env var
stay zero-overhead.
Ordering requirement: createNodeBeacon(...).start() must be called
AFTER your instrumented modules have loaded and populated
globalThis.__coverage__. Calling start() in a file that imports
nothing, or at the top of the entry point before any await import(...)
has run, will detect "no sources available" and fire
onRuntimeMismatch. With Bun's --preload, this is the natural order:
the preload instruments every file on load, so by the time the entry
point's app code runs, globalThis.__coverage__ is already populated by
the first instrumented module.
Browser (Istanbul)
import { createBrowserBeacon } from "@fallow-cli/beacon/browser";
const beacon = createBrowserBeacon({
apiKey: "fallow_live_k1_...",
projectId: "my-app",
endpoint: "https://api.fallow.cloud",
sampleRate: 0.01, // 1% of sessions
});
beacon.start();Reads window.__coverage__ from Istanbul-instrumented builds. Sends via
navigator.sendBeacon() on page visibility change.
Local file capture (transport: "fs")
For trial / offline / air-gapped / CI-sandbox scenarios, the beacon can
write each batch as a JSON file to a local directory instead of POSTing
to the ingest endpoint. All capture logic (batching, beforeSend,
denyPaths, lifecycle hooks, maxQueueSize, retry on transient disk
errors) is identical to HTTP mode.
import { createNodeBeacon } from "@fallow-cli/beacon";
const beacon = createNodeBeacon({
projectId: "my-app",
transport: "fs",
writeToDir: "./.fallow-coverage",
// apiKey and endpoint are ignored in fs mode
});
beacon.start();
process.on("SIGTERM", async () => {
await beacon.flush();
await beacon.stop();
process.exit(0);
});Each flush produces one file named <timestamp-ms>-<firstPayloadId>.json.
The file is written atomically via a .tmp sibling + rename() so
readers never see a half-written payload. The directory is created
recursively on first flush if it doesn't exist.
File format matches the ingest endpoint's POST body exactly:
{
"v": 1,
"batch": [
{
"payloadId": "...",
"projectId": "my-app",
"environment": "production",
"commitSha": "...",
"timestamp": "2026-04-20T10:00:00.000Z",
"functions": [
{
"filePath": "src/index.ts",
"functionName": "main",
"lineNumber": 1,
"hitCount": 5,
"trackingState": "called"
}
]
}
],
"clientReports": [
/* optional; queue_overflow / ratelimit_backoff etc */
]
}v is the payload envelope schema version. 0.3.0 onwards always emits "v": 1. Pre-0.3.0 beacons omit the field (implicit v1); both are accepted by the ingest endpoint. Future breaking changes to the payload shape will bump this number, and consumers that don't understand the new version reject early instead of silently misinterpreting rows.
Consume the directory with fallow health --runtime-coverage-dir
(the sidecar handles the JSON shape directly).
Loud failure
The beacon never crashes the host app, but it no longer fails silently
either. When no coverage source is available (or the V8 path throws
ERR_NOT_IMPLEMENTED on Bun), onRuntimeMismatch fires once with a
detail object explaining what was attempted, what failed, and how to
fix it. When no callback is supplied, a single message is logged to
console.error.
createNodeBeacon({
// ...
onRuntimeMismatch: (detail) => {
logger.error("coverage unavailable", {
runtime: detail.runtime, // "node" | "bun" | "deno" | "unknown"
reason: detail.reason, // see table below
attempted: detail.attempted, // "v8" | "istanbul"
message: detail.message,
});
},
});onRuntimeMismatch reasons
| reason | When it fires |
| :----------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| no-v8-and-no-istanbul | Neither globalThis.__coverage__ nor NODE_V8_COVERAGE is available. |
| v8-takeCoverage-threw | The V8 snapshot loop caught ERR_NOT_IMPLEMENTED (bun) or another unrecoverable error. |
| istanbul-global-malformed | globalThis.__coverage__ is set but doesn't have the expected Istanbul shape. |
| config-exceeds-server-limits | A caller-supplied projectId, environment, or commitSha is longer than the ingest endpoint will accept. Fired synchronously by createNodeBeacon before the first POST. |
| transport-misconfigured | transport: "http" was selected (default) without endpoint/apiKey, or transport: "fs" was selected without writeToDir. Fired synchronously by createNodeBeacon before the first flush. |
commitSha is a git SHA, not a container ref
commitSha is capped at 40 characters (CONFIG_LIMITS.commitSha).
That matches a git SHA-1. Fly's FLY_IMAGE_REF
(registry.fly.io/<app>:deployment-01..., ~64 chars), Kubernetes'
image digests, and similar container references do NOT fit. Passing
one of those triggers config-exceeds-server-limits immediately and
the beacon returns a no-op. Inject a real git SHA at deploy time
instead (e.g. GIT_SHA="$GITHUB_SHA" via your CI workflow's --env).
Configuration
| Option | Type | Default | Max length | Description |
| ------------------- | -------------------- | ----------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| projectId | string | required | CONFIG_LIMITS.projectId (200) | Repository/project identifier |
| transport | "http" \| "fs" | "http" | n/a | Transport mode. "fs" writes to disk instead of POSTing. |
| apiKey | string | required* | n/a | API key (required when transport is "http") |
| endpoint | string | required* | n/a | Ingest API URL (required when transport is "http") |
| writeToDir | string | required* | n/a | Output directory (required when transport is "fs") |
| environment | string | undefined | CONFIG_LIMITS.environment (64) | Deployment environment tag |
| commitSha | string | undefined | CONFIG_LIMITS.commitSha (40) | Git commit SHA |
| sampleRate | number | 1.0 | n/a | Session sampling rate (browser only, 0–1) |
| flushIntervalMs | number | 30000 | n/a | Flush interval in milliseconds |
| maxQueueSize | number | 1000 | n/a | Max queued payloads before dropping |
| maxBatchSize | number | 100 | n/a | Max payloads per API request / fs file. HTTP requests are also capped at 25,000 function observations; lower this for very large single-file snapshots or smaller retry units. |
| enabled | boolean | true | n/a | Kill switch |
| coverageSource | "v8" \| "istanbul" | auto | n/a | Override auto-detection |
| denyPaths | RegExp[] | [] | n/a | Skip functions matching these paths |
| beforeSend | function | undefined | n/a | Transform/redact payload before sending |
| onFallback | function | undefined | n/a | Legacy: called when NODE_V8_COVERAGE not set |
| onRuntimeMismatch | function | undefined | n/a | Called when coverage cannot be captured in this runtime |
| onBudgetWarning | function | undefined | n/a | Called once per warning / danger budget transition |
| onBudgetExhausted | function | undefined | n/a | Called when monthly function budget pauses the beacon |
| onBudgetSampled | function | undefined | n/a | Called when the server enters 206 sampled mode |
* Requiredness depends on transport: "http" needs apiKey + endpoint; "fs" needs writeToDir. Missing the relevant fields fires onRuntimeMismatch with reason: "transport-misconfigured" and returns a no-op beacon.
Budget callbacks
The ingest API reports monthly-budget pressure through response headers:
onBudgetWarning(snapshot)fires once per transition intowarningordanger.onBudgetExhausted(snapshot)fires on a 402 response. The HTTP transport pauses untilsnapshot.resetAtand then resumes automatically.onBudgetSampled(snapshot)fires once per transition into server sampled mode. The server returns206 Partial Contentwithx-ingest-budget-state: sampledandx-ingest-budget-sample-ratewhen the payload cap is exhausted but the function cap still has headroom. The beacon keeps sending, setstransport.state()to"sampled", throttles its flush interval by1 / snapshot.sampleRate, and reports the estimated dropped payload count asserver_sampledin the nextclientReportspayload.onBatchShrunk(snapshot)fires when the server rejects a request with 413 and the HTTP transport lowers its activemaxBatchSizebefore retrying. No data is dropped when this callback fires; singleton oversized payloads are unretryable and are reported separately asoversized_payload_dropped.
CONFIG_LIMITS is exported from the package entry — import it to
validate values before constructing a beacon:
import { CONFIG_LIMITS, createNodeBeacon } from "@fallow-cli/beacon";
if (commitSha.length > CONFIG_LIMITS.commitSha) {
throw new Error("commitSha must be a 40-char git SHA");
}Serverless
Auto-detects Lambda, Vercel, and Netlify environments. Switches to per-invocation flush mode automatically.
Minified / bundled code
The beacon reports V8 coverage against the deployed JavaScript. For bundled builds, that means raw bundle paths and offsets.
Source maps are uploaded from CI, not by the beacon. The beacon runs in production and has no access to .map files. Upload them from CI via POST /v1/coverage/:repo/source-maps after the build, keyed by the commit SHA the beacon will report — the ingest pipeline resolves positions to original sources automatically. See the AGENTS.md "Source maps" section for the full workflow.
Known limitations
- Worker threads (V8 and Istanbul). Each worker has its own isolate
and its own
globalThis. The main-thread beacon does not see worker coverage. Customers with worker-heavy workloads must ship their ownpostMessage-based export. - Coverage under the
node:specifier prefix. Filtered out by default in V8 mode.
License
MIT
