npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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, beforeSendLog or beforeSendMetric.
  • stage: app (the app's own hook threw or rejected, or a beforeSend, beforeSendTransaction or beforeBreadcrumb answered undefined, a forgotten return: a bare SDK refuses that from the first two and throws on it from the third) or shared (this package's scrub could not read the entry: a getter that threw).
  • outcome: dropped (nothing was sent), or reduced for an event or a transaction sent without its request, contexts.response and extra because 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's isError sees it) as its serialiser sends it (breadcrumbs, normalize: its own enumerable fields plus name, message and stack; events and logs, JSON.stringify: its own enumerable fields alone; a log's MESSAGE that is an Error is read as String(message)); any other class instance as its own enumerable fields, or as what its toJSON returns (a URL; how far is under "What is read is what is sent"); a DOM Event as the serialiser sends it (normalize: type, target, currentTarget, detail and own fields; a log's JSON.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, a DataView, an ArrayBuffer, Node's Buffer, whose toJSON is not used), or a Map, Set, Date, RegExp, Promise with 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, an HTML…Element; on the server and in logs they are entered); the event's own top-level sdkProcessingMetadata (never sent); non-enumerable fields (an Error's cause, which the SDK does not send either). An own enumerable getter IS read, and its value walked; one that throws costs the event its request, contexts.response and extra (the rest walked again), or the whole event when the rest throws too.
  • What is read is what is sent. A breadcrumb reaches beforeBreadcrumb raw and is serialised later by the SDK's normalize (Session Replay records it as the hook returns it), so there a toJSON is followed until the result has none and a boxed String / Number / Boolean is read as its own fields. An EVENT reaches beforeSend with its breadcrumbs, user, contexts, extra and span data already normalised by the SDK (prepareEvent), and the envelope sends the whole event with JSON.stringify; a log's attributes are sent with JSON.stringify too. So events and logs are read the way JSON.stringify sends them: one toJSON step, 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]; maxDepth is 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 (its sdkProcessingMetadata is 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 flat rawHeaders (an event's top-level fingerprint is 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 what console.log('token', value) records), and a list of exactly two strings inside a list is a [name, value] tuple ([['details', 'hint']] loses hint). 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 not name=value pairs (#<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: after beforeSend, and the SCOPE's attributes (scope.setAttributes), which the SDK merges into a log and a metric after beforeSendLog and beforeSendMetric have run. On SDK 11 a standalone span (INP's) gets the scope's attributes and sentry.segment.name (its root span's name) after the spanEnd listener too, and on both majors an envelope's header carries a custom-named root span's name (trace.transaction) as it was before beforeSendTransaction: keep secrets out of scope attributes and span names. A database error's MESSAGE is kept as the driver wrote it (createErrorLogger drops its detail, hint and where), and Postgres quotes the offending input in some (invalid input syntax for type uuid: "…"). Those are the job of dataCollection and 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 in standardOptions' event hooks and the request-credential scrubber's: a package's name is not a property name, and read as one, input-otp, jsonwebtoken and cookie lost 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: or jsr:, 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: or jsr: and such a range (workspace:*);
    • file:, link: or portal: and a local path: letters, digits, _, spaces and . / ~ - (file:../x, and file://server/x too; not a scoped directory, @acme/x, nor a Windows path);
    • github:, gitlab:, bitbucket: or gist:, then owner/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's patch: 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-level modules, as the plain object the SDK writes: a class instance or an object with a toJSON gets every rule, and so do the same names under extra or contexts. createUrlScrubber().deep and walk, used on their own, know nothing of events and still cut them.

  • 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>x or <n>.<n>x, .min or not, and an image extension; one label and js, mjs, cjs, jsx, ts, tsx, css, html, htm, json, wasm, mp3, mp4, m4a, m4v, woff2 or bz2 ([email protected], [email protected]; never after an IP literal: dana@[::1].js is cut); or numbers and x joined by dots, a pre-release or not, then .js, .mjs, .cjs or .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 or xn--, whatever follows (com, uk1, com-2024: the address rule reads a run-on top-level domain whole, so [email protected]/inbox and [email protected]/x are cut), unless its leading letters are alpha, beta, rc, canary, nightly, preview, pre, experimental or snapshot (beta2, rc-1 too), none of them a top-level domain (next and dev are, so [email protected]/x is 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; abc is 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's EMAIL_RE), and dataCollection keeps 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@… becomes da![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 base64 data: payload, an @ encoded five times or more, %2525252540, or escaped as \u0040 in 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 named url, query, from, to, referer or referrer (an email-send log's to: 'dana@…', a search's query, 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= and state= in text are cut, wherever they are written, a vendor's words included: a message reading code=card_declined state=requires_action arrives as code=[Filtered] state=[Filtered]. A text rule cannot tell a decline code from Google's authorization code, 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 named code, decline_code, type or state is 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 as code=….

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.

  1. Any beforeSendSpan drops every transaction's trace links (test/spans.test.ts, SDK 10.76.2). With one set, processBeforeSend rebuilds the transaction's root from convertTransactionEventToSpanJson, which carries no links, and merges it back at a depth that replaces contexts.trace whole, 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's beforeSendSpan drops them. Use registerSpanRedaction, a spanEnd listener, which also reaches the standalone INP spans no beforeSend* hook sees. On SDK 11 (11.11.0, test/sdk-11-real.test.ts) under the 'static' lifecycle standardOptions sets, a plain beforeSendSpan is never called at all, and one wrapped in withStaticSpan is called for every span and keeps the links: the link loss is SDK 10's, the rule against beforeSendSpan stays for both.
  2. 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 to sendDefaultPii; with dataCollection unset it collects the client IP, cookies, every header and body, query strings and database query values. On v10, any dataCollection replaces the PII-off base with that all-on one, so a partial object widens collection; and v11 reads urlQueryParams only (the queryParams alias is ignored). piiOffDataCollection() is v10's sendDefaultPii: false, key for key (the test compares the resolved options), with v11's mcp and queues off.

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, enableLogs and sendDefaultPii from Sentry.init. v11 removed all three, and each is a type error beside the spread. v11 sends logs without enableLogs, and beforeSendLog still scrubs them. standardOptions' dataCollection already stands in for sendDefaultPii.
  • Keep the spread's traceLifecycle: 'static'. v11's default is 'stream', under which beforeSendTransaction never runs.
  • An app's own beforeSendSpan stops running. Under 'static', v11 calls only a callback wrapped in withStaticSpan. Move what it cuts into registerSpanRedaction or standardOptions' input hooks.
  • vercelEnv: false no longer keeps vercel-production. @sentry/nextjs 11 names the environment without the vercel- prefix: on the server @sentry/core's getVercelEnv (VERCEL_TARGET_ENV || VERCEL_ENV), in the browser its own (NEXT_PUBLIC_VERCEL_TARGET_ENV || NEXT_PUBLIC_VERCEL_ENV), where 10.76.2 returned vercel-${VERCEL_ENV} (read off the published builds). An app whose alert rules filter on vercel-production moves them when it upgrades, or names the environment itself.
  • Pass v11's spanToJSON as it is, not spanToStaticSpanJSON: registerSpanRedaction reads 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.