• CMSnpm version

    • Overview
    • ContentBuilder
    • InviteUserForm
    • LoginForm
    • RenewPasswordForm
    • WysiwygInput
  • UInpm version

    • Overview
    • Accordion
    • AlertBubble
    • AnnouncementBar
    • Avatar
    • AvatarFileInput
    • Badge
    • Button
    • ButtonGroup
    • ButtonList
    • Calendar
    • Checkbox
    • CheckboxButton
    • CheckboxInput
    • CheckboxList
    • Chip
    • ColorRadio
    • ColorRadioGroup
    • Combobox
    • DatePicker
    • DatePickerInput
    • DateRangePicker
    • DateRangePickerInput
    • DatetimePicker
    • DatetimePickerInput
    • Dialog
    • Dropdown
    • Dropzone
    • ErrorMessage
    • FileInput
    • FlashMessages
    • FormComponent
    • Icon
    • IconButton
    • ImageGallery
    • InfoBox
    • Input
    • Label
    • Layout
    • Lightbox
    • ListItem
    • Loader
    • Lozenge
    • Menu
    • Message
    • Modal
    • ✅ ModalDialog
    • ✅ ModalHeader
    • MultiCombobox
    • MultiSelect
    • Pagination
    • Paper
    • Popover
    • Radio
    • RadioGroup
    • RasterImage
    • Select
    • Switch
    • ✅ Tabs
    • TextInput
    • TextLink
    • Textarea
    • TimePicker
    • TimePickerInput
    • Toggle
    • Tooltip
    • Typography
  • Formnpm version

    • Overview
    • AvatarFileInput
    • CheckboxButton
    • CheckboxInput
    • CheckboxList
    • ColorRadioGroup
    • Combobox
    • DatePickerInput
    • DateRangePickerInput
    • DatetimePickerInput
    • Dropzone
    • FileInput
    • Form
    • FormRenderer
    • GpsInput
    • MoneyInput
    • MultiCombobox
    • MultiSelect
    • NumberInput
    • PasswordInput
    • RadioGroup
    • Select
    • TextInput
    • Textarea
    • TimePickerInput
    • Toggle
  • DataGridnpm version

    • Overview
    • DataGrid
    • DataGridCustomExample
    • ExportButton
    • FilterList
    • Filters
    • FiltersButton
    • FulltextInput
    • HiddenColumns
    • HiddenColumnsButton
    • Pagination
    • RowCounts
    • RowsPerPageSelect
    • SelectedRowsToolbar
    • TableV2
    • ToolbarControl
    • ToolbarCustoms
    • ToolbarTabs
  • Wysiwygnpm version

    • Overview
  • Resizernpm version

    • Overview
  • Routernpm version

    • Overview
  • Corenpm version

    • Overview
  • Core-Reactnpm version

    • Overview
  • DnDnpm version

    • Overview
  • Sentrynpm version

    • Overview
  • Stylesnpm version

    • Overview
  • Localizenpm version

    • Overview
  • Analyticsnpm version

    • Overview
  • Datepickernpm version

    • Overview
  • Icons-generatornpm version

    • Overview
  • Smart-addressnpm version

    • Overview
  • E2Enpm version

    • Overview
  • E2E-Playwrightnpm version

    • Overview

@uxf/sentry

npm license

Sentry wiring for UXF Next.js apps: what gets scrubbed before an event leaves the process, what session replay may record, and how the build uploads source maps.

When to use

Use it instead of copying instrumentation-client.ts, sentry.server.config.ts, sentry.edge.config.ts and the withSentryConfig block between projects. It is not a wrapper around the Sentry SDK — you still call captureException and friends from @sentry/nextjs directly.

Each runtime is a separate subpath import, so a browser entry point never pulls in server code:

  • @uxf/sentry/client — instrumentation-client.ts
  • @uxf/sentry/server — sentry.server.config.ts
  • @uxf/sentry/edge — sentry.edge.config.ts
  • @uxf/sentry/config — next.config.ts
  • @uxf/sentry/privacy — the scrubber and the privacy integration on their own
  • @uxf/sentry/constants — the defaults, for composing a config from scratch
  • @uxf/sentry/verify — a post-build check, also available as the uxf-sentry-verify binary

Installation

yarn add @uxf/sentry

Peers: @sentry/nextjs, @uxf/core, next.

Usage

// src/instrumentation-client.ts
import { initBrowserSentry } from "@uxf/sentry/client";

export const onRouterTransitionStart = initBrowserSentry({ tracesSampleRate: 0.1 });
// sentry.server.config.ts
import { initServerSentry } from "@uxf/sentry/server";

initServerSentry();
// next.config.ts
import { withUxfSentry } from "@uxf/sentry/config";

export default withUxfSentry(nextConfig, { project: "rehatab-web" });

withUxfSentry returns the function form of a Next.js config, because it needs the phase: next start loads the config too, on a server that rightly has no auth token, and only next build should ask for one. Next.js accepts a function export as it is, and withUxfSentry takes either an object or another config function. Put plugins that only accept an object inside it, not around it.

instrumentation.ts stays as Next.js generates it. Its dynamic imports exist for bundling and hiding them behind a helper would save nothing.

Masking

Replay masking is driven by two presets.

presetwhat it doeshow you open it up
strictmasks all text, blocks all mediadata-sentry-unmask
selectiveshows everything, masks only what is markeddata-sentry-mask

strict applies when privacy is omitted, so a project that configures nothing errs toward privacy. selective suits apps whose replays are unreadable otherwise, and which have gone through their screens marking sensitive areas.

initBrowserSentry({ privacy: "selective" });

// adjustments are appended to the preset, so its own selectors stay
initBrowserSentry({ privacy: { preset: "selective", mask: [".invoice-total"] } });

// a function takes the list over entirely
initBrowserSentry({ privacy: { preset: "strict", block: () => [] } });

mask, unmask, block, unblock and ignore follow the same rule as everywhere else in the package: an array is added to what the preset already has, a function receives it. Adding one selector therefore cannot silently remove [data-sentry-unmask] or stop blocking canvases.

data-sentry-mask and .sentry-mask are Sentry's own default selectors. data-sentry-unmask is not — it works because both presets pass it to unmask.

Anything portalled escapes an ancestor's marker. Modals, drawers, popovers, tooltips, toasts and the menus of selects and comboboxes render into a portal, outside the DOM subtree they belong to, so a data-sentry-mask on a page wrapper does not reach them. Mark the portalled content itself. The four select-like components from @uxf/ui and @uxf/form (Select, MultiSelect, Combobox, MultiCombobox) take a class for their menu:

import { SENTRY_MASK_CLASS_NAME } from "@uxf/sentry/constants";

<Combobox dropdownClassName={SENTRY_MASK_CLASS_NAME} … />;

The literal "sentry-mask" works just as well, but a typo in it disables the masking without any error, so the constant is worth the import.

Grid cells are masked by column, through @uxf/data-grid rather than here. Set isSensitive on the column config in the PHP grid type (->replaceConfig(isSensitive: true), uxf/datagrid 3.84.2 or newer), or pass sensitiveColumns to the DataGrid component. The data-grid README has the details. That marks the body cells of the table and nothing else: the chips of active filters show the filtered value, so a grid filtered by surname shows the surname above the masked column. Mark those where it matters.

Request and response bodies never enter a replay: both presets set networkDetailDenyUrls: [/.*/]. Overriding that takes an explicit networkDetail.

Extending

Nothing here is a black box. browserSentryOptions() composes the options and hands them back without calling init, and every default is exported from @uxf/sentry/constants.

Lists accept either an array, appended to the defaults, or a function that receives the defaults:

initBrowserSentry({
    ignoreErrors: ["ResizeObserver loop limit exceeded"],
    denyUrls: (defaults) => defaults.filter((url) => !String(url).includes("facebook")),
    integrations: (defaults) => [...defaults, myIntegration()],
});

Sampling and noise

Defaults, taken from where the live projects actually settled rather than from the template:

optiondefaultwhy
tracesSampleRate0.110 of the 11 projects that set it use this
replaysSessionSampleRate0.0all 11
replaysOnErrorSampleRate1.0all 11

Noise groups

Third-party noise is grouped by where it comes from, and only the groups that cannot be first-party code are on by default:

groupdefaultwhat it covers
extensionsonbrowser extensions injecting scripts, crypto wallets
in-app-browsersonSeznam, iOS WKWebView and Android WebView JS bridges*
cross-originoffblocked frames and storage, non-error promise rejections
adsoffGPT/AdSense, Sklik, viewability trackers, consent, embeds
networkoffFailed to fetch, aborted requests

* One of its entries, Seznam's injected touchmove handler, raises the generic V8 text Cannot read properties of undefined (reading 'touches'). A real bug in first-party gesture code raises the same text, so that entry drops an error only when none of its frames comes from a real file (ignoreFramelessErrors on the group). Everything else in the table matches by message or by script URL, the way ignoreErrors and denyUrls do.

initBrowserSentry({ noise: ["ads", "cross-origin"] });

// drop a default group
initBrowserSentry({ noise: (defaults) => defaults.filter((group) => group !== "in-app-browsers") });

The entries come from what production actually reports, not from a list of plausible vendors, so they are worth revisiting against Sentry now and then. A new vendor shows up as a cluster of low-count issues whose stack ends in one third-party file.

The three off-by-default groups each hide something an app might genuinely need to see: cross-origin matters to a widget embedded in someone else's page, ads can swallow a bug in your own ad callback, and network hides genuinely broken endpoints. Turn them on deliberately.

Project-specific filters stay in the project. A business error like Article does not exist or a cart-expiry message belongs in that app's ignoreErrors, not here.

Two opt-ins

Both of these are off by default because neither works on the client alone, and both fail in a way the app cannot see.

Profiling. isProfilingEnabled adds browserProfilingIntegration together with profileLifecycle: "trace" and profileSessionSampleRate: 0.01. It needs the document to be served with Document-Policy: js-profiling, which is what withJsProfilingHeader adds, so turn both on together:

// next.config.ts
export default withJsProfilingHeader(withUxfSentry(nextConfig, { project: "rehatab-web" }));

// instrumentation-client.ts
export const onRouterTransitionStart = initBrowserSentry({ isProfilingEnabled: true });

The lifecycle is the part worth knowing. profileLifecycle defaults to "manual" in the SDK, which waits for a startProfiler() call. No UXF app makes one, so every project that set only profileSessionSampleRate was shipping the profiling integration and collecting nothing. Projects on the deprecated profilesSampleRate were unaffected, because that option predates the lifecycle split.

Third-party error filtering. isThirdPartyErrorFilterEnabled adds thirdPartyErrorFilterIntegration, which drops errors raised exclusively by code the bundler did not build. It recognises first-party frames by an application key the bundler plugin injects — withUxfSentry passes NEXT_PUBLIC_SENTRY_DSN as that key, and the filter looks for the same variable, never for a dsn passed to initBrowserSentry, which the build cannot see — and when nothing injects it, every frame reads as third-party and every error is dropped. Sentry goes quiet and nothing says why, which is why this is opt-in and the noise groups above, which need no build-time support, are not.

Turbopack injects the key only on Next 16 and newer; webpack always does. uxf-sentry-verify does not check this — it checks debug IDs, which are a different injection.

URL scrubbing

Every event, breadcrumb and replay URL passes through a scrubber before it leaves the process: the request URL and query string, the Referer header, span descriptions and the URL keys of span data (the root span's included, which lives in the trace context), and the network and navigation frames of a replay recording. Query parameters are an allowlist (page, perPage, section, sort, tab, view); everything else becomes [Filtered]. Path segments are filtered when they look like identifiers: UUIDs, hex digests, anything made only of digits, anything containing @, and any segment of 16 characters or more that is not a kebab- or snake-cased route name. Segments are judged decoded, so editace-doplňkové-služby counts as a slug although it arrives percent-encoded. Credentials embedded in a URL (https://user:secret@host/) are dropped.

The fragment is scrubbed too, which is easy to forget because it never reaches the server: #access_token=… is the OAuth implicit-flow shape and hash routers put record ids there. A fragment is split at ? first, so a hash route keeps its shape while both halves are scrubbed: #/detail/<uuid>?tab=a becomes #/detail/[Filtered]?tab=a. Without the ?, a fragment containing = is treated as a query string and filtered by the same allowlist, and anything else is treated as a path, so #kontakt survives.

That last rule matters: a plain length threshold also matches ordinary route names, and /editace-doplnkove-sluzby/<uuid> reaching Sentry as /[Filtered]/[Filtered] costs you the transaction name for no privacy gain. It errs the other way now — a long single word is filtered, and so is a bare number, which loses a little grouping on date-shaped routes and is the right trade for a privacy layer. Projects using other id formats can tighten it:

initBrowserSentry({ scrub: { isOpaqueSegment: (segment) => segment.startsWith("ord_") } });

One URL cannot be scrubbed: the page URL inside a replay recording. The recording's meta event holds window.location.href verbatim, and Sentry offers no hook for it: beforeAddRecordingEvent receives custom frames only, and the recording never passes through an event processor. Whatever the address bar shows, path, query and fragment alike, is readable in every replay. The scrubber keeps an id out of an error event; only keeping it out of the URL keeps it out of the replay.

Webpack and Turbopack

withUxfSentry works with either bundler, but they do not read the same options the same way, and one difference is silent.

The SDK enables browser source maps under Turbopack only when productionBrowserSourceMaps is undefined. An explicit false makes it bail out, and the build then emits no source maps at all while still looking healthy. Under webpack the same false is harmless, because the Sentry webpack plugin sets devtool itself when Next left it unset.

So withUxfSentry drops an explicit productionBrowserSourceMaps: false before handing the config over. That restores Next's own default, which is false, so webpack behaves exactly as before, and Turbopack gets source maps instead of quietly getting none. The config you pass in is not mutated.

If you genuinely want no source maps, say so with sourcemaps: { disable: true }. Both bundlers honour that, and uxf-sentry-verify will then correctly report that there is nothing to match.

Transaction names are not URLs and are not treated as such. A server transaction is "GET /route" and an outgoing request span is "GET https://api…/route", so the method is kept and only the path or URL after it is scrubbed; a custom name that is neither is left alone. Running those through the URL scrubber would hand Sentry back "/GET%20/route" and break performance grouping.

Verifying the build

Every step of the source map pipeline fails quietly on its own. A missing token skips the upload. A plugin that does not run injects nothing. Both leave a green build behind, and the damage surfaces weeks later as a stack trace nobody can read.

Run the check right after the build:

RUN --mount=type=secret,id=sentry_auth_token \
    SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null)" \
    yarn build && npx uxf-sentry-verify

The Sentry plugin logs during next build, so the build log shows what it uploaded. It stays quiet whenever the config loads outside a build.

uxf-sentry-verify fails when the emitted client chunks carry no Sentry debug ID, which means an uploaded source map could never be matched to them however well the upload itself went. --dist-dir points it elsewhere than .next.

The same check is available programmatically:

import { assertDebugIds, inspectBuildOutput } from "@uxf/sentry/verify";

inspectBuildOutput(); // { chunkCount: 147, chunksWithDebugIds: 147 }

It does not check that the upload reached Sentry — nothing in the build can, short of querying the API. What it does check is the half that is silently wrong more often: that there is anything to match against at all.

Source map auth token

The token is read from process.env.SENTRY_AUTH_TOKEN of the build process, by exact name and with no fallback. It should be an organization token (sntrys_) scoped to org:ci, held in a masked instance-level GitLab CI/CD variable named GLOBAL_SECRET_SENTRY_AUTH_TOKEN — one variable for every project on the instance, rotated in one place.

Getting it into the build is the part that takes work. A GitLab CI variable is not visible inside docker build, and the SECRET_* mapping UXF uses in docker-stack*.yml happens at deploy, so it never reaches a build either. That gap is why projects ended up writing the token into .env.production — the one channel that did reach the build, and which carried it into git and into the build context along the way.

Give the build its own channel instead. A BuildKit secret lands in no layer and no image metadata:

# docker/docker-compose.yml
services:
    web:
        build:
            context: ./../web
            dockerfile: $PWD/web/Dockerfile
            secrets:
                - sentry_auth_token

secrets:
    sentry_auth_token:
        environment: GLOBAL_SECRET_SENTRY_AUTH_TOKEN
# docker/web/Dockerfile
ENV NODE_ENV=production
RUN --mount=type=secret,id=sentry_auth_token \
    SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token 2>/dev/null)" \
    yarn build

The name says where it lives: GLOBAL_ for instance level, SECRET_ for a secret. It stays on the GitLab side, and the Dockerfile maps the mounted secret onto SENTRY_AUTH_TOKEN, the name the Sentry tooling expects inside the build. That is the same split docker-stack*.yml already uses for runtime secrets.

Nothing else is needed in .gitlab-ci.yml: GitLab already puts every CI variable into the job environment, which is where compose reads it from. The 2>/dev/null and the quotes matter: without the secret the variable is simply empty instead of the RUN dying on a shell error, and the build then fails one step later, inside withUxfSentry, with a message that names the variable.

authTokenEnvName takes a different variable name for a project that cannot use this one.

Do not use --build-arg unless you have checked the Dockerfile. In a multi-stage build whose final image only copies artifacts out of the build stage the value does not reach the published image, but it does sit in the build stage's own metadata on the runner. In a single-stage build, or when the ARG is used in the final stage, it is published with the image.

A missing token fails the build rather than skipping the upload silently, so "uploaded" and "not uploaded" stay distinguishable in CI. It fails next build only. When next start loads the same config, the server runs without the token and that is correct, so nothing is asked of it. (The standalone output's server.js does not load next.config at all.)

isEnabled defaults to every production build outside the local stage — review builds included, since they report into the same Sentry projects as production and need readable stack traces just as much. So a review build asks for the token too, which is why the CI variable is instance-level and unprotected rather than per project.

isEnabled: false is all-or-nothing: no debug IDs, no upload, no release, no tunnel route. sourcemaps: { disable: true } keeps the release and the tunnel route, but it turns off debug ID injection along with the upload, so uxf-sentry-verify then fails. @sentry/nextjs has no mode that injects debug IDs and skips only the upload.

Options you pass are merged into the defaults, sourcemaps included: sourcemaps: { ignore: [...] } keeps deleteSourcemapsAfterUpload: true, so the maps do not stay in .next/static to be served publicly.