Skip to content

fix(onboarding): register Replay integration during onboarding flow#114774

Merged
jaydgoss merged 11 commits into
masterfrom
jaygoss/replay-init-during-onboarding
May 11, 2026
Merged

fix(onboarding): register Replay integration during onboarding flow#114774
jaydgoss merged 11 commits into
masterfrom
jaygoss/replay-init-during-onboarding

Conversation

@jaydgoss
Copy link
Copy Markdown
Member

@jaydgoss jaydgoss commented May 4, 2026

TL;DR

Forced replays for SCM onboarding never start because the Replay integration isn't registered on /onboarding/* routes (those mount under OrganizationContainerRoute, not OrganizationLayout). Hoist useReplayInit to the App root so registration covers every route.

Stack

  • PR 1 (this): fix Replay integration not registering during onboarding
  • PR 2: bump sample rate to 50%

Details

The forced replay path for SCM onboarding (useReplayForCriticalFlow at static/app/views/onboarding/onboarding.tsx:201) is silently no-op'ing for SaaS users. The hook bails out when Sentry.getReplay() returns null, and during onboarding it always does: gsApp's useReplayInit was mounted inside OrganizationHeader, which only renders under OrganizationLayout. The onboarding routes use OrganizationContainerRoute instead, so the Replay integration was never registered. Recording only kicked in once the user navigated into the org, which matches the symptom we observed (no replays tagged critical_flow:scm_onboarding over 30d, and tag-less recordings starting after the user leaves /onboarding/).

Move useReplayInit to the App root via a new component:replay-init hook. The new gsApp ReplayInit component is mounted in App (sibling of all route trees), so registration happens once and covers OrganizationContainerRoute and OrganizationLayout alike. useReplayInit no longer mounts under OrganizationHeader.

Registration is async (dynamic import of @sentry/react), so on a fresh load straight into /onboarding/welcome/ the consumer's effect can still fire before the integration resolves. To handle that without making the consumer drive init, useReplayReady is split out of useReplayInit — it subscribes to a module-level listener set that flips when registration completes. useReplayForCriticalFlow calls useReplayReady instead of useReplayInit, so the call site reads as "wait for Replay" rather than "redundantly initialize Replay."

Per-mount sample gate switched from useState lazy-init to useMemo with empty deps; the value never updates, and useMemo signals that more clearly.

Same sample rates pass through (isStaff ? 1.0 : 0.05, 1.0), so behavior outside onboarding is unchanged. Self-hosted falls through to the OSS noop (no gsApp hook registered), matching the prior behavior that motivated 48ee2e9.

useReplayForCriticalFlow's early return on Sentry.getReplay() being
null was masking a real bug for SaaS users: the Replay integration is
only registered by gsApp's useReplayInit, which mounts inside
OrganizationHeader (under OrganizationLayout). Onboarding routes use
OrganizationContainerRoute, so during the SCM onboarding flow the
integration is never registered, getReplay() returns null, and the
forced replay path no-ops.

Move the implementation back behind a HookStore-registered gsApp impl
that calls useReplayInit before tagging and flushing. useReplayInit
now exposes a ready boolean so the critical-flow effect can wait for
the dynamic import to resolve. The module-level singleton in
useReplayInit makes the OrganizationHeader call later in the session
a safe no-op, and the sample rates passed in match the existing
defaults (isStaff ? 1.0 : 0.05, 1.0) so behavior outside onboarding
is unchanged.
@linear-code
Copy link
Copy Markdown

linear-code Bot commented May 4, 2026

@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label May 4, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

📊 Type Coverage Diff

✅ No new type safety issues introduced. Coverage: 93.46%

Each useReplayInit call has its own setReady, and the init() closure
inside its effect can call it directly. Concurrent callers each run
init(), each await the cached import, and each fall through to their
own setReady — the singleton replayRef guard still ensures only one
registration. Late callers see replayRef !== null synchronously in the
useState initializer.
Drops the previous simplification: when two useReplayInit callers are
mounted around the same in-flight init, the initiator's local setReady
only updates its own component. Without the listener Set, a parallel
caller (e.g. OrganizationHeader mounting before onboarding's
useReplayForCriticalFlow has resolved its dynamic import) would stay
stuck on ready=false.

Add comments explaining the listener pattern, the readyListeners
Set's purpose, and why `replayRef !== null` is a valid initial-state
check (replayRef is assigned synchronously immediately before
client.addIntegration, with no await between them).
@jaydgoss jaydgoss marked this pull request as ready for review May 4, 2026 22:41
@jaydgoss jaydgoss requested review from a team as code owners May 4, 2026 22:41
@jaydgoss jaydgoss requested review from a team May 4, 2026 22:41
Comment thread static/gsApp/utils/useReplayInit.tsx
Comment thread static/gsApp/utils/useReplayInit.tsx
Without an explicit `= null`, replayRef's runtime value is undefined,
so `useState(() => replayRef !== null)` evaluates `undefined !== null`
to true. The ready flag started true before the integration was
registered, the critical-flow effect bailed out on the first run, and
the later setReady(true) was a no-op so the effect never re-ran.
Verifies useReplayInit returns false on first render before the dynamic
replay integration import resolves. This catches the regression where
declaring `let replayRef: ... | null;` without an initializer caused
`undefined !== null` to seed `ready` as true.
Use UseReplayForCriticalFlowOptions in the HookStore type map instead
of inlining the option shape, matching the UseExperimentOptions
precedent.

The gsApp useReplayForCriticalFlow now calls out that useReplayInit
runs unconditionally even when enabled or shouldForce is false: it is
the same registration OrganizationHeader does post-onboarding, so
running it early is benign and avoids a hooks-ordering violation.

Update the replayRef invariant comment in useReplayInit to call out
the absence of an `await` between the assignment and
`client.addIntegration`, which is what makes the singleton check
valid for late mounters.
Comment thread static/gsApp/hooks/useReplayForCriticalFlow.tsx
@evanpurkhiser evanpurkhiser requested a review from billyvg May 5, 2026 17:41
Copy link
Copy Markdown
Member

@evanpurkhiser evanpurkhiser left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, but I think we probably should adjust the naming

Comment thread static/gsApp/hooks/useReplayForCriticalFlow.tsx
Copy link
Copy Markdown
Contributor

@Abdkhan14 Abdkhan14 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a small comment

Comment on lines +14 to +16
// The Replay integration is normally registered by OrganizationHeader,
// which doesn't mount during /onboarding/* (those routes use
// OrganizationContainerRoute, not OrganizationLayout). Calling it here
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh I don't remember exactly how this works, but would it help if we registered it higher up in the tree? iirc useReplayInit used to depend on organization to be available as part of our rollout strategy, but that's no longer the case.

Comment thread static/gsApp/hooks/useReplayForCriticalFlow.tsx
Move useReplayInit out of OrganizationHeader and into a new
component:app-init hook mounted at the App root. OrganizationHeader
only renders under OrganizationLayout, which doesn't cover
/onboarding/* (those routes mount under OrganizationContainerRoute).
With init at the App root the Replay integration registers for every
route, so the comment block in useReplayForCriticalFlow is no longer
describing a workaround.

Switch the per-mount sampling gate from useState lazy-init to useMemo
with an empty dep array. Two reviewers read the useState form as
something that could update; useMemo signals "computed once" more
clearly.

Self-hosted is unchanged: no gsApp hook registered, so the App-level
mount renders nothing and Replay stays off.

Refs VDY-48
…layInit

Rename the gsApp App-root component AppInit -> ReplayInit (and the hook
key component:app-init -> component:replay-init) since Replay is all it
drives.

Split a new useReplayReady out of useReplayInit. It subscribes to the
"integration registered" listener set without trying to drive init.
useReplayForCriticalFlow uses it so the call site reads as "wait for
Replay" rather than "redundantly initialize Replay" — the App-root
ReplayInit is the only initializer now.

The subscriber is still load-bearing: registration is async (dynamic
import of @sentry/react), so a fresh load straight into a critical-flow
route can fire the consumer's effect before the integration resolves.
useReplayReady flipping triggers a re-run.

Refs VDY-48
Comment thread static/gsApp/hooks/useReplayForCriticalFlow.tsx Outdated
useMemo with empty deps is a perf hint React reserves the right to
discard (e.g. offscreen prerendering). A recalc would re-roll
Math.random() and could flip the sampling decision mid-session.
useState's lazy initializer is guaranteed to run exactly once per mount.

Comment added at the call site so the next reader doesn't try the same
useMemo swap.

Refs VDY-48
## TL;DR

Bump the forced replay sample rate for SCM onboarding from 30% to 50%,
now that replays actually start in this flow.

## Stack

- [PR 1](#114774): fix Replay
integration not registering during onboarding
- **PR 2 (this):** bump sample rate to 50%

## Details

The prior PR fixes the bug where the Replay integration wasn't
registered on `/onboarding/*` routes, so the 30% `sampleRate` we
configured was effectively 0%. With registration working, 50% gives a
faster signal on the SCM onboarding funnel without doubling baseline
replay storage. Easy to dial back if volume is too high.
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit eb6f2a8. Configure here.

Comment thread static/app/utils/replays/useReplayForCriticalFlow.tsx Outdated
The gsApp implementation calls hooks internally, so the local
variable must start with 'use' for eslint-plugin-react-hooks to enforce
rules-of-hooks on the call site.
@jaydgoss jaydgoss merged commit 7fd3f48 into master May 11, 2026
71 checks passed
@jaydgoss jaydgoss deleted the jaygoss/replay-init-during-onboarding branch May 11, 2026 19:15
nikkikapadia pushed a commit that referenced this pull request May 12, 2026
…114774)

## TL;DR

Forced replays for SCM onboarding never start because the Replay
integration isn't registered on `/onboarding/*` routes (those mount
under `OrganizationContainerRoute`, not `OrganizationLayout`). Hoist
`useReplayInit` to the App root so registration covers every route.

## Stack

- **PR 1 (this):** fix Replay integration not registering during
onboarding
- [PR 2](#114795): bump sample
rate to 50%

## Details

The forced replay path for SCM onboarding (`useReplayForCriticalFlow` at
`static/app/views/onboarding/onboarding.tsx:201`) is silently no-op'ing
for SaaS users. The hook bails out when `Sentry.getReplay()` returns
null, and during onboarding it always does: gsApp's `useReplayInit` was
mounted inside `OrganizationHeader`, which only renders under
`OrganizationLayout`. The onboarding routes use
`OrganizationContainerRoute` instead, so the Replay integration was
never registered. Recording only kicked in once the user navigated into
the org, which matches the symptom we observed (no replays tagged
`critical_flow:scm_onboarding` over 30d, and tag-less recordings
starting after the user leaves `/onboarding/`).

Move `useReplayInit` to the App root via a new `component:replay-init`
hook. The new gsApp `ReplayInit` component is mounted in `App` (sibling
of all route trees), so registration happens once and covers
`OrganizationContainerRoute` and `OrganizationLayout` alike.
`useReplayInit` no longer mounts under `OrganizationHeader`.

Registration is async (dynamic import of `@sentry/react`), so on a fresh
load straight into `/onboarding/welcome/` the consumer's effect can
still fire before the integration resolves. To handle that without
making the consumer drive init, `useReplayReady` is split out of
`useReplayInit` — it subscribes to a module-level listener set that
flips when registration completes. `useReplayForCriticalFlow` calls
`useReplayReady` instead of `useReplayInit`, so the call site reads as
"wait for Replay" rather than "redundantly initialize Replay."

Per-mount sample gate switched from `useState` lazy-init to `useMemo`
with empty deps; the value never updates, and `useMemo` signals that
more clearly.

Same sample rates pass through (`isStaff ? 1.0 : 0.05`, `1.0`), so
behavior outside onboarding is unchanged. Self-hosted falls through to
the OSS noop (no gsApp hook registered), matching the prior behavior
that motivated 48ee2e9.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants