Spectra Developer Documentation
Spectra is interface intelligence for the web. It checks every visit in your users' own browsers, finds the controls they cannot use and the errors they hit, names the release that caused them, and keeps the replay of the visits where something went wrong, so you can watch exactly what happened. Recording runs in the background. The checks it makes on your page wait for a moment nobody is using it, and the recorder's size is held to a budget on every release.
60-Second Quickstart
Add the snippet from your Sites page to the head of every page you want recorded. It carries your key, your site id and the address recordings are sent to; the exact values are shown when a key is issued. The recorder starts as soon as the script arrives. Anything your page needs to tell it before then can wait in window.spectraQ, as described below.
The tag is read for these attributes and no others. A misspelled one is ignored in silence, so if the recorder is not doing what you expect, check the spelling here first.
| Attribute | Required | What it does |
|---|---|---|
| data-api-key | Yes | The key issued for this site. Without it the recorder does not start at all. |
| data-site-id | Yes | Which site these recordings belong to. Left out, the key is used as the site id and the sessions are filed under a site that does not exist. |
| data-endpoint | Yes | Where recordings are sent. Left out, the recorder guesses the origin the script came from, which is only right while the bundle is served from the ingest host. |
| data-tunnel | No | The prefix on your own domain that forwards to Spectra. Set this instead of the endpoint for a first-party install. See Serving Spectra from your own domain. |
| data-journey-origins | No | The other origins this visitor's journey crosses, comma separated, for example https://app.example.com. Without it a visit that moves from your marketing site to your application becomes two unrelated recordings. Name them on both sides. See Recording a whole journey. |
| data-tracing | No | Present, with no value needed, sends W3C trace headers with requests to this page's own address, so a replay and your server logs name the same request. See Linking a replay to your server logs. |
| data-tracing-origins | No | Other addresses that may carry those headers, comma separated, for example your API on https://api.example.com. Those servers must allow traceparent and baggage first, or the requests fail. Naming any address turns tracing on. |
| data-trace-sample-rate | No | What fraction of traces your backend is asked to keep, 0 to 1. Defaults to all of them. Turn it down on a busy site if your tracing tool bills by volume; log correlation keeps working at any rate. |
| data-sample-rate | No | What fraction of visitors to record, 0 to 1. Records all of them by default. The answer comes from the visitor rather than from a coin toss, so you get that fraction of your visitors with whole journeys, not that fraction of your page views with holes in them, and a visit already under way is always finished. Most sites should leave this alone: an uneventful visit already costs almost nothing, and sampling throws away the visits that turn out to matter. |
| data-environment | No | Which deployment this is, for example production or staging. Carried with every recording so a staging session cannot be mistaken for a customer's. |
| data-block-all-media | No | Present, with no value needed, replaces every image, video and canvas with a placeholder of the same size. For a site where the pictures themselves are the confidential part and a missed selector would be an incident rather than a gap. The layout is unchanged, so the replay is the real page with the pictures removed. Images referenced from CSS backgrounds are not replaced, but none of them is kept with a recording: a replay shows them only for as long as your site still serves them. |
| data-keyboard | No | false stops recording the keys that are decisions: Enter, Escape, Tab, the arrows, Backspace, and combinations such as Ctrl+Z. On by default, because without them a replay cannot tell somebody submitting a form from somebody giving up on it, and a menu driven by the keyboard looks like nothing is happening. What a visitor types is never recorded this way on any setting: characters reach a recording only as a field's value, under the masking rules, and a combination pressed in a masked field has its letter replaced too. |
| data-long-tasks | No | false stops marking the moments the page stopped responding. On by default. Without them a frozen replay is indistinguishable from a visitor who simply stopped moving. Reported by Chrome and Edge; Safari and Firefox do not measure it, and a recording from those browsers has no such marks. |
| data-console-levels | No | Which console levels to record, comma separated, for example debug,log,warn,error. The default is warnings and errors, which is what most sites want: on a busy page the other levels are a running commentary that fills a recording without explaining anything. Uncaught errors and unhandled promise rejections are always recorded whatever you choose. |
| data-network-bodies | No | The addresses whose request and response bodies a replay may keep, comma separated, for example /api/,/graphql. Every request is already recorded with its method, address, status, size and timing; this adds the part that says why it failed. Nothing is kept for an address you do not name here. What is kept is redacted first: a value under a key such as password, token or cardNumber is replaced before anything reads it, every remaining piece of text goes through the same scrubbing your page content does, a body that is not text is recorded as its size and type, and there is a limit on each body and on the visit as a whole. |
| data-network-headers | No | Which request and response headers may be kept, comma separated, for example content-type,x-request-id. Nothing is kept unless you name it. Authorization, Cookie and the other credential headers are refused even when named, and a header whose own name says it carries a secret is replaced rather than kept. |
| data-network-deny-keys | No | Extra field names to treat as secrets, comma separated. The built-in list covers the usual ones; use this for the fields only you would recognise, for example an internal reference that identifies a person. |
| data-network-allow-keys | No | Field names you know are not secrets, comma separated, kept although a built-in rule would hide them: a short name such as key, pin or card, or one that ends in it, such as issueKey. Exact names only. A name with a secret in it, such as apiKey, password or token, stays hidden however it is listed, so does anything in your deny list, and the values are still checked for card numbers and email addresses. |
| data-network-sockets | No | false stops recording WebSocket, server-sent event and beacon activity. On by default, and it records only the connection and how much went each way, never what was said over it. Without it a replay of a page that does its work over a socket shows an empty network list under a screen that is plainly updating. |
| data-release | No | The build this page came from, for example 2026.09.11. Sessions and issues are grouped by it, which is how you tell whether a problem arrived with a deploy, and the Integrity page says how each broken control stands in your newest release against the others: new in it, gone from it, or more or less often. This is the one place a templating layer can write it for a script-tag install. |
| data-context | No | The state this page is in, read from the page itself, for an install with no code to describe it, for example theme: html@data-theme; member: body.logged-in. Each label is a name, a colon and where to find it, separated by semicolons: a selector followed by @ and an attribute gives that attribute's value, and a selector on its own gives yes or no. The Integrity page uses it to say where a broken control happens. |
| data-consent | No | true or false. Say false when your consent banner has not been answered yet, and nothing is recorded until it is. Leave the attribute off entirely and the recorder behaves as it always has, so adding it is a decision rather than a default. To answer later, from the banner itself, call spectraQ.push(['consent', true]). |
| data-capture-before-consent | No | Reproduce bundle only (/v1/sr.js), and only with data-consent="false". Present, the visit is recorded from the page's load while your banner is unanswered, but held in the page's memory: nothing is written to the visitor's device and nothing is sent to Spectra until they accept. Accepted, it is sent from the start, so the test begins where the visit began. Refused, or left unanswered when the tab closes, it is dropped and never leaves the browser. Whether recording before consent is lawful for your site is your decision, per region; it is off unless you add this. |
| data-inputs-as-typed | No | Reproduce bundle only (/v1/sr.js). There, what visitors type is recorded as typed, emails and phone numbers included, so a replay can sign in or search the way the visitor did. Say data-inputs-as-typed="false" to keep typed text scrubbed of personal details instead. Passwords, every card field and one-time codes are never recorded, only their shape, whatever this says. The base script has no such setting. |
| data-reproduce-on-failure | No | Reproduce bundle only (/v1/sr.js). There, each page is held in the visitor's browser from its load and sent only when something goes wrong in it, and healthy visits are sent whole as your monthly Reproduce budget allows. Say data-reproduce-on-failure="false" to record every visit whole instead, for a test environment or a small internal app. The base script has no such setting. |
| data-respect-dnt | No | Present, with no value needed, honours Do Not Track and Global Privacy Control and records nothing for visitors who send either. Off by default on purpose: Brave sends GPC on every request and Firefox private windows send DNT, so switching this on will stop recording a real share of your visitors. That is the right trade for some sites and the wrong one for others, which is why it is yours to make. |
| data-persistence | No | local (the default), session, or memory. How long anything the recorder writes may stay on a visitor's device. Consent is required for storing something on a device, not for recording a visit, so session lets you keep recording when your banner has been refused. It keeps nothing past the visit: the visitor id moves to sessionStorage and undelivered data is held in the page. Sessions still join across the pages of a visit, journeys still work, and a refused upload is still retried while the page is open. What you give up is returning visitors, who arrive as new ones, and an upload still waiting when the tab closes, which is lost rather than sent later. memory writes nothing at all and every page becomes its own recording, which also means every page counts as a separate session against your plan. That makes session the one to reach for unless you have a reason not to. Cross-domain journeys are unaffected by any of these, because they travel in the link rather than in storage. |
| data-ignore-bots | No | Present, with no value needed, records automated browsers too. The recorder refuses them by default, which is almost always what you want; turn this on when you are testing your own install with a headless browser and wondering why nothing appears. |
| data-debug | No | Present, with no value needed, makes the recorder narrate what it is doing to the browser console. Use it while installing, then take it off. It is the only way a script-tag install can ask the recorder what it decided. |
After the snippet: what to set up
The snippet on its own records visits, finds the moments people struggled, and keeps the sessions worth keeping. Everything below is optional, and each one answers a question you will eventually ask of a recording. They are in the order most teams want them.
1. Say who the visitor is
Without this every session belongs to an anonymous visitor, and a support request that starts with a customer name has nowhere to go. One call, at sign-in:
Spectra.current()?.identify(user.id);
It applies to the whole session including everything before the call, so signing in halfway through a visit still attaches the part that led up to it. Their previous visits group under the same person too. In the session list, clicking a visitor shows everything else they did.
2. Say which build and which deployment
Two attributes your build writes, and the reason to bother with them is the question "did this start with the last deploy", which nothing else can answer.
<script src="…/v1/s.js" data-api-key="…"
data-release="2026.09.25"
data-environment="production"></script>
The Issues page then groups problems by build and tells you which one a problem arrived in and whether it is still there. The environment keeps your staging visits from being read as customers: set it on every deployment, not only production, or the untagged ones are the ambiguous ones.
3. Say what state the page is in
One address is often many pages: signed in or out, a plan, an experiment, a feature you are trying out. Say which one this is, and the Integrity page tells you where a broken control happens, for example "when plan is free and on screens 360px and narrower", instead of only that it happens somewhere.
Spectra.record({
apiKey: '…',
integrity: {
context: () => ({ plan: user.plan, checkout_test: variant }),
},
});
// When the state changes without a page load:
Spectra.current()?.setContext({ plan: 'pro' });
<script src="…/v1/s.js" data-api-key="…"
data-context="theme: html@data-theme; member: body.logged-in"></script>
Up to ten labels, each a short state that many visitors share. A value that looks like an email address, an account number or a token is refused. Whether the visitor is signed in, their screen size, browser, language and light or dark mode are known without asking, and the build too when you set it above. Passing null to setContext removes a label.
Experiments are read without asking from Optimizely Web Experimentation and PostHog when they are on the page, in the room your own labels leave. For any other testing tool, pass the arm where the tool reports it. The name can be the tool's own; it is made into a label for you.
new GrowthBook({
trackingCallback: (experiment, result) => {
Spectra.current()?.setExperiment(experiment.key, result.key);
},
});
A button that reads the same in two layouts of a page is counted as one, as long as the page never shows both at once. A button with no text, or one of several that read the same, is known by where it sits, so in another layout it can be counted as two. To keep it one button in every version of a page, give it a name: data-spectra-id="checkout".
Some faults are what you meant: an export button behind a paywall on the free plan, a link hidden from people who already pay. Mark one as intended on the Integrity screen, everywhere or only in the state it was found in, and it leaves the list and the score. In any other state the control is still checked. The same screen sets how many visits a fault must have met before it is listed.
4. Report the errors your own code catches
Uncaught errors are recorded for you. An error your framework catches never reaches the browser, so nothing outside your code can see it, and the better your error handling the less a replay knows. One line puts them back. See Reporting errors your code catches for React, Vue, Angular and script-tag examples.
5. Say where to be told when something breaks
Under each site, in Tell me when it breaks, paste a Slack incoming webhook or any address of your own. Without it, a page that stops working is found immediately and mentioned whenever somebody next opens the dashboard, which at three in the morning is not much use. See Being told when something breaks.
6. Decide what a replay may keep of your traffic
Every request your page makes is already recorded with its address, status, how long it took and what asked for it, and so is everything the page loaded. What is not kept, until you name the addresses, is what the requests actually said:
networkCapture: {
bodies: { urls: ['/api/'] },
headers: ['content-type', 'x-request-id'],
}
That is the difference between "it returned 400" and "it returned 400 because the postcode was empty". Nothing is kept for an address you did not name, credential headers are refused even if you list them, and values under names like password or cardNumber are replaced before anything reads them. Start with one address and widen it.
7. Mark anything that must never be recorded
Input values, and what a visitor types into an editable region such as a chat box or a rich-text editor, are masked by default. Passwords, card fields and one-time codes always are, whatever else you set. For a region of the page, add data-spectra-block to the element and it is replaced by a placeholder of the same size. For a screen you cannot mark up in advance, such as a document another customer uploaded, pause instead:
Spectra.current()?.pause(); // before showing it
Spectra.current()?.resume(); // after
The session carries on either way. If the pictures themselves are the confidential part and a missed selector would be a problem rather than a gap, use data-block-all-media and every image, video and canvas becomes a placeholder. See On-Device Privacy and PII Masking.
What you do not have to do
Page loads, scripts, stylesheets, fonts and images are recorded without any configuration, as are clicks, typing, scrolling, the keys that are decisions such as Enter and Escape, how fast each page was for that visitor, and the moments the page stopped responding. Rage clicks, dead clicks, form trouble, failed requests and stuck loading screens are detected for you. None of it needs an option set.
Verifying Installation
Confirm the install from your browser's developer tools console. Run the following command on any page where the recorder is installed:
Within a few seconds the Replay screen lists this visit, kept for the reason Install test.
Spectra is the name the script tag gives the recorder. If you installed the npm package instead, import current from it and call current()?.flag('test_install') from your own code; the same goes for every Spectra.current() on this page.
Serving Spectra from your own domain
Content blockers refuse requests to third-party hosts that appear on a filter list. Served from Spectra's host, the recorder is third-party to your site. Served from your own domain, there is nothing to refuse: the script, its configuration and every recording travel on your origin under a path that says nothing about what it is. This is the install the Sites page recommends, and it needs one forwarding rule on your host.
Your own Content-Security-Policy is the second reason, and on measurement it is the larger one. We ran the recorder against forty public sites: seventeen of them refused the direct install at connect-src before it could fetch its configuration, so it started, threw nothing, and recorded nothing. Nothing on your page would have told you. Served from your own origin the recorder is same-origin, which connect-src 'self' already permits, so a policy you have not changed keeps working.
If you would rather install directly and your site sets a policy, allow https://api.spectra-trace.com in both script-src and connect-src. The recorder now says so in your browser console when a policy blocks it, once per page, naming the directive that refused it.
How it works
Your host forwards one prefix, for example /q4hx/*, to https://api.spectra-trace.com/v1/*. The Sites page shows the tag with your key and a prefix chosen for your site; any prefix works as long as the tag and the rule agree.
Every rule below forwards the whole prefix, which is what we recommend. If your host or firewall needs the paths listed instead, these are all of them. A path your proxy does not forward is lost with no error on your page and nothing on ours, so the list has to be complete:
| Path | What it carries |
|---|---|
| /s.js | The recorder itself. |
| /i | Recordings. |
| /c | This site's configuration. |
| /p | One small anonymous count per page. |
| /u | The per-visit analytics summary. |
| /us | The page snapshot a heatmap is drawn on. |
| /a | Stylesheets, stored once per version and shared by every recording that uses them. |
| /m | Images and fonts, so a replay looks like the page did. |
The rule, per host
Recording a whole journey
Do you need this?
If your whole site sits at one address, like example.com, then no. Paste the snippet and you are finished. A visit that moves through ten of your pages is already one recording.
You need this page if your visitors move between two different addresses that both belong to you. A marketing site at www.example.com and an app at app.example.com is the usual shape. Without the setting below, somebody who reads your pricing page and then signs up becomes two separate recordings, and the interesting part is split down the middle.
The rule that matters most
So put the snippet on every page you want to watch, at every address. In practice that means putting it in the shared layout or header template all your pages are built from, once, so nobody has to remember. If your two sites are separate projects, each one needs it in its own layout.
The setting below does a different job. It does not reach other pages. It tells two snippets that are already running that they are watching the same visit, so their two recordings become one.
Joining two of your addresses
List your addresses in the tag, and use the same tag on every page of both sites:
Two things to get right, and that is all of it:
List every address, including the one the page is on. The simplest thing is to paste the identical tag everywhere rather than tailoring the list per site. An address you have not listed is never given a session and never accepts one, so listing only one side leaves you exactly where you started: two recordings, nothing broken.
Use the same data-site-id on both. A recording is filed under its site and its session together, so two different site ids mean two recordings no matter what else is true.
What this covers
People leave a page in more than one way, and something that only handled links would miss most of them:
| How the visitor moves | Between your subdomains | To another domain of yours |
|---|---|---|
| Clicks a link, or submits a form | Yes | Yes |
| Your server redirects them, after signing in | Yes | With one header, below |
| Types the address, or follows a link in an email | Yes | No |
| Your own code sets location.href | Yes | With one line, below |
About the cookie, and visitors who refuse it
Between subdomains of one site, a small cookie carries the session. It is the only thing that survives a redirect made by your own server, because the browser sends it whatever caused the visitor to move.
If you list no addresses, no cookie is written at all. That is the ordinary install, and you can tell a privacy reviewer plainly that the product sets no cookie. When you do list them, the cookie holds a reference to the session and nothing about the person, has no expiry date, and disappears when the browser closes. It is a handover between two pages of one visit, not a record of who somebody is.
If a visitor blocks or refuses cookies, nothing breaks. Moving through one address keeps working, because that never used a cookie. Clicking a link or submitting a form to your other address keeps working, because the session travels on the link itself. Only the two cases that need the cookie fall back, and they fall back to what you had before: two recordings instead of one, with no errors and nothing for the visitor to notice.
A redirect your own code makes
There is one move nothing can see: your code setting location.href. If it goes to another domain of yours, hand the session over in one line:
An address you did not list comes back unchanged, so this is safe to put before any redirect, including one to a payment page. When the destination is somewhere no recording is possible, it also marks the moment in the replay, which is worth doing on its own.
A redirect your server makes, between two of your domains
This is the last case, and the only one that needs anything from your backend. Between subdomains it is already handled and you can skip this. It applies when your server decides to send the visitor from one of your domains to a genuinely different one, for example after a sign-in.
Nothing in the browser can carry that on its own. There is no link to attach anything to, because the move was decided on your server after the page was gone, and a cookie cannot cross from one domain to another. The only thing in a position to carry it is the server issuing the redirect. So give it something to carry:
The arriving page reads it and carries on as the same recording. The header name is yours to choose; only the _spz parameter matters, and it is removed from the address before the page records anything.
What is in that token. A reference to the session, a counter, a timestamp, and the key that lets the page it opens carry on recording this visit. No API key, no user id, no personal data of any kind. The key reaches this one visit and nothing else, because every visit is given a key of its own. Treat the token as you would a short-lived session link, and keep it out of logs you share. A page stops accepting it after one minute, and only ever at an address you listed. If you pass it somewhere you did not list, nothing happens at all.
Sites you do not own
This is the distinction that matters, and it is about whose page it is, not which address it sits at. Your second domain works because you can put the snippet on it. A payment page at Stripe or Dodo, or a sign-in page at an identity provider, is somebody else's site: you cannot put a script there, so nobody can record it. Not us, and not any other session replay product either.
What you get instead is the shape of the absence. The replay shows left for checkout.stripe.com, then returned after 3m 12s, and the visit carries on as the same recording. What happened before the payment and what happened after it stay one story rather than becoming two.
Linking a replay to your server logs
A replay stops at the edge of the browser. It shows you that a request took nine hundred milliseconds and came back a 500; it cannot show you what your server did inside it. So the person debugging has the replay open in one window and their logging or tracing tool in the other, trying to match a timestamp against a list of thousands, for a request that happened four hours ago.
Turn this on and both sides give the request the same name, so you can go straight from one to the other.
Every request your page makes to its own address now carries two extra headers:
| Header | What it is |
|---|---|
| traceparent | The W3C standard for naming a request across systems. If you run OpenTelemetry, Datadog, Jaeger, Honeycomb or anything else that speaks it, your server already understands this header and will hang its own work underneath it. Nothing to configure on your side. |
| baggage | Carries spectra.session=<id>. If you have no tracing tool at all, log this one value and every line of your server log can say which replay it belongs to. |
The replay records the same trace id beside each request, so a viewer has something to paste into your tracing tool.
Requests to a different address
If your API lives somewhere else, name it:
Requests to your own address are safe without any of that, which is why tracing: true covers them and nothing else.
Keeping your tracing bill and your metrics sane
Two things are worth setting deliberately before you turn this on across a busy site.
How many traces your backend is asked to keep. The traceparent header carries an instruction: keep this one. By default every request carries it, so correlation works without tuning, but that also means a tracing tool that charges by volume will keep everything. If you have sampling turned down, turn this down to match:
Turning it down does not turn off the link. The headers still go out on every request; the trace is simply marked as one your backend may drop. Since logs are usually not sampled at all, correlating a log line with a replay keeps working for every single request, whatever rate you pick.
Where the session id is allowed to end up. The session id is unique per visit, which is exactly what makes it useful for correlation and exactly what makes it dangerous as a metric dimension: a metric split by it becomes one series per visitor, which is how monitoring bills get out of hand.
By default there is nothing to worry about. OpenTelemetry does not copy baggage onto spans unless you have added the baggage span processor yourself, and nothing copies it onto metrics. If you have added that processor, or you generate metrics from span attributes, do one of these:
On a trace or a log line it costs nothing: both are stored per event and looked up by id, which is the whole point of putting it there. It is only aggregation that cannot take a value unique to every visitor.
Things it will not do
It will not disturb tracing you already have. If your page already sends traceparent, that one is left exactly as it is and we record its id as the one to search for. If you already use baggage, ours is added to yours rather than replacing it.
Nothing is sent unless you ask. With no tracing setting, no request is touched at all.
Spectra is not a tracing tool. We do not collect spans or show you a flame graph, and we are not trying to replace what you already run. All this does is make sure both systems call the request by the same name.
Nothing personal travels in these headers. A trace id, a request id and the session id. No account details, no API key.
What does not work
A DNS alias is not first-party. Pointing a subdomain of yours at Spectra's host with a CNAME does not help: uBlock Origin on Firefox resolves the alias and blocks the request anyway. The forwarding has to happen on your own host.
Some visitors cannot be recorded by anyone. A visitor with JavaScript disabled, or a blocker set to refuse every script, does not run the recorder at all. No install changes that.
Content security policy and bundled apps
If your site sends a Content-Security-Policy, add worker-src blob: whichever install you chose. The recorder compresses recordings on a worker it builds from a blob: URL, and worker-src falls back to script-src when it is absent, so a policy of script-src 'self' refuses it. Nothing breaks: the recorder compresses on your visitor's own thread instead, on the page whose responsiveness you installed us to measure, and says so once in the browser console with the line to add.
Beyond that, a first-party install needs nothing added, because everything else it asks for is 'self'. The direct install also needs https://api.spectra-trace.com in script-src and connect-src.
These are the directives Spectra needs; keep whatever else your policy already sends.
If your policy refuses Spectra's address, the recorder says so once in the browser console, names the directive that refused it, and stops on that page, so it costs your visitors nothing. A policy sent as Content-Security-Policy-Report-Only refuses nothing, and recording carries on as normal.
If you bundle the recorder rather than loading the tag, pass the prefix as the tunnel option and it is used for every request:
A tunnel value that already carries a query string keeps the older ?target= form, so proxies written against it keep working. New installs should use the prefix: one rule instead of a hand-written proxy, and nothing for a filter to match.
Recording rules: what gets recorded
Spectra does not keep every session. A session is kept when something worth seeing happens: an error, a sign of frustration, or a moment your own rules mark as important. Everything else is discarded, which is why your quota goes a long way.
Automatic Default Triggers
| Shown as | What happened | Code |
|---|---|---|
| JS error | An uncaught JavaScript error. | uncaught_error |
| Unhandled rejection | A promise rejected with nothing to catch it. | unhandled_rejection |
| Console error | A console.error() call. | console_error |
| Failed request | A request answered with a 4xx or 5xx status. Which ones count is yours to decide: see Choosing which failed requests count, below. | network_error |
| Slow response | A request somebody pressed a button for and then waited ten seconds or more to be answered. Only requests that began while somebody was waiting count, so long-polling, uploads and background work are not mistaken for a wait. | slow_request |
| Unreachable request | A request that never completed at all: refused by the browser as cross-origin, a connection closed, a name that would not resolve, a certificate rejected, or the network gone. Requests your own code cancelled are not counted, and neither are those cut short by the visitor leaving. | network_unreachable |
| Resource failed | A script or stylesheet the page asked for and did not get, which is what a bad deploy looks like from the browser. Images and media that fail are recorded too, and shown on the recording, but do not start one on their own. | resource_load_error |
| Blocked by policy | Your Content Security Policy refused something the page tried to load or run. The browser writes this to its own console, where no script can read it, so it is heard from the policy event instead. | csp_violation |
| Rage click | Several rapid clicks on the same spot that changed nothing. | rage_click |
| Dead click | A click on something that looks interactive and is not. | dead_click |
| Form error | A form submitted and answered with a validation error. | form_error |
| Form thrashing | A field cleared and retyped repeatedly. | form_thrash |
| Abandoned after effort | A visitor left after sustained interaction with no completion. | abandoned_after_effort |
| Stuck loading | A page that stayed in a loading state past the threshold. | stuck_loading |
| Struggle | A run of the friction signals above in one short window. | struggle |
| Abandoned a blank page | The page never drew anything and the visitor left. | abandoned_blank_page |
| Page did not render | The page loaded but the things people use on it are not there, which is what a broken route or a failed deep link looks like. | ghost_render |
| Recorded for Integrity | A control on the page was covered by something else, hidden, or had no size at that moment, so the visit is kept for the integrity check on the paid plans. It is not reported as a problem on its own: the Integrity page lists a control only once visitors were kept from using it. | unusable_control |
| Install test | The check you run from the browser console to confirm the install. | test_install |
Choosing which failed requests count
Until you say otherwise, every failed request is a problem: any answer from 400 up, and any request that never completed, which a rule names as 0. A request blocked by the browser for being cross-origin (CORS) is one of those: the browser hides its status, so it is a 0 and not a 4xx. Each failed request records the visit and is listed under Issues.
Some failures are expected, such as a 401 from a check for whether the visitor is signed in. A rule leaves those out, or counts one again. Rules are read in order and the last one that matches a request decides it.
- status: codes such as 404, '4xx' or '5xx', a range such as '500-503', or 0 for a request that never completed. Left out, every code.
- urls: text found anywhere in the address, such as '/api/session', or a pattern. Left out, every address. Accented and non-Latin paths can be written as they read.
- count: whether a request the rule matches is a problem. True unless it says false.
The same rules can be set without a deploy, under Failed requests for each site on the Sites page. Only a workspace admin or owner can change them there; everybody else in the workspace can see them. They are read after the rules in your code, so a rule on the Sites page has the last word, and every page uses a change within five minutes. A rule the recorder could not use is refused when it is saved, with the reason, and one in your code is skipped with a warning in the browser console.
Three kinds of request are never a problem, whatever a rule says: one your own code cancelled, one cut short by the visitor leaving the page, and Spectra's own.
The error your code raises about a failed request, such as "Request failed with status code 401" or "Failed to fetch", is the same problem as the request and is not listed again. When a rule leaves the request out, the error goes with it. When an address answers with a web page where your code expected JSON, the error from reading it is listed as Expected JSON, got a web page, with the request named when only one page arrived at that moment. Chrome does not tell a page when its own response.json() fails, so in Chrome that error is seen only when your code reads the text and parses it.
Programmatic Code Triggers
Dispatch custom business events or update session properties dynamically from your application logic:
Zero-JS HTML Markup Triggers
Annotate HTML elements directly to capture friction points without custom script logic:
Dashboard Trigger Rule Conditions
Configure trigger rules in the Spectra dashboard, under Sites and then Trigger rules. Every page uses a change within five minutes, with no redeploy:
| Operator (op) | Description |
|---|---|
| eq / neq | Equals / Not equals target value. |
| gt / gte | Greater than / Greater than or equal to numeric threshold. |
| lt / lte | Less than / Less than or equal to numeric threshold. |
| contains | String substring matching. |
| exists | Property key presence check. |
Framework Integration Reference
React and Next.js (App Router)
Install the package from npm:
Render this once, in your root layout. Importing the package on the server is safe; recording starts in the browser, when the effect runs. In development React runs an effect twice, and the recorder is stopped and started again, which leaves one recorder running.
Next.js (Pages Router)
React with Vite, and other single-page React apps
Start the recorder before the app renders, so the first screen is in the recording.
React Router and Remix
In framework mode, start it from the root route, which every page renders.
Vue and Nuxt
A Vue app starts the recorder before it mounts:
In Nuxt, use a client plugin. Nuxt 4 reads plugins from app/plugins/; Nuxt 3 reads them from plugins/ at the project root. A plugin in the wrong folder is never loaded and says nothing, so check which one your version uses.
SvelteKit
Start it when the root layout mounts, which happens once, in the browser.
Angular
Start it before the application boots. The recorder works outside Angular's change detection, so it does not make your components check themselves again.
Astro
Add this to the layout every page uses. Astro bundles it and runs it once per page, with or without view transitions.
WordPress, Shopify, Webflow, Google Tag Manager and other site builders
Use the script tag from the Sites page rather than the package. Paste it wherever your platform lets you add code to the head of every page: the theme header in WordPress, theme.liquid in Shopify, the site-wide custom code setting in Webflow, Wix, Squarespace or Framer. In Google Tag Manager, add it as a Custom HTML tag firing on all pages; the tag keeps its settings when Tag Manager adds it after the page has loaded.
SDK Configuration Parameters
The record() initialization method accepts an object with the following parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
| apiKey | string | Required | The key issued for this site. Shown once when issued; rotate it from the Sites page. |
| siteId | string | Required | Unique site identifier linking recorded sessions to your organization workspace. |
| endpoint | string | 'https://api.spectra-trace.com/v1/ingest' | Where recordings are sent. Shown with your snippet on the Sites page. Leave it out to send to Spectra directly, or set tunnel instead for an install on your own domain. |
| tunnel | string | null | A path on your own site that forwards to Spectra, so recording is not affected by content blockers. See Serving Spectra from your own domain. |
| tracing | boolean | object | off | Send W3C traceparent and baggage headers so a replay and your server logs name the same request. true covers your own address; { origins: [...] } adds others, which must allow the headers. See Linking a replay to your server logs. |
| journeyOrigins | string[] | [] | The other origins your visitors' journeys cross. Named on both sides, a visit that moves between them stays one recording. Nothing is ever carried to an origin not in this list. |
| userId | string | null | Internal user identification string for cross-session correlation. |
| privacyMode | string | 'mask-pii' | One of 'strict', 'mask-pii', 'mask-inputs' or 'allow'. The default masks every input value and everything typed into an editable region, and scrubs recognisable personal data out of page text and out of attributes such as title and alt. 'strict' redacts all plain text across the document tree, attributes that carry words included. |
| maskInputs | boolean | true | Masks the values of form fields, and the text a visitor types into an editable region (an element with contenteditable, which is how chat boxes and rich-text editors are built), before recording. Add data-spectra-unmask to an editable region to record what is typed into it. Password fields, every card field and one-time codes are masked even when this is false, and so is the name of a file a visitor chooses. |
| captureBeforeConsent | boolean | false | Reproduce bundle only, with hasConsent: false. Records from the page's load while the consent banner is unanswered, holding everything in page memory: nothing written to the device, nothing sent, until consent(true), which sends it all from the start. consent(false), or leaving the page first, drops it unsent. Lawfulness per region is your decision. |
| inputsAsTyped | boolean | true (Reproduce) | Reproduce bundle only. What visitors type is recorded as typed, emails and phone numbers included; false keeps typed text scrubbed of personal details. Passwords, card fields and one-time codes are never recorded whatever this says. The base recorder ignores it. |
| respectDoNotTrack | boolean | false | Stop recording a visitor whose browser sends Do Not Track or Global Privacy Control. Off by default because Brave sends Global Privacy Control for every visitor and Firefox sends Do Not Track in private windows, so turning it on for you would quietly remove a large share of your traffic from your own recordings. Turn it on when that is what you want. |
| hasConsent | boolean or function | None | A consent gate. Recording waits until this is true, so a consent banner can hold it back. Pass a function to have it checked when recording starts. Nothing is written to the visitor's browser while it is false, including the anonymous visitor id. |
| pulse | boolean | true | Send a small anonymous count of page views and interactions per page, used to detect a page whose conversion has dropped. No content, no identity. |
| analytics | boolean | true | Measure behaviour on this site: where people click, how far they scroll, what frustrates them and how fast each page is. Only ever collected for a workspace that has the UX analytics add-on; set false to switch it off for one site that does. |
| frameBridge | boolean | { parentOrigins: string[] } | true | On a page, let iframes from your other origins on the same key record into its session. Inside such a frame, name the pages allowed to adopt it, for example { parentOrigins: ['https://app.example.com'] }; a frame that names none sends nothing to whatever framed it. Inputs inside those frames are always masked. A frame on the page's own origin needs none of this: the page records what it shows and what is done in it, and a copy of the snippet inside it does not start a recording of its own. One that started before the page's own snippet stops as soon as the page starts recording. |
| requireAuth | boolean | false | Record only when a userId is set; anonymous visitors are not recorded. |
| securityKey | string | undefined | A second value that must be given, and must equal your apiKey, before recording starts. Anything else stops recording silently, so set it only if you have a reason to require the key twice. |
| customFilter | function | undefined | A function receiving the configuration and returning whether to record this visit. |
| ignoreBots | boolean | false | Record automated browsers too. By default crawlers and headless browsers are skipped. |
| idleThresholdMs | number | 3000 | How long with no activity before the visitor is considered idle. Idle time is skipped in playback. |
| flushIntervalMs | number | 5000 | How often queued activity is uploaded while a session is being kept. |
| smartRecording | object | enabled | Keep only sessions where something happened. Pass { enabled: false } to keep every session from page load, within your plan's limits. |
| triggerRules | array | [] | Rules evaluated against track() and setProps(). Rules saved in the dashboard are merged in automatically. |
| onError | function | undefined | Called with any error the recorder encounters. Recording never throws into your page. |
| release | string | None | The build this page came from, for example 2026.09.25. Sessions and issues group by it, which is how you tell whether a problem arrived with a deploy, and the Integrity page says how each broken control stands in your newest release against the others. |
| environment | string | None | Which deployment this is: production, staging, a review app. Filterable in the session list, so a staging visit is never read as a customer. |
| sampleRate | number | 1 | What fraction of visitors to record, 0 to 1. Decided from the visitor rather than a coin toss, so you get that fraction of visitors with whole journeys rather than that fraction of page views with holes, and a visit already under way is always finished. Most sites should leave this alone: an uneventful visit already costs almost nothing. |
| networkCapture | object | metadata only | What a replay may keep about your requests beyond the address, status and timing it already records. { bodies: { urls: [...] }, headers: [...], denyKeys: [...], allowKeys: [...], bodyLimit: 8192, sockets: true }. Nothing is kept for an address you do not name. bodyLimit is how many bytes of each body are kept (8192 unless raised, up to 1048576); raise it where a visit must be replayable from its recording. See After the snippet: what to set up. |
| consoleLevels | string[] | ['warn', 'error'] | Which console levels are recorded. Add log, info or debug when your own diagnostics go through them. Uncaught errors and unhandled rejections are always recorded whatever you choose. |
| keyboard | boolean | true | Records the keys that are decisions: Enter, Escape, Tab, the arrows, and combinations such as Ctrl+Z. What a visitor types is never recorded this way on any setting; characters reach a recording only as a field value, under the masking rules. |
| longTasks | boolean | true | Marks the moments the page stopped responding. Without them a frozen replay is indistinguishable from a visitor who stopped moving. Measured by Chrome and Edge; Safari and Firefox do not report it. |
| blockAllMedia | boolean | false | Replaces every image, video and canvas with a placeholder of the same size, for a site where the pictures themselves are confidential. The layout is unchanged. Images referenced from CSS backgrounds are not replaced, but none of them is kept with a recording: a replay shows them only for as long as your site still serves them. Icons drawn in SVG are part of the page and are kept, including those drawn from a sprite file. |
| persistence | 'local' | 'session' | 'memory' | 'local' | How long anything the recorder writes may stay on a visitor's device. session keeps nothing past the visit, which lets you keep recording when a consent banner has been refused. memory writes nothing at all, and makes every page its own session. |
| dashboardUrl | string | Spectra | Where getSessionUrl() points. Only needed for a self-hosted deployment. |
| slowRequestMs | number | 10000 | How long a request somebody is waiting on may take before the session is kept. Only counts requests that started while a visitor was waiting, so background polling does not flag every visit. |
| failedRequests | array | every failure counts | Which failed requests are a problem: rules by code and address, the last match deciding. See Choosing which failed requests count, under Recording rules. |
| stuckLoadingMs | number | 15000 | How long a loading indicator may stay on screen before the session is kept as a stuck loading screen. |
| debug | boolean | false | Prints what the recorder is doing to the console. For checking an install; not for production. |
| onDiagnostic | function | undefined | Called with what the recorder decided and why, as it happens. The programmatic form of debug. |
| blockClass | string | RegExp | 'spectra-block' | A class name that replaces an element, and everything inside it, with an empty box of the same size. A string matches a class exactly, so secret matches class="secret" and not class="secret-note"; a regular expression is tested against each class on the element. The default already works, so you can hide an element today by giving it class="spectra-block". The box keeps the size the page was laid out around, and for a table cell or a dropdown option it keeps the few attributes that hold the layout together, including that option's value. |
| blockSelector | string | null | null | The same as blockClass, written as a CSS selector, for hiding something you cannot add a class to. Checked once when recording starts: a selector this browser cannot parse is ignored and said once in the console, and everything else you asked to hide still is. That is the one place a mistake here hides nothing rather than too much, so check the console when you set it. |
| maskTextClass | string | RegExp | 'spectra-mask' | A class name that replaces the text inside an element with mask characters, while leaving the element itself in the recording. Put it on a container and every piece of text beneath it is masked, along with the attributes that carry words, such as title and alt, and the values of fields inside it even when maskInputs is false. A string matches a class exactly, and a class holding a slash or a colon is safe. The default already works, so class="spectra-mask" masks text today. |
| maskTextSelector | string | null | null | The same as maskTextClass, written as a CSS selector. It matches the element or any element above it, so a selector naming a container masks everything inside, fields included. Validated when recording starts, with the same console warning as blockSelector. |
| maskChar | string | '*' | The character used when something is masked and you have not supplied a function to do it. One per character of the original, so the length of a hidden value is still visible in the recording. An email address and an authorisation token are replaced with a fixed run instead, so their length is not. Keep it to a single character: a longer string is repeated per character and doubles the length of every masked value. |
| maskInputFn | function | one mask character per visible character | Called with a field's value and its element, once per value, and what it returns is what the recording keeps. It is called for the fields that are masked outright. It is deliberately not called for an email or telephone field, or one your markup marks as a card number: those are replaced with a fixed run so their length says nothing either. Returning the value unchanged turns masking off for every field it covers. |
| maskTextFn | function | one mask character per visible character | Called with the text of each masked text node and its parent element, and with the value of each masked attribute that carries words, such as a title, and its element. Per text node, not per element, so a sentence a framework has split into three pieces arrives in three calls and your function cannot see the whole of it. When you set this, maskChar no longer applies to text. |
| scrubPII | boolean | true | Finds and masks personal detail in text the recording would otherwise keep as written: card numbers that pass a checksum, so order numbers and product codes survive; social security numbers in the United States format; email addresses; telephone numbers written the way people write them, with a country code (+91 98765 43210, +44 20 7946 0958) or as a national number of ten to twelve digits, but not digits that are part of an identifier, a date, an address or an amount; and authorisation tokens. It covers text, the values of fields, and the attributes that carry words: title, alt, placeholder, aria-label and every data- attribute. In a link it covers the query, and an email or telephone link loses its address or number, while the page the link points at is kept. It does not know names, postal addresses or account numbers. Use customPIIRegexes, a class or a selector for those. |
| customPIIRegexes | RegExp[] | [] | Your own patterns, masked wherever they appear in text, after the built-in ones. Every match is masked, whether or not a pattern has the g flag. These run on every piece of text the recorder keeps, console messages and error messages included, so a pattern that backtracks badly costs the visitor's browser. Turning scrubPII off turns these off for page text and attributes; console and error messages are always scrubbed. |
| recordCanvas | boolean | true | Records what a two-dimensional canvas is showing, so charts, maps and signature pads appear in the replay rather than as an empty rectangle. Turn it off for a canvas whose contents are confidential, or hide it with blockClass. |
| recordWebGL | boolean | false | Records three-dimensional canvases as well. Off by default because reading one back costs the visitor's browser more than a flat canvas, and because a page can be built so that reading it back is not possible at all. A three-dimensional scene is an empty rectangle in the replay until you turn this on. |
| inlineStylesheets | boolean | true | Keeps the text of a stylesheet the page loaded, so the replay looks like the page did even after you deploy a new one. Turn it off and the replay asks your own site for the stylesheet when somebody watches, which looks wrong once that file has changed or moved. |
| keepIframeSrc | function | keeps no frame addresses | Called with the address of each frame on the page. Return true to keep that address in the recording, so the replay loads the frame live. By default no frame address is kept, because loading a third party's frame at watch time shows whatever it shows today rather than what the visitor saw. |
| userDedupeWindowMs | number | 0 | A cooling-off window, in milliseconds, for one signed-in person. Zero turns it off. Set it and the same userId is recorded at most maxSessionsPerWindow times within the window, which is for a page somebody reloads repeatedly. |
| maxSessionsPerWindow | number | 3 | How many recordings one signed-in person may produce inside userDedupeWindowMs. Ignored while that window is zero. |
| smartRecording.frictionDetection | boolean | true | Whether a recording is kept when a visitor clicks something repeatedly, clicks something that does nothing, or is shown a form error. Turn it off to stop those from being reasons on their own. |
| smartRecording.struggleDetection | boolean | true | Whether a recording is kept when a visitor tries the same thing several ways, or puts real effort in and then leaves without finishing. |
| smartRecording.loadingDetection | boolean | true | Whether a recording is kept when a loading indicator stays on screen longer than stuckLoadingMs. |
User Identity and Event Tracking
Link incoming replay recordings to your internal user accounts using identify():
What the recorder returns
Spectra.record() returns an instance, also reachable as Spectra.current(), with these methods:
| Method | What it does |
|---|---|
| identify(userId) | Attach the whole session, including everything before this call, to one of your user ids. Pass a string; the first 128 characters are kept. |
| track(name, props) | Record a business event. It appears in the replay's event list, is evaluated against your rules, and is counted on the Overview's Events panel. Give it a value and a three-letter currency to total what it was worth. |
| setProps(props) | Set properties of the session such as plan or cart value. Rules watching a property re-evaluate when it changes. |
| flag(reason, details) | Keep this session now, under a reason you name. triggerRecording is the same call. |
| emitCustomEvent(tag, payload) | Add a custom marker to the timeline. |
| pause() / resume() | Stop watching the page without ending the session, for a screen a recording must not contain. Everything recorded before the pause still arrives, the visit stays one visit, and nothing about the page is observed while paused. Resuming photographs the page again. |
| getSessionUrl() | Where to watch this session, for your own logs and tickets. Null when nothing is being recorded. |
| captureError(error, context) | Report an error your own code caught. Uncaught errors and unhandled promise rejections are recorded automatically; an error your framework catches never reaches the browser, so this is how it reaches the replay. See Reporting errors your code catches. |
| setContext(labels) | Say what state the page is in now, such as a plan or a feature you are trying out. null removes a label. See After the snippet: what to set up. |
| setExperiment(name, variant) | Say which arm of an experiment this visitor is in, under the testing tool's own names. |
| handoff(url) | The address to send the visitor to so this visit carries on at another address you listed in journeyOrigins. Any other address comes back unchanged. |
| journeyToken() | This visit as a token your server can put on a redirect between two of your domains. See Recording a whole journey. |
| consent(granted) | The visitor's answer to your consent banner, given after the page loaded. false stops recording, sends nothing more, and removes what the recorder stored on the device. |
| takeFullSnapshot() | Capture the page again from scratch, for pages that rebuild their whole DOM. |
| stop() | Stop recording for this page. |
| sessionId | The id of the current session, for your own logs. |
Reporting errors your code catches
Uncaught errors and unhandled promise rejections are recorded for you, and the session they happened in is kept. But a framework catches: that is what an error boundary is for, and an error it catches never reaches the browser, so nothing outside your code can see it. The better your application handles its failures, the less a replay knows about them. One line puts them back.
class Boundary extends React.Component {
componentDidCatch(error, info) {
Spectra.current()?.captureError(error, { component: info.componentStack });
}
render() { return this.props.children; }
}
app.config.errorHandler = (error, instance, info) => {
Spectra.current()?.captureError(error, { where: info });
};
@Injectable()
export class SpectraErrorHandler implements ErrorHandler {
handleError(error: unknown) {
Spectra.current()?.captureError(error);
}
}
// app.config.ts: register it, or Angular keeps its own handler
providers: [{ provide: ErrorHandler, useClass: SpectraErrorHandler }]
window.spectraQ = window.spectraQ || [];
try { risky(); } catch (error) {
window.spectraQ.push(['captureError', error, { where: 'checkout' }]);
}
The error is recorded with its message, its stack and any properties you attached to it, so a tagged error keeps the status code that explains it. The session is kept for review, and the message is scrubbed for personal data exactly like every other message.
Errors from scripts on another domain
An error thrown by a script loaded from another domain, such as a CDN or a third-party widget, reaches every error tool as Script error. with no message, file or line. The browser hides the details on purpose, so that one site cannot read another's code through its errors. To see them in full, add crossorigin="anonymous" to the script tag that loads it, and have its host send an Access-Control-Allow-Origin header. Many CDNs send it already; your own servers may need it added.
Add the attribute only where the header is sent. A browser refuses to run a script marked crossorigin whose host does not send it.
Replay and filters
Replay lists every recording kept for the selected site, newest first, with why it was kept. Filter by the reason, by one of your user ids, by a release or environment, or by a page URL, which matches anywhere the visit went rather than only where it started. The second row of filters narrows by what they were on and how the visit went: phone, tablet or desktop, browser, operating system, how long they stayed, and whether anything broke. Clicking a visitor shows everything else that person did. A site with no recordings yet shows the install steps in place until the first one arrives.
Issues
Issues groups recordings by what went wrong, so the same error on the same page is one row however many people hit it. Open a row to see the pages affected and the recordings behind it. Choose the window in the bar at the top, from the last day to the last ninety.
An issue you cannot or need not fix, such as an error from somebody else's script, can be ignored: open it and choose Ignore. It leaves the list, the daily email and the weekly brief for everybody in the workspace. Its recordings and counts are kept, Status set to Ignored shows it again, and Stop ignoring brings it back. Only a workspace admin or owner can ignore an issue. To stop a kind of failed request counting at all, rather than one issue, see Choosing which failed requests count.
Traffic
On the paid plans the Overview leads with your traffic, counted for every visit: visitors, visits, pageviews, bounce rate, the time on site of the middle visit, and how often your events fired, each against the window before. Each figure is drawn as a line over the window: by the hour for the last 24 hours, by the day for longer, in UTC. On a very large site a long window is estimated from a share of its visitors, and the Overview says so beneath the chart.
Your traffic is kept for three months, or for your plan's history window if that is longer.
Below the figures: the top, entry and exit pages; where visits came from, with search engines and networks named, a visit with no referrer credited to the utm_source of the link it arrived on, and visits from AI assistants such as ChatGPT, Perplexity and Gemini on their own tab; the five campaign tags, utm_source, utm_medium, utm_campaign, utm_content and utm_term; countries; devices, systems and browsers; and your events, with the share of visitors who fired each and what they were worth.
Visits from crawlers, link previews, audit tools such as Lighthouse, WebPageTest and GTmetrix, and headless browsers are not counted, and do not use your allowance. The country comes from the visitor's time zone, not their address, which Spectra never reads. It is country only, and a visitor on a VPN is placed where their computer's clock is set. To count an event, call spectra.track('Purchase', { value: 49.99, currency: 'USD' }); the value and the three-letter currency are optional, and are the only properties counted.
Analytics
Replay and Issues answer questions about one visit at a time. Analytics answers the ones that only exist across many: which control nobody finds, how far down a page people actually get, which page quietly loses them. It measures every visit rather than the share worth recording in full, so a page with no recordings still has numbers.
Overview is how visits went for the window you choose: how many ran into something that went wrong, how long people stayed on a page and how much of that was active, and the three speed measures a visitor actually experiences. Visitors, sources and countries are on the main Overview.
Pages ranks every route by traffic, with time on page, how far people scrolled, and how many arrived from elsewhere on your site and were gone within five seconds. Open a row for that page on its own.
The heatmap is drawn over your real page rather than a screenshot. A masked capture of the page is rebuilt in the browser and the counts are attached to the elements themselves, so the map stays correct when your layout changes and can be shown at desktop, tablet or phone width. Switch between clicks, rage clicks, dead clicks and clicks that caused an error. Each busy element wears its share of the page's clicks in the view you are on, the rail beside the picture ranks the busiest and explains the colours, and hovering a row lights its element on the picture. Click any element to list the visits that clicked it, with replays first.
Scroll draws how far down the page visits got, as bands over the page itself: the share of visits that reached each part, labelled every quarter, and a dashed line where a typical first screen ended. Anything that matters wants to sit above that line.
The picture of a page appears after a few visits: a small share of visitors contribute one, so a busy page costs almost nothing to keep current. Refresh picture takes a new one, and Pin picture keeps the current one whatever changes.
Findings is the ranked list of what is going wrong, ordered by how many people each thing affects rather than how often it happens: forty visitors clicking a broken button once each matters more than one visitor clicking it forty times. Every finding states the rule it applied and links to a replay of somebody hitting it.
The replay player
A recording plays back what the visitor saw. Idle and away time is skipped automatically, so a twenty-minute visit with two minutes of activity plays in two. The panel beside the player has six tabs.
Activity is what the visitor did, in order, lighting up as playback reaches it: clicks, typing, page changes, the keys that are decisions such as Enter and Escape, and the moments the page stopped responding. Pages is each page of the visit, and Issues is what went wrong in it, each one a place to jump to. Details is who they were, what they were on, and how fast the page was for them, banded against the same thresholds Google uses so a number here means what it means in Search Console. Console is what the page logged, with errors shown as errors rather than as empty braces. Network is everything the page asked for and everything it loaded, each row saying what asked for it and how long the visitor waited, and opening to show the timing, the headers, and the bodies for the addresses you have named.
Copy bug report puts the page, the user, the errors and a link to this recording on the clipboard. Share link makes a read-only link that opens this one recording without a sign-in.
Sites and keys
Each site has its own key and its own snippet. The key is shown once, when it is issued; only a hash is kept, so there is no copy to show later. Replace key issues a new one and stops the old one immediately, so update the snippet in the same change. Rules for what to keep live under each site; every page uses a change within five minutes, without a deploy.
Failed requests under each site says which failed requests count as a problem, by code and address (see Choosing which failed requests count, under Recording rules). Anybody in the workspace can see the rules; only an admin or the owner can change them.
Being told when something breaks
Spectra already notices when a page stops working. It watches how often the people on a page click the things people normally click there, and how often a given failure happens compared with how often it usually happens, and when either changes sharply it starts recording the visitors it affects. What it could not do was tell you, so all of that waited until somebody opened the dashboard.
Under each site, in Tell me when it breaks, paste a Slack incoming webhook or any address of your own that accepts a POST. You get one message when a problem starts, naming the page and how much worse it has got, with a link to the recordings. One message per problem, not one per check: something that repeats itself every few minutes is something people turn off, and an alert nobody reads is worse than no alert because it feels like coverage.
When several things break at once, you are not sent a message for each. After a message, a site is quiet for ten minutes, and anything found in that time arrives together as one message that lists it. A site is sent at most ten messages a day. The tenth says it is the last, and anything else that breaks that day is on the Issues page. For a receiver of your own: every message carries an alerts list with each problem in it, and a message about one problem also carries it as alert. The text is written for Slack, so &, < and > stand for &, < and >; the fields beside it carry every value as it was.
If your pages say which release they came from, you are also told when your newest release has a control people could not use that your other releases did not, or has one more often: one message for the release, naming each control, its page, and how many visits met it in that release against the others, counted on the screen sizes it happens at. Each one is told once. Nothing is said until enough visits have come in to be sure and the answer has held for half an hour, so a release a few minutes old takes a little while.
To see a message arrive before anything has broken, choose Send a test message under the same heading. It goes at once to every place your alerts go, and the screen says how it went at each. It never holds back a real alert, and one can be sent every few seconds.
The same alerts can go to Telegram as well, or instead. Under Tell me when it breaks, choose Connect Telegram, then open Telegram from the link it gives you and press Start for a private chat, or choose a group to add Spectra's bot to your team's. The link works once, for thirty minutes. If Start does nothing, because the Telegram app is not installed, choose Open in Telegram Web, or open Spectra's bot yourself and send it /start followed by the code, which is the end of the link after ?start=. The Sites screen shows the whole command. Each alert reaches Telegram once, exactly as it reaches your webhook, and Disconnect stops it without touching the webhook. If the bot is removed from the group or blocked, the next alert finds that out: Spectra stops sending there, and the site shows Telegram as not connected.
If you set a signing secret, every message carries a X-Spectra-Signature header: sha256= followed by an HMAC-SHA256 of the exact body, keyed with your secret. Recompute it over the raw bytes you received and compare. That is how your receiver knows the message came from us. The secret is never shown again after you save it, not even to you.
Team and invitations
Invite colleagues by email from the Team page. An invitation link is good for seven days and is also shown to you, so you can pass it on if the email does not arrive. Roles: an owner pays and can see billing; an admin manages people, sites and keys; a member watches recordings and edits rules; a viewer reads. Anyone with admin or above can change a role or remove a person, but not themselves. You only see what your role allows, so a page you have no use for is not shown to you.
You can be in two workspaces: one of your own and one you were invited to. When you are in both, a workspace picker appears at the bottom of the sidebar, and everything on the page follows the one you choose. To join a different workspace, leave one first from its Team page. The owner of a workspace cannot leave it, since the subscription belongs to them.
Plan and quota
Billing shows your plan, the sessions this period against your allowance, and your retention window. When the allowance is used up, new recordings pause until the period resets or you move up a plan; nothing already kept is affected. On the paid plans your traffic keeps counting past it, up to twenty times your plan's visits, so the Overview's figures stay whole for the month; past that, nothing more is counted until the period resets, and the recorder stops sending anything at all. A plan change applies immediately: the new plan is charged in full and starts a fresh month, with no credit for the days left on the old one, and the confirmation says so before you agree. Invoices and payment details are managed from the same page.
The free plan is session replay: recordings, the replay player, sessions and issues. The integrity check, weekly summaries and Spectra AI are part of the paid plans, and heat maps and page analytics are the analytics add-on.
Reproduce: test every pull request with real visits
Reproduce records what real visitors do on your live site and replays it against every pull request. If something people did yesterday no longer works in the pull request, the check fails and tells you which journey and which step. Nobody writes or maintains tests. It is an add-on, switched on per site: ask us to switch it on, then follow these steps. They take about ten minutes.
What you need
- A site in Spectra (Sites), and its API key. The key is shown once, when the site is made; if you no longer have it, issue a new one on the Sites page.
- A GitHub repository for the site, where you can add secrets and workflows.
- A build command that writes your site to a folder (dist, build or out), or a preview URL for each pull request.
Step 1. Put the Reproduce tag on your live site
Open the dashboard, choose the site in the bar at the top, and go to Reproduce, then Set up. Copy the tag from step 1 there and put it first inside <head> on every page, in place of your current Spectra tag. Replace YOUR_API_KEY with the site's key. Leave out async: a visit is replayed from its first moment, so the script has to start first.
Deploy the site. Every visit from then on is recorded as a possible test. What visitors type is kept as typed, so a replay can sign in the way they did; passwords, card fields and one-time codes are never recorded, only their shape. To keep typed text scrubbed, add data-inputs-as-typed="false" to the tag.
Step 2. Create a CI token
Still on Reproduce, Set up, step 2: type a name such as github-actions and press Create token. Copy the token straight away: it is shown once. If you lose it, revoke it on the same page and create another.
Step 3. Give GitHub the token and the site
In your GitHub repository, open Settings, then Secrets and variables, then Actions, and add a secret named SPECTRA_REPRODUCE_TOKEN holding the token from step 2.
Step 4. Add the workflow
Create the file .github/workflows/spectra-reproduce.yml in the repository with the workflow from Set up, step 3 (it has your site's id filled in), or this one with YOUR_SITE_ID replaced. Change two lines: base is your live site's address, and candidate is the folder your build writes. If your build command is not npm run build, change that line too.
The folder is uploaded to Spectra and served privately for the length of the check, so you do not need a preview deployment. Uploaded builds are kept for seven days.
The report is posted on the pull request as one comment and rewritten on every push, never a new one each time; without pull-requests: write it stays in the job summary. To see the reports before letting Spectra fail a job, add fail-on: never under with:. If you deploy previews on Vercel or Netlify, give the preview's address as candidate and run the workflow on: deployment_status, with if: github.event.deployment_status.state == 'success' on the job and candidate: ${{ github.event.deployment_status.environment_url }}. A deployment is not a pull request, so that report goes to the job summary and the check, not to a comment.
Step 5. Let people use the site
Checks are made of real visits, so the first one needs some. Use the site yourself for a few minutes, or wait for visitors. A visit counts once it has ended, about 20 minutes after its last activity. Reproduce, Journeys shows the tasks found so far; Add recent visits adds the latest ones now. Each check also adds them on its own.
Step 6. Open a pull request
The workflow runs on every pull request, and its result appears on the pull request and under Reproduce, Checks. Each journey gets one verdict:
- Same: it does what it did.
- Broken: a step that worked now fails. It can't be done, an answer that succeeded fails, an error appears, or a press does nothing. The check fails.
- Changed: it works, differently: a new page, a different request, or other words on screen at a step (an error message that now says something else, a message that appears and goes, a renamed heading). Shown for review; the check passes.
- Couldn't replay: Spectra could not judge it, for example because the pull request asks the server for something no recorded visit asked. It goes to Reproduce, Inbox, where you answer it once; it is never counted as a pass.
Spectra compares what each step showed on screen, not only where it led: a failed payment that said "Something went wrong" in production and now says "Account balance should be greater than 500" is reported with both messages. Text nobody can see (kept for screen readers, folded away) is left out, and so is text that changes by itself between two replays of the same build, such as a clock or a rotating banner. Visitors' own moves in the browser, Back, Forward and an address typed, are replayed as they made them.
A journey that cannot be replayed even on your live site is tried again on another real visit of the same journey before it is reported. If none replays, you can Leave this journey out from the inbox, with a reason: it is left out of checks for 30 days, every report in that time lists it, and then it is judged again. When one change shows on many journeys (a button renamed everywhere), one Intended on every journey, for the difference named beside it, covers all of them. In the pull request's report Spectra quotes your app's own words, never what a visitor typed or what your server answered them: those read "[what the visitor typed]" and "[the server's answer]", and the dashboard shows them in full.
Every journey that is not the same is said in plain words: which step, what it did in production, what it does in the pull request, and why, as the browser saw it (for example, "another element covers it and takes the click"). Open the check under Reproduce, Checks to watch both replays side by side (Play both), and Watch the real visit opens the recorded visit at that moment. Copy fix prompt for an agent copies the same account, written for an AI coding agent to act on; the pull request's report carries it too. Replays are kept as video for 30 days.
If something is not right
- The Reproduce page shows only Set up, with "Reproduce isn't on for this site yet": the add-on is not on for the site chosen in the bar at the top. Check the site; the page names the site id it is looking at.
- The check says there are no journeys: no visit has been recorded and ended yet. Check that the tag is on the live site (step 1), use the site, and wait about 20 minutes.
- The check fails with "This token does not open this site": the secret holds a revoked token, or a token from another site. Create a new one (step 2).
- A journey is "Couldn't replay": open the Inbox. It says what was missing and lets you give the answer once, or decide that the difference is intended.
What counts as friction
A recording is kept when something went wrong or somebody struggled. These are the reasons, in the words the session list and the Issues page use. Nothing here needs configuring; they are what the recorder watches for on every page.
| Reason | What it means |
|---|---|
| Rage click | The same control clicked over and over in a burst. Almost always means the control answered too slowly to feel like it had answered at all, rather than that it was broken. |
| Dead click | A click a visitor made on something that looks interactive and produced no response: nothing navigated or downloaded, nothing opened in another tab or window, nothing was copied or printed, nothing played, redrew or restyled, nothing changed, no request started. Judged after a pause, and a click that started a request or sent the visitor to another page is never called dead just because the answer was slow. |
| Form error | A field was rejected. Reported once per attempt rather than once per keystroke, so clearing a box to retype it is not a finding. |
| Form thrashing | The same field edited and re-edited without the form moving on. The shape of somebody who cannot work out what a field wants. |
| Abandoned after effort | Real work put in, and then the visitor left with nothing completed. |
| Struggle | Repeated effort with no progress: submitting and returning to the same screen, hunting between the same controls. |
| Stuck loading | A loading indicator that never resolved. See the stuckLoadingMs option for the threshold. |
| Slow response | A request somebody was waiting on took far too long and then succeeded. A failure is already a finding; this is the one that is invisible otherwise, because nothing broke. |
| Abandoned a blank page | The page never rendered anything and the visitor left. |
| Page did not render | The page loaded but the things people interact with are not there, which is what a failed deeplink or a broken route looks like. |
| JS error, Unhandled rejection, Console error | An error was thrown, a promise rejected with nobody handling it, or your code logged an error. |
| Failed request, Unreachable request | A request came back as a failure, or never completed at all: refused, unresolvable, or the network went away. The two are separated because only the second means the address could not be reached. A page that itself arrived as an error says how visitors reached it: a link on one of your pages, a campaign, another site, or an address typed in. A broken link on your own pages, or one from a campaign or an AI assistant, is listed first. |
| Resource failed | A script or stylesheet the page needs did not load. |
| Blocked by policy | Your own Content-Security-Policy refused something the page tried to load. |
| Install test | A recording you asked for from the browser console, to prove the snippet works. See Verifying Installation. |
| Recorded for Integrity | A control was covered by something else, hidden, or had no size at that moment, so the visit is kept for the integrity check on the paid plans. It is not a problem on its own: the Integrity page lists a control only once visitors were kept from using it. |
You can add reasons of your own with track(), setProps() or a data-spectra-trigger attribute, and turn them into recordings with rules. See Recording rules.
What analytics collect
Analytics answers questions that only exist across many visits: which pages people reach, where they stop, where they click, how far they scroll, how fast pages were. It works differently from recordings and it is worth knowing how, because the honest answer to "are you tracking my visitors" depends on it.
A recording is kept only when something happened. Analytics needs every visit, so each page life sends one small anonymous summary as the page is left: which page, how long, whether the visitor interacted, how far they scrolled, which elements were clicked, the speed the browser measured, and where they came from. It is a summary, not a stream: there is no replay in it and no way to reconstruct one from it.
What it never contains: no name, no address, and no text from the page. It carries the same anonymous visitor id your recordings do, a random value with nothing about the person in it, plus your own user id if you set one with identify(). That visitor id is kept on the device so a returning visitor is recognised; with persistence: 'session' or 'memory' it ends with the visit. Element labels go through the same masking your recordings do, so a button whose text you have masked is a button whose label is masked here too.
It is collected only for sites whose workspace has the Analytics add-on. A site without it sends nothing at all, rather than sending it and having it discarded at our end. Set analytics: false to opt a site out even with the add-on active.
Heatmaps need one more thing: a picture of the page to draw on. A small fraction of visitors to a page that has no usable picture contribute one, which is the page as it was built in their browser, with the same masking applied. Everybody else contributes nothing to it.
On-Device Privacy and PII Masking
Spectra hides sensitive information in the visitor's browser, before anything is sent. Every input value is masked by default, and so is what a visitor types into an editable region such as a chat box or a rich-text editor. Passwords, card fields and one-time codes always are, whatever else you set. Card numbers, email addresses and authorisation tokens found in the page's text, and in attributes such as titles and image descriptions, are masked too. These attributes change what is kept for one element and everything inside it:
| HTML Attribute | Behavior |
|---|---|
| data-spectra-mask | Replaces text contents, the attributes that carry words, and form field values with the mask character, an asterisk unless you set maskChar. On an iframe, or on anything that contains one, it covers the whole page the frame shows. |
| data-spectra-block | Completely hides the element and all child nodes, replacing it with a placeholder box. On an iframe, or on anything that contains one, nothing inside the frame is recorded. |
| data-spectra-unmask | Shows an element and everything inside it unmasked: its text, even in strict mode, its field values, and what is typed into it if it is an editable region. Recognisable personal data is still scrubbed from its text, and passwords, card fields and one-time codes stay masked whatever it says. |
Erasing one person's recordings
When somebody asks you to delete their data, this removes every session recorded for them on a site, wherever it is kept, and the analytics of every visit of theirs, recorded or not. It needs the admin role in the workspace that owns the site.
Authenticate with your dashboard session token, not the ingest key. The ingest key can only write recordings; it cannot read or delete them, which is what keeps a key pasted into a public page from being able to erase your history. Open your browser's developer tools while signed in to the dashboard and copy the Authorization header from any request. It stops working when your sign-in does, so copy a fresh one when you need it.
The reply says how many sessions were found and how many were removed. If a stored copy cannot be deleted the whole request fails rather than reporting a partial erasure as complete, so it is safe to retry.
Deleting a site
Deleting a site deletes everything recorded under it, everywhere it is kept. It cannot be undone and there is no recycle bin. Also admin or higher.