Skip to content

Replay: support a user-consented runtime switch from masked buffer to unmasked reproduction window #20410

@peter-schout

Description

@peter-schout

Problem Statement

Our app (enterprise multi-tenant SaaS, financial data) runs Replay in the canonical buffer-mode, privacy-first configuration:

Sentry.init({
  replaysSessionSampleRate: 0,
  replaysOnErrorSampleRate: 1.0,
  integrations: [
    Sentry.replayIntegration({
      maskAllText: true,
      maskAllInputs: true,
      blockAllMedia: true,
    }),
  ],
})

This is the setup recommended when the app handles PII or customer data that must not leave the browser by default: errors flush a masked buffer, no per-error consent needed, DPA-safe.

Inside that app, a "Give Feedback" popover offers a checkbox — "Record a 30-second screen recording of me reproducing this issue (unmasked)." The intent is exactly what the label says: for the next 30 seconds, with the user's explicit consent, capture an unmasked replay slice that support can play back verbatim. The masked buffer alone is useless for bugs like "the dropdown shows the wrong name" because the name is █████.

This pattern — masked by default, per-interaction consent for a single bounded unmasked window — is not expressible in the current SDK.

When the checkbox is ticked we do the obvious thing:

const existing = Sentry.getReplay()
await existing?.stop?.()

const unmasked = Sentry.replayIntegration({
  maskAllText: false,
  maskAllInputs: false,
  blockAllMedia: false,
})
Sentry.getClient()?.addIntegration(unmasked)
await unmasked.start()

which throws synchronously from the replay integration's setup:

Error: Multiple Sentry Session Replay instances are not supported

Reproduced against @sentry/browser@10.49.0. The cause is the module-level _isInitialized flag that is set on first init and, as far as I can tell from the bundle, never reset — not even after replay.stop(). Once one Replay integration has been installed, a second replayIntegration({...}) cannot complete setup for the remainder of the page lifetime.

The usual alternatives all fail the "per-interaction consent" requirement:

  • Init-time mask / unmask selectors are static; new selectors cannot be registered at runtime.
  • Full-page reload to reinitialize with maskAllText: false destroys the bug-reproduction context and creates a window where every post-reload frame is unmasked, well beyond the 30s the user actually consented to.
  • Init unmasked from the start would silently ship unmasked content on every replaysOnErrorSampleRate: 1.0 auto-flush, with no consent moment anywhere — the exact property masked-default exists to avoid.
  • Mutating internals like client.getIntegrationByName("Replay").<internal>.maskAllText = false (mentioned in loader script: configure replay options #8704) is undocumented, and from the bundled source the recording pipeline doesn't re-read the flag after setup.

Solution Brainstorm

Any one of these would unblock the pattern. Listed in order of smallest-change-to-mental-model.

1. Let replay.stop() (or a new replay.destroy()) reset the module-level _isInitialized flag, so a subsequent client.addIntegration(replayIntegration({...})) installs a freshly configured instance. Semantically: once the old one is stopped, the SDK lets you install a new one with different options.

2. Runtime masking toggle on the running instance:

const replay = Sentry.getReplay()
replay.updateMasking({
  maskAllText: false,
  maskAllInputs: false,
  blockAllMedia: false,
})
// …30s reproduction window…
replay.updateMasking({
  maskAllText: true,
  maskAllInputs: true,
  blockAllMedia: true,
})

Only frames captured after the toggle reflect the new config — that's the correct semantics for consent: past frames stay masked, future frames reflect the user's explicit choice.

3. First-class "unmask for the next N seconds" API that bakes in the consent semantics:

const replay = Sentry.getReplay()
const replayId = await replay.captureUnmaskedReproduction({
  durationMs: 30_000,
})
// Returns the replayId for the unmasked slice so it can be attached
// to a Sentry feedback envelope alongside the masked baseline.

Option 1 is the smallest SDK change — userland libraries can build (2) and (3) on top.

Additional Context

Reproducer:

import * as Sentry from "@sentry/browser"

Sentry.init({
  dsn: "<dsn>",
  replaysSessionSampleRate: 0,
  replaysOnErrorSampleRate: 1.0,
  integrations: [Sentry.replayIntegration({ maskAllText: true })],
})

const unmasked = Sentry.replayIntegration({ maskAllText: false })
Sentry.getClient().addIntegration(unmasked)
// → Uncaught Error: Multiple Sentry Session Replay instances are not supported

SDK version: @sentry/browser@10.49.0.

Why prioritize this

This is the canonical feedback-loop pattern for privacy-conscious products:

  • Masked-by-default so automated error replays are DPA-safe.
  • Explicit, ephemeral, user-initiated unmasked window attached to a feedback submission.
  • No page reload, no global state flip, no second-guessing what the user agreed to.

Today teams either drop Replay entirely (losing incidental error context) or run fully unmasked (losing compliance posture). A runtime switch closes that gap.

Related prior reports where the same underlying constraint surfaces:

Happy to sketch a PR against option 1 if the direction is agreed.

Metadata

Metadata

Assignees

No one assigned
    No fields configured for issues without a type.

    Projects

    Status

    Waiting for: Product Owner

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions