@xeonr/forest
v0.4.1
Published
Client SDK for Forest — send logs, metrics and analytics events from anything that runs JavaScript.
Readme
@xeonr/forest
Send logs, metrics and analytics events to Forest from anything that runs JavaScript — browsers, Node, Deno, Bun, React Native, Cloudflare Workers.
Zero dependencies. One class.
import { Forest } from "@xeonr/forest";
const forest = new Forest({ url: "https://forest.prod.wtf", token });
forest.info("user signed in");
forest.error(new Error("upload failed"));
forest.metric("fps", 59.5);
forest.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/forest/server";
app.get("/telemetry-token", async (req, res) => {
const token = await mintToken({
apiKey: process.env.FOREST_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/forest/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 forest = new Forest({
url: "https://forest.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 forest 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. |
| release | — | Your app's release; sent as service.version on every record. |
| 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 Forest({
url, token,
onResponse: (r) => {
if (r.droppedFields) console.warn("forest dropped:", r.droppedFieldNames);
},
});forest.error(err) puts the stack in the log body, the way
console.error(err) does, so it survives on forest whatever is declared. It
also sends the OpenTelemetry exception attributes (see below), which forest
drops unless declared — expect exception.* in droppedFieldNames there.
Errors
forest.error(err);
forest.error(err, {
release: "1.4.2", // service.version (defaults to the client's `release`)
route: "/player", // url.path — the route or screen
sessionId: playbackId, // session.id — stitches the record to a session
attrs: { engine: "hls" },
});
forest.error("upload failed", { code: 500 }); // the attrs form still worksAn Error becomes a log record at level error whose attrs carry
exception.type (the error's name, or its class when a subclass never set
one), exception.message and exception.stacktrace; the options become
service.version, url.path and session.id. That is the shape Lantern's
Issues screen groups on, so device and native crashes land next to browser and
server errors. The telemetry tier (below) treats all of those keys as built-in,
so they need no declaration there.
The second argument is read as options only when every key in it is
release, route, sessionId or attrs; anything else is plain attrs, as
before options existed. So { route: "/a", code: 1 } is attrs, while a lone
{ route: "/a" } is the route option.
There is no session state in the client: pass sessionId per error, and put
"session.id" in the attrs of anything else that belongs to the session.
Endpoint
url defaults to https://in.prod.wtf (since 0.4.1): Lantern's client ingest
tier, which accepts the same /v1/ingest batch and the same device tokens
unchanged, and forwards each batch of a migrating project on to forest as well.
https://forest.prod.wtf and https://telemetry.xeonr.io route to the same tier
and keep working; the name is not a telemetry. host because those are on
browser block lists and home DNS filters (Pi-hole, NextDNS), where devices run.
new Forest({ token, release }); // → https://in.prod.wtfLeave url unset for a project that exists in Lantern; its tokens only verify
there once it does.
Catching crashes
const stop = forest.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 forest.flush(); // send what is queued
await forest.close(); // flush and stop the background timerIn browsers the client also flushes on pagehide and when the tab is hidden,
as a keepalive fetch so the batch survives the page going away. That send
carries the token in the query string, a text/plain body and no
credentials, so it needs no preflight and passes forest's wildcard CORS
policy from any origin; sendBeacon (which always sends credentials, and is
therefore refused cross-origin) is only the fallback where fetch is missing.
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.
