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

@fawadmahmoodwashmen/sentry-core

v0.4.4

Published

Shared Sentry reporting core for Washmen frontends: grouping, redaction, beforeSend normalisers and release naming for React Native and web

Readme

sentry-core

Shared Sentry reporting core for Washmen frontends. One place decides how an error is grouped, what is redacted, how its title and culprit are fixed, and how releases are named. Each app keeps its own Sentry.init; everything after a developer calls reportToSentry lives here.

Serves React Native and web from one package, the way washmen-js-native-bridge does.

| Import path | Use in | Adds | | --- | --- | --- | | @fawadmahmoodwashmen/sentry-core | anywhere | the core: createReporter, buildBeforeSend, redaction, fingerprint, release naming | | @fawadmahmoodwashmen/sentry-core/web | PWA, embedded webflows, any browser bundle | core + webIgnoreErrors, webDenyUrls, buildWebBeforeSend, isInsideNativeWebView | | @fawadmahmoodwashmen/sentry-core/native | React Native (Metro resolves it automatically) | core + buildNativeBeforeSend, createReduxStateTransformer | | @fawadmahmoodwashmen/sentry-core/testing | your app's tests | createTestClient, a real Sentry client with an in-memory transport |

Zero runtime dependencies. The package never imports a Sentry SDK; you pass yours in. Subpath imports resolve everywhere: exports for modern resolvers, typesVersions for TypeScript moduleResolution: node, and web/, native/, testing/ folder shims for Jest 27, webpack 4 and Metro builds without package exports.

npm install @fawadmahmoodwashmen/sentry-core

Wiring

PWA and webflows

import * as Sentry from "@sentry/react";
import {
  buildDist, buildRelease, buildWebBeforeSend, createReporter, webDenyUrls, webIgnoreErrors,
} from "@fawadmahmoodwashmen/sentry-core/web";

Sentry.init({
  dsn: process.env.REACT_APP_SENTRY_KEY,
  environment: process.env.REACT_APP_SENTRY_ENV,
  release: buildRelease("react-washmen-pwa", APP_VERSION, GIT_SHA), // [email protected]+a1b2c3d
  dist: buildDist(FLAVOUR),                                          // apps | uc | tdh
  beforeSend: buildWebBeforeSend(),
  ignoreErrors: webIgnoreErrors,
  denyUrls: webDenyUrls,
  sendDefaultPii: false,
});

export const { reportToSentry, messageToSentry } = createReporter(Sentry);

Customer app (React Native)

import * as Sentry from "@sentry/react-native";
import {
  buildNativeBeforeSend, createReduxStateTransformer, createReporter,
} from "@fawadmahmoodwashmen/sentry-core/native";

Sentry.init({
  dsn: Config.SENTRY,
  environment: Config.SENTRY_ENV,
  beforeSend: buildNativeBeforeSend({
    // app-specific drops run first
    filters: [(event, hint) => ((hint as { originalException?: { name?: string } })?.originalException?.name === "ServerError" ? null : event)],
  }),
  sendDefaultPii: false,
});

// The Redux enhancer attaches the WHOLE store by default. Allowlist what a triager needs; never the auth slice.
const sentryReduxEnhancer = Sentry.createReduxEnhancer({
  stateTransformer: createReduxStateTransformer([["ui"], ["appState"], ["orderDetailsUi"]]),
});

export const { reportToSentry, messageToSentry } = createReporter(Sentry);

Reporting

reportToSentry({
  error,                                  // anything you caught; strings get a stack, Errors keep theirs
  errorOrigin: "business",                // api | component | navigation | state | business | validation | initialization | analytics | bridge | unknown
  originatedFrom: "usePlaceOrderMutation",// component, hook or function name
  description: "Failed to place order",   // CONSTANT text. Ids and variable parts go in extras.
  tags: { paymentMethod: "apple_pay" },   // low-cardinality, searchable
  extras: { orderId, amount },            // high-cardinality detail; redacted before it leaves the device
  level: "error",                         // optional: fatal | error | warning
});

messageToSentry({
  description: "WebView healed after process termination",
  originatedFrom: "ShoeCareEmbeddedWebView",
  errorOrigin: "component",
  level: "info",                          // default; message events never trip error-level alerts
  tags: { serviceLine: "shoes" },
});

What reportToSentry does: validates required fields for the origin (warns through onInternalError, never throws), gives stackless values a stack, wraps in a NamedError whose name is ErrorNames[errorOrigin] (or your name) and whose message is description | original.message, keeping the original stack and cause, sets errorOrigin and originatedFrom tags, applies your tags (stringified, 200 chars), sets the fingerprint, and puts redacted extras under extra.extraData.

Customer-impact reporting (0.4.0)

One event per customer-facing failure, tagged so alarms filter on what the customer hit. Separate from createReporter, which is unchanged. Every function is fire-and-forget, never throws, and is a no-op while the kill switch is off.

import * as Sentry from "@sentry/react";
import {
  createImpactReporter, createFlowStepResolver, createImpactQueryCacheHandlers, installAxiosImpactInterceptors,
  createImpactFetch, documentVisibilitySource, installImpactPagehideFlush, instrumentNativeRpc,
  isInsideNativeWebView, startStuckWatchdog, withImpactDedupeBypass, HttpError,
} from "@fawadmahmoodwashmen/sentry-core/web";

Sentry.init({
  // ...as above, plus: impact events bypass Dedupe, everything else is still deduped
  integrations: (defaults) => defaults.map((i) => (i.name === "Dedupe" ? withImpactDedupeBypass(i) : i)),
});

const FLOW_STEPS = ["session", "select_service", "address", "pickup", "dropoff", "photos", "payment", "promo",
  "place_order", "post_order", "unmapped"] as const;

const stepForRoute = createFlowStepResolver([
  ["/order/:id/pay", "payment"],
  ["/pickup", "pickup"],
  ["/wizard/*", "select_service"],
] as const);

export const impact = createImpactReporter<(typeof FLOW_STEPS)[number]>(Sentry, {
  impactApp: "webflow",                    // webflow | order_tracking | pwa | customer_app
  emitter: "web",                          // web | native
  flowSteps: FLOW_STEPS,                   // anything else is sent as `unmapped`
  currentFlowStep: () => stepForRoute(location.pathname), // the app's one route → step mapper
  requestWatchdogMs: 30_000,               // clients with no timeout of their own (laundry, PWA); default off
  enabled: () => remoteConfig.impactReporting !== false,  // impact-only kill switch, read on every call; off drops timers, watchdogs, counters
  serviceLine: "laundry",                  // in every fingerprint: two webflows in one project never share an issue
  visibility: documentVisibilitySource(),
  release: RELEASE,
  isExpectedHttpFailure: (e) => (e as HttpError).backendCode === "400.7", // wrong OTP, invalid promo...
  counterAttributes: () => ({ service_line: "laundry", host_app_version: hostVersion }),
  shownErrorKeys: SHOWN_ERROR_KEYS,         // 0.4.2: the app's closed list of shown-error keys (counter cardinality)
});
installImpactPagehideFlush(impact);

// Owners: React Query (v4 or v5)
const handlers = createImpactQueryCacheHandlers(impact);
const queryClient = new QueryClient({
  queryCache: new QueryCache({ onError: handlers.queryCache.onError }),       // meta: { flowStep, impact?, business? }
  mutationCache: new MutationCache({ onError: handlers.mutationCache.onError }),
});
// on native present: impact.startImpactSession({ presented: true }); handlers.reportErroredQueriesOnPresent(queryClient);

// The HTTP client: axios (install it BEFORE the app's own interceptors)...
installAxiosImpactInterceptors(impact, api);
// ...or fetch (authFetchJson-style): rejects with HttpError, `httpErrorBody(error)` for the customer message
const impactFetch = createImpactFetch(impact, { timeoutMs: 20_000 });

Adopting it in an app that already reports failed requests

  • Install installAxiosImpactInterceptors before the app's own interceptors. Attempts begin when axios actually dispatches (a transformRequest hook) and are tracked per logical request (ImpactRequestIdentity, kept by config copies and replays), so any order works for refresh-and-replay, config-copying and rejecting request interceptors. Installed first, its response interceptor also sees every raw response. Installed later, an app interceptor that swallows a failure or replaces it with a non-axios error leaves that attempt open until the same request is dispatched again or the session ends (meanwhile the net waits up to its 120 s ceiling for that request, and requestWatchdogMs may report it as pending).
  • Remove reportHttpFailure from the HTTP interceptors once the safety net is wired (installAxiosImpactInterceptors, createImpactFetch, or beginAttempt by hand). The owners and the net report instead, one event per failure. If a call stays, pass httpFailures: {} to createReporter: with an impact reporter on the same SDK live, reportHttpFailure then hands the failure to the net instead of sending a second event (routeToSafetyNet, default true). Pass the error too: a call without an error object can be linked to its owner only through request: { method, url }.
  • Pass httpFailures: {} (or an endpoint bodyAllowlist) to createReporter in every adopting app. Without it, reportHttpFailure keeps its earlier behaviour and attaches request and response bodies.
  • Thunks report in their own catch, with the original error, before rejectWithValue. If only the serialized action.error or a string reaches the owner, pass request: requestOf(error) (taken in the catch, where the AxiosError or HttpError is) to reportImpactFailure. Without it the copy cannot be linked (nothing is matched by message or stack), so the net's unconfirmed report is sent as well.
  • Signal attempts (beginAttempt → succeed / fail, done by the helpers): while any attempt of a request is in flight the net waits, so a retry with a 20 s timeout, or none, never lets it fire before the owner. A request that never settles still reports at the 120 s ceiling.

| Call | When | Event | | --- | --- | --- | | impact.startImpactSession({ presented }) / endImpactSession() | native present / dismiss (PWA: page load, login, logout) | none; dismiss flushes the safety net, stops watchdogs, sends the capped marker and counters | | impact.reportImpactFailure({ error, flowStep, impact?, business?, failureKind? }) | an owner's terminal point: QueryCache.onError, MutationCache.onError, a thunk, a write wrapper, a direct call's final catch | one captureException(error), customer_impact derived; returns sent, duplicate, capped, disabled or ignored (a cancellation) | | impact.httpSafetyNet.beginAttempt({ method, url, identity }) → .succeed() / .fail(error) | the HTTP client, on every attempt (the axios and fetch helpers do it) | after 15 s with no owner, no recovery and no attempt in flight (at most 120 s after the first failure): one unconfirmed warning; never pages | | impact.reportDetector({ reason, flowStep, extras? }) | a failure that throws nothing | one captureMessage("customer_impact: <flow_step>/<reason>"), no stack | | startStuckWatchdog(impact, { reason, flowStep, timeoutMs, abandon? }) | a blocking state starts; .stop("resolved" \| "abandoned") | the detector after timeoutMs of presented and visible time | | instrumentNativeRpc(client, { reporter: impact, watchdogs?, hostAppVersion? }) | wrap each bridge client once | rpc_error on a rejection, rpc_timeout from a version-gated watchdog | | impact.showCustomerErrorReport(key, error?, { flowStep?, impact?, business?, expected?, breadcrumbOnly? }) | inside the app's showCustomerError / <CustomerErrorState> | shown_error with a constant key, or a breadcrumb when error was already reported (or breadcrumbOnly); since 0.4.2 also one customer_error.shown count per call | | impact.countShownError(key, { flowStep?, business?, expected? }) | a display that never goes through showCustomerErrorReport (an error boundary's fatal page) | one customer_error.shown count, no event (0.4.2); expected defaults exactly as in showCustomerErrorReport (expected, else business, else false) | | impact.checkHostSupport({ hostAppVersion, minHostVersion, capability }) | before a capability an old shell lacks | false and one host_unsupported info per device per release; fails closed (false) on unreadable input or an internal error | | impact.countJourney(journey, stage, attrs?) | flow_open, place_order, pay attempts and outcomes | a customer_journey.attempt / .outcome counter via Sentry.metrics (no-op on SDKs without it) |

HTTP client. The helpers above do this. By hand: throw one HttpError (status, method, path = endpoint template, backendCode, cfRay, ownTimeout, cause; the message is kept) with httpErrorFromResponse(res, { method, url, body }) or httpErrorFromAxios(error), and settle each beginAttempt with succeed() or fail(error) (armFailure / recordSuccess remain for clients without attempts). Pass the same identity object for one logical request's retries and its 401 replay: a write is cancelled only by its own identity's success, a read by any success of the same method and concrete path. The request key (concrete path plus sorted query) is held in memory only. impact.dispose() stops everything without sending, except a failure reportHttpFailure routed to the net that is still pending: that one is sent (kill switch permitting), since the routing call sent nothing itself.

Classification. Use the shared table rather than an app copy:

  • classifyBackendRejection("order_submit" | "pay" | "card_add" | "fix_payment", { status, backendCode, checkoutErrorCode: checkoutErrorCodeOf(body) }) gives "business" (a decline: pass business: true) or "blocked". A 422.1 CHECKOUT_SYSTEM_ERROR is a decline only when its Checkout.com code is in CHECKOUT_DECLINE_CODES;
  • paymentOutcomeOf(kind, { status, backendCode, checkoutErrorCode }) gives the payment_outcome tag value: "declined", "failed", or undefined for no payment outcome;
  • isBenignNativeRejection(error), isCannotPresentRejection(error) (degraded) and isNoHandlerRejection(error) (breadcrumb) classify native answers. instrumentNativeRpc already applies them.

Chunk loads. A chunk that failed to load is failure_kind: "chunk_load". Since 0.4.1 its default impact is the same as any failure: customer_impact=blocked at error while presented, degraded at warning while not presented (prewarm), because a module script refused after a deploy can be the entry chunk (a white screen), not just a lazy route. An app that passes impact keeps what it passes (pass impact: "degraded" for a chunk you know is optional). reportImpactFailure derives it on its own when isChunkLoadError(error) is true. It recognises, also anywhere in the cause chain:

  • webpack: ChunkLoadError, "Loading chunk 12 failed", "Loading CSS chunk 4 failed";
  • Vite: "Unable to preload CSS for /assets/x.css" (the vite:preloadError payload);
  • a failed dynamic import(): "Importing a module script failed" (WebKit), "Failed to fetch dynamically imported module" (Chromium), "error loading dynamically imported module" (Firefox);
  • a module script served with a non-JavaScript MIME type, which is what a stale chunk looks like after a deploy when the CDN answers a missing /assets/*.js with index.html (0.4.1). Only WebKit's thrown error names the MIME type: 'text/html' is not a valid JavaScript MIME type. (iOS) or … for module script '<url>'. (WebKit 27), any MIME in the quotes. Chromium and Firefox reject the import() with their "dynamically imported module" errors above; their "Failed to load module script…" and "Loading module from … was blocked because of a disallowed MIME type" texts are console-only and are not matched.

Stylesheet MIME refusals, Worker and service-worker script refusals and ordinary TypeErrors are not chunk loads. A failure in a static module graph (the entry <script type="module"> and its static imports) throws nothing catchable in any engine, so it is never reported this way; it reaches isChunkLoadError only through a dynamic import() (React.lazy, Vite's preload helper).

Native calls. A rejected native call reported by instrumentNativeRpc (failure_kind=rpc_error, the call's promise rejected) is captured as a capture-only copy (0.4.1): NativeCallError: <method> failed: <message>. Event contract:

  • the top exception's type is NativeCallError; its value is the method name (the rpc_method tag, validated) plus the original message, scrubbed like every impact value;
  • tag native_error_type: the type 0.4.0 sent (the rejection's name, Error for a string rejection), only when it matches /^[A-Za-z][A-Za-z0-9_]{0,63}$/, otherwise omitted. Added alongside the existing tags, which are unchanged;
  • the copy is a real Error carrying the original's stack, with cause set to the original: never a synthetic exception (Sentry would drop the type at ingest), and with the SDK's LinkedErrors integration the original follows as a linked exception with its own type;
  • the fingerprint (["rpc", emitter, method, "rpc_error"]) is unchanged, so grouping is too;
  • the caller gets the same rejection object, untouched (name, message, stack, properties).

A synchronous throw from the host-side client wrapper (this.post is not a function) is a JS error, not a native answer: it is still rpc_error with the same fingerprint (as in 0.4.0) but keeps its own type and has no native_error_type. Other impact events, including rpc_timeout detectors and an rpc_error an owner passes to reportImpactFailure, keep their own type.

Stackless errors keep their type (0.4.2). The SDK attaches no frames to an error without a stack, so the impact reporter captures a wrapper that has one. Up to 0.4.1 that wrapper was a plain Error, so WebKit's stackless TypeError: Importing a module script failed. arrived as Error: Importing a module script failed.. The wrapper now keeps the original name when it is a plain identifier (prepareForCapture(value, { keepName: true })), with the same message, cause and a stack. Impact fingerprints are built from the caller's own error, never from the wrapper, so grouping is unchanged; only the issue's type/title changes on new events. createReporter.reportToSentry and prepareForCapture without keepName are unchanged (their default grouping reads the type), and the native_error_type tag still carries the type 0.4.1 sent.

Grouping. A silent_guard (any detector with an extras.guard) groups and dedupes per guard. A crash reported through reportImpactFailure groups by its normalised message; render crashes sent through the SDK's own capture keep stack grouping.

Guardrails. Once per session per fingerprint; 25 events per session, then one capped marker with the suppressed counts; crashes have their own budget (crashBudget, default 10 per session, outside the 25) and their own capped marker (failure_kind=crash); before present (prewarm) never blocked, always warning, presented=false, at most 3 per boot. A thrown value already reported in this session (or a wrapper of it, through cause) is a duplicate, and the composed beforeSend drops the SDK's own second capture of it. Marks are per session: the same object reported again in a later session (a cached query error replayed on present) is sent again there. A value whose event was dropped (a same-session duplicate or a cap) is suppressed for that session: its own SDK capture is dropped too, but a later session reports it. A session counts as live until the next one starts, so a capture landing right after dismiss is still dropped.

Customer stuck retrying. When the same blocked failure (same fingerprint) reaches a customer three times in one session, each at least 3 s after the previous counted one, one extra event is sent. It is tagged repeated=true and repeat_count=3, at level error, with its own fingerprint and the title customer_impact: <flow_step>/repeated. Every other impact event carries repeated=false. Existing count()-based alarms should exclude repeated:true. The thresholds are options: repeatThreshold (3) and repeatMinIntervalMs (3000).

Self-diagnostics. When sentry-core's own code throws, the host never sees it. The reporter sends one warning event per site per session, tagged sentry_internal_error=true and sentry_internal_where=<site>; that happens even with a custom onInternalError, unless you pass selfReportInternalErrors: false.

Metrics carry no personal data. The SDK adds the scope user (user.id, user.email, user.name) to every metric, so every metric is reduced to METRIC_ATTRIBUTE_ALLOWLIST: journey, stage, outcome, impact_app, service_line, host_app_version, flavour, dist, release and environment, the shown-error counter's flow_step, shown_error_key, expected and presented (0.4.2), plus the SDK's release, environment and sequence attributes. Extend it with metricAttributeAllowlist. With several reporters on one client a key survives only when every one allows it (a second reporter narrows, never widens). user / user.* and any key naming an email or a phone (user_email, customer_email, customerPhone) can never be allowed.

  • Wired automatically: createImpactReporter wires the scrubber (installMetricScrubber) into the SDK's client. Created after Sentry.init, at once; created before it (no client yet), on its first countJourney, shown-error count or metric flush, before its first metric. This covers attributes, the scope attributes the SDK adds at serialization, and the envelope's IP and user-agent inference.
  • buildBeforeSendMetric() alone does not cover scope attributes. The SDK adds them (Sentry.setAttribute) after beforeSendMetric runs, so that hook cannot remove them; only installMetricScrubber can. Use it in addition to the scrubber, never instead of it. An app with metrics but no impact reporter calls installMetricScrubber(Sentry.getClient()) after Sentry.init.
  • countJourney takes a flavour attribute (for example the PWA dist apps / uc / tdh), also settable through counterAttributes.

Shown errors are counted (0.4.2). Events cannot count "error messages shown per hour": they are deduped per session per key, capped, and an error its owner already reported is only a breadcrumb. So every call to showCustomerErrorReport also adds 1 to a counter metric, customer_error.shown (SHOWN_ERROR_METRIC), before and independently of its event or breadcrumb, which are unchanged. A metric creates no issue and no event.

  • Attributes: impact_app, service_line (the events' service line: the option, else counterAttributes, else the scope tag), host_app_version and flavour (from counterAttributes), flow_step, shown_error_key, expected ("true" / "false": the expected option, else business, else false, on both entry points) and presented ("false" for a message shown in a hidden prewarmed WebView, before present or after dismiss). The SDK adds release and environment; dist, release and environment set as scope attributes are kept, as for the journey counters. Nothing else survives the scrubber, and never user.*, email or phone keys.

  • shown_error_key is bounded by the reporter's shownErrorKeys option: a listed key is sent as is, a valid key that is not listed as other (so a new key never creates a new series), free text or a secret as invalid_key, a missing key as unknown. Without shownErrorKeys every valid key is other. At most MAX_SHOWN_ERROR_KEYS (200) distinct keys are kept, in list order: the rest count as other and the overflow is self-reported once (sentry_internal_where=shownErrorKeys). Events keep sending the key itself.

  • Same path as countJourney: the kill switch (off, nothing is counted), the metric scrubber (installed before the first count, even for a reporter created before Sentry.init), feature detection (a no-op on an SDK without metrics, such as @sentry/react 9) and the flush on dismiss and pagehide. Never awaited; a failure is swallowed and self-reported (sentry_internal_where=countShownError), and never costs the event.

  • breadcrumbOnly: true counts the display and writes a breadcrumb instead of any event, for a failure its owner already reported without the error object reaching this call (a thunk's rejectWithValue string, a detector) or an expected answer kept quiet (a wrong OTP, a location denial). It replaces an app's own Sentry.addBreadcrumb for those sites. The breadcrumb is the one core writes when the error's owner already sent it: { category: "customer_impact", level: "info", message: "shown_error <key>", data: { shown_error_key, flow_step } }, plus data.expected: true when expected: true is passed. breadcrumbOnly does not imply expected: an owner-reported failure is not expected, so pass expected: true only for quiet expected answers. A passed error is settled as quiet, never reported: the safety net's pending report for it (or a value it wraps or is wrapped by) is cancelled, so no unconfirmed event follows safetyNetDelayMs later, and it is suppressed for the impact session, so the SDK's own capture of it is dropped and a same-session report of it is a duplicate (a later session reports it again). Without breadcrumbOnly nothing changes.

  • countShownError(key, { flowStep?, business?, expected? }) counts a display that never reaches showCustomerErrorReport, for example an error boundary's fatal page: impact.countShownError("fatal_error", { expected: false }) from the boundary's onError.

  • A Sentry metric monitor on the counter (Terraform sentry_metric_monitor, Explore → Metrics), one monitor per slice:

    dataset    = "events_analytics_platform"
    query_type = "performance"
    aggregate  = "sum(value,customer_error.shown,counter,-)"
    query      = "impact_app:webflow service_line:laundry presented:true expected:false"

    Alert on a percent change against the same window a week earlier (for example above +200% over 1 hour, ignored below 20 shown errors in the earlier window), and drill down by shown_error_key and flow_step. Alarm expected:true on its own baseline.

React Native. Same API from /native, with emitter: "native", impactApp: "customer_app" and visibility: appStateVisibilitySource(AppState) (inactive counts as hidden). buildNativeBeforeSend removes the synthetic stack React Native attaches to detector messages.

Grouping

exceptions:  [errorOrigin, originatedFrom, description]     // description absent → Sentry default grouping in that slot
messages:    ["message", errorOrigin, originatedFrom, description]

Every app uses this rule. Changing it re-groups every project at once, so it is a major version.

For a migration, createReporter(Sentry, { fingerprintOverride: { exception, message } }) reproduces an app's historical grouping in a first release; drop the override in a second release to adopt the shared rule.

Redaction

Key-based and case-insensitive. Any object key containing one of these fragments has its value replaced with [REDACTED]:

authorization auth_token authtoken access_token accesstoken refresh_token refreshtoken token password secret api_key apikey cookie bearer cardnumber expirationdate cvv last4 phone email

Addresses, locations and names too: any key containing address, street, building, villa, apartment, latitude, longitude or customername, and the whole keys lat, lng, lon, flat, flatno, flatnumber. A key ending in id, ids, type, types or count (addressId, address_type, apiAddressCount) is kept.

Extend per app with sensitiveKeyFragments. Free-form strings are never rewritten (customer-impact events and detector extras additionally have emails and phone numbers scrubbed from their text; addresses and names in free text are not detected). Depth is capped at 8, cycles are marked, hostile getters and revoked proxies never throw.

Applied twice: to extras before they reach the scope, and event-wide in beforeSend over extra, contexts, request, breadcrumbs[].data. event.user is reduced to id, email, username and segment by default (allowUserFields).

beforeSend

buildBeforeSend(options) composes, in order: your filters (return null to drop), event redaction, and normalizeMessageEvent, which gives captureMessage events a real title and culprit instead of withScope$argument_0. A throw anywhere returns the untouched event, because a throwing beforeSend drops everything.

buildWebBeforeSend and buildNativeBeforeSend are the same with platform helper-frame names pre-loaded.

Releases

buildRelease("react-washmen-pwa", "3.4.1", "a1b2c3d")  // "[email protected]+a1b2c3d"
buildDist("uc")                                         // "uc"
buildDist("ios", "1788771712")                          // "ios-1788771712"

Pass the same release string to sentry-cli:

sentry-cli releases new "$RELEASE" && \
sentry-cli sourcemaps inject ./build && sentry-cli sourcemaps upload --release "$RELEASE" --dist "$DIST" ./build && \
sentry-cli releases set-commits "$RELEASE" --auto && sentry-cli releases finalize "$RELEASE"

One release per commit; dist per platform or flavour. Every webflow sets its own release; the host app version is a tag, not the release.

Testing your app

import { createTestClient } from "@fawadmahmoodwashmen/sentry-core/testing";
import { createReporter } from "@fawadmahmoodwashmen/sentry-core";

const t = createTestClient();
createReporter(t.sdk).reportToSentry({ error: "boom", errorOrigin: "business", originatedFrom: "x", description: "d" });
await t.flush();
expect(t.lastEvent()!.exception!.values![0]!.stacktrace!.frames!.length).toBeGreaterThan(0);

Assert on the delivered event, never on a mocked captureException argument. t.attachments holds anything scope.addAttachment produced, decoded. Requires @sentry/browser as a dev dependency in your app.

Versioning

Semver. Major: any change to the fingerprint rule, the default redaction list, or what beforeSend normalisers do. Minor: new optional inputs and exports. Patch: everything else. Consumers pin to a major and upgrade one app at a time.

Changelog

What changed in each version, and whether a consumer has to do anything. Full detail is in CHANGELOG.md. Every release adds its row here (see Releasing this package).

| Version | What changed | Consumer action | | --- | --- | --- | | 0.4.4 | new HttpError(...) no longer throws "Cannot call a class as a function" where Babel transpiles classes (React Native / Metro default profile): HttpError's Symbol.hasInstance now checks the prototype first and keeps the brand as the cross-bundle fallback. No API change. | None. Webflows on Vite and the PWA were never affected. | | 0.4.3 | Watchdog onReport callback: StuckWatchdogInput.onReport?(result) is called once when a stuck watchdog times out and reports, so an app can record the failure it stood for. token_state is no longer redacted: it's an enum (missing / rejected), and only that exact key is exempt from scrubbing, which makes token_state a reserved key name: never use it for a secret. | Before upgrading, check for any object key that normalises to tokenstate (token_state, tokenState, token-state, …) and rename it if it holds a secret: from 0.4.3 its value is sent unredacted. Nothing else: onReport is optional, and events are otherwise unchanged. | | 0.4.2 | customer_error.shown counter: counts every error message shown to a customer. New option shownErrorKeys: bounds the counter's keys. New ShownErrorOptions fields: breadcrumbOnly, expected. New method countShownError. Stackless errors keep their type: an iOS TypeError is no longer sent as Error. | None. A hand-written fake ImpactReporter must add countShownError. | | 0.4.1 | Chunk loads: a stale chunk served as HTML counts as a chunk load, and a chunk load on screen is now blocked (was degraded). Native calls: a failed native call is titled NativeCallError. Timing: one timing fix. | Review alarms on failure_kind:chunk_load customer_impact:degraded. Pass impact: "degraded" for chunks known to be optional. | | 0.4.0 | Customer-impact reporting API: createImpactReporter, the HTTP safety net, detectors and stuck watchdogs, journeys and counters. Redaction: the key denylist also covers addresses, locations and names. | @sentry/browser peer is now >=9. Location and name keys are now redacted. | | 0.3.6 | Shared failed-request reporting: reporter.reportHttpFailure (server, network, rejected or ignored), plus fixes from the 0.3.5 review. | None. | | 0.3.5 | Fixes from a review of the 0.3.4 dist: a transaction that fails redaction stays a transaction, and malformed options can no longer break beforeSend or redaction. | None. | | 0.3.4 | Redaction gaps closed: URL credentials are also removed from exception values, messages, tags, transaction and the fingerprint. | None. | | 0.3.1 | Fingerprint over-match fixed: the 0.3.0 normaliser could merge unrelated errors into one issue. | None. | | 0.3.0 | Adversarial QA hardening: the release and dist builders never throw. Crash, freeze and credential-leak fixes. | None. | | 0.2.0 | Hardening: redaction walks everything JSON.stringify would emit. Grouping and redaction semantics change. | A minor bump. Callers that pin fingerprintOverride keep their grouping. | | 0.1.3 | Testing: createTestClient records attachments. | None. | | 0.1.2 | React Native typing: withScope typing works with @sentry/react-native, no cast needed. | None. | | 0.1.1 | SDK compatibility: event types are assignable to beforeSend. Folder shims for older resolvers. | None. | | 0.1.0 | First version: createReporter, the fingerprint rule and key-based redaction. Tagged but never published; 0.1.1 is the first published version. | None. |

Releasing this package

  1. Add the version's entry to CHANGELOG.md and its row to the Changelog table above: what changed, and what a consumer has to do. A release without both is not ready.
  2. On master with a clean tree: npm run release -- --increment minor (or patch, major). release-it runs typecheck, tests and build, bumps package.json, commits chore: release vX.Y.Z and tags vX.Y.Z.
  3. git push --follow-tags. The Publish npm workflow runs on the tag: verifies the tag equals package.json, then npm publish --access public.

Prerequisites, once: npm login on a laptop so the scope accepts the first package, and an npm Automation token in the repository secret NPM_TOKEN (2FA accounts fail with EOTP on any other token type).

Moving to the Washmen organisation

Transfer the repository, change name to the org scope, set publishConfig.registry to the CodeArtifact URL and swap the publish step for the one npm-common uses. Move NPM_TOKEN into a Publishing environment. No source change.

Development

npm install          # public registry via .npmrc
npm test             # vitest against a real @sentry/browser client
npm run typecheck
npm run build        # tsup → dist/{index,web,native,testing}.{js,cjs,d.ts}, then check-dist

Design and plan: docs/superpowers/specs/2026-09-23-sentry-core-design.md, docs/superpowers/plans/2026-09-23-sentry-core-v0.1.md.