@fallow-cli/beacon
v0.6.6
Published
Lightweight runtime coverage beacon for Fallow Cloud
Maintainers
Readme
@fallow-cli/beacon
Runtime coverage beacon for Fallow Cloud. Collects V8 coverage in Node.js or Istanbul coverage in Node.js, Bun, Deno, and the browser. Sends coverage data 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. No build changes required. |
| 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 | Instrument your build to populate 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",
coverageOrigin: "production",
runtimeSurface: "server",
});
beacon.start();
process.on("SIGTERM", async () => {
await beacon.flush();
await beacon.stop();
process.exit(0);
});V8 mode uses function-level best-effort coverage.
NODE_V8_COVERAGE must be present when the Node process starts; the
beacon cannot enable V8 collection retroactively at runtime.
Node resets execution counters after every v8.takeCoverage() call. The beacon
therefore treats each snapshot as a complete interval rather than subtracting it
from the previous snapshot. Repeated equal or lower counts still represent new
calls. Zero-count functions are reported once per process so repeated intervals
do not inflate ingest usage.
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 src/index.tsThe beacon then auto-detects Istanbul mode, with 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();Instrumentation adds work to each tracked function call. Gate the preload behind an env var
(FALLOW_SERVER_COVERAGE=true) so production deploys without the env var
skip instrumentation.
Call createNodeBeacon(...).start() 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)
Use an ingest-only API key with the prefix fallow_pub_k1_. This key can only upload
coverage. Never include a full-access fallow_live_k1_ key in a browser bundle.
import { createBrowserBeacon } from "@fallow-cli/beacon/browser";
const beacon = createBrowserBeacon({
apiKey: "fallow_pub_k1_...",
projectId: "my-app",
endpoint: "https://api.fallow.cloud",
coverageOrigin: "production",
runtimeSurface: "browser",
sampleRate: 0.01, // 1% of sessions
});
beacon.start();Reads window.__coverage__ from Istanbul-instrumented builds. Snapshots on a
flushIntervalMs timer, and again when the page is hidden or unloading. All
of these paths apply beforeSend once per payload. Returning null drops the
payload; a thrown callback also drops it rather than sending unredacted data. The
unload path uses fetch(keepalive: true) so the Authorization header reaches
the ingest endpoint, falling back to navigator.sendBeacon() only where fetch
is unavailable.
Coverage origin
coverageOrigin describes where coverage ran. It is separate from
coverageSource, which selects or reports the V8 or Istanbul collector.
const beacon = createNodeBeacon({
apiKey: "fallow_live_k1_...",
projectId: "my-app",
endpoint: "https://api.fallow.cloud",
coverageOrigin: "production",
});Allowed values are "production", "test", "ci", "build_time", and
"unknown". Only "test" and "ci" are side-channel evidence.
"production", "build_time", "unknown", and an omitted origin stay on the
normal verdict-driving path. Only explicit "production" can confirm
is_production; the other normal evidence remains unconfirmed as production.
Public Istanbul helpers
One-shot coverage uploaders can reuse the same Istanbul extraction and diff logic as the long-running Node and browser beacons:
import {
diffIstanbulSnapshot,
extractFromIstanbul,
type IstanbulCoverageMap,
} from "@fallow-cli/beacon";
declare const coverageMap: IstanbulCoverageMap;
const entries = extractFromIstanbul(coverageMap, { projectId: "ci-coverage" });
const { incremental, snapshot } = diffIstanbulSnapshot(new Map(), entries);extractFromIstanbul applies the normal path filters and returns
IstanbulSnapshotEntry[] with line, column, and span identity fields.
diffIstanbulSnapshot returns wire-ready FunctionCoverage[] in incremental
plus the next snapshot map. The helpers collect and diff data only; callers own
transport and payload construction.
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.
These files contain normalized ingest payloads, not raw V8 dumps or Istanbul
coverage maps. Use an ingest-compatible consumer to read them; do not pass them
to fallow health --runtime-coverage as raw coverage input.
Loud failure
The beacon reports capture failures without crashing the host app. 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, commitSha or runtimeSurface is longer, or has other characters, than the ingest endpoint will accept. Fired synchronously by createNodeBeacon, and by start() of the browser beacon, 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)
and may contain only letters, digits, dots, underscores and hyphens (not
only dots). Fallow Cloud applies the same rule to deployments, inventories
and source maps, so the runtime SHA always matches them. An empty string
counts as no SHA. 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 |
| coverageOrigin | "production" \| "test" \| "ci" \| "build_time" \| "unknown" | undefined | n/a | Identify where coverage ran; independent of collector selection |
| runtimeSurface | string | undefined | CONFIG_LIMITS.runtimeSurface (64) | Stable deployed process name, such as web, worker, or browser; used to compare like-for-like deployments |
| denyPaths | RegExp[] | [/node_modules/, /\.test\./] | n/a | Skip functions matching these paths |
| beforeSend | function | undefined | n/a | Transform/redact each payload before sending, including browser unload. Return null to drop it. Callback errors drop the payload safely. |
| 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 |
| onBatchShrunk | function | undefined | n/a | Called when a 413 lowers the active maxBatchSize. No data is dropped; singleton oversized payloads are reported separately as oversized_payload_dropped. |
* 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. A consumed monthly budget pauses untilsnapshot.resetAt. A zero allowance means runtime access is inactive and is rechecked every five minutes, so starting a trial or choosing a plan resumes an already-running beacon.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 bundle paths plus best-effort generated line/column positions when the deployed file is readable by the Node process.
Source maps are uploaded from CI, not by the beacon. The beacon runs in production and has no access to .map files. Upload them after the build with FALLOW_API_KEY=... fallow coverage upload-source-maps --dir dist --git-sha "$GITHUB_SHA"; the ingest pipeline resolves positions to original sources automatically. See the public beacon guide for the full workflow.
Process lifetime
The beacon's first snapshot is a full observation, not a warm-up baseline. A function that runs only while the process boots (module initialisers, schema and client construction, connection setup) has already incremented its counter by the time the beacon starts, and never increments again. Reporting that first snapshot is the only way such code is distinguishable from code that never ran.
Start the beacon as late as you like: whatever ran before it still lands in the first report.
The other end of the process is not symmetric. The beacon flushes on
process.beforeExit, so a shutdown handler that runs after that flush, or a
process ended by process.exit(), a signal handler that exits, or SIGKILL,
is not observed at all. Call await beacon.flush() (or await beacon.stop())
at the END of your own shutdown handler to capture it, and see the limitation
below for what remains uncapturable.
await beacon.flush() joins any automatic transport send already in progress,
including its network retries. The Node beacon's await beacon.stop() waits for
that drain before tearing down the transport. A server-directed rate-limit or
budget pause can still leave payloads queued for a later flush.
Known limitations
- Shutdown code that runs after the last flush.
beforeExitdoes not fire when the process is ended byprocess.exit()or by a signal, and any teardown that runs after the beacon's own flush is by definition unobserved. Such functions are reported as never called, not as untracked. Flush from the end of your shutdown handler to narrow the window; the final teardown step cannot report itself. - Worker threads (V8 and Istanbul). Each worker has its own isolate
and its own
globalThis. The main-thread beacon does not see worker coverage. To collect worker coverage, provide apostMessage-based export. - Coverage under the
node:specifier prefix. Filtered out by default in V8 mode.
License
MIT
