Architecture
The packages, the parts of @mark1russell7/lag, the worker, and the design rules that keep each part replaceable and testable.
Note
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.
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/workerand@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
./workerof 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 withterminate(). 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 aMonitorRegistry.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
MonitorHandlewith astop(). 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,WorkerLagMonitorandEventTimingMonitor. Arrows to: Browser main thread (measure). - Lifecycle state machine (module of @mark1russell7/lag).
LifecycleStateMachinefollows 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
timeOriginone 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-tsstarts the OpenTelemetry SDK in the page: oneservice.instance.idfor each SDK instance, exponential histograms, and a flush when the page becomes hidden. ItsonBeforeFlushhook usesmonitors.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 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
| Part | Files | What it does |
|---|---|---|
| Monitors | DriftLag.ts, WorkerLagMonitor.ts, vitals/PageViewVitals.ts and the other monitor classes | Each class measures one signal. It gets its dependencies in its constructor and has start() and stop(). |
| Instrumented factories | instrumented/*.ts | One factory for each monitor. The factory makes the monitor, makes its instruments from the catalog, connects the measurement conditions, and gives a MonitorHandle. |
| Registry | monitor-registry.ts | Keeps the handles. stopAll() stops them in the reverse sequence of their start. |
| Composition root | setup-all-monitors.ts | Starts each monitor whose dependencies exist. Connects the parts that work together, for example the page-view ID for the worker. |
| Measurement conditions | reliability.ts, measurement-conditions.ts | One shared service: the intervals in which samples are not valid, and the validators that discard or classify samples. |
| Metric catalog | metric-catalog.ts | Every metric and event, with its name, type, unit and permitted attribute values. |
| Lifecycle | LifecycleStateMachine.ts | The Page Lifecycle state, with capture-phase listeners. |
| Clocks | absolute-clock.ts, ClockDriftMonitor.ts, WorkerClockSync.ts | The absolute clock of the page, the drift monitor, and the clock sync with the worker. |
| Browser adapter | browser/*.ts | createBrowserDeps, createPageSource and createIndexedDbHangJournal. |
Ports and adapters
The core depends on small interfaces (ports). Adapters connect the ports to real systems.
| Port | What it gives | Adapters |
|---|---|---|
Meter | Counters and histograms | An OpenTelemetry Meter fits it directly. createNoopMeter() for no output. |
EventSink | Structured events | createOtelEventSink(logger): OpenTelemetry log records with an event name. withEventContext() adds the page-view ID. |
Logger | Diagnostic messages of the monitors | createOtelLoggerAdapter(), createTeeLogger() |
Clock, WallClock, AbsoluteClock | performance.now(), Date.now(), and the absolute time of the context | createAbsoluteClock(performance) |
PageSource | The navigation entry, the prerender state, the hidden times and the URL of the document | createPageSource(document, performance) |
HangJournal | Storage for hangs in progress | createIndexedDbHangJournal(indexedDB), createMemoryHangJournal() |
WorkerLike | The message channel to the worker | createLagWorker() of @mark1russell7/lag/worker |
CrashReportContextLike | The crash-report context of Chromium | window.crashReport |
The worker
The worker bundle (@mark1russell7/lag/worker) operates createWorkerHandler() of the core. The main thread and the worker talk with these messages:
| Direction | Message | What it does |
|---|---|---|
| Main to worker | start | Starts 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 worker | ack | Acknowledges one heartbeat. The worker uses the acknowledgements to detect hangs. |
| Main to worker | sync | Asks for the time of the worker, for the clock sync. |
| Main to worker | context | Gives the context of the page, for example the page-view ID. |
| Main to worker | liveness-start, liveness-stop | Starts and stops the shared-memory liveness watcher. |
| Main to worker | stop | Stops the heartbeats. A hang in progress ends, because the main thread sent the message. |
| Worker to main | heartbeat | The sequence number, the absolute send time and the lateness of the timer of the worker. |
| Worker to main | sync-reply | The time of the worker. |
| Worker to main | hang-ended | A hang ended. The main thread reads it when it can operate again, also while the monitor is stopped, but not after dispose(). |
| Worker to main | liveness-block | A 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 endedThe 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:
| Principle | How the library obeys it |
|---|---|
| Single responsibility | A monitor measures. A factory connects. The registry stops. The adapter reads the browser. The catalog names. |
| Open for extension | A new monitor is a new class, a new factory, new catalog entries and one line in setupAllMonitors(). No other part changes. |
| Substitution | Each handle has the same MonitorHandle shape. The registry stops each handle in the same way. |
| Interface segregation | Each factory asks only for the dependency groups that it uses (dep-groups.ts), for example CoreDeps & ObserverDeps. |
| Dependency inversion | The monitors depend on ports (duck types), not on the DOM or on OpenTelemetry. |
More rules:
- No globals in the core. Only
createBrowserDepsreadswindow. - 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 withpnpm 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
- Write the monitor class in
packages/lag/src. Give it its dependencies in the constructor. - Add its metrics and events to
metric-catalog.ts. - Write its factory in
packages/lag/src/instrumented. Make the instruments withcreateHistogramandcreateCounterfrom the catalog. - Add one line to
setupAllMonitors(), behind the check of its dependencies. - 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. - Write its reference page in
packages/site/content/docs/monitors.