JORVEL

@jorvel/observability

Three hooks bridge runtime telemetry to your collector of choice. Adapters wire the hooks to a specific backend; the library never sends anything by itself. This shape keeps the package small and lets you swap collectors without touching application code.

The hook contract

Every runtime event flows through one of three functions: reportError, reportMetric, reportRemoteLoad. Adapters subscribe via onError / onMetric / onRemoteLoad. No global state beyond the handler set; safe in tests.

Hooks

ts
type Source = 'host' | 'remote' | 'ssr' | 'sw';

interface ErrorEvent {
  error: unknown;
  source: Source;
  context?: Record<string, unknown>;
}
interface MetricEvent {
  name: string;
  value: number;
  tags?: Record<string, string>;
  ts?: number;
}
interface RemoteLoadEvent {
  remote: string;
  phase: 'start' | 'success' | 'error';
  durationMs: number;
  cached?: boolean;
  error?: unknown;
}

onError(handler: (e: ErrorEvent) => void): () => void;
onMetric(handler: (m: MetricEvent) => void): () => void;
onRemoteLoad(handler: (e: RemoteLoadEvent) => void): () => void;

reportError(e: ErrorEvent): void;
reportMetric(m: MetricEvent): void;
reportRemoteLoad(e: RemoteLoadEvent): void;

clearHandlers(): void;            // tests

Subscribing

ts
import { onError, onMetric, onRemoteLoad } from '@jorvel/observability';

onError((e) => Sentry.captureException(e.error, { extra: e.context }));
onMetric((m) => fetch('/beacon', { method: 'POST', body: JSON.stringify(m), keepalive: true }));
onRemoteLoad((e) => {
  if (e.phase === 'error') Sentry.captureMessage(`remote load failed: ${e.remote}`);
});

Reporting

ts
import { reportError, reportMetric, reportRemoteLoad } from '@jorvel/observability';

try {
  await save();
} catch (err) {
  reportError({ error: err, source: 'host', context: { route: '/dashboard/settings' } });
}

reportMetric({ name: 'route.duration', value: 142, tags: { route: '/dashboard' } });

Structured logger

Emits one JSON line per record by default. Bindings are merged into every record from the child logger; use logger.child({ remote: "dashboard" }) to scope.

ts
createLogger(opts?: {
  name?: string;
  level?: 'debug' | 'info' | 'warn' | 'error';
  bindings?: Record<string, unknown>;  // included on every record
  sink?: (record: LogRecord) => void;  // default: console + JSON line
}): Logger;

interface Logger {
  debug(msg: string, ctx?: object): void;
  info(msg: string, ctx?: object): void;
  warn(msg: string, ctx?: object): void;
  error(msg: string, ctx?: object): void;
  child(bindings: Record<string, unknown>): Logger;
}

interface LogRecord {
  time: string;
  level: 'debug' | 'info' | 'warn' | 'error';
  name?: string;
  msg: string;
  ctx?: Record<string, unknown>;
}
ts
import { createLogger } from '@jorvel/observability';

export const log = createLogger({
  name: 'shell',
  level: 'info',
  bindings: { svc: 'shell', region: process.env.REGION },
});

log.info('boot complete', { durationMs: 240 });
// {"time":"...","level":"info","name":"shell","msg":"boot complete","ctx":{"svc":"shell","region":"us-east-1","durationMs":240}}

Web Vitals

Wraps the web-vitals library and forwards each metric through reportMetric. By default reports the final value only — set reportAllChanges for intermediate readings (useful for CLS debugging).

ts
collectWebVitals(opts?: {
  metrics?: Array<'LCP' | 'CLS' | 'FID' | 'INP' | 'TTFB' | 'FCP'>;
  reportAllChanges?: boolean;
}): () => void;                  // returns disposer
apps/shell/src/bootstrap.tsx
ts
import { collectWebVitals } from '@jorvel/observability';

if (typeof window !== 'undefined') {
  collectWebVitals({ metrics: ['LCP', 'CLS', 'INP'] });
}

Adapters

Pre-built bridges to common collectors. Each adapter returns a disposer.

Console

ts
useConsoleAdapter(opts?: {
  level?: 'debug' | 'info' | 'warn' | 'error';
  metrics?: boolean;             // default true — emit metrics as console.debug
}): () => void;

Sentry

ts
useSentryAdapter(Sentry: typeof import('@sentry/browser'), opts?: {
  tags?: Record<string, string>;
  beforeReport?: (e: ErrorEvent) => boolean;  // false → drop
}): () => void;
ts
import * as Sentry from '@sentry/browser';
import { useSentryAdapter } from '@jorvel/observability';

Sentry.init({ dsn: '...', tracesSampleRate: 0.1 });
const dispose = useSentryAdapter(Sentry, {
  tags: { app: 'shell' },
  beforeReport: (e) => !isExpectedError(e.error),
});

OpenTelemetry

ts
useOtelAdapter(opts: {
  tracer?: Tracer;
  meter?: Meter;
  serviceName: string;
  serviceVersion?: string;
}): () => void;

RUM beacon

Pure-browser real-user-monitoring helper. Batches metrics + errors, flushes via navigator.sendBeacon on pagehide so the last payload survives a tab close.

ts
createRumBeacon(opts: {
  url: string;                    // POST endpoint
  flushIntervalMs?: number;        // default 10_000
  maxBatch?: number;               // default 50
  meta?: () => Record<string, unknown>;   // attached to every flush
}): { dispose(): void };

Fingerprint

Compute a stable error-grouping key. Strips remote-specific path prefixes so the same bug across federation deploys groups together — the Sentry default groups by stack frame, which breaks when chunk hashes change.

ts
computeFingerprint(opts: {
  error: unknown;
  remote?: string;
  source?: Source;
  stripPrefixes?: string[];
}): string[];                     // ready to pass as Sentry's fingerprint

// Shorthand
groupBy(opts: Parameters<typeof computeFingerprint>[0]): string[];

End-to-end pattern

Wire Web Vitals + a structured logger + Sentry in the shell bootstrap. Adapters are idempotent — calling them twice in StrictMode is safe.

apps/shell/src/bootstrap.tsx
ts
import * as Sentry from '@sentry/browser';
import {
  collectWebVitals,
  createLogger,
  useSentryAdapter,
  useConsoleAdapter,
} from '@jorvel/observability';

Sentry.init({ dsn: process.env.SENTRY_DSN });

export const log = createLogger({ name: 'shell' });

if (process.env.NODE_ENV === 'production') {
  useSentryAdapter(Sentry, { tags: { app: 'shell' } });
  collectWebVitals();
} else {
  useConsoleAdapter({ level: 'debug', metrics: false });
}