JORVEL

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

Sources (@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

ts
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

ts
import { collectWebVitals, useConsoleAdapter } from '@jorvel/observability';
useConsoleAdapter();
collectWebVitals();
// Reports LCP / INP / CLS / TTFB / FCP as metrics

Vitals details

FCP is read from the 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.

ts
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

Workers, Edge functions, and unit tests can supply { transport: async (batch) => fetch(...) } instead of endpoint to skip the sendBeacon path entirely.

Sentry adapter

ts
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.

ts
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.

ts
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

ts
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

HookPayload
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

ts
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 > 1500ms for 5 minutes.
  • Remote load error rate — alert at > 1% for 5 minutes.
  • JS errors per session — alert at > 0.5 rolling 1h.

Don't double-report

If both your global error handler and a per-component 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.

ts
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.

ts
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

ts
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.

ts
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.

ts
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.

ts
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();