Skip to the content

Architecture

The packages, the parts of @mark1russell7/lag, the worker, and the design rules that keep each part replaceable and testable.

Note

This page is a draft. The content is not complete and can change.

The monitors operate on the main thread of the page. They record metrics and events through ports (Meter, EventSink), and an OpenTelemetry setup exports them to the Grafana stack. Select a box on the map to read what it does.

usesusesstartsmakes loadvalidates samples withrecords metrics and eventsmeasurewritesreports hangsexportsmetricseventstraces
@lag/site
@lag/report
@mark1russell7/ste-lint
@lag/load
@mark1russell7/lag/worker
@mark1russell7/lag
otel-ts and the OpenTelemetry SDK
Browser main thread
Web Worker
IndexedDB hang journal
OTLP over HTTP
Grafana Alloy
Mimir
Loki
Tempo
Grafana dashboards
Monitor registry
Instrumented factories
Monitors
Lifecycle state machine
Measurement conditions
Metric catalog
Browser adapter
Page-view vitals
Clocks

Package: a box with a solid border. Module of @mark1russell7/lag: a shaded box. Runtime: a box with a dashed border. Backend: a round box. An arrow points from the part that uses, starts or sends to the part that it acts on.

Show the map as a list
  • @lag/site (package). This website. It shows the documentation, the test results and a live playground. The playground uses @mark1russell7/lag, @mark1russell7/lag/worker and @lag/load. Arrows to: @mark1russell7/lag (uses), @lag/report (reads).
  • @lag/report (package). The data contract of the test reports, and the converters from the reports of Vitest, Istanbul and Stryker. The results viewer reads these files.
  • @mark1russell7/ste-lint (package). Examines the text of the site, the READMEs and the TSDoc comments against the writing rules of ASD-STE100. It is an automated approximation, not a certification. Arrows to: @lag/site (examines the text of).
  • @lag/load (package). Makes synthetic main-thread load: busy loops, layout thrash, garbage, long animation frames and task floods. The tests and the playground use it. Arrows to: Browser main thread (makes load).
  • @mark1russell7/lag/worker (package). The export ./worker of the package. createLagWorker() starts the Web Worker for the worker-lag monitor. The worker sends heartbeats, detects hangs, and keeps the hang journal. The caller owns the worker and stops it with terminate(). Arrows to: @mark1russell7/lag (uses), Web Worker (starts).
  • @mark1russell7/lag (package). The monitors, their OpenTelemetry wiring and setupAllMonitors(). The caller gives every browser API, thus the package has no DOM or OpenTelemetry dependency.
  • Monitor registry (module of @mark1russell7/lag). setupAllMonitors() adds the handle of each monitor to a MonitorRegistry. stopAll() stops the handles in the reverse sequence. Arrows to: Instrumented factories (holds the handles of).
  • Instrumented factories (module of @mark1russell7/lag). One factory for each monitor. A factory makes the monitor, makes its instruments from the metric catalog, and gives a MonitorHandle with a stop(). Arrows to: Monitors (makes), Measurement conditions (validates samples with), Metric catalog (makes instruments from), otel-ts and the OpenTelemetry SDK (records metrics and events).
  • Monitors (module of @mark1russell7/lag). Classes that each measure one signal of main-thread health, for example DriftLag, WorkerLagMonitor and EventTimingMonitor. Arrows to: Browser main thread (measure).
  • Lifecycle state machine (module of @mark1russell7/lag). LifecycleStateMachine follows the Page Lifecycle state, with capture-phase listeners. Its subscribers know about each change before an exporter flushes.
  • Measurement conditions (module of @mark1russell7/lag). The reliability tracker and the sample validators. They discard each sample that overlaps a hidden, frozen or suspended interval, and they classify very long samples as a hang or a suspend. Arrows to: Lifecycle state machine (pauses with).
  • Metric catalog (module of @mark1russell7/lag). The single list of every metric and event: name, type, unit and the permitted attribute values. The factories and this website read it, and a test makes sure that the code agrees with it.
  • Browser adapter (module of @mark1russell7/lag). createBrowserDeps(window, options) is the only part that reads the browser globals. It examines each API, so that each browser gets the monitors that it can support.
  • Page-view vitals (module of @mark1russell7/lag). The Core Web Vitals of each page view (a load, a back/forward cache restore or a soft navigation), with the rules of web-vitals. Each event gets the ID of the current page view.
  • Clocks (module of @mark1russell7/lag). One absolute clock that reads timeOrigin one time, the clock-drift monitor that detects suspends and clock steps, and the clock sync with the worker.
  • otel-ts and the OpenTelemetry SDK (runtime). otel-ts starts the OpenTelemetry SDK in the page: one service.instance.id for each SDK instance, exponential histograms, and a flush when the page becomes hidden. Its onBeforeFlush hook uses monitors.flush(). Arrows to: OTLP over HTTP (exports).
  • Browser main thread (runtime). Does the work of the app and of the monitors. The monitors measure how long the main thread is busy.
  • Web Worker (runtime). Sends heartbeats from its own timer. The delay until the main thread handles a heartbeat shows main-thread blocking. During a hang, the worker reports the hang itself. Arrows to: Browser main thread (sends heartbeats to), IndexedDB hang journal (writes), OTLP over HTTP (reports hangs).
  • IndexedDB hang journal (runtime). The worker keeps a record of each hang in progress. The next page of the origin reports the records of hangs that a page did not survive.
  • OTLP over HTTP (runtime). The OpenTelemetry protocol. The SDK sends the metrics and the events with it. The worker sends its hang reports in the same format. Arrows to: Grafana Alloy.
  • Grafana Alloy (backend). Receives the OTLP data and sends each signal to its store. Arrows to: Mimir (metrics), Loki (events), Tempo (traces).
  • Mimir (backend). Stores the metrics, as native histograms and counters. Arrows to: Grafana dashboards.
  • Loki (backend). Stores the events (log records with an event name). The details go into structured metadata. Arrows to: Grafana dashboards.
  • Tempo (backend). Stores the traces. Arrows to: Grafana dashboards.
  • Grafana dashboards (backend). Shows the dashboards. The dashboards read from Mimir, Loki and Tempo.
The parts of the system and how they connect. The list under the map gives the same information as text.

The layers

Show the diagram source
flowchart TB
    G[Browser globals: window, performance, indexedDB] --> A[Browser adapter: createBrowserDeps]
    A --> D[AllMonitorDeps: dependency groups]
    D --> S[setupAllMonitors: the composition root]
    S --> R[MonitorRegistry]
    R --> H[One MonitorHandle for each monitor]
    H --> M[Monitor class: measures one signal]
    H --> I[Instruments from the metric catalog]
    I --> P[Ports: Meter and EventSink]
    P --> O[OpenTelemetry adapters]

Only the browser adapter reads the browser globals. All other parts get their dependencies as arguments. Thus a test can give a fake clock, fake timers and a fake PerformanceObserver, and examine each rule with exact values.

Inside @mark1russell7/lag

PartFilesWhat it does
MonitorsDriftLag.ts, WorkerLagMonitor.ts, vitals/PageViewVitals.ts and the other monitor classesEach class measures one signal. It gets its dependencies in its constructor and has start() and stop().
Instrumented factoriesinstrumented/*.tsOne factory for each monitor. The factory makes the monitor, makes its instruments from the catalog, connects the measurement conditions, and gives a MonitorHandle.
Registrymonitor-registry.tsKeeps the handles. stopAll() stops them in the reverse sequence of their start.
Composition rootsetup-all-monitors.tsStarts each monitor whose dependencies exist. Connects the parts that work together, for example the page-view ID for the worker.
Measurement conditionsreliability.ts, measurement-conditions.tsOne shared service: the intervals in which samples are not valid, and the validators that discard or classify samples.
Metric catalogmetric-catalog.tsEvery metric and event, with its name, type, unit and permitted attribute values.
LifecycleLifecycleStateMachine.tsThe Page Lifecycle state, with capture-phase listeners.
Clocksabsolute-clock.ts, ClockDriftMonitor.ts, WorkerClockSync.tsThe absolute clock of the page, the drift monitor, and the clock sync with the worker.
Browser adapterbrowser/*.tscreateBrowserDeps, createPageSource and createIndexedDbHangJournal.

Ports and adapters

The core depends on small interfaces (ports). Adapters connect the ports to real systems.

PortWhat it givesAdapters
MeterCounters and histogramsAn OpenTelemetry Meter fits it directly. createNoopMeter() for no output.
EventSinkStructured eventscreateOtelEventSink(logger): OpenTelemetry log records with an event name. withEventContext() adds the page-view ID.
LoggerDiagnostic messages of the monitorscreateOtelLoggerAdapter(), createTeeLogger()
Clock, WallClock, AbsoluteClockperformance.now(), Date.now(), and the absolute time of the contextcreateAbsoluteClock(performance)
PageSourceThe navigation entry, the prerender state, the hidden times and the URL of the documentcreatePageSource(document, performance)
HangJournalStorage for hangs in progresscreateIndexedDbHangJournal(indexedDB), createMemoryHangJournal()
WorkerLikeThe message channel to the workercreateLagWorker() of @mark1russell7/lag/worker
CrashReportContextLikeThe crash-report context of Chromiumwindow.crashReport

The worker

The worker bundle (@mark1russell7/lag/worker) operates createWorkerHandler() of the core. The main thread and the worker talk with these messages:

DirectionMessageWhat it does
Main to workerstartStarts the heartbeats, with the hang options and the page ID. The page ID comes only with a hang journal. A second start ends a hang in progress.
Main to workerackAcknowledges one heartbeat. The worker uses the acknowledgements to detect hangs.
Main to workersyncAsks for the time of the worker, for the clock sync.
Main to workercontextGives the context of the page, for example the page-view ID.
Main to workerliveness-start, liveness-stopStarts and stops the shared-memory liveness watcher.
Main to workerstopStops the heartbeats. A hang in progress ends, because the main thread sent the message.
Worker to mainheartbeatThe sequence number, the absolute send time and the lateness of the timer of the worker.
Worker to mainsync-replyThe time of the worker.
Worker to mainhang-endedA hang ended. The main thread reads it when it can operate again, also while the monitor is stopped, but not after dispose().
Worker to mainliveness-blockA block that the liveness watcher saw.
Show the diagram source
sequenceDiagram
    participant W as Worker
    participant M as Main thread
    participant C as Collector
    W->>M: heartbeat 1
    M->>W: ack 1
    Note over M: A long task starts
    W--xM: heartbeats wait in the queue
    Note over W: 5 s without an ack
    W->>C: hang started (fetch with keepalive)
    W->>W: write the hang journal each second
    Note over M: The long task ends
    M->>W: ack
    W->>M: hang-ended
    W->>C: hang ended

The path of a sample

Show the diagram source
flowchart LR
    P[Probe: a timer step, a heartbeat, a frame] --> V{Sample validator}
    V -->|overlaps hidden, frozen or suspend| X[Discard: lag_samples_discarded]
    V -->|5000 ms or more| Q[Quarantine for 2000 ms]
    Q -->|overlaps an interval now| X
    X -->|suspend, 5000 ms or more| S[Stall sample, kind suspend]
    Q -->|no evidence| K[Stall sample, kind hang]
    S --> N[Stall episode: lag_stalls]
    K --> N
    V -->|valid| H[Histogram]
    K --> H
    H --> E[Export at the interval and before page hide]

Refer to measurement validity for the rules.

Design rules

The design obeys the SOLID principles:

PrincipleHow the library obeys it
Single responsibilityA monitor measures. A factory connects. The registry stops. The adapter reads the browser. The catalog names.
Open for extensionA new monitor is a new class, a new factory, new catalog entries and one line in setupAllMonitors(). No other part changes.
SubstitutionEach handle has the same MonitorHandle shape. The registry stops each handle in the same way.
Interface segregationEach factory asks only for the dependency groups that it uses (dep-groups.ts), for example CoreDeps & ObserverDeps.
Dependency inversionThe monitors depend on ports (duck types), not on the DOM or on OpenTelemetry.

More rules:

  • No globals in the core. Only createBrowserDeps reads window.
  • Error boundaries. A factory that fails gives a handle with no monitor, and the other monitors continue. A listener or a report function that throws does not stop the monitor.
  • Full teardown. stop() releases each timer, listener, observer and worker loop. The tests make sure that no timer or listener stays. The soak test (soak/soak.test.ts, on request with pnpm test:soak) also operates all monitors for 3 minutes under load. Then it makes sure that no timer, animation frame or idle callback stays, and that the heap did not grow without limit.
  • One source for names. Metric names, units and attribute values come only from the catalog.

How to add a monitor

  1. Write the monitor class in packages/lag/src. Give it its dependencies in the constructor.
  2. Add its metrics and events to metric-catalog.ts.
  3. Write its factory in packages/lag/src/instrumented. Make the instruments with createHistogram and createCounter from the catalog.
  4. Add one line to setupAllMonitors(), behind the check of its dependencies.
  5. Write the unit tests, and add the metrics to the activity of setup-all-monitors.test.ts. The catalog test then makes sure that the metrics agree with the catalog.
  6. Write its reference page in packages/site/content/docs/monitors.

lag: Main-thread responsiveness monitoring for browser apps, exported as OpenTelemetry metrics.

To change a page, edit its file in packages/site/content/. The writing style guide tells you how.

An AI model (Claude, from Anthropic) wrote most of the text and the code of this site and of the library, under the direction of the author. The tests and the STE linter examine them. The writing standard gives the reason for this note.