Skip to content

Data protection and scrubbing

What the SDK and the node remove or mask, what cannot be guaranteed, and how to check it safely.

Markdown
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#

PlaceHoldsWho can read it
Your appThe event before it is sent; scrubbed by the SDK firstYour 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 daysOnly 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 ticketNo one — nothing is kept
Your node, in your Hetzner projectEvents, issues, attachments, source maps — scrubbed again before storingPeople in your workspace, through the app
Belvador's control planeAccount, workspace, project and configuration metadata, countsBelvador, 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 webhookIf you add one: the alert's metadata (names, codes, times, a dashboard link), signed — see Custom webhooksWhoever runs that endpoint
Backups in Cloudflare R2Encrypted copies of your node's dataWhoever 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-IP and Forwarded;

  • remove request bodies, cookies and environment data from request info;

  • keep only user.id from user data (up to 128 characters) — emails, names and IP addresses are dropped;

  • mask as [Filtered] any value under a sensitive name — names containing password, passwd, pwd, secret, api_key/apikey, access_token, auth, credentials, credit_card/card_number, cvv, private_key, session, cookie, token, bearer, ssn or dob — in nested data, tags, contexts, extra data, breadcrumbs and query strings;

  • mask in text: credentials in URLs, name=value pairs and JSON strings with sensitive names, bearer tokens and other Authorization: <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 as jane%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.com removed;

  • 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-Cookie and Proxy-Authorization headers 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:

  1. 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.

  2. 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.

  3. Your rules, in order, up to 100.

  4. Built-in detectors (layer 2).

A rule is a selector, a type and a method:

PartValues
SelectorA 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.
Typeanything (the whole value), pattern (a regular expression), creditcard, ip, email, phone_e164 (international numbers such as +84901234567)
Methodremove, 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:

TypeScript
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 x or value is 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_count or token_count are masked too. Rename the field if you need its value.

  • Attachments are size-checked, not content-scrubbed. Only enable attachments for 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 captureException context, tags or setContext.

  • 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.

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

Spotted something wrong or missing? Tell us: hello@belvador.dev