Observability
@jorvel/observability exposes three hooks you wire to whatever backend your org uses. Runtime code dispatches telemetry events; the package bridges them to Sentry / OTEL / your own collector. The library never sends anything by itself — you pick the adapter.
Architecture
@jorvel/runtime, your code) call reportError / reportMetric / reportRemoteLoad. Subscribers (Sentry adapter, Console adapter, your code) receive every event. The bridge is in-memory; no cross-network hop until your adapter chooses to send. The hook registry is pinned to globalThis via Symbol.for(...), so duplicate MF bundles all share the same subscriber list — register an adapter once and every remote feeds it.Hooks
import { onError, onMetric, onRemoteLoad } from '@jorvel/observability';
const off = onError((e) => sendToBackend(e));
onMetric((m) => statsd.gauge(m.name, m.value, m.tags));
onRemoteLoad((e) => console.log(e.remote, e.phase, e.durationMs));Web Vitals
import { collectWebVitals, useConsoleAdapter } from '@jorvel/observability';
useConsoleAdapter();
collectWebVitals();
// Reports LCP / INP / CLS / TTFB / FCP as metricsVitals details
paint entry (first-contentful-paint). INP is now collected (FID is kept for back-compat). CLS uses the session-window algorithm rather than a lifetime sum, and the visibilitychange listener is removed on dispose.Real User Monitoring (RUM)
startRum subscribes to every error, metric, and remote-load hook, batches them, and ships each batch to your collector via navigator.sendBeacon (with a fetch fallback). Auto-flushes on visibilitychange === 'hidden', on pagehide, on a periodic interval, when the batch threshold is reached, and on dispose(). The sampleRate decision is made once per session (not per event), so a sampled-in session captures all of its events. Filtering and queue caps are built in.
import { startRum } from '@jorvel/observability';
const rum = startRum({
endpoint: 'https://rum.acme.dev/ingest',
app: 'shop',
release: process.env.GIT_SHA,
sampleRate: 0.25, // keep 25% of events
batchSize: 20,
flushIntervalMs: 10_000,
});
// Optional explicit teardown (also fires on page hide):
window.addEventListener('beforeunload', () => rum.dispose());Pass a transport for non-browser hosts
{ transport: async (batch) => fetch(...) } instead of endpoint to skip the sendBeacon path entirely.Sentry adapter
import * as Sentry from '@sentry/browser';
import { useSentryAdapter } from '@jorvel/observability';
Sentry.init({ dsn: process.env.SENTRY_DSN });
useSentryAdapter(Sentry);OpenTelemetry adapter
useOtelAdapter bridges onError + onRemoteLoad into a duck-typed Tracer. Each remote-load lifecycle becomes one span; each error becomes a stand-alone span with recordException + ERROR status.
import { trace } from '@opentelemetry/api';
import { useOtelAdapter } from '@jorvel/observability';
const tracer = trace.getTracer('jorvel-shell');
const off = useOtelAdapter(tracer, {
baseAttributes: { 'service.name': 'shell', 'service.version': '1.2.3' },
});
// later — closes any in-flight spans with ERROR status
off();Pair with the health endpoint so dashboards see both synchronous probe state and span timelines for the same remote.
Error grouping (fingerprints)
computeFingerprint (or its shorthand groupBy) returns a stable Sentry-compatible fingerprint built from the remote name, source bucket, error class, first non-node_modules stack frame, and a normalized message (ids / hex hashes / UUIDs collapsed). Two crashes from the same call site collapse into one issue.
import * as Sentry from '@sentry/browser';
import { onError, groupBy } from '@jorvel/observability';
onError((e) => {
Sentry.captureException(e.error, {
fingerprint: groupBy({
error: e.error,
remote: (e.context?.remote as string) ?? 'host',
source: e.source,
stripPrefixes: [process.cwd()],
}),
tags: { remote: (e.context?.remote as string) ?? 'host', source: e.source },
});
});Structured logger
import { createLogger } from '@jorvel/observability';
const log = createLogger({ name: 'shell', level: 'info' });
log.info('boot', { region: 'us-east' });
// {"time":"...","level":"info","name":"shell","msg":"boot","ctx":{"region":"us-east"}}Runtime telemetry source
@jorvel/runtime emits jorvel:remote-load and jorvel:error DOM events for every remote load. Observability bridges them into the hook registry automatically when you import the package.
Event shapes
| Hook | Payload |
|---|---|
onError | { error: unknown; source: 'host' | 'remote' | 'ssr' | 'sw'; context?: Record<string, unknown> } |
onMetric | { name: string; value: number; tags?: Record<string, string>; ts?: number } |
onRemoteLoad | { remote: string; phase: 'start' | 'success' | 'error'; durationMs: number; cached?: boolean; error?: unknown } |
Recipe: OpenTelemetry bridge
import { onError, onMetric, onRemoteLoad } from '@jorvel/observability';
import { trace, metrics } from '@opentelemetry/api';
const tracer = trace.getTracer('jorvel');
const meter = metrics.getMeter('jorvel');
const navDuration = meter.createHistogram('jorvel.remote.load_ms');
onRemoteLoad((e) => {
if (e.phase !== 'success') return;
navDuration.record(e.durationMs, { remote: e.remote, cached: String(!!e.cached) });
});
onError((e) => tracer.startActiveSpan('jorvel.error', (span) => {
span.recordException(e.error as Error);
span.setAttributes({ source: e.source, ...(e.context ?? {}) });
span.end();
}));
onMetric((m) => {
// route generic metrics to your collector
});Recipe: per-remote dashboard
Three SLI you almost certainly want a dashboard for. Track each by remote tag and you can spot a misbehaving service in seconds.
- Remote load p95 — alert at
> 1500msfor 5 minutes. - Remote load error rate — alert at
> 1%for 5 minutes. - JS errors per session — alert at
> 0.5rolling 1h.
Don't double-report
ErrorBoundary call reportError for the same error, Sentry counts two issues. The bundled ErrorBoundary reports automatically; if you wrap it, swallow the call or let it bubble — never both.Distributed tracing (W3C traceparent)
Propagate a traceparentfrom host → remote so a request's spans stitch together across federation boundaries.
import { generateTraceparent, parseTraceparent, propagateTraceparent } from '@jorvel/observability';
const tp = generateTraceparent(); // '00-<traceId>-<spanId>-01'
// forward it on remote fetches:
const headers = propagateTraceparent(incomingHeaders); // reuses/creates traceparent
await fetch(remoteUrl, { headers });Analytics adapters
Dependency-free pageview/event adapters (POST via fetch, no SDK) for PostHog, Plausible, and Vercel Analytics behind one AnalyticsAdapter interface.
import { posthogAdapter, plausibleAdapter } from '@jorvel/observability';
const analytics = posthogAdapter({ apiKey: process.env.POSTHOG_KEY! });
analytics.pageview(location.href);
analytics.track({ name: 'signup', properties: { plan: 'pro' } });Source-map upload & session replay
import { uploadSourcemaps, createSessionReplay } from '@jorvel/observability';
// CI post-build: ship maps to a Sentry release
await uploadSourcemaps({ distDir: 'dist', org: 'acme', release: process.env.RELEASE!, authToken: process.env.SENTRY_TOKEN!, fs });
// lightweight interaction replay (masks inputs by default)
const replay = createSessionReplay({ sink: (events) => beacon('/replay', events), bufferSize: 100 });DevTools extension
The runtime exposes window.__JORVEL__ (version, loaded remotes + SRI status, per-remote load timings, share scope). The JORVEL DevTools Chrome extension (packages/devtools-extension) renders it in a dedicated panel — load it unpacked from chrome://extensions (Developer mode → Load unpacked). MV3, no build step; the same sources load in Firefox via about:debugging.
import { getDevtoolsSnapshot } from '@jorvel/runtime';
// same data the extension reads:
const snap = getDevtoolsSnapshot();
// { version, remotes: { dashboard: { entryUrl, loadedAtMs, integrity? } }, shareScope, timings: [...] }Log drains (Datadog / Logtail / HTTP)
Batch structured logs and ship them to a platform over fetch — no SDK. Use a drain as the sink for createLogger, or drive it directly; call flush() before shutdown.
import { createLogger, datadogDrain } from '@jorvel/observability';
const drain = datadogDrain({ apiKey: process.env.DD_API_KEY!, service: 'shell', batchSize: 50 });
const log = createLogger({ sink: (entry) => drain.log(entry) });
log.info('checkout.completed', { orderId });
// also: logtailDrain({ token }), httpDrain({ endpoint, headers })
addEventListener('beforeunload', () => void drain.flush());Web Vitals dashboard
Aggregate reported vitals into a p75 + Core-Web-Vitals rating (good / needs-improvement / poor). Framework-agnostic — read getSummary() into your own UI, or drop the built-in toHTML() panel into a dashboard.
import { collectWebVitals, createWebVitalsDashboard } from '@jorvel/observability';
collectWebVitals(); // start reporting LCP/CLS/INP/FCP/TTFB
const dash = createWebVitalsDashboard(); // auto-subscribes to reported metrics
dash.getSummary(); // [{ name: 'lcp', p75, rating: 'good', count }, …]
panel.innerHTML = dash.toHTML();