getsentry / getsentry/sentry-javascript

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

オープン
#20,410 コメント 2 件 リアクション 0 件 担当者 1 名 @logaretm が担当を希望しています GitHub で見る
Improvement Replays
主要言語
TypeScript
スター
8.7k
フォーク
1.8k
平均マージ
1日 17時間
マージ済み PR(30日)
515

説明

## Problem Statement

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

```js
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:

```js
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")..maskAllText = false` (mentioned in #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:**

```js
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:

```js
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:

```js
import * as Sentry from "@sentry/browser"

Sentry.init({
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:

- #9111 and getsentry/sentry#47532 — original "Multiple instances" report.
- Discussion #8414 — maintainer explanation of why a second integration is rejected.
- Discussion #8704 — users attempting to reconfigure masking at runtime.
- #10293 — masking options "reverting to `true`" after navigation, which points at the same lifecycle gap.

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

コントリビューションガイド

コントリビューションガイドを開く

評価

この issue はまだ評価されていません。

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。