@symbo.ls/analyze
v3.14.779
Published
Runtime audit logger for smbls apps. Errors, warnings, lifecycle traces, browser events, network calls, and session replay — designed for smbls's declarative model so the runtime is the source of truth.
Maintainers
Keywords
Readme
@symbo.ls/analyze
Runtime audit logger for smbls apps. Errors, warnings, lifecycle traces, browser events, network calls, and session replay — designed for smbls's declarative model so the runtime is the source of truth.
Think of it as Grafana Faro built for DOMQL: error collection by default, deep tracing in debug mode, and a clean transformer→sink pipeline for analytics, AI agents, and e2e replay.
At a glance
- Auto-registered by smbls when
analyze: true|{...}is set oncreate(). - Errors and warnings on by default, everything else opt-in.
- Dormant until hydration completes — no noise from initial render, no first-paint cost beyond registration. Pre-hydration errors are buffered and drained at activation.
- Transformer → sinks pipeline: events flow through pure transforms before reaching destinations. Default:
[redact → enrich → summarize] → [console, memory]. - Browser-event capture for clicks, keys, forms, scroll, viewport, performance, console — all opt-in, all redacted by default.
- Network capture chains with
@symbo.ls/fetch(smbls-aware events with cache key, mode, transform name) AND wrapswindow.fetch+ XHR for non-smbls traffic. - Session replay uses smbls's element-tree-derived rendering to record state + input events instead of DOM mutations. Tiny payload, perfect fidelity.
- Cached error ring buffer queryable at runtime via
context.analyze.query()— for in-app debug panels and AI agents.
Defaults
import { create } from 'smbls'
create(App, {
analyze: true // → errors + warnings, redact → enrich → summarize → [console, memory]
})In production, this captures window.onerror, unhandledrejection, lifecycle handler throws, and el.warn / el.error calls — nothing else.
Configuration
create(App, {
analyze: {
enabled: true,
level: 'warn', // error | warn | info | debug | trace
capture: {
// on by default
errors: true,
warnings: true,
// browser tracking — off by default
pointer: false,
keyboard: false,
forms: false,
scroll: false,
viewport: false,
network: false,
performance: false,
navigation: false,
console: false,
// smbls internals — debug-only
lifecycle: false,
state: false,
updates: false,
// presets — expand into combinations of the above
replay: false, // full session-replay set
telemetry: false // analytics-friendly subset
},
// throttling (ms)
throttle: {
pointermove: 80,
mousemove: 80,
scroll: 100,
resize: 200
},
// privacy
redact: ['password', 'token', /secret/i, /ssn/i],
maskFormValues: true,
maskStrategy: 'mask', // 'mask' | 'hash' | 'redact'
// ring-buffer cache for memory sink
cache: {
max: 500,
dedupeWindowMs: 1000
},
// pipeline
transformers: ['enrich', 'summarize'], // 'redact' is always first
sinks: ['console', 'memory'],
// hydration gating
captureDuringHydration: false,
// debug overrides
debug: false, // or set ?analyze=debug in URL
onEvent: null // optional catch-all (ev) => void
}
})What gets captured
Always-on: errors and warnings
window.onerrorandunhandledrejection(after activation).- Lifecycle handler throws (
onClick,onUpdate, etc.). Element-core routes these throughtriggerLifecycle('error', ...)so analyze sees them with element context. Falls back toconsole.errorwhen no plugin is installed. el.warn(...)andel.error(...)from element prototype.
Browser events (opt-in)
| Category | Captures | Notes |
|---|---|---|
| pointer | click, dblclick, pointermove, contextmenu | move events throttled |
| keyboard | keydown, keyup | key codes + modifiers only, never values |
| forms | input, change, submit | values masked by default |
| scroll | window + element scroll position | throttled |
| viewport | resize, orientationchange, visibilitychange | + initial snapshot at activation |
| network | fetch + XHR | URL, method, status, duration |
| performance | LCP, CLS, INP, longtasks, paint | PerformanceObserver |
| navigation | route changes via DOMQL renderRouter | no listeners — free |
| console | console.{log,warn,error,debug} proxy | console sink uses saved originals — no recursion |
Element targeting uses the DOMQL key path (e.g. App > Sidebar > MenuItem_3), built by walking up data-key DOM attributes. Stable across renders. Falls back to a CSS selector for events on non-DOMQL nodes.
smbls internals (debug-only)
lifecycle—init,create,render,complete,done,attachNode,lazyLoad,frame,beforeRemove,remove.state—stateInit,stateCreated,stateUpdate,beforeStateUpdate. Coarse-grained: which keys changed, by whom.updates— everyupdate()call. Only useful when actively debugging; high volume.
Network capture (chained with fetch plugin)
@symbo.ls/fetch calls context.analyze.emitNetwork(...) at the start, success, and error of every runFetch and runMutation. The call is a no-op when analyze isn't installed — so projects without analyze pay nothing.
Default-off, including in the
remotepreset. The analyzed server discards un-opted-inlogType=networkenvelopes at ingest (servercd64446f), so the emitter no longer captures or ships them by default — no fetch/XHR wrapping, no batching, no egress. Re-enable with the same levers the server honors:debug: true(or?analyze=debug), which also stampsapp.debugon every envelope so the server's per-envelope lever accepts the rows end-to-end; or an explicitcapture: { network: true }/ runtimestate.setCapture('network', true)— the client half of the per-workspaceOrganization.settings.analyzedNetworkCaptureopt-in.
When analyze IS installed and network: true is set, you get smbls-aware events with:
{
type: 'network',
hook: 'fetch.start' | 'fetch.success' | 'fetch.error',
source: 'symbo-fetch',
mode: 'query' | 'mutation',
from: 'users', method: 'select',
cacheKey: 'users:select::en',
durationMs: 12.4,
ok: true,
status: 200
}In addition, window.fetch and XMLHttpRequest are wrapped to catch any non-smbls traffic (third-party SDKs, raw fetches in user code). Beacon traffic is filtered out via the X-Analyze-Beacon header to avoid capturing analyze's own events.
Session replay (smbls-native)
Conventional session replay (rrweb, FullStory) records DOM mutations and replays them into a clean DOM. That's heavy, lossy with CSS-in-JS, and re-implements a rendering pipeline that smbls already owns.
Because every visible byte in a smbls app is derived from element tree + state, replay only needs:
replay payload = initial state snapshot
+ input event log (clicks, keys, forms, viewport, scroll)
+ state mutations (the stateUpdate hook)To replay: restart the app with the snapshot, then replay input events in order. The DOM regenerates itself.
Enable with:
analyze: {
level: 'info',
capture: { replay: true },
sinks: [{ type: 'memory', max: 5000 }]
}Then context.analyze.replay() returns { initialSnapshot, events } ready to ship.
Transformer pipeline
Every captured event flows through a single ordered pipeline:
hook fires → build event → transformers[] → sinks[]A transformer is (event) => event | null | event[]. Return null to drop. Return an array to fan out.
Built-in transformers
| Name | What it does |
|---|---|
| redact | Walks the event, masks keys matching redact config. Always runs first — cannot be reordered. Disable with redact: false. |
| enrich | Adds session id, route, viewport, app id, build hash. Active by default. |
| summarize | Replaces raw element refs and Error instances with safe slices. Active by default. |
| dedupe | Drops events identical to one within dedupeWindowMs. |
| sample(rates) | Drops 1 - rate of events of a given type. |
Custom transformers are functions:
transformers: [
'enrich',
'summarize',
(ev) => ev.level === 'trace' ? null : ev,
(ev) => ({ ...ev, app: 'workspace' })
]Sinks
| Sink | Purpose |
|---|---|
| console | Pretty-printed, color by level. Default. Saves console.error/warn/log references at construction so the console proxy doesn't recurse. |
| memory | Ring buffer in-process. Queryable via context.analyze.query(). Default. |
| beacon | Batched POST. Falls back to navigator.sendBeacon on pagehide / beforeunload. |
Custom sinks are functions: (event) => void.
sinks: [
'console',
{ type: 'memory', max: 1000 },
{ type: 'beacon', url: '/analyze', batchMs: 5000 },
(ev) => myCustomSink(ev)
]Runtime API
Exposed on context.analyze:
context.analyze.query({ level: 'error', sinceMs: 60000 }) // ring buffer query
context.analyze.snapshot() // full current buffer
context.analyze.flush() // force-send pending beacons
context.analyze.replay() // export replay payload
context.analyze.activate(context) // (called by smbls after onCreate)
context.analyze.destroy() // (called by smbls destroy())
context.analyze.pause() / .resume() // toggle capture without unregistering
context.analyze.setLevel('debug') // change log level at runtime
context.analyze.setCapture('pointer', true) // toggle a category at runtime
context.analyze.emit(event) // raw emit (used by element-core)
context.analyze.emitNetwork(data) // helper used by @symbo.ls/fetchDebug mode
Three ways to enable:
// 1. Config
analyze: { debug: true }
// 2. URL parameter (no rebuild)
// https://app.localhost/?analyze=debug
// 3. Runtime toggle
context.analyze.setLevel('debug')
context.analyze.setCapture('updates', true)debug: true (or ?analyze=debug) flips on lifecycle, state, updates, and console capture, and sets level to debug. To turn analyze off entirely via URL: ?analyze=off.
How it works
Plugin registration
Auto-registers in packages/smbls/src/createDomql.js when context.analyze is truthy:
if (context.analyze && !hasPlugin('analyze')) {
const analyzeConfig = context.analyze === true ? {} : context.analyze
context.analyze = createAnalyzeState(analyzeConfig)
context.plugins.push(analyzePlugin)
}So context.analyze IS the live state object — .emit, .query, .activate are all there.
Lifecycle hooks tapped
Hooks dispatched by triggerLifecycle() in packages/element/src/create.js and runPluginHook() in packages/element/src/update.js:
error— handler throws + explicit error reportsupdate,beforeUpdate(debug only)stateUpdate,beforeStateUpdate(debug only, also for replay)init,create,render,complete,done(debug only)beforeRemove,remove(debug only)renderRouter(always-on ifnavigationenabled)
Hydration gate
The plugin sits in context.plugins from creation, but every hook is a guarded no-op until context.analyze.__ready === true. The flag flips in one place: packages/smbls/src/index.js, immediately after app.onCreate fires.
A small pre-ready buffer keeps error events that fire during init — so first-paint crashes still surface, even though the plugin is technically dormant.
Element-core integration
Two small integrations in packages/element/src/:
create.js— lifecycle handler throws now go throughtriggerLifecycle('error', element, { hook, error })instead of bareconsole.error. The framework still falls back toconsole.errorwhen no plugin handles the hook.methods.js—el.warn()andel.error()emit throughcontext.analyzefirst, then fall through to the original env-gated console.warn / throw.
packages/utils/function.js — runPluginHook now returns true when at least one plugin handled the hook, so callers can fall back to default behavior when no plugin captured.
Browser-event listeners
When opt-in browser categories are enabled, the plugin attaches passive listeners on window and document during activate(). Listeners are detached during destroy(app). All listeners are throttled or debounced per the throttle config and route through the same pipeline as lifecycle events.
Privacy
Browser-event capture without redaction is a compliance disaster. The plugin defaults are tuned for that:
redacttransformer always runs first and cannot be reordered. Disable explicitly withredact: falseif you mean it.- Form values: masked by default. Opt in per-field with
data-analyze="track"(data-analyze="skip"always skips). <input type="password">,<input type="hidden">, and fields matching/email|token|secret|ssn|card|cvv|pin|otp/iare never recorded.- Mask strategies:
'mask'(***),'hash'(32-bit hash with#prefix),'redact'(drop the field entirely).
Performance
- Plugin registration: zero cost (one object pushed to
context.plugins). - Hooks while dormant: one boolean check.
- Hooks after activation, with category off: one boolean check.
- Hooks after activation, category on: build event, run pipeline, write to sinks. Sub-microsecond on hot paths with sampling.
- Browser listeners: passive + throttled.
updates capture is the one hot-path category and is debug-only for that reason.
Comparison
| | rrweb | LogRocket | Sentry SR | analyze | |---|---|---|---|---| | Replay model | DOM mutations | DOM mutations | DOM mutations | state + events | | Payload size | MB | MB | MB | KB | | CSS-in-JS fidelity | partial | partial | partial | perfect | | smbls integration | none | none | none | native | | Privacy default | opt-out | mixed | mixed | opt-in everywhere |
Multi-app caveat
window.fetch and XMLHttpRequest wrappers are global. If multiple smbls apps mount on the same realm and each enables network: true, the wrappers nest. Destroy order matters — the last app destroyed correctly restores window.fetch, but interleaved destroys may leave a stale wrapper. For the multi-app case, install network capture on a single shell app and let nested apps emit through the same context.analyze.
License
CC-BY-NC-4.0
