@catalystiq/sentry-config
v0.1.5
Published
Shared Sentry configuration for the getcatalystiq apps: scrubbers, data collection, environment naming, sample rates and an error-log helper.
Readme
@catalystiq/sentry-config
The Sentry configuration every getcatalystiq app shares: the scrubbers that keep credentials and PII out of what Sentry
receives, an explicit dataCollection that means the same on SDK v10 and v11, one set of environment names, one
sample-rate policy, a span redaction that keeps trace links, and one error-log helper.
The package imports nothing at runtime (it runs on the server, the edge and in the browser) and takes the SDK as an argument where it needs one. It is built as ESM and CommonJS.
Use
// sentry.server.config.ts
import * as Sentry from '@sentry/nextjs';
import { standardOptions } from '@catalystiq/sentry-config';
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
enabled: process.env.VERCEL_ENV === 'production',
...standardOptions({
runtime: 'server',
vercelEnv: process.env.VERCEL_ENV,
// The app's own filters and scrubs run FIRST; the shared ones after.
beforeSend: appBeforeSend,
}),
skipOpenTelemetrySetup: true, // SDK 10 only: removed in 11 (see "An app moving to @sentry/nextjs 11")
});In the browser, pass process.env.NEXT_PUBLIC_VERCEL_ENV written out in the app's own file, so the bundler inlines it.
standardOptions returns environment, dataCollection, beforeSend, beforeSendTransaction, beforeBreadcrumb,
beforeSendLog, beforeSendMetric (a metric's name and attributes get the log rules), traceLifecycle: 'static' and, in
the browser, the policy's sample rates. An app's own hooks go IN, as standardOptions' input (beforeSend,
beforeSendTransaction, beforeBreadcrumb, beforeSendLog, beforeSendMetric), and run first: a hook of those names
set beside the spread is replaced by, or replaces, the shared one, and on SDK 10 an _experiments.beforeSendMetric is
not called once the top-level hook exists. onScrubFailure (input only) is told when a hook fails: see below.
traceLifecycle: under 'stream' the SDK sends spans one by one and ignores beforeSendTransaction, on both majors,
so the transaction scrub would never run. 'static' is SDK 10's default already and is not SDK 11's
(test/sdk-11-spans.test.ts, test/review-round-34.test.ts). An app that sets 'stream' after the spread sends its
spans unscrubbed; one that had it before the spread is moved to 'static'. Every part is also exported on its own:
| Export | What it does |
| --- | --- |
| scrubRequestCredentials, createRequestCredentialScrubber | Cuts cookies, the Cookie, Set-Cookie, Authorization, Proxy-Authorization, x-vercel-oidc-token and x-vercel-protection-bypass headers (any case) and request.env from request and contexts.response, the Server Action wrapper's form data and result from extra, a credential header anywhere else in the event (a vendor request attached as extra, a span's http.request.header.*), and, for a URL under /api/auth/ (Better Auth: with or without the slash, in any case, percent-encoded inside another URL): its query and the #fragment right after it cut from any string that quotes it; the # stays only when a literal & follows the auth URL's path, or its percent-encoded query, before it (?next=/api/auth/session&x=1#top, ?next=%2Fapi%2Fauth%2Fcb%3Fcode%3D1&y=2#top: taken for the outer page's; so is the fragment of a malformed /api/auth/cb&x=1#…), never for a & in the auth URL's own literal query (/api/auth/callback?code=1&state=2#id_token=… loses both; an encoded %23 is not read as a fragment), the password-reset token in its path, and, in an object that names it in a URL field (url, url.full, http.url, http.target, url.path, next.span_name, a long animation frame's script URL, code.filepath on SDK 10 and code.file.path on SDK 11, and the largest contentful paint's resource, lcp.url and browser.web_vital.lcp.url), the fields that carry a query or body (data, query_string, url.query, http.query, url.fragment, http.fragment, http.request.body(.data), body, query, params, payload, form, formData, json, input, variables, values). Three options, all off by default: keptRequestHeaders makes request.headers an ALLOWLIST (only the listed names are kept, in any case; a credential header is dropped even when listed; contexts.response keeps the credential rule alone), for an app that will not send the caller's IP headers, Vercel's geolocation or a signature header a platform adds later; dropRequestBody drops request.data from every request, not only an auth URL's; quotedAuthUrls (0.1.5) reads an auth URL QUOTED anywhere in another URL (/sign-in?next=/api/auth/callback/google) as an auth request's: a record whose URL field names the mount anywhere, its query too, loses the query and body fields above (by default only a URL whose own PATH is under the mount does), and a quoted auth URL's query is cut to the end of the URL whether its ? is literal or %3F, across a &, with the #fragment after it whatever the path ran over (the two exceptions above no longer apply). It runs wherever the credential rules run, so on breadcrumbs, logs and metrics too, not only on events and transactions; but in a log and a metric the URL rule runs BEFORE the credential rules (as in 0.1.4), so there a quoted auth URL that is a secret-named parameter's value (?state=/api/auth/cb&y=1#…) is cut by the parameter rule up to the &, and the #fragment after it is not read as the auth URL's. From easy-carnet's scrubQuotedAuthUrls, which ran on events and transactions only. Its longer cut can also end a URL before a later @ that the userinfo rule (which runs to the LAST @ of an authority) would otherwise have run to, so text that rule took only by running that far is sent: in a malformed authority, never a userinfo. |
| createUrlScrubber, defaultUrlScrubber, URL_VALUE_KEYS | Cuts the values of query parameters named as secrets (token, OAuth's code and state, keys, signatures, and the names the key rule cuts as properties: password, passwd, passphrase, private_key, privateKey, authorization, credential, credentials, cookie, otp, pin, pwd, ssn, cvv, cvc; a text rule, so pin=false in a message is cut too) or every query value (mode: 'allQueryValues'), in a query's text or held as an object or [name, value] tuples (query_string, query, url.query, http.query, searchParams), properties whose key names a secret (one rule for events, breadcrumbs and logs, src/keys.ts: client_secret, newPassword, privateKey, refreshTokens, SSNLast4, otp, a Postgres error's detail / where / internalQuery; a token key with a NUMBER value is a usage count and stays, a boolean is never cut (a log's cutAttributeKeys can say otherwise for a log's attributes), and a key whose last word is a label (type, kind, outcome, status, reason: auth.otp_type, token_type) is not a secret), an app's own path patterns (pathPatterns: each global, group 1 the part kept, the rest of the match cut; from 0.1.5 they read the RAW string, so an @ inside a path a pattern names, wss://browserbase.com%2Fdebug%2Fu@x, no longer lets the userinfo cut take the host and leave the tail after the @ unread; every cut the order of 0.1.4 makes (the userinfo, the addresses, the patterns, the named parameters and, through standardOptions, the credential rules' auth-URL cut a log runs after them, each on the text the ones before it left; createUrlScrubber's second argument, tracedAfter, names such later cuts) is traced back to the raw string and cut as well, so the one pass cuts every character 0.1.4 cut, and more; cuts that overlap or touch are one marker (or are removed, when each of them removes text: an auth URL's query), and the patterns and parameters run once more over the result (a marker a traced cut put before a debugger path, the auth-URL rule's reset token, is read there); a group 1 that is not the start of its match gets the whole match cut; without pathPatterns or jsonEscapedAmpersand none of this runs and the cuts are 0.1.4's), and a URL's userinfo, in any string and for any scheme (postgres://app:<password>@host/db becomes postgres://[Filtered]@host/db; a protocol-relative //u:p@host too; up to the last @ of the authority; an empty userinfo is left, and so are an address in prose and a mailto:, which have no //; a text rule, so inside a query too: ?next=https://x.test&[email protected] loses x.test&email=a, which only cuts more). And an EMAIL ADDRESS IN A URL, its @ literal or percent-encoded (0.1.4, the owner's standard of 2026-11-11: no address reaches Sentry in a URL): [email protected], [email protected], dana%40example.com, double-encoded %2540 and deeper, with an encoded +tag, dot or ', or UTF-8 bytes (ren%C3%A9, m%C3%BCnchen), at a punycode or IDN domain ([email protected], dana@пример.рф, dana%40%D0%BF%D1%80%D0%B8%D0%BC%D0%B5%D1%80.%D1%80%D1%84) or an IP literal (dana@[192.168.0.1], dana%40%5BIPv6%3A2001%3Adb8%3A%3A1%5D), in a query value, a path segment, a fragment, a URL nested in another's query or a whole URL of any scheme (mailto:, chrome-extension:, moz-extension:, safari-web-extension:, blob:, data:, file:, and any other), so request.url, request.query_string (text, object or tuples), a span's http.target / http.url / url.full / url.path / url.query / url.fragment / code.filepath / code.file.path / browser.script.invoker / lcp.url / browser.web_vital.lcp.url, contexts.trace.data, a breadcrumb's data.url / from / to, a transaction named by its URL, a message and a log all lose it. An encoded address is cut wherever it is; a literal one is cut unless it is a word of prose (below). Under a URL key (URL_VALUE_KEYS: the query holders, http.fragment, url.fragment, url, url.full, http.url, http.target, url.path, code.filepath, code.file.path, browser.script.invoker, lcp.url, browser.web_vital.lcp.url, next.span_name, from, to, referer, referrer, http.request.header.referer; any property of those names, any case) every address is cut, a bare one too (SDK 11 writes url.fragment without its #), and so it is in every string of an array or object under one, at any depth up to 16 (past it, [Filtered: too deep]), a key too ({ email: ['dana@…'] }, [['email', ['dana@…']]], { filter: { email: 'dana@…' } }, { url: ['dana@…'] }, a breadcrumb's data.query list), and, in registerSpanRedaction, in a span attribute of those names whatever redact does. Only the address becomes [Filtered]: nothing is decoded, and the separators around it stay (?q=hi%20dana%40example.com%20bye becomes ?q=hi%20[Filtered]%20bye, next=%2Flogin%3Femail%3D[Filtered]); a raw + before it is taken for the address's own +tag and goes with it, and a hyphen or a digit run into it after the top-level domain goes with it too ([email protected]/x becomes [Filtered]/x: what follows with no separator may be more of the domain). The userinfo rule runs first, so https://user:[email protected]/ loses its userinfo, not a made-up address [email protected]. A package path or a version (@sentry/core, %40scope%2Fpkg, [email protected], pnpm's .pnpm/@[email protected]/, webpack://@scope/) is left, so is a package version that reads as a domain ([email protected]/x.js, [email protected], [email protected], literal or encoded: numbers or x joined by dots and a pre-release or not, then a / or .js, .mjs, .cjs, .css or .min. one of those; a pre-release with a label after a dot that starts with two letters or xn-- is not one unless that label starts with a pre-release word, so [email protected]/x, [email protected]/inbox and [email protected]/x are still cut), so is a high-density image's file name ([email protected], [email protected], [email protected], logo%402x.png, icon%403x.webp, as next/image URLs carry them: a name that is wholly <n>x or <n>.<n>x, .min or not, and png, jpg, jpeg, webp, avif, gif, svg or ico; dana%402x.png.example.com is still cut), so is a script's file name after an @ (a stack frame's [email protected]:12:3: a name that is wholly one label and js, mjs, cjs, jsx, ts, tsx, css, html, htm, json, wasm, mp3, mp4, m4a, m4v, woff2 or bz2, none a top-level domain, and never after an IP literal (dana@[::1].js is cut); .map is one, so [email protected] is cut; [email protected] is still cut), and so is an address in prose (below). With cutFragmentKeys (off by default) the value under http.fragment / url.fragment is cut wherever it is, not only beside an auth URL. With jsonEscapedAmpersand (0.1.5, off by default) the \u0026 a JSON encoder writes for & is a boundary before a parameter name too (…?a=1\u0026token=<secret>, a URL quoted in a JSON body; in allQueryValues mode too); it does not end a value (token=a\u0026b=1 is cut to the end, as without it), so it only cuts more: the one pass also traces the order without its pattern, and, through standardOptions, the auth-URL rule after both, in every carrier (a hand composition that runs the credential text after url.text without tracedAfter sees a value it cuts that quotes an auth URL take that URL along, so a #fragment after it is sent, as after 0.1.4's &state=). Its pattern reads the raw string in the one pass, as a path pattern does, so a userinfo cut that runs over its value (//h.test<escape>token=u@x) cannot leave the value's tail. .text for a string, .deep for an event. |
| createLogScrubber, defaultLogScrubber | beforeSendLog: Postgres Key (col)=(value) and Failing row contains (…) details (events get these too; a failing row is cut to the END of the string, what follows it with it), blob URLs, URL secrets, and secret keys, through structured attributes. A message that is not a string (an Error, a URLSearchParams) is scrubbed as String(message), the text the SDK sends, and replaced by it. In standardOptions a log also gets the credential and URL rules, so it loses what an event would, and a metric gets the same; with url.pathPatterns or url.jsonEscapedAmpersand (0.1.5) the URL rule's one pass also traces the credential rules' auth-URL cut, which a log runs after it, so a pattern's cut cannot take away an /api/auth path that rule would have read. One option, off by default (0.1.5; standardOptions' log, or createLogScrubber's third argument): cutAttributeKeys, a list of words and RegExps, cuts a log ATTRIBUTE whose key matches one, at any depth and in the SDK's { value, unit } form, WHATEVER its value: a boolean (has_token: true), a number (tokens_used: 5), a label (token_type) and an object, which the shared key rule keeps. A word matches a key that contains it, in any case ('cc_' matches acc_id too); a RegExp is tested against the key as given (/^details?$/i), its g and y flags ignored. The log's own fields (level, message) are not attribute keys (in a plain log, as the SDK hands over; a log that is a class instance has its own fields tested too). Logs only: a metric keeps the shared rules (a metric's gen_ai.usage.input_tokens is what it measures). |
| piiOffDataCollection, noDataCollection, PII_OFF_DATA_COLLECTION, NO_DATA_COLLECTION, PII_DENY_SNIPPETS | An explicit dataCollection. See below. |
| sentryEnvironment, isProductionEnvironment | production, preview or development from VERCEL_ENV. |
| BROWSER_SAMPLE_RATES, SERVER_SAMPLE_RATES, browserSampleRates, assertServerTracingLeftToVercel | The sample-rate policy, and a departure with its reason. |
| registerSpanRedaction, assertNoBeforeSendSpan | Redacts ui.* spans on spanEnd (both majors' op names: SDK 11 renamed ui.long-animation-frame and ui.long-task to ui.long_animation_frame and ui.long_task; an ops of an app's own must name both): the name and string attributes through redact, an attribute whose key names a secret cut whole, every email address cut from an attribute under a URL key (url.full, url.fragment, lcp.url, …: URL_VALUE_KEYS) whatever redact does, and on a fault the name and attributes replaced. Reads SDK 10's span JSON (op, description, data) and SDK 11's (name, attributes, the op under sentry.op). Never beforeSendSpan: see below. |
| createErrorLogger, normalizeError | logError(context, error, extra?, { level }). |
| chainEventHook, walk, combineRules, createCredentialRules, scrubWithCredentials, TOO_DEEP_VALUE, FILTERED | The building blocks: an app hook then a shared scrub, failing closed (each can say that it failed: onScrubFailure below); the copy-on-write walk and its rules; the markers. |
Every scrubber returns what it was handed when there is nothing to cut, never edits it in place, and never throws: a
scrub that fails drops the event (null) rather than send what it did not read.
When a hook fails: onScrubFailure
A hook that throws drops its entry, the app's own hook (it runs inside the chain, which catches its throw) as much as
a shared scrub. To the SDK that is a hook answering null, the same as a noise filter's, so by default nothing says that errors have stopped arriving.
standardOptions takes a listener that is told each time:
standardOptions({
runtime: 'server',
vercelEnv: process.env.VERCEL_ENV,
beforeSend: appBeforeSend,
onScrubFailure: ({ hook, stage, outcome }) => {
process.stderr.write(`sentry scrub failed: ${hook} ${stage} ${outcome}\n`);
},
});hook:beforeSend,beforeSendTransaction,beforeBreadcrumb,beforeSendLogorbeforeSendMetric.stage:app(the app's own hook threw or rejected, or abeforeSend,beforeSendTransactionorbeforeBreadcrumbansweredundefined, a forgottenreturn: a bare SDK refuses that from the first two and throws on it from the third) orshared(this package's scrub could not read the entry: a getter that threw).outcome:dropped(nothing was sent), orreducedfor an event or a transaction sent without itsrequest,contexts.responseandextrabecause a field in one of them could not be read.
It is handed those three fixed words and nothing else: never the entry and never the error, since neither has been
scrubbed. It is not called for an entry the app's hook dropped by answering null (for a log or a metric,
undefined too). A throw or a rejection from it is swallowed.
Only its synchronous part is guarded: a failure that happens while it runs is not reported to it, so a console.error
in it, which the SDK can turn into a breadcrumb through the same hooks, does not call it again. Nothing guards what it
starts and leaves running. A listener that sends a request (fetch) or captures to Sentry (captureMessage with a
fixed message) must limit itself, once a minute, say: the breadcrumb or the event that makes goes through the same
hooks after the listener has returned, and if the failing hook fails on every entry, each report would start the next.
chainEventHook and scrubWithCredentials take the same signal as a last argument (onDropped(stage),
onReduced()).
The contract: what is walked, and its limits
The scrubbers are a backstop under dataCollection and the app's own discipline, not a promise about any JavaScript
value. What they reach, exactly (src/walk.ts, src/credentials.ts):
- Entered: plain objects and arrays; an
Error(any realm, as the SDK'sisErrorsees it) as its serialiser sends it (breadcrumbs,normalize: its own enumerable fields plusname,messageandstack; events and logs,JSON.stringify: its own enumerable fields alone; a log's MESSAGE that is an Error is read asString(message)); any other class instance as its own enumerable fields, or as what itstoJSONreturns (aURL; how far is under "What is read is what is sent"); a DOMEventas the serialiser sends it (normalize:type,target,currentTarget,detailand own fields; a log'sJSON.stringify: own fields, as for an Error); a React SyntheticEvent as its own fields; an SVG element or a text node as its own fields; a subclass of a built-in only for the fields it adds. Not entered: an exact built-in binary (a typed array, aDataView, anArrayBuffer, Node'sBuffer, whosetoJSONis not used), or aMap,Set,Date,RegExp,Promisewith no added fields; the global (globalThis, the window:[Global]to the SDK); in the browser's events and breadcrumbs (runtime: 'browser'), what the browser SDK sends as a short string (the document, anHTML…Element; on the server and in logs they are entered); the event's own top-levelsdkProcessingMetadata(never sent); non-enumerable fields (an Error'scause, which the SDK does not send either). An own enumerable getter IS read, and its value walked; one that throws costs the event itsrequest,contexts.responseandextra(the rest walked again), or the whole event when the rest throws too. - What is read is what is sent. A breadcrumb reaches
beforeBreadcrumbraw and is serialised later by the SDK'snormalize(Session Replay records it as the hook returns it), so there atoJSONis followed until the result has none and a boxedString/Number/Booleanis read as its own fields. An EVENT reachesbeforeSendwith its breadcrumbs,user,contexts,extraand span data already normalised by the SDK (prepareEvent), and the envelope sends the whole event withJSON.stringify; a log's attributes are sent withJSON.stringifytoo. So events and logs are read the wayJSON.stringifysends them: onetoJSONstep, then a boxed primitive unboxed. - Keys are text too: an object key goes through the string rules (a URL used as a map key).
- Shared and cyclic references to a cut value are the copy everywhere; a value with nothing cut comes back as itself.
- Limits: depth 16 by default (a deeper value becomes
[Filtered: too deep];maxDepthis clamped to 256); a breadcrumb's walk visits at most 2,000 values and a log's 10,000 (past that, the rest is cut and an array ends with the marker); events have no breadth limit: the parts the SDK normalises (breadcrumbs,user,contexts,extra, span data) are bounded by it, and the rest (request,tags,exception) is what the app or the SDK put there, walked in linear time (itssdkProcessingMetadatais not walked); a long array on an event is walked by its keys (the SDK keeps a sparse array's length). - Header lists are recognised as objects keyed by header name,
[name, value]tuples and Node's flatrawHeaders(an event's top-levelfingerprintis never read as one: its strings get the string rules alone, through four levels of arrays (deeper, the walk reads it), and an object in it is walked with every rule), for lists of at most 1,000 items. A flat list is any even-length array of strings whose even items are HTTP tokens, under any key: nothing but position says it is pairs, so a list of words that holds a secret's name at an even place loses the item after it (['token', 'expired'], which is also whatconsole.log('token', value)records), and a list of exactly two strings inside a list is a[name, value]tuple ([['details', 'hint']]loseshint). The one exception is under "Not cut, on purpose"; a query held as an object or[name, value]tuples is rewritten whole on an event, and in a breadcrumb or a log for at most 1,000 entries and no more than the node budget left (past that it is left to the walk and its budget). - Scans are linear in the string's length (the auth-URL and Postgres scans are written as scans, not regexes that can backtrack). A log line is cut to 16 KB first.
- Under
mode: 'allQueryValues', what is still sent: a parameter with no=(/verify?<token>: the secret is the name, and only values are cut), a fragment that is notname=valuepairs (#<token>), and the attribute values in an element path the browser SDK writes (button[aria-label="…"],[HTMLElement: …]). divorce-virtual's own filters cut all three and stay in that app until this package does. - What it cannot see: a secret in free text that names nothing (a token pasted into a message with no
token=), a credential in a URL's userinfo (https://<token>@host/…, a database connection string), a personal name, and anything the SDK adds after a hook: afterbeforeSend, and the SCOPE's attributes (scope.setAttributes), which the SDK merges into a log and a metric afterbeforeSendLogandbeforeSendMetrichave run. On SDK 11 a standalone span (INP's) gets the scope's attributes andsentry.segment.name(its root span's name) after thespanEndlistener too, and on both majors an envelope's header carries a custom-named root span's name (trace.transaction) as it was beforebeforeSendTransaction: keep secrets out of scope attributes and span names. A database error's MESSAGE is kept as the driver wrote it (createErrorLoggerdrops itsdetail,hintandwhere), and Postgres quotes the offending input in some (invalid input syntax for type uuid: "…"). Those are the job ofdataCollectionand of not putting them in an error, or on the scope, in the first place.
Not cut, on purpose, and cut on purpose
Each is pinned by test/review-pr-903.test.ts, beside tests of what is still cut next to it.
An event's
modules(the SDK's list of installed packages, name to version, at the top of every server event) keep their versions instandardOptions' event hooks and the request-credential scrubber's: a package's name is not a property name, and read as one,input-otp,jsonwebtokenandcookielost theirs (0.1.1). A version is one of these forms, and nothing else, each place with its own characters:- a version, a range or a tag: letters, digits,
_, spaces and. + ~ ^ < > = | * -(1.4.2,^1.4.2,>=1 <2,latest); npm:orjsr:, a package name (letters, digits,_ . -, with or without a scope of the same,@scope/), and with or without@and such a range (npm:pkg@1);workspace:,catalog:orjsr:and such a range (workspace:*);file:,link:orportal:and a local path: letters, digits,_, spaces and. / ~ -(file:../x, andfile://server/xtoo; not a scoped directory,@acme/x, nor a Windows path);github:,gitlab:,bitbucket:orgist:, thenowner/repo(each letters, digits,_ . -) and an optional#ref: a range's characters without|, and: /(github:org/repo#semver:^1).
Each name and each version still goes through the string rules. Anything else under a secret-named package is cut by the key rule as before: a URL with a scheme of its own, with its slashes or without (
https://…,git+ssh://…,https:<token>@host/…: a git or tarball dependency's spec can carry a token before its@that no string rule knows), yarn'spatch:spec (of a URL or of a version: its version is lost), any form not listed (a version form with any other character in it,@,%and?among them), and any value that is not a string. A tag, a name, a path or a ref is any word of its characters, so a string of nothing but those is taken for one, whatever it is. Only the event's own top-levelmodules, as the plain object the SDK writes: a class instance or an object with atoJSONgets every rule, and so do the same names underextraorcontexts.createUrlScrubber().deepandwalk, used on their own, know nothing of events and still cut them.- a version, a range or a tag: letters, digits,
A Postgres field name in a flat list of bare key words (
detail,details,where,internalQuery,internal_query, any case) is a word, not a header's name: an error's key names logged for triage (['details', 'hint', 'code', 'message']) arrive whole (0.1.1 sent['details', '[Filtered]', …]). Only when EVERY item of the list, names and values, is a bare key word: a letter,_or$, then letters, digits,_and$, at most 40 characters in all.[Filtered]at a value's place counts as one, whoever wrote it (this rule on an earlier walk, so a breadcrumb reads the same for Replay and inside its event, or an app's own hook, which runs first). Beside a sentence, a row, a query, an address or a string of digits (console.log('detail', error.detail)) the name is a name and the value is cut, as before, and so it is in a[name, value]tuple (Object.entries(error)) and under the property. What this lets through that 0.1.1 cut: one bare key word after one of those names, in a list of nothing but bare key words, whatever the word is (['details', 'hunter2'], an identifier-shaped key too); Postgres writes no such value in those fields. Every other secret name (token,password, a header's) keeps its meaning in any flat list.A high-density image's file name, a script's file name and a package version are left (
/_next/image?url=%2Fimg%2Flogo%402x.png,[email protected],[email protected],[email protected]:12:3,[email protected]/x.js,[email protected],[email protected], literal or encoded): each reads as an address (test/encoded-email.test.ts,test/literal-email.test.ts,test/review-pr-11.test.ts). Each exemption is the WHOLE token after the@, so an address whose domain merely starts like one ([email protected],[email protected]) is cut. What this lets through: an address whose whole domain is<n>xor<n>.<n>x,.minor not, and an image extension; one label andjs,mjs,cjs,jsx,ts,tsx,css,html,htm,json,wasm,mp3,mp4,m4a,m4v,woff2orbz2([email protected],[email protected]; never after an IP literal:dana@[::1].jsis cut); or numbers andxjoined by dots, a pre-release or not, then.js,.mjs,.cjsor.css(.min.or not) or a/. None of those extensions is a top-level domain, and no label of a version's pre-release after a dot may be one or start one: a label that starts with two letters orxn--, whatever follows (com,uk1,com-2024: the address rule reads a run-on top-level domain whole, so[email protected]/inboxand[email protected]/xare cut), unless its leading letters arealpha,beta,rc,canary,nightly,preview,pre,experimentalorsnapshot(beta2,rc-1too), none of them a top-level domain (nextanddevare, so[email protected]/xis cut): no mail host has such a domain. Cut on purpose: a pre-release label after a dot that starts with letters, such as a commit hash ([email protected]/x.js;abcis a top-level domain).An email address in prose is left; one in a URL is cut (the owner's standard, 2026-11-11: no address in a URL, literal or encoded;
test/literal-email.test.ts,test/review-pr-11.test.ts). Left: a literal address that is a word of prose, alone between whitespace (or the start or end of the string) but for wrapping< ( [ { " 'or a backtick before it and> ) ] } " ' . , ; : ! ?or a backtick after it (mail [email protected] about it,Contact <[email protected]>.,Is it [email protected]?), in a message, an exception or a log; an app's own email rule is what cuts those (easy-passport'sEMAIL_RE), anddataCollectionkeeps the user's own fields out. Cut: a literal address with anything a URL puts against it ([email protected],/u/[email protected]/,#[email protected],mailto:[email protected],file:///Users/[email protected]/,[email protected]in a log line, a JSON"email":"[email protected]"quoted in a message), every encoded one, and every one under a URL key, a bare value too. What this lets through besides prose and the names above, exactly: an address with no top-level domain (dana@localhost,dana@intranet), or at a bare IP address with no brackets ([email protected]: it reads as a version,[email protected]); a top-level domain of one letter or of digits ([email protected],[email protected]); a local part in quotes ("dana smith"@example.test, sent whole); a local part with a character the rule does not read as one (!,#,$,&,*,/,=,?,^,`,{,|,}), whose part before that character is sent (da!na@…becomesda![Filtered]); the part of a local part longer than 64 characters, the RFC's limit, before its last 64; a domain with a label longer than 63 characters (sent whole), or of more than eleven labels (past the eleventh, sent; with one-letter labels, all of it); and an address a URL carries in a form no text rule reads (a base64data:payload, an@encoded five times or more,%2525252540, or escaped as\u0040in JSON text); and an address written with a character that only looks like the ASCII one: a full-width at sign (dana@example.com) or an ideographic full stop as the dot (dana@example。com). Cut on purpose beyond URLs: a URL key is matched by name wherever it is, so ANY property namedurl,query,from,to,refererorreferrer(an email-send log'sto: 'dana@…', a search'squery, a Referer header that holds a bare address) loses its addresses too; put an address that triage needs in a field of another name, if at all.code=andstate=in text are cut, wherever they are written, a vendor's words included: a message readingcode=card_declined state=requires_actionarrives ascode=[Filtered] state=[Filtered]. A text rule cannot tell a decline code from Google's authorizationcode, and an exception by the value's shape is how one would leak. The structured fields are kept, and are what triage should read: a property namedcode,decline_code,typeorstateis not a secret key (extra.stripe.code, a tag, a log attribute). Put a vendor's code in a field or a tag, not into the message ascode=….
What the breadcrumb walk costs
The browser SDK calls beforeBreadcrumb on every console call, fetch and click, on the main thread, and hands it a
console call's raw arguments; the shared hook walks them whole (at most 2,000 values), because Session Replay records
the breadcrumb as the hook returns it. Measured with npm run bench:breadcrumb (Node 26, V8, an Apple M-series
laptop, 2026-11-10; microseconds per breadcrumb, beside a scrub of the arguments' top-level strings alone):
| Breadcrumb | Shared hook | Top-level strings only | | --- | --- | --- | | a click, a fetch, a console call with one string | about 0.6 to 1.5 | 0.01 to 0.07 | | a string and a small object, a React warning, a string and an Error | about 2 to 3 | 0.06 to 0.25 | | an API response of 10 rows (about 200 values) | about 30 | 0.06 | | an API response of 50 rows (about 1,000 values) | about 140 | 0.06 | | the same with a secret in every row (every row copied) | about 210 | 0.06 | | an API response of 500 rows (stops at the 2,000 budget) | about 270 | 0.09 | | a graph of 5,000 React fibers (stops at the budget) | about 530 | 0.07 |
So an ordinary breadcrumb costs one to three microseconds, and the worst one, a console call handed a huge object, about half a millisecond on that machine: a few milliseconds on a slow phone, for that one call. It was not measured in a browser or on a phone. The walk was left as it is: the cost is paid only by a console call that logs a large object, and a walk of top-level strings alone would let a secret in a logged object into Session Replay.
Two facts, settled on 2026-11-08
Both are pinned by tests against the real SDK, so an SDK upgrade that changes either fails here first.
- Any
beforeSendSpandrops every transaction's trace links (test/spans.test.ts, SDK 10.76.2). With one set,processBeforeSendrebuilds the transaction's root fromconvertTransactionEventToSpanJson, which carries nolinks, and merges it back at a depth that replacescontexts.tracewhole, whatever the callback returns. In the browser that is the previous-trace link on every pageload and navigation. divorce-virtual's comment is right; easy-passport'sbeforeSendSpandrops them. UseregisterSpanRedaction, aspanEndlistener, which also reaches the standalone INP spans nobeforeSend*hook sees. On SDK 11 (11.11.0,test/sdk-11-real.test.ts) under the'static'lifecyclestandardOptionssets, a plainbeforeSendSpanis never called at all, and one wrapped inwithStaticSpanis called for every span and keeps the links: the link loss is SDK 10's, the rule againstbeforeSendSpanstays for both. - SDK v11 removes
sendDefaultPii, and its defaults are all on (test/data-collection.test.ts, SDK 10.76.2 and 11.11.0). v11 has no reference tosendDefaultPii; withdataCollectionunset it collects the client IP, cookies, every header and body, query strings and database query values. On v10, anydataCollectionreplaces the PII-off base with that all-on one, so a partial object widens collection; and v11 readsurlQueryParamsonly (thequeryParamsalias is ignored).piiOffDataCollection()is v10'ssendDefaultPii: false, key for key (the test compares the resolved options), with v11'smcpandqueuesoff.
An app moving to @sentry/nextjs 11
The peer range (>=10.70.0 <12) admits 11, and the package's calls do not change: standardOptions(...) spreads into
v11's NodeOptions and BrowserOptions without a cast, and registerSpanRedaction and createErrorLogger take v11's
own spanToJSON and captureException (test/sdk-11-real.test.ts runs @sentry/node and @sentry/browser 11.11.0,
each beside a control). What changes is around the spread:
- Drop
skipOpenTelemetrySetup,enableLogsandsendDefaultPiifromSentry.init. v11 removed all three, and each is a type error beside the spread. v11 sends logs withoutenableLogs, andbeforeSendLogstill scrubs them.standardOptions'dataCollectionalready stands in forsendDefaultPii. - Keep the spread's
traceLifecycle: 'static'. v11's default is'stream', under whichbeforeSendTransactionnever runs. - An app's own
beforeSendSpanstops running. Under'static', v11 calls only a callback wrapped inwithStaticSpan. Move what it cuts intoregisterSpanRedactionorstandardOptions' input hooks. vercelEnv: falseno longer keepsvercel-production.@sentry/nextjs11 names the environment without thevercel-prefix: on the server@sentry/core'sgetVercelEnv(VERCEL_TARGET_ENV || VERCEL_ENV), in the browser its own (NEXT_PUBLIC_VERCEL_TARGET_ENV || NEXT_PUBLIC_VERCEL_ENV), where 10.76.2 returnedvercel-${VERCEL_ENV}(read off the published builds). An app whose alert rules filter onvercel-productionmoves them when it upgrades, or names the environment itself.- Pass v11's
spanToJSONas it is, notspanToStaticSpanJSON:registerSpanRedactionreads v11's span JSON.
@sentry/nextjs 11 itself was not installed for these tests; its wrappers and build plugin are the app's to check.
SDK 11's renamed span attributes
SDK 11 renamed span attributes and ops (its MIGRATION.md at 11.11.0, "Span attribute changes" and "Span operation
(op) changes"): http.target became url.path and url.query, http.query url.query, http.fragment
url.fragment, http.method http.request.method, http.host server.address, a long animation frame's
code.filepath code.file.path and its op ui.long-animation-frame ui.long_animation_frame. The text rules here
read every string under any key, and every list that names keys or ops names both majors' (test/sdk-11-span-keys.test.ts
runs each major's real long-animation-frame tracker, and each listed key with a red proof that takes it out of its list).
An app's own scrubber that lists keys by name (divorce-virtual's SPAN_URL_KEYS) has to list both too: one naming only
code.filepath sends SDK 11's code.file.path (chrome-extension://…?email=…) as it is.
Upgrading to 0.1.5
Nothing changes for an app that sets none of url.pathPatterns, url.jsonEscapedAmpersand, log.cutAttributeKeys and
credentials.quotedAuthUrls: its outputs are byte for byte 0.1.4's (test/single-pass-app-rules.test.ts holds the
SHA-256 of a corpus' outputs through every scrub, an event, a breadcrumb, a log, a metric, a span redaction and the URL
scrubber's text and deep, to the one 0.1.4's src/ gives). An app with pathPatterns (easy-carnet) gets the one
pass, which cuts every character 0.1.4's order cut (it traces that order's cuts back to the raw string) and more. The
same test checks that against the same src/ with the one pass off, over URLs an @, an auth URL or a Postgres row
meets, through the URL scrubber, standardOptions (an event, a breadcrumb, a log, a metric) and a span redaction, for
easy-carnet's patterns, the invite pattern above and four other pattern shapes, with quotedAuthUrls off and on; a
random fuzz against 0.1.4's own src/ (run for this release, not in the suite: every carrier, six pattern sets) found
no secret 0.1.4 cut that this sends, with every 0.1.5 option off (outputs differ by design: the one pass cuts more).
A hand composition that runs the credential rules' text AFTER url.text (a createLogScrubber(url, [credentials.rules])
built by hand, a span redact that cuts auth URLs last) passes their edits to the URL scrubber when it has
pathPatterns: createUrlScrubber(options, { tracedAfter: [credentials.edits] }), as standardOptions does. Without it,
a pattern's cut can take away an /api/auth path those rules would have read. One that runs them before needs nothing.
The three options let easy-carnet's last app-only rules (url-secret-redaction.ts' JSON-escaped boundary,
sentry-log-scrub.ts, sentry-auth-record-drop.ts) move into the package's walk; that test holds each option to the
easy-carnet rule it replaces. easy-carnet's KNOWN GAP test in sentry-scrub-nothing-lost.test.ts pins 0.1.4's
behaviour, so it goes red on the bump: the bump closes it.
Upgrading to 0.1.4
All four apps pin 0.1.3 (easy-carnet, divorce-virtual, easy-passport, easy-plans, checked 2026-11-11), so each needs the bump to get the email-address rule (literal and encoded, in every URL-bearing field) and the SDK 11 key names.
Environment names
production, preview, development. On 2026-11-08 easy-passport and easy-plans sent production; easy-carnet and
divorce-virtual set none, so the SDK named theirs vercel-production / vercel-preview, and their Sentry alert rules
filter on vercel-production. Moving an app to these names moves its alert rules in the same change; until then it passes vercelEnv: false, and no environment is returned (the SDK's own name stands).
Releasing
Raise version in package.json in a pull request; after it merges, publish a GitHub release tagged v<version>
(.github/workflows/publish.yml checks the tag, runs the checks and publishes to npm through npm's trusted publishing (OIDC), so no npm token is needed: npmjs.com names this repository, publish.yml and the GitHub environment npm, which admits main and v* tags only, as the package's trusted publisher).
Callers pin an exact version.
