Data protection and scrubbing
What the SDK and the node remove or mask, what cannot be guaranteed, and how to check it safely.
On this page
Error reports can carry things you never meant to collect: a password in a form value, a token in a URL, an email in a message. Belvador filters at two independent layers — in your app and on your node — and keeps the data in your own Hetzner project. Filtering lowers the risk; it cannot remove it.
Where your data goes#
| Place | Holds | Who can read it |
|---|---|---|
| Your app | The event before it is sent; scrubbed by the SDK first | Your code |
Belvador's edge (ingest.belvador.dev) | The event in transit. If your node is unreachable, a copy sealed with your node's key is buffered for up to 14 days | Only your node can open sealed data |
Belvador's edge (edge.belvador.dev) | Nothing stored. When someone in your workspace opens errors, traces or logs, the edge passes them from your node to their browser in memory, after checking a short-lived ticket | No one — nothing is kept |
| Your node, in your Hetzner project | Events, issues, attachments, source maps — scrubbed again before storing | People in your workspace, through the app |
| Belvador's control plane | Account, workspace, project and configuration metadata, counts | Belvador, for running the service |
Belvador's heartbeat service (ping.belvador.dev) | For ping URL monitors only: a fingerprint of each ping URL, which signal arrived and when, deadlines and incident state — never request bodies, headers or IP addresses (output a job POSTs goes to your node, like an event) | Belvador, so missing pings alert even when your node is down |
| Your own webhook | If you add one: the alert's metadata (names, codes, times, a dashboard link), signed — see Custom webhooks | Whoever runs that endpoint |
| Backups in Cloudflare R2 | Encrypted copies of your node's data | Whoever holds the backup key — see Backups |
Events are kept for the retention period of your plan (30 days by default); daily summaries for 13 months.
Layer 1 — the SDK, before anything leaves your app#
Belvador's SDKs (@belvador/*) always:
remove the headers
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-Forwarded-For,X-Real-IP,X-Client-IP,CF-Connecting-IP,True-Client-IPandForwarded;remove request bodies, cookies and environment data from request info;
keep only
user.idfrom user data (up to 128 characters) — emails, names and IP addresses are dropped;mask as
[Filtered]any value under a sensitive name — names containingpassword,passwd,pwd,secret,api_key/apikey,access_token,auth,credentials,credit_card/card_number,cvv,private_key,session,cookie,token,bearer,ssnordob— in nested data, tags, contexts, extra data, breadcrumbs and query strings;mask in text: credentials in URLs,
name=valuepairs and JSON strings with sensitive names, bearer tokens and otherAuthorization: <scheme> <value>text, JWTs, keys named by their provider prefix (Stripe, GitHub, GitLab, OpenAI, Anthropic, AWS access key IDs, Slack, Google API keys, Stripe webhook secrets, npm, Supabase secret keys and Belvador ingest keys; all but Stripe since SDK 0.5.2), the secret path of Slack and Discord webhook URLs and Telegram bot URLs, card numbers that pass the Luhn check, the local part of email addresses, also when percent-encoded asjane%40example.com(the domain stays), and the last part of public IPv4 addresses.
Data deeper than 10 levels or beyond 1,000 keys is not walked. Your beforeSend hook runs before this scrubbing.
The Node.js and Python setups add a beforeSend hook that applies the same key list and similar text rules.
Layer 2 — your node, before storing#
Your node runs its own scrubber, written separately from the SDK so one bug cannot disable both:
the same list of sensitive names, masked as
[Filtered];patterns for bearer tokens and other
Authorization: <scheme> <value>text, Stripe secret keys, JWTs, card numbers and email addresses (also percent-encoded,%40);from node 0.1.31, the same provider-named keys and webhook URL paths as the SDKs;
the user name and password in URLs such as
https://user:pass@db.example.comremoved;IP addresses masked: the last part of IPv4 is zeroed, IPv6 keeps only its first 48 bits. In free text, such as an error message, public IPv4 addresses are masked the same way; private, loopback, CGNAT and link-local addresses (
10.0.3.17,127.0.0.1) stay readable because they usually name your own servers;user IDs replaced by a salted hash;
Authorization,Cookie,Set-CookieandProxy-Authorizationheaders removed.
It covers exception values, stack frame variables and source context, breadcrumbs, tags, contexts, extra data, messages, user fields and request data.
Built-in detectors#
Project → Privacy → Built-in detectors lists these groups; all are on by default. Turning one off means data of that kind sent by an SDK is stored as-is and shown to everyone in the workspace. Only owners and admins can change it; turning one off needs your authenticator code, owners get an email, and every change is recorded in the audit log.
Layer 3 — project rules (the Privacy page)#
Project → Privacy decides what your node never stores for that project. Your node applies it before anything is written — to errors, OpenTelemetry spans and logs — from node agent 0.1.36. A saved change applies to data that arrives afterwards; data already stored is not rewritten.
The order never changes:
Allowlist (Strict mode only). Strict keeps the minimum an error, span or log needs — level, error type, stack frames without variables, release, environment, trace ids — plus the fields you list. Standard keeps everything the next steps do not remove.
Always removed, whatever the project says (cannot be turned off or allowlisted): cookies, auth headers, one-time and verification codes, passwords, secrets and private keys, request, response and message bodies, card numbers (Luhn-checked) and CVV, stack frame variables.
Your rules, in order, up to 100.
Built-in detectors (layer 2).
A rule is a selector, a type and a method:
| Part | Values |
|---|---|
| Selector | A lower-case path such as extra.phone, request.headers.x-api-key or contexts.*.token; * is one level, ** any depth. Typed selectors: $string (any text value), $request, $user, $frame.vars, $log.attributes, $span.attributes. Combine with &&, || and !. A log's own text is message. |
| Type | anything (the whole value), pattern (a regular expression), creditcard, ip, email, phone_e164 (international numbers such as +84901234567) |
| Method | remove, replace (a fixed text of at most 32 characters), mask (keeps the last 2 characters), hash (a keyed per-project hash, 16 hex characters: the same input gives the same output, so you can still count and search) |
Patterns must be safe to run on every event: back-references, look-arounds, nested repeats such as (a+)+ and patterns longer than 256 characters are refused when you save. Each text value is checked up to 16 KiB; if a payload takes longer than its time budget, the remaining fields a rule would touch are removed, and the page counts it.
Test rules runs the rules in your browser on a sample you paste or a synthetic one — the sample is never sent. It shows the result after the always-removed fields and your rules (built-in detectors run after that, on your node), and which rule matched which path, never the matched value. Hashes show as [hash] because only your node holds the key.
Saving creates a new version; Version history shows who changed what and when, with a diff. A change that keeps more data — deleting or turning off a rule, switching back to Standard, adding to the allowlist, turning a built-in detector off — needs your authenticator code; owners get an email and the change is recorded in the audit log. Owners and admins can edit; other members see the page read-only. Over the API and MCP, privacy.get reads the policy and privacy.set always waits for a person's approval.
Node status. Your node reports which version it applies. If it has no valid policy — for example its configuration could not be verified — it stores only the strict minimum and counts those payloads; the page shows the count.
The same rules in your app (optional)#
From SDK 0.6.0, your app can apply the project's rules before anything leaves it, with the same engine your node runs. Copy as code on the Privacy page copies the saved policy as JSON; save it in your app and pass it to init:
import { init, privacyPolicy } from "@belvador/browser"; // every @belvador/* package exports itimport policy from "./belvador-privacy.json";init({ dsn: "https://<public-key>@ingest.belvador.dev/<project-id>", privacyPolicy: privacyPolicy(policy) });Your node still applies the project's current policy, so an app with an older copy is never less protected — copy again after a change to also filter in the app. A JSON that is not a valid policy is not applied and reports BLV_PRIVACY_POLICY_INVALID. The rule engine is bundled only into apps that import privacyPolicy.
See exactly what would be sent. preview: true in init (or BELVADOR_PREVIEW=1 on a server) prints each envelope — after your beforeSend, these rules and Belvador's scrubbing — to the console (stderr on servers) and sends nothing. Use it while developing; turn it off to send.
Monitors and traces#
A check-in carries the monitor slug, status, duration, environment, release, attempt numbers, an optional short reason code and the trace id — no message text, request data or user data. It is stored on your node. Belvador's own database keeps only each monitor's settings and state (healthy, failing, since when) and its incidents, so pages and alerts work while your node is offline. Trace ids are random identifiers; they are stored with errors and runs on your node and are never put in alert messages. OpenTelemetry spans and logs you send are also stored only on your node and filtered there first — see OpenTelemetry privacy.
What filtering cannot guarantee#
Unusual names. A secret stored under a name like
xorvalueis not recognised.Free text. A message such as
`Login failed for jane@example.com with Hunter2!`has the email partly masked, but the password is just text. Names, phone numbers and street addresses in text are not recognised, and neither are IPv6 addresses written inside a sentence.Broad names. Matching is by "contains", so harmless names such as
author,session_countortoken_countare masked too. Rename the field if you need its value.Attachments are size-checked, not content-scrubbed. Only enable
attachmentsfor files you know are safe.Your own metadata.
release,environment, fingerprints and tag names are sent as written.Other SDKs. If you keep an SDK other than Belvador's, its defaults apply until your node's scrubber runs.
Safe habits#
Never pass secrets, tokens or full user objects to
captureExceptioncontext, tags orsetContext.Use
setUser({ id })with an internal ID, not an email.Test scrubbing with fake values, such as
password=not-a-real-secret— never with real credentials.If a real secret ever reaches Belvador, rotate that secret first; deleting the event does not undo the exposure.
Legal#
The Security page describes Belvador's controls and how to report a vulnerability. The Data processing page explains roles under data-protection law.
Posts on this topic
All posts- How to send production errors to an LLM safelyBefore you paste an error into ChatGPT, Claude or a coding agent: strip secrets, fence untrusted text, check retention. A 72-run injection test shows why.
- Secrets in exception messages: what the Node SDK sends by defaultAPI keys, tokens and webhook URLs end up in error messages. We tested 15 real cases with @sentry/node defaults and two scrubbers: what leaked, and why.
- How to scrub PII from error monitoring without losing contextA real data scrubber on 16 synthetic error samples: what pattern rules remove, what they miss, what they remove by mistake, and what to add in beforeSend.
Spotted something wrong or missing? Tell us: hello@belvador.dev