JSON-spec instrumentation
Faultsense accepts instrumentation from two peer sources: fs-* HTML attributes (the canonical path) and a JSON spec passed to the agent at init time or at runtime. Both flow through the same pipeline; both produce identical assertion behaviour. JSON exists so teams who cannot or do not want to edit production HTML — third-party QA, contractors, agencies, generators (recorders, importers, LLM authors) — can still ship assertions.
This page is the authoring reference for the JSON path. The semantics of every assertion type, modifier, trigger, and feature documented under triggers, assertions, and modifiers applies unchanged in JSON mode — the only difference is where the fs-* key/value pairs live.
When to choose JSON vs HTML
| Situation | Choose |
|---|---|
| You own the codebase that renders the HTML and can edit templates | HTML attributes. Lives where the element is defined, survives DOM mutation automatically, easy for engineers to find. |
| You cannot edit the HTML (third-party widget, generated output, SaaS template) | JSON spec. Declare assertions in a config object instead. |
| You're generating instrumentation programmatically (recorder, importer, LLM author) | JSON spec. A machine-emittable target with a stable JSON Schema. |
| You want to load instrumentation from a remote source at runtime | JSON spec. Faultsense.setSpec(entries) accepts entries any time after init. |
| You're prototyping in the browser devtools and don't want to redeploy | JSON spec via devtools. Call Faultsense.setSpec([...]) from the console. |
The two modes coexist on the same page. A third-party widget can ship HTML attrs while your own JSON spec covers the rest of the app — neither knows about the other.
The SpecEntry shape
Each entry is a JSON object whose keys mirror the fs-* attribute names. Two keys are unconditionally required:
| Key | Purpose |
|---|---|
fs-target |
JSON-only. CSS selector that resolves to the element the trigger binds to. The HTML equivalent is "the element itself"; JSON needs an explicit selector. |
fs-assert |
The assertion key. Hierarchical with / separators, stable across releases. |
A trigger is also required, but the schema accepts three different keys (anyOf):
fs-trigger— the canonical user-action trigger (same values as the HTML triggers reference). Required for everything that isn't an OOB assertion.fs-assert-oob— for OOB child entries that fire when a listed parent assertion passes. When present,fs-triggeris not required.fs-assert-oob-fail— for OOB child entries that fire when a listed parent assertion fails. When present,fs-triggeris not required.
This mirrors HTML semantics: OOB elements never carry fs-trigger because their trigger is the parent's status change.
Everything else — type assertions (fs-assert-added, fs-assert-removed, …), modifiers (fs-assert-timeout, fs-assert-mpa, fs-assert-mutex), conditional dynamic types (fs-assert-added-success) — uses the same key names as on the HTML side.
{
"fs-target": "#submit-btn",
"fs-trigger": "click",
"fs-assert": "checkout/submit-order",
"fs-assert-added": ".confirmation[text-matches=Order #\\d+]"
}
The full schema is published at /spec.schema.json. Validate spec entries before shipping — typos in fs-* keys are silently ignored by the agent (same as on the HTML side), and the schema is the only place that surfaces them loud.
Wiring up
At init time
Faultsense.init({
apiKey: 'fs_secret_…',
releaseLabel: '2.4.1',
collectorURL: 'https://collector.example.com/events',
spec: [
{
'fs-target': '#submit-btn',
'fs-trigger': 'click',
'fs-assert': 'checkout/submit-order',
'fs-assert-added': '.confirmation',
},
{
'fs-target': '.cart-row',
'fs-trigger': 'mount',
'fs-assert': 'cart/row-rendered',
'fs-assert-visible': '.cart-row',
},
],
});
At runtime
Three methods are exposed on window.Faultsense after init:
// Replace the active spec. Diffs custom-event listeners against the
// previous spec and installs/tears down as needed.
Faultsense.setSpec(entries);
// Append entries to the active spec. Never removes.
Faultsense.addSpec(entries);
// Read a frozen snapshot of the current spec.
const entries = Faultsense.getSpec();
Each call rebuilds the registry indices atomically — partial state is never observable. setSpec will also kick off an initial scan for newly-registered mount / invariant entries against any matching elements already in the DOM, so you don't have to wait for a re-render.
How fs-target works
The selector is re-resolved on every matching event. Elements added to the DOM after setSpec are picked up automatically — there's no need to re-register when content changes. SPAs work the same way they do with HTML attrs.
Same selector semantics for both modes:
- A
fs-targetselector matching multiple elements will produce one assertion per match. Withfs-trigger="click", only the clicked element among them fires. - An invalid CSS selector logs a warning once and skips the entry.
- A missing
fs-targetlogs a warning and skips the entry.
Selector stability
Selector stability is the load-bearing concern in JSON mode. An entry that targets .button-primary > span.label works exactly until a designer changes the structure. Authoring tips, ordered by how unlikely they are to break:
- Prefer
data-testidor stable IDs.[data-testid=checkout-submit]survives almost every refactor.#submitis fine if IDs are managed. - Avoid positional selectors.
nth-child(3),:nth-of-type, deeply chained descendants — anything that depends on sibling order or nesting will break. - Avoid framework-mangled class names. CSS-in-JS class names (e.g.,
_styles_button__h7G2p) often change between builds. Use semantic data attributes instead. - Don't rely on text content for matching. Translations and copy edits change text; use
text-matchesas a modifier on a structurally-stable target rather than baking text into the selector.
For generator-authored specs (recorders, importers), produce selectors in this order: data-testid → stable id → semantic class → fallback. Document the strategy so customers know what to expect.
JSON string escape rules
This is the single biggest difference between HTML and JSON authoring. HTML attribute values are raw strings; JSON string values follow JSON string-escape rules. The agent's regex compilation is unchanged — the authoring convention shifts.
<!-- HTML: backslash is literal -->
<button fs-assert-updated="#counter[text-matches=\d+]">…</button>
// JSON: backslash must be escaped to \\
{
"fs-target": "#btn",
"fs-trigger": "click",
"fs-assert": "counter-update",
"fs-assert-updated": "#counter[text-matches=\\d+]"
}
If you forget the double backslash, the agent compiles the regex as d+ (one or more literal ds) and your assertion silently never matches. Schema validation does not catch this — only the runtime regex compile would, and that's a permissive operation. Validate generator output against real fixtures.
Other characters that need escaping in JSON strings: " becomes \", newlines become \n, etc. Standard JSON.stringify handles this — emit your spec as JSON.stringify(entries) rather than concatenating strings.
Self-targeting
In HTML mode, an empty fs-assert-* value with only an inline modifier means "the element itself":
<input fs-trigger="input" fs-assert-updated="[value-matches=^abc]" fs-assert="form/abc-prefix">
JSON mode preserves the same convention. When the value is empty or starts with [modifier=…], the target resolves to the fs-target element:
{
"fs-target": "#field",
"fs-trigger": "input",
"fs-assert": "form/abc-prefix",
"fs-assert-updated": "[value-matches=^abc]"
}
Both produce a single assertion against #field itself, gated on the value-matches modifier.
Disabling HTML attributes entirely
To run an app purely through the JSON path on a page that still has fs-* attributes in its templates (e.g., validating an existing HTML-instrumented app through JSON without redeploying), pass ignoreHtmlAttrs: true at init:
Faultsense.init({
...,
ignoreHtmlAttrs: true,
spec: [/* ... */],
});
With the flag on, the agent never scans for fs-trigger attributes — only the JSON spec drives assertions. The HTML attributes can stay in the DOM; they're inert.
This is the canonical path for "prove my JSON spec matches my HTML attrs end-to-end" workflows: keep both, flip the flag, run your existing E2E test suite, and compare results. See configuration for the full caveat list.
Co-existence with HTML attributes
Both sources flow through the same pipeline. A page can have HTML-decorated elements and a JSON spec simultaneously, and they emit independent assertions:
<!-- HTML side -->
<button fs-trigger="click" fs-assert-added="#confirmation" fs-assert="checkout/html-path">Checkout</button>
<!-- DOM that JSON wants to assert against -->
<button id="modal-trigger">Open modal</button>
Faultsense.init({
// ... other config
spec: [
{
'fs-target': '#modal-trigger',
'fs-trigger': 'click',
'fs-assert': 'modal/json-path',
'fs-assert-visible': '.modal-content',
},
],
});
Both checkout/html-path and modal/json-path emit independently when their respective triggers fire. If the same fs-assert key is declared via both HTML and JSON on the same host element with the same trigger, they collapse into one assertion — same behaviour as two HTML attrs declaring the same key.
Custom events and listener pool
JSON entries with event:foo triggers install document-level listeners on the page, exactly like HTML elements with fs-trigger="event:foo". The listener pool is shared across sources — a document-level listener is installed once for a given event name and torn down only when neither source still references it. setSpec([]) removes JSON references; the listener stays installed if any HTML element still uses the event name.
Common mistakes
| Symptom | Likely cause |
|---|---|
| Spec entry never seems to fire | fs-target selector doesn't match any element at the trigger time. Check with document.querySelectorAll(spec.fs-target) in the console. |
| Regex modifier never matches | Forgot to double-escape backslashes in JSON. "text-matches=\\d+", not "text-matches=\d+". |
| Warning: "missing 'fs-target'" | Entry is missing the required fs-target key. Three keys are required: fs-trigger, fs-target, fs-assert. |
| Warning: "Invalid CSS selector in fs-target" | Selector is syntactically invalid. Try it in the browser console first. |
| Schema validation passes but the assertion is wrong | The schema validates shape, not semantics. A typo in a CSS selector or a regex parses as a valid string. Test against a real DOM. |
| Two assertions emit when you only expected one | An element matches multiple entries' fs-target selectors (e.g., both .btn and .btn-primary). Each match produces an assertion. |
What's NOT in JSON
fs-target is JSON-only. It has no HTML counterpart — the HTML side uses "the element itself" as the implicit target. This means you can't directly translate a JSON spec entry's fs-target back into an HTML attribute. The conversion direction is one-way today: HTML → JSON is mechanical, JSON → HTML requires injecting attributes onto each matched element.
fs-assert-route works in JSON mode but doesn't bind to a DOM target (route assertions match the URL, not an element). Provide any valid fs-target for schema compliance — it's not consulted at resolution time.
Authoring with an AI agent
The Faultsense instrumentation skill installs decision-tree guidance into Claude Code, Cursor, OpenCode, Goose, or any agentskills.io-compatible tool. The skill teaches both HTML and JSON authoring — it picks the mode that matches the codebase context. For a JSON-spec-authoring agent, validate every emitted entry against spec.schema.json before applying.
Schema URL
The published JSON Schema lives at /spec.schema.json. The schema is drift-tested against the agent's runtime configuration on every release — if the agent ships a new fs-* key, the schema covers it.
See also
- Agent reference — top-level fs-* documentation
- Triggers — every
fs-triggervalue, applicable verbatim in JSON - Assertions — every assertion type, applicable verbatim in JSON
- Modifiers — every inline modifier, applicable verbatim in JSON
- Configuration — agent init options including
spec - Payload spec — wire format the agent POSTs to the collector