@xeonr/telemetry-web
v0.2.0
Published
Browser telemetry for Xeonr consoles: errors, reachability, web vitals and RPC spans as OTLP/HTTP JSON to Lantern's client ingest, and a reader for a client project's own telemetry (./read). Replaces Sentry.
Readme
@xeonr/telemetry-web
Browser telemetry for Xeonr consoles, sent to Lantern's client ingest
(https://in.prod.wtf; 0.1.0 defaulted to telemetry.xeonr.io, which still
works as an alias — the name moved because telemetry. hosts are on browser
block lists and home DNS filters). It replaces Sentry: uncaught errors, failed and
timed-out requests, web vitals and one span per RPC, as OTLP/HTTP JSON, with
an offline buffer so a console that cannot reach us still reports once it
can. No runtime dependencies; the browser entry is ~9 KiB min+gzip.
Every console that mounts @xeonr/console-shell ≥ 0.7 already has it — the
shell's ConsoleProvider starts it. Use this package directly on a page
without the shell, or for the extra pieces below (your own Connect clients,
the Vite plugin).
Design: fleet observability plan §10.2; wire contract:
lantern/docs/WAVE4-client-ingest.md §7 (both in platform-docs).
Install
pnpm add @xeonr/telemetry-webStart it
import { init } from "@xeonr/telemetry-web";
init({
service: "functions-ui", // the Lantern client project; default <meta name="lantern-service">
product: "functions", // service.namespace
// release: default <meta name="lantern-release">, else "dev"
// endpoint: default https://in.prod.wtf
// sampleRate: 0..1 for RPC spans and web vitals; errors and reachability always go
// enabled: default on, except on localhost/*.localhost/*.test and under
// <meta name="lantern-telemetry" content="off">
});Once per page: a second init returns the first client. Nothing the SDK does
ever throws into the page, and it never reports its own sends.
Authentication is the public-origin model: every request goes to
<endpoint>/v1/logs?service=<service> (or /v1/traces), credentials: "omit",
as text/plain so no request needs a preflight. The ingest accepts it only
from an origin on the project's allow-list.
What it sends
| Record | When | Key attributes |
|---|---|---|
| exception (ERROR) | window error, unhandled rejection, the React boundary, captureException, a kit error toast | exception.type/message/stacktrace (raw stack — Lantern symbolicates), exception.mechanism, code.filepath/lineno/column, url.path, http.response.header.x-trace-id, app.breadcrumbs |
| client.request.failed (WARN) | a request through wrapFetch or the interceptor that got no answer, timed out, or failed server-side | server.address, rpc.method, error.type = timeout\|http_5xx\|cors\|network\|aborted, client.online, client.since_last_success_ms |
| web_vital (INFO, sampled) | LCP, INP, CLS (on hide), FCP, TTFB | web_vital.name/value/rating |
| page_view, route_change (INFO) | load; trackRoute(path) | url.path, document_load.* |
| CLIENT span (sampled) | every RPC / wrapped fetch | named by the procedure; rpc.method, server.address, rpc.response.status_code |
Every record carries session.id (per tab session); the resource carries
service.instance.id (per browser install), telemetry.sdk.* and browser.*.
Breadcrumbs are the last 20 route changes and request outcomes — procedure and
code, never a payload. Messages lose URL query strings, email addresses and
bearer-looking secrets before they leave; stack frames are sent untouched.
A browser cannot tell DNS from TLS from a refused connection: fetch rejects
with the same TypeError for all of them. Offline is network; a same-origin
failure is network; a cross-origin one is cors if a no-cors probe of the
same URL gets an answer, else network. dns and tls are never sent from a
browser.
Delivery: batches every 5 s or at 50 records. A send that gets no answer, a
408, a 429 or a 5xx moves its records to localStorage (at most 200 records /
256 KiB, oldest non-errors dropped first) and waits for Retry-After or a
backoff; the next page load replays them. On pagehide / hidden, whatever is
left goes by navigator.sendBeacon.
Errors
import { captureException, traceIdFrom } from "@xeonr/telemetry-web";
try { await save(); } catch (err) {
captureException(err, { extra: { "app.form": "schedule" } }); // route and X-Trace-Id are filled in
showError(err, traceIdFrom(err)); // "trace 1a2b3c4d… [copy]"
}traceIdFrom(errOrResponse) reads X-Trace-Id from a ConnectError's
metadata, a fetch Response's headers, or a traceId (and from their
cause). The kit's TraceRef renders it.
React
import { TelemetryErrorBoundary } from "@xeonr/telemetry-web/react";
<TelemetryErrorBoundary resetKeys={[pathname]} fallback={({ error, traceId, reset }) => <Oops traceId={traceId} onRetry={reset} />}>
<App />
</TelemetryErrorBoundary>Requests
// ConnectRPC: first in the list, so it times what the others do.
import { telemetryInterceptor } from "@xeonr/telemetry-web/connect";
createConnectTransport({ baseUrl, interceptors: [telemetryInterceptor(), ...yours] });
// fetch
import { wrapFetch } from "@xeonr/telemetry-web";
const api = wrapFetch(); // wraps the global fetch; or wrapFetch(myFetch)A same-origin call carries a W3C traceparent, so the browser span parents the
server's trace at the edge. A cross-origin API gets none unless you say so —
its CORS policy has to allow the header first:
init({ service: "x-ui", getTraceHeaders: (url, { traceparent }) => url.hostname === "api.x.xeonr.io" ? { traceparent } : undefined });Releases and source maps (Vite)
// vite.config.ts
import { lanternTelemetry } from "@xeonr/telemetry-web/vite";
export default defineConfig({
plugins: [react(), lanternTelemetry({ service: "functions-ui" })],
});The plugin:
- writes
<meta name="lantern-service">and<meta name="lantern-release">intoindex.html— the release isVERSION, elseVITE_APP_VERSION, elsedev— so the SDK needs no configuration for either; - forces
build.sourcemap = "hidden"for the browser's build (maps are written, no bundle links them). A server environment (SSR, a Worker) writes none unless its ownenvironments.<name>.build.sourcemapasks for them; - after the browser's build, with
CI_JOB_TOKEN(orLANTERN_RELEASE_KEY) set, registers the release (POST /v1/releases) and uploads every**/*.mapin that build's outDir (gzipped) to/v1/sourcemaps?service=&release=&path=keyed by the.jsit belongs to. A map with emptymappings(an empty chunk's) is skipped.LANTERN_INGEST_URLmoves the endpoint. A failed upload warns; it never fails the build; - deletes the
.mapfiles from that outDir, so they are never served.
The CI job token must come from a GitLab project listed in the Lantern client project's CI projects (LAN-136); a release key needs no binding.
Vite 6+ environment builds (React Router framework mode, SSR, Workers)
Vite calls the plugin once per environment. Only the client environment's
build is a browser's, so the upload reads that environment's own outDir
(build/client in React Router), never the top-level dist nothing writes,
and the server's build is left alone. There is no index.html to put the
tags in, so render them from the values the plugin defines:
// root.tsx
<meta name="lantern-service" content={import.meta.env.VITE_LANTERN_SERVICE} />
<meta name="lantern-release" content={import.meta.env.VITE_LANTERN_RELEASE} />The k8s pipeline already passes CI_JOB_TOKEN and VERSION to every image
build; declare them in the builder stage, before vite build:
FROM node:24-slim AS builder
WORKDIR /src
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
ARG CI_JOB_TOKEN
ARG VERSION
RUN pnpm run build # vite build: meta tags, hidden maps, upload, maps deleted
FROM nginx:alpine
COPY --from=builder /src/dist /usr/share/nginx/htmlThe ARGs sit after the install so a new version or token does not bust the
dependency layer, and they never reach the final stage.
Reading telemetry back (./read)
A product that shows its own customers their devices' logs (an admin "Logs" pane, a TV's session log) reads them from Lantern with a client token: a device token minted from one of the client project's device keys with a read grant, by the product's backend. The grant is the whole authorization; lantern-ingest ANDs its filters onto every read, so the page can narrow and never widen.
import { createReader } from "@xeonr/telemetry-web/read";
const reader = createReader({ token: () => api.mintTelemetryReadToken(scope) });
// History, newest first, cursor-paged.
const { records, nextCursor } = await reader.listLogs(
{ minSeverity: "warn", filters: [{ key: "attr:session_id", values: [sessionId] }] },
{ limit: 200 },
);
// Events, facets and a timeline.
await reader.listEvents({ from });
await reader.facets("logs", ["severity", "service.instance.id"], { from });
await reader.timeline("logs", { from }, "severity");
// Live: records as they land. Reconnects by itself (a stream lasts 30
// minutes, and the token function is called again each time); "refused"
// is a verdict and stops it.
const tail = reader.tail({ onRecords: (rs) => append(rs), onStatus: (s, why) => show(s, why) });
tail.close();Filter keys are service.instance.id (the device, install or tab),
service.version, severity, body, event.name, trace_id, span_id
and attr:<key> for a record's own attribute. It is the Connect protocol
over JSON with no dependencies, so React Native can use it too, except
tail(), which needs a streaming fetch body. Poll listLogs there. The
service is lantern.api.v1.ClientReadService (xeonr/protocol
modules/lantern-api).
Testing against it
createClient(options, env) builds a client against an injected environment
(fetch, storage, timers, events) instead of the page's, without touching the
page-wide singleton.
