@xeonr/forrest
v0.1.0
Published
Client SDK for Forrest — send logs, metrics and analytics events from anything that runs JavaScript.
Readme
@xeonr/forrest
Send logs, metrics and analytics events to Forrest from anything that runs JavaScript — browsers, Node, Deno, Bun, React Native, Cloudflare Workers.
Zero dependencies. One class.
import { Forrest } from "@xeonr/forrest";
const forrest = new Forrest({ url: "https://forrest.prod.wtf", token });
forrest.info("user signed in");
forrest.error(new Error("upload failed"));
forrest.metric("fps", 59.5);
forrest.event("checkout_completed", { plan: "pro" });That's the whole API. Records are queued and sent in batches in the background;
nothing blocks and nothing throws — a telemetry client that can break the
application it observes is worse than no telemetry. Delivery problems go to
onError.
Getting a token
A device never holds your API key. Your backend holds it and hands out short-lived per-device tokens:
// server-side only
import { mintToken } from "@xeonr/forrest/server";
app.get("/telemetry-token", async (req, res) => {
const token = await mintToken({
apiKey: process.env.FORREST_API_KEY!, // fk_<id>_<secret>
clientId: req.user.deviceId, // your own id for this device
ttlSeconds: 3600,
attrs: { plan: req.user.plan }, // stamped on every record
});
res.json({ token });
});Minting lives at @xeonr/forrest/server — a separate entry point so it can
never be pulled into a browser bundle by accident. Never ship your API key to
a device.
Tokens expire, so pass a function and the client refreshes on its own:
const forrest = new Forrest({
url: "https://forrest.prod.wtf",
token: async () => (await fetch("/telemetry-token").then((r) => r.json())).token,
});The token is a plain HS256 JWT with the key id in kid, so any language with an
HMAC can mint one. mintToken just saves you reaching for a JWT library.
Options
| Option | Default | |
|---|---|---|
| url | — | Your forrest host. Required. |
| token | — | A token, or a function returning one. Required. |
| attrs | — | Merged into every record; per-record attrs win. |
| logger | — | Sent as the logger on every log line. |
| batchSize | 100 | Flush once this many records are queued. |
| flushInterval | 5000 | Flush at least this often (ms). 0 disables. |
| maxQueue | 10000 | Queue cap; past it the oldest are dropped. |
| retries | 3 | Attempts per batch. |
| onError | — | Delivery failures and dropped records. |
| onResponse | — | The server's reply, including dropped field names. |
| fetch | globalThis.fetch | Override for tests or unusual runtimes. |
Custom fields
Attributes must be declared on the project before they can be filtered on. Anything undeclared is dropped server-side and named in the response — sending it is never an error, so shipping a new field before declaring it will not break your fleet. To see what is being dropped:
new Forrest({
url, token,
onResponse: (r) => {
if (r.droppedFields) console.warn("forrest dropped:", r.droppedFieldNames);
},
});forrest.error(err) puts the stack in the log body, the way
console.error(err) does — not in an attribute, because undeclared attributes
are dropped and a stack is not something you would filter by.
Catching crashes
const stop = forrest.captureErrors(); // uncaught errors + unhandled rejectionsOpt-in on purpose: installing global handlers from a constructor is the kind of side effect that is miserable to debug when you did not know it happened. Call the returned function to remove them.
Shutting down
await forrest.flush(); // send what is queued
await forrest.close(); // flush and stop the background timerIn browsers the client also flushes on pagehide and when the tab is hidden,
using sendBeacon so the batch survives the page going away. In Node the
background timer is unref'd, so it never keeps a process alive.
Behaviour worth knowing
- Retries are exponential with jitter, and honour
Retry-Afteron a 429. A 4xx that is not 429 is not retried — a bad token does not fix itself. - The queue is capped. A long offline stretch drops the oldest records rather than growing until the tab or process dies.
- Batches are all-or-nothing. A rejected batch is not partially applied.
