@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
Maintainers
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-coreWiring
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
installAxiosImpactInterceptorsbefore the app's own interceptors. Attempts begin when axios actually dispatches (atransformRequesthook) 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, andrequestWatchdogMsmay report it as pending). - Remove
reportHttpFailurefrom the HTTP interceptors once the safety net is wired (installAxiosImpactInterceptors,createImpactFetch, orbeginAttemptby hand). The owners and the net report instead, one event per failure. If a call stays, passhttpFailures: {}tocreateReporter: with an impact reporter on the same SDK live,reportHttpFailurethen hands the failure to the net instead of sending a second event (routeToSafetyNet, default true). Pass theerrortoo: a call without an error object can be linked to its owner only throughrequest: { method, url }. - Pass
httpFailures: {}(or an endpointbodyAllowlist) tocreateReporterin every adopting app. Without it,reportHttpFailurekeeps its earlier behaviour and attaches request and response bodies. - Thunks report in their own
catch, with the original error, beforerejectWithValue. If only the serializedaction.erroror a string reaches the owner, passrequest: requestOf(error)(taken in thecatch, where the AxiosError or HttpError is) toreportImpactFailure. Without it the copy cannot be linked (nothing is matched by message or stack), so the net'sunconfirmedreport 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: passbusiness: true) or"blocked". A 422.1 CHECKOUT_SYSTEM_ERROR is a decline only when its Checkout.com code is inCHECKOUT_DECLINE_CODES;paymentOutcomeOf(kind, { status, backendCode, checkoutErrorCode })gives thepayment_outcometag value:"declined","failed", orundefinedfor no payment outcome;isBenignNativeRejection(error),isCannotPresentRejection(error)(degraded) andisNoHandlerRejection(error)(breadcrumb) classify native answers.instrumentNativeRpcalready 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:preloadErrorpayload); - 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/*.jswithindex.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 theimport()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
typeisNativeCallError; its value is the method name (therpc_methodtag, validated) plus the original message, scrubbed like every impact value; - tag
native_error_type: the type 0.4.0 sent (the rejection'sname,Errorfor 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
Errorcarrying the original's stack, withcauseset 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:
createImpactReporterwires the scrubber (installMetricScrubber) into the SDK's client. Created afterSentry.init, at once; created before it (no client yet), on its firstcountJourney, 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) afterbeforeSendMetricruns, so that hook cannot remove them; onlyinstallMetricScrubbercan. Use it in addition to the scrubber, never instead of it. An app with metrics but no impact reporter callsinstallMetricScrubber(Sentry.getClient())afterSentry.init.countJourneytakes aflavourattribute (for example the PWA distapps/uc/tdh), also settable throughcounterAttributes.
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, elsecounterAttributes, else the scope tag),host_app_versionandflavour(fromcounterAttributes),flow_step,shown_error_key,expected("true"/"false": theexpectedoption, elsebusiness, elsefalse, on both entry points) andpresented("false"for a message shown in a hidden prewarmed WebView, before present or after dismiss). The SDK adds release and environment;dist,releaseandenvironmentset as scope attributes are kept, as for the journey counters. Nothing else survives the scrubber, and neveruser.*, email or phone keys.shown_error_keyis bounded by the reporter'sshownErrorKeysoption: a listed key is sent as is, a valid key that is not listed asother(so a new key never creates a new series), free text or a secret asinvalid_key, a missing key asunknown. WithoutshownErrorKeysevery valid key isother. At mostMAX_SHOWN_ERROR_KEYS(200) distinct keys are kept, in list order: the rest count asotherand 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 beforeSentry.init), feature detection (a no-op on an SDK withoutmetrics, such as@sentry/react9) and the flush on dismiss andpagehide. Never awaited; a failure is swallowed and self-reported (sentry_internal_where=countShownError), and never costs the event.breadcrumbOnly: truecounts 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'srejectWithValuestring, a detector) or an expected answer kept quiet (a wrong OTP, a location denial). It replaces an app's ownSentry.addBreadcrumbfor 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 } }, plusdata.expected: truewhenexpected: trueis passed.breadcrumbOnlydoes not implyexpected: an owner-reported failure is not expected, so passexpected: trueonly for quiet expected answers. A passederroris 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 nounconfirmedevent followssafetyNetDelayMslater, 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 aduplicate(a later session reports it again). WithoutbreadcrumbOnlynothing changes.countShownError(key, { flowStep?, business?, expected? })counts a display that never reachesshowCustomerErrorReport, for example an error boundary's fatal page:impact.countShownError("fatal_error", { expected: false })from the boundary'sonError.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_keyandflow_step. Alarmexpected:trueon 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
- 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.
- On
masterwith a clean tree:npm run release -- --increment minor(orpatch,major). release-it runs typecheck, tests and build, bumpspackage.json, commitschore: release vX.Y.Zand tagsvX.Y.Z. git push --follow-tags. The Publish npm workflow runs on the tag: verifies the tag equalspackage.json, thennpm 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-distDesign and plan: docs/superpowers/specs/2026-09-23-sentry-core-design.md, docs/superpowers/plans/2026-09-23-sentry-core-v0.1.md.
