Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
453e60a
docs(onboarding): design resume telemetry identities
WcaleNieWolny Aug 15, 2026
22fddcb
docs(onboarding): plan resume telemetry identities
WcaleNieWolny Aug 15, 2026
01d8d2b
feat(onboarding): persist telemetry identity metadata
WcaleNieWolny Aug 15, 2026
7be89b2
feat(onboarding): add resume telemetry identity context
WcaleNieWolny Aug 15, 2026
9064123
feat(onboarding): connect resume telemetry identities
WcaleNieWolny Aug 15, 2026
266595b
test(onboarding): enforce resume telemetry ordering
WcaleNieWolny Aug 15, 2026
e46b476
fix(onboarding): persist resume identity before step view
WcaleNieWolny Aug 15, 2026
370edc0
fix(onboarding): avoid resume tracking after unmount
WcaleNieWolny Aug 15, 2026
504b952
fix(onboarding): gate resume tracking on persisted identity
WcaleNieWolny Aug 15, 2026
a551e4a
fix(onboarding): abort dismissed resume mounts
WcaleNieWolny Aug 15, 2026
0903f61
fix(onboarding): preserve progress on persist conflicts
WcaleNieWolny Aug 15, 2026
f2d4560
fix(onboarding): keep persistence blocked after conflicts
WcaleNieWolny Aug 15, 2026
d84edf7
fix(onboarding): guard queued writes after conflicts
WcaleNieWolny Aug 15, 2026
32f36fd
fix(onboarding): allow terminal progress after conflicts
WcaleNieWolny Aug 15, 2026
54147dd
fix(onboarding): recover tracking after persistence outage
WcaleNieWolny Aug 15, 2026
604c0a5
fix(onboarding): preserve tracking during persistence outages
WcaleNieWolny Aug 15, 2026
59c3fec
chore: merge main into onboarding telemetry
WcaleNieWolny Aug 15, 2026
90f2824
test(onboarding): exercise persistence lifecycle
WcaleNieWolny Aug 15, 2026
3d5e209
test(onboarding): type lifecycle capture mock
WcaleNieWolny Aug 15, 2026
bb51d6b
docs(onboarding): align persistence lifecycle plan
WcaleNieWolny Aug 15, 2026
690521b
fix(onboarding): skip persistence after unmount
WcaleNieWolny Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
# Frontend Onboarding Resume Telemetry Identities

## Goal

Preserve one logical frontend onboarding attempt across the persisted resume flow
introduced by PR #3062, while distinguishing each browser visit and recording
the resume dialog outcome without duplicating existing step events.

The design follows the CLI identity model from PR #3058 but retains the
frontend's existing `onboarding_attempt_id` nomenclature and underscore-style
event names.

## Problem

`createOnboardingProgressTracker()` currently creates a new
`onboarding_attempt_id` every time `AppOnboardingFlow` mounts. PR #3062 persists
the wizard's operational state in `users.onboarding`, but it does not persist
the analytics attempt identity.

As a result, a user can view and complete early steps under attempt A1, leave,
then continue the same saved wizard under attempt A2. PostHog reports an
abandoned A1 funnel and an unrelated resumed A2 funnel even though the database
correctly treats both visits as one onboarding journey.

## Scope

This change covers the persisted frontend create-app resume dialog and the
shared frontend onboarding event helper. It adds run identity to all events
produced by that helper and preserves attempt continuity when the user accepts
the persisted resume offered by the pre-organization flow.

It does not:

- change the resume dialog, wizard copy, or navigation behavior;
- change PostHog transport, retry, or delivery behavior;
- add frontend run-started or run-ended events;
- reconstruct identities for events captured before this change;
- change the database-backed admin wizard drop-off chart;
- change the existing-org pending-app resume behavior beyond attaching a run ID
to events produced during that run.

## Identity Contract

Keep these identities distinct:

- `onboarding_attempt_id` is the existing frontend logical-attempt identity. It
survives an accepted resume and remains the grouping key used by current
PostHog funnel queries. It retains the existing raw UUID format.
- `onboarding_run_id` identifies one mount of `AppOnboardingFlow`. It uses the
CLI-compatible `ir_<UUID>` format and is never replaced during that mount.
- `initial_onboarding_attempt_id` appears only on an accepted-resume event. It
records the fresh attempt generated for the returning mount before that mount
switches to the saved attempt.
- `resumed_from_run_id` is the previously persisted `last_run_id`, when valid.
It links a returning run to the most recent run that worked on the saved
attempt.
- `resume_onboarding_attempt_id` identifies the saved attempt offered by the
resume dialog while the fresh attempt remains active.

Every mount creates a fresh attempt ID and run ID before inspecting saved
progress. Given saved attempt A1/run R1, the returning mount begins with fresh
attempt A2/run R2. A2 is allowed to remain ephemeral when the user continues
A1; it still identifies the pre-decision dialog event for that mount.

## Persistence Contract

Extend the existing `users.onboarding` JSON object with:

```json
{
"onboarding_attempt_id": "A1",
"last_run_id": "R2"
}
```

New writes always store the pair together:

- Fresh onboarding stores A1/R1 with its first progress snapshot.
- While a resume dialog is pending, the saved A1/R1 pair remains untouched.
- Continue retains A1 and replaces `last_run_id` with R2.
- Restart replaces both values with A2/R2.
- Later progress writes in the same run preserve the active pair.

`last_run_id` means the most recent run that worked on the persisted attempt.
Before it is replaced during a later accepted decision, its saved value is
reported as `resumed_from_run_id`.

Operational progress remains valid when telemetry fields are absent or
malformed. The parser ignores invalid telemetry metadata without rejecting the
saved wizard step or form fields. A valid saved attempt without a valid previous
run may still be continued; the next successful write repairs the pair.

The existing JSONB column, size check, and partial step index can contain these
fields without structural changes. To keep the implementation PR small, do not
add a comment-only migration, constraint, column, index, or Postgres test.

## Event Contract

All existing onboarding events emitted through the progress tracker gain
`onboarding_run_id`. They retain their existing names, properties,
`onboarding_attempt_id`, and `onboarding_version` semantics.

Add these lifecycle events:

### `onboarding_resume_dialog_viewed`

Emit at most once per mount, immediately after the valid resume dialog is
opened and before waiting for a decision.

Properties include:

- `onboarding_attempt_id: A2`;
- `onboarding_run_id: R2`;
- `resume_onboarding_attempt_id: A1` when saved metadata contains A1;
- `resumed_from_run_id: R1` when saved metadata contains R1;
- `flow`, `saved_step`, `step_index`, `total_steps`, and
`onboarding_version`.

No step-view event is emitted while the dialog is pending.

### `onboarding_resume_continued`

When saved progress contains a valid A1, switch the active attempt to A1 before
emitting this event. Emit it at most once per mount.

Properties include:

- `onboarding_attempt_id: A1`, or A2 for legacy progress without a valid saved
attempt;
- `onboarding_run_id: R2`;
- `initial_onboarding_attempt_id: A2` when the active attempt switched to A1;
- `resumed_from_run_id: R1` when available;
- `flow`, `saved_step`, `step_index`, `total_steps`, and
`onboarding_version`.

Afterward, progress persistence stores A1/R2.

### `onboarding_resume_restarted`

Keep fresh A2 active and emit this event at most once per mount.

Properties include:

- `onboarding_attempt_id: A2`;
- `onboarding_run_id: R2`;
- `resume_onboarding_attempt_id: A1` when available;
- `resumed_from_run_id: R1` when available;
- `flow`, `saved_step`, `step_index`, `total_steps`, and
`onboarding_version`.

Afterward, progress persistence stores A2/R2.

## Step-Event Ownership And Ordering

Resume lifecycle handlers do not emit `onboarding_step_viewed` directly and do
not add a watcher for `flowStep`.

Existing flow code remains the sole owner of step-view events:

- `initializeProgressTracking()` emits the first real visible step after
hydration;
- `completeAndViewStep()` emits the next step during forward navigation;
- `viewPreviousStep()` emits the destination step during backward navigation.

The resume path must preserve this order:

### Continue

1. Open the dialog and emit `onboarding_resume_dialog_viewed` with A2/R2.
2. Switch the active identity to A1/R2.
3. Emit `onboarding_resume_continued` with
`initial_onboarding_attempt_id: A2`.
4. Restore the saved form and `flowStep` and return `resumed = true`.
5. Let the existing `initializeProgressTracking(true)` call emit exactly one
`onboarding_step_viewed` for the restored step with A1/R2 and
`resumed: true`.

### Restart

1. Open the dialog and emit `onboarding_resume_dialog_viewed` with A2/R2.
2. Keep A2/R2 active and emit `onboarding_resume_restarted`.
3. Reset the form, set `flowStep` to `intent`, and return `resumed = false`.
4. Let the existing `initializeProgressTracking(false)` call emit exactly one
`onboarding_step_viewed` for `intent` with A2/R2 and `resumed: false`.

This ordering prevents a temporary `intent` view before the resume decision and
prevents a duplicate restored-step or restart-step view afterward.

## Architecture

Create a small typed frontend identity context alongside the existing
onboarding analytics helper. It owns:

- fresh attempt and run generation;
- the optional saved resume candidate;
- the active-attempt switch on Continue;
- persisted identity metadata;
- shared resume-event properties;
- at-most-once guards for dialog and decision events;
- best-effort delegation to the existing `pushEvent` service.

`createOnboardingProgressTracker()` stops generating its own attempt ID. Its
caller supplies the active `onboarding_attempt_id` and `onboarding_run_id`, and
the tracker adds both values to existing step, interaction, copy, and dashboard
exploration events.

`AppOnboardingFlow.vue` remains the orchestration layer. It prepares the resume
candidate from parsed progress, records the dialog and decision, applies or
resets wizard state, and only then initializes the existing progress tracker.
It does not build identity properties itself.

`userOnboardingProgress.ts` parses, validates, builds, and clamps the two
optional persisted identity fields. Invalid identity fields are dropped rather
than invalidating otherwise resumable progress.

## Legacy Progress

Saved progress created before this change has no recoverable PostHog attempt or
run identity. Historical events cannot be joined retroactively.

For such progress, the returning mount's fresh A2/R2 pair becomes the active and
persisted pair after either Continue or Restart. The dialog and decision still
emit, but omit unavailable previous-attempt and previous-run properties. All
future resumes of that progress preserve A2 and advance the saved run ID.

## Failure And Race Handling

- Analytics remains best-effort and must never block the dialog, navigation, or
progress writes.
- A failed progress write may prevent continuity on a later visit, but it must
not alter the active in-memory identity for the current run.
- The existing `updated_at` compare-and-swap behavior remains authoritative for
concurrent tabs. A stale tab does not overwrite newer progress merely to
claim telemetry identity ownership.
- No identity pair is written while initial hydration or the resume decision is
pending.
- Local development continues to suppress PostHog through `pushEvent`.
- New analytics properties contain only generated IDs, enums, and numeric step
metadata; they do not include user-entered text.

## Testing

Add deterministic unit coverage for the identity context and integration
coverage for the component lifecycle:

- fresh onboarding creates and persists A1/R1;
- every existing tracker event contains the supplied attempt and run IDs;
- dialog-viewed uses A2/R2 while saved progress remains A1/R1;
- no `onboarding_step_viewed` occurs while the dialog is open;
- Continue emits with A1/R2 and
`initial_onboarding_attempt_id: A2`;
- initialization emits exactly one restored-step view with A1/R2 and
`resumed: true`;
- Restart emits with A2/R2;
- initialization emits exactly one `intent` view with A2/R2 and
`resumed: false`;
- repeated resumes preserve the attempt while advancing R1 to R2 to R3;
- dialog and decision events cannot duplicate within one mount;
- legacy and malformed identity metadata do not break operational resume;
- persistence and capture failures do not interrupt onboarding;
- no new direct `viewStep()` call exists in either resume decision branch.

Extend the existing registration Playwright scenario to retain its functional
Continue/Restart assertions. Keep event-order assertions in deterministic unit
tests where PostHog capture can be injected and observed directly.

## Success Criteria

- A user who continues saved onboarding contributes one
`onboarding_attempt_id` to the existing PostHog funnel across visits.
- Each browser visit is distinguishable by `onboarding_run_id`.
- Dialog view, Continue, and Restart are independently measurable.
- A pending dialog produces no false `intent` view.
- Continue and Restart each produce exactly one subsequent real step-view event
through the existing navigation/initialization path.
- Existing PostHog funnel queries and the database-backed admin drop-off chart
require no behavioral changes.
36 changes: 31 additions & 5 deletions src/components/dashboard/AppOnboardingFlow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import {
clearOnboardingAppDraft,
loadOnboardingAppDraft,
} from '~/utils/onboardingAppDraft'
import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics'
import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics'
import { allowOnboardingDashboardExploration, ONBOARDING_DASHBOARD_EXPLORED_EVENT } from '~/utils/onboardingRedirect'
import { slugifyOnboardingSegment } from '~/utils/onboardingSlug'
import {
Expand All @@ -78,6 +78,7 @@ const organizationStore = useOrganizationStore()
const dashboardAppsStore = useDashboardAppsStore()
const onboardingUserId = computed(() => main.user?.id ?? main.auth?.id ?? null)
const config = getLocalConfig()
const onboardingTelemetry = createOnboardingTelemetryIdentity({ flow: props.preOrg ? 'pre_org' : 'existing_org', supaHost: config.supaHost })
const STORE_ICON_FETCH_TIMEOUT_MS = 10_000
const removeBeforeUnloadWarning = useBeforeUnloadWarning(Boolean(props.preOrg))

Expand Down Expand Up @@ -304,6 +305,8 @@ let progressTracker: ReturnType<typeof createOnboardingProgressTracker> | null =
let persistChain = Promise.resolve()
let persistFieldsTimer: ReturnType<typeof setTimeout> | undefined
let pendingDashboardExplored = false
let onboardingFlowDisposed = false
let onboardingInitialPersistInFlight = false

function trackV2DetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) {
if (props.preOrg)
Expand All @@ -322,6 +325,8 @@ function initializeProgressTracking(resumed: boolean) {
resumed,
steps: trackedSteps,
supaHost: config.supaHost,
onboardingAttemptId: onboardingTelemetry.attemptId,
onboardingRunId: onboardingTelemetry.runId,
})
progressTracker.viewStep(flowStep.value)
if (pendingDashboardExplored)
Expand Down Expand Up @@ -354,6 +359,7 @@ function viewPreviousStep(nextStep: OnboardingFlowStep) {

function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress') {
const flow = props.preOrg ? 'pre_org' : 'existing_org'
const telemetry = onboardingTelemetry.getProgressMetadata()
return buildUserOnboardingProgress({
status,
step: clampResumableOnboardingStep(flowStep.value, flow),
Expand All @@ -367,6 +373,8 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress'
importedStoreAppId: importedStoreAppId.value,
orgName: orgNameInput.value,
estimatedUsersIndex: estimatedUsersIndex.value,
onboardingAttemptId: telemetry.onboardingAttemptId,
lastRunId: telemetry.lastRunId,
})
}

Expand Down Expand Up @@ -509,6 +517,13 @@ async function maybeResumeSavedOnboarding() {
return false
}

const resumableStep = clampResumableOnboardingStep(saved.step, flow)
onboardingTelemetry.prepareResumeCandidate({
onboardingAttemptId: saved.onboarding_attempt_id,
lastRunId: saved.last_run_id,
savedStep: resumableStep,
steps: appOnboardingSteps.value.map(step => step.id),
})
dialogStore.openDialog({
title: t('onboarding-resume-title'),
description: t('onboarding-resume-description'),
Expand All @@ -518,15 +533,18 @@ async function maybeResumeSavedOnboarding() {
{ text: t('onboarding-resume-continue'), id: 'onboarding-resume-continue', role: 'primary' },
],
})
onboardingTelemetry.recordResumeDialogViewed()
await dialogStore.onDialogDismiss()

if (dialogStore.lastButtonRole === 'onboarding-resume-restart') {
onboardingTelemetry.recordResumeRestarted()
resetOnboardingForm()
existingApp.value = true
existingAppSetup.value = 'manual'
return false
}

onboardingTelemetry.recordResumeContinued()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
applyOnboardingProgress(saved)
return true
}
Expand Down Expand Up @@ -1405,17 +1423,25 @@ onMounted(async () => {
}
finally {
isHydratingOnboarding.value = false
isLoading.value = false
initializeProgressTracking(resumedFlow)
void persistOnboardingProgress()
onboardingInitialPersistInFlight = true
await persistOnboardingProgress()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
onboardingInitialPersistInFlight = false
function finishOnboardingMount() {
if (onboardingFlowDisposed)
return
isLoading.value = false
initializeProgressTracking(resumedFlow)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
finishOnboardingMount()
}
})

onBeforeUnmount(() => {
onboardingFlowDisposed = true
window.clearTimeout(persistFieldsTimer)
window.removeEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored)
detailsFieldTracker.dispose()
if (!isHydratingOnboarding.value)
if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight)
void persistOnboardingProgress()

if (localIconPreview.value.startsWith('blob:'))
Expand Down
Loading
Loading