Admin
Verifying access…

You're signed in, but you're not on the admin allowlist.

Server

3 events
analyze.ok #

Server-side /analyze succeeded.

Mirror of the iOS `analysis_completed` from the server perspective. Useful when the client event is dropped (offline, queue drain, etc.) — the server still records the success.

No properties.

How it's captured

functions/src/routes/events.ts

Cloud Function logs the event after Gemini returns and Firestore writes.

Read alongside

analyze.failed #

Server-side /analyze threw.

Mirror of `analysis_failed` from the server perspective.

No properties.

How it's captured

functions/src/routes/events.ts

Cloud Function catch branch.

Read alongside

analyze.dedup_hit #

Server deduped a duplicate analyze request.

A client retried a request the server had already serviced; the dedupe layer returned the cached result instead of re-calling Gemini.

No properties.

How it's captured

functions/src/routes/events.ts

Dedupe layer in the analyze route.

Permissions

3 events
permission_requested #

User-action that needs a system permission — fires before we touch the system API.

Fires regardless of whether the OS prompt actually appears (an already-granted or already-denied status fires this too, with `initial_status` set accordingly). Pair with `permission_resolved` — which only fires when `initial_status` was `not_determined` — to compute prompt-conversion rates.

Properties

Name Type Description
permission enum cameramicrophonephoto_library Which permission was about to be touched.
initial_status enum not_determinedauthorizeddeniedrestrictedlimited Auth status BEFORE the system request (or check) was made.
trigger enum record_startphoto_importapp_foreground User-journey step that motivated the touch.

How it's captured

ios/Plai/Models/PermissionsService.swift

PermissionsService wraps AVCaptureDevice / PHPhotoLibrary requests; CameraView (record_start) and ImportFlow.begin() (photo_import) call through here.

permission_resolved #

User responded to the system permission prompt.

Only fires when the preceding `permission_requested` had `initial_status == not_determined` — i.e. the OS prompt actually appeared. `latency_ms` is a proxy for prompt friction (a long delay is a user reading the rationale carefully). `limited` photo access counts as granted.

Properties

Name Type Description
permission enum cameramicrophonephoto_library Which permission was resolved.
granted bool True when authorized or limited; false otherwise.
final_status enum authorizeddeniedlimitedrestricted Auth status AFTER the user responded.
trigger enum record_startphoto_import Same trigger that fired the matching `permission_requested`.
latency_ms number Milliseconds between request kickoff and the user tap.

How it's captured

ios/Plai/Models/PermissionsService.swift

Fires inside `requestCamera/Microphone/PhotoLibrary` after the async `requestAccess` completion.

Read alongside

permission_status_changed #

Catches Settings.app permission flips — the only signal iOS gives us post-prompt.

Fires on app foreground when the current status differs from what we cached on the previous run. Suppressed on first launch (no baseline to diff against — that run only seeds the cache).

Properties

Name Type Description
permission enum cameramicrophonephoto_library Which permission flipped.
previous_status string Last-known status we'd persisted in UserDefaults.
new_status string Status iOS reports right now.

How it's captured

ios/Plai/Models/PermissionsService.swift

`PlaiApp` `.scenePhase` → `.active` calls `permissions.syncStatusesOnForeground()` which diffs each permission against its UserDefaults cache.

Creation

15 events
record_started #

User tapped the camera's record button.

Fires the moment `AVCaptureMovieFileOutput.startRecording` is called — the user is committed to a take. Pair with `record_completed` for the capture-completion rate and `record_cancelled` to bucket the drop-off cause.

Properties

Name Type Description
speed enum slow_moregular Active speed when the user tapped Record.

How it's captured

ios/Plai/Views/CameraView.swift

`CameraViewController.startRecording` — synchronous fire right after `output.startRecording`.

record_completed #

A clip was successfully captured and is about to flow into the trim flow.

Fires from the AVCaptureFileOutputRecordingDelegate's `didFinishRecordingTo` callback when `error == nil`. `hit_max_cap` flags takes that auto-stopped at the duration cap rather than being stopped by the user.

Properties

Name Type Description
duration_sec number Wall-clock from tap-record to delegate fire.
speed enum slow_moregular Speed at completion.
size_bytes number File size on disk; dropped from the event if the stat fails.
hit_max_cap bool True when the timer auto-stopped at `Config.maxRecordSeconds`.

How it's captured

ios/Plai/Views/CameraView.swift

`fileOutput(_:didFinishRecordingTo:from:error:)` success branch.

record_cancelled #

User dismissed the camera screen without a usable take.

`was_recording: true` is a mid-take bail (informative on recording-quality friction); `was_recording: false` is a 'decided not to record after opening the camera' signal — more likely a permission or preview issue.

Properties

Name Type Description
was_recording bool True if the user had hit Record before cancelling.
elapsed_sec number Seconds into the take when cancelled (0 if not recording yet).

How it's captured

ios/Plai/Views/CameraView.swift

`@objc cancel` action on the Cancel UIButton.

import_picker_opened #

User tapped the FAB's 'Import from Photos' item.

Measures intent at the front of the import funnel. Pair with `import_completed` to compute drop-off through the Photos picker → confirm sheet flow.

No properties.

How it's captured

ios/Plai/Models/ImportFlow.swift

`ImportFlow.begin()` — fires right before the picker sheet is presented.

import_completed #

A clip the user picked from Photos is now in the local library.

One event per clip (staged + confirmed). `size_bytes` is best-effort — dropped on permission errors so a stat failure does NOT drop the whole event.

Properties

Name Type Description
duration_sec number Source clip duration.
size_bytes number File size on disk (best-effort).

How it's captured

ios/Plai/Models/ImportFlow.swift

`ImportFlow.trackImportEvents` — one fire per successfully-ingested clip.

import_failed #

Import couldn't complete.

Fires when either every staged clip failed (PHPicker / file-copy errors) or the confirm pass dropped clips from the accepted set.

Properties

Name Type Description
reason enum staging_failed_allconfirm_partial Failure bucket.

How it's captured

ios/Plai/Models/ImportFlow.swift

`ImportFlow.resolveAfterStaging` / `trackImportEvents`.

Read alongside

clip_added #

Unified 'a clip joined the library' event — both acquisition paths fire it.

Lets the dashboard answer 'of all clips in the library, what % came from each source × speed combination' with a single GROUP BY query. Imports report `speed: unknown` until we wire a `PHAsset.mediaSubtypes.videoHighFrameRate` probe.

Properties

Name Type Description
source enum recordedlibrary_import Where the clip came from.
speed enum slow_moregularunknown Capture speed (`unknown` for imports until probe lands).

How it's captured

ios/Plai/Views/CameraView.swift + ios/Plai/Models/ImportFlow.swift

Fires alongside `record_completed` (camera path) and `import_completed` per-clip (import path).

clip_trimmed #

A trim export landed.

Fires once `AVAssetExportSession` completes — one event whether the clip went straight to analyze (online) or got diverted to the offline queue. Pair with `record_completed` to see the 'captured → trimmed → analyzed' funnel.

Properties

Name Type Description
duration_sec number Trimmed window length (NOT the source clip).
was_online bool True if routed to live analyze; false if queued for later.

How it's captured

ios/Plai/Views/TrimScreen.swift

After `await export.export()` succeeds and the reachability probe has settled.

analyze_tapped #

User tapped the Analyze CTA on a clip.

Fires BEFORE the prep / transcode / pose pipeline kicks in — `analysis_started` covers the later moment when the actual /analyze network call goes out. The gap between this event and `analysis_started` captures prep-stage drop-off.

Properties

Name Type Description
clip_id string Library id of the clip the user tapped Analyze on.
is_retry bool True when this came from `reanalyze` (clip was already in a terminal state).

How it's captured

ios/Plai/Models/ClipAnalyzeCoordinator.swift

`ClipAnalyzeCoordinator.analyze(clip:isRetry:)` — first line, before the guard.

analyze_cancelled #

User tapped 'Cancel analysis' inside the ProcessingSheet.

Distinct from the swipe-down dismiss (which keeps the analyze running in the background). `percent` distinguishes 'bailed at 5%, impatient' from 'bailed at 80%, network stall'.

Properties

Name Type Description
percent number Simulated progress at cancel time, 0–100.

How it's captured

ios/Plai/Views/Library/ProcessingSheet.swift

Cancel pill action in `swipeDownHint`'s VStack.

analyze_retried #

User tapped Retry in the ProcessingSheet failure UI.

Fires on the user-tap, not on the success/failure of the retry itself. `recoverable` mirrors the failure's `isRecoverable` so the dashboard can answer 'of recoverable failures, what % actually retry'.

Properties

Name Type Description
recoverable bool Whether the underlying failure was 5xx / network-recoverable.

How it's captured

ios/Plai/Models/ProcessingViewModel.swift

`ProcessingViewModel.retry()` — fires at the top of the method before clearing the failure state.

analysis_started #

The /analyze network call has gone out.

Anchors the actual server-roundtrip in the funnel. Earlier than this, `analyze_tapped` captures intent; later, `analysis_completed` / `analysis_failed` captures the outcome.

Properties

Name Type Description
has_pose bool Whether a pose summary was attached to the request.
has_priors bool Whether prior-findings context was attached.
sport string Active sport tag (may be empty).
action string Active action tag (may be empty).

How it's captured

ios/Plai/Models/ProcessingViewModel.swift

Inside `runAnalysis()` right before `await service.analyze(...)`.

analysis_completed #

Gemini returned and Firestore wrote successfully.

End-of-funnel success event for the analyze path. `latency_ms` and `findings_count` are the headline quality signals.

Properties

Name Type Description
latency_ms number End-to-end /analyze request time.
findings_count number Number of findings the model returned.
model string Model id that produced the response.

How it's captured

ios/Plai/Models/ProcessingViewModel.swift

Inside `runAnalysis()` after `await service.analyze` succeeds + mirror-to-cache lands.

analysis_failed #

/analyze threw.

Both the failure reason (short, low-cardinality bucket) and the recoverability flag are captured so the dashboard can split user-facing failures from queue-able transient ones.

Properties

Name Type Description
reason enum read_failedhttp_4xxhttp_5xxdecode_failednetworkunknown Short failure bucket. Real http codes interpolated, e.g. http_500.
recoverable bool True for 5xx / network; false for 4xx permanent failures.

How it's captured

ios/Plai/Models/ProcessingViewModel.swift

`handleAnalyzeFailure` after `QueueDrainer.classifyDrainResult` classifies the error.

clip_deleted #

User deleted a clip from the review screen.

`status` distinguishes 'deleted a failed clip' (strong negative — analysis didn't help) from 'deleted an analyzed clip' (could be tidying, sharing complete, etc.). Fired up-front so the event lands even if a cleanup branch throws.

Properties

Name Type Description
clip_id string Library id of the deleted clip.
status enum unanalyzedqueuedanalyzedfailed Clip's state at deletion time.

How it's captured

ios/Plai/Models/ClipAnalyzeCoordinator.swift

`ClipAnalyzeCoordinator.delete(clip:)` — first line, before the switch on `clip.status`.

Feed

9 events
feed_viewed #

Library feed shell appeared on screen.

Per-screen visit count; useful for "how often do users return to browse."

No properties.

How it's captured

ios/Plai/Views/Library/LibraryShellView.swift

View `.onAppear`.

clip_paged #

User flicked to a new clip in the immersive vertical feed.

Highest-volume event in the taxonomy — one per page change.

Properties

Name Type Description
clip_id string The clip now centered in the pager.
position number 0-indexed position from the top of the feed.

How it's captured

ios/Plai/Views/Library/ImmersiveClipPager.swift

`onActivePageChanged` hook on `PagedVerticalFeed`.

clip_opened #

User tapped into a clip's review screen from the library grid.

Distinguishes "scrolled past it" from "actually engaged with it."

Properties

Name Type Description
clip_id string The clip the user opened.

How it's captured

ios/Plai/Views/Library/LibraryFeedView.swift

Navigation push from the library grid.

findings_viewed #

User opened a past clip's findings sheet.

Counts how often users *open the collection* of findings for a clip — pair with `finding_viewed` (one event per individual finding card) to measure the open→drill-in funnel.

Properties

Name Type Description
clip_id string Clip whose findings sheet was opened.
findings_count number How many findings the clip has.

How it's captured

ios/Plai/Models/ClipAnalyzeCoordinator.swift

`presentFindings` after the detail fetch lands.

Read alongside

finding_viewed #

User paged onto an individual finding card.

Distinguishes "opened the findings sheet" from "actually read individual findings."

Properties

Name Type Description
clip_id string Parent clip id.
finding_id string The finding now centered.
position number 1-indexed position within the findings list.

How it's captured

ios/Plai/Views/Library/ProcessingSheet.swift

FindingsScrollFeed's `onActivePageChanged`.

Read alongside

finding_explain_requested #

User tapped "Tell me more" on a specific finding.

Fires only on the open transition, not the collapse. Pair with `findings_viewed` to measure depth of engagement.

Properties

Name Type Description
clip_id string Parent clip id.
finding_id string Which finding the user wanted more on.

How it's captured

ios/Plai/Views/Cards/FindingCard.swift

`handleTellMeMoreTap` — fires when the disclosure expands.

finding_explain_completed #

/explain returned successfully.

Pairs with `finding_explain_requested` for the open→resolution funnel. `latency_ms` is the user's actual wait.

Properties

Name Type Description
clip_id string Parent clip id (omitted in previews).
finding_id string Which finding the breakdown was for.
latency_ms number Request kickoff → response.

How it's captured

ios/Plai/Models/FindingExplainViewModel.swift

Inside `load()` after `service.explain` succeeds.

finding_explain_failed #

/explain threw.

Cancellations are excluded (user-driven teardown). `reason` shares the analyze-failure vocabulary.

Properties

Name Type Description
clip_id string Parent clip id (omitted in previews).
finding_id string Which finding the breakdown was for.
reason enum read_failedhttp_4xxhttp_5xxdecode_failednetworkunknown Failure bucket.

How it's captured

ios/Plai/Models/FindingExplainViewModel.swift

Inside `load()` catch branch, excluding `CancellationError`.

clip_scrubbed #

User completed a horizontal scrub gesture on a clip player.

Fires once per drag, on gesture end, only when the gesture actually committed to horizontal-scrub. Summed `duration_ms` is total scrub time per user.

Properties

Name Type Description
clip_id string The clip being scrubbed.
finding_id string Empty for full-clip context (ClipReviewView, FullClipCard).
distance_ms number Absolute content-time distance scrubbed.
duration_ms number Gesture wall-clock duration.
loop_ms number Scrub window size — dashboards can compute % of clip scrubbed.

How it's captured

ios/Plai/Views/Player/AnnotatedClipPlayer.swift

Scrub gesture end callback when direction-locked horizontal.

Roster

4 events
player_added #

User created a new player roster entry.

Fires only on the new-player path (edits are not roster growth).

Properties

Name Type Description
sport string Player's default sport (may be empty for sport-less players).

How it's captured

ios/Plai/Views/Library/PlayerEditorSheet.swift

`PlayerEditorSheet.save()` `.new` branch.

Read alongside

player_switched #

User flipped the active player in the switcher menu.

Gated on an actual transition — re-tapping the already-active player is a no-op. The denominator is 'user opened the switcher AND picked a different player'.

Properties

Name Type Description
to_all_players bool True when the new selection is the aggregate view.

How it's captured

ios/Plai/Views/Library/PlayerSwitcher.swift

Menu button actions for individual players and the "All players" row.

player_archived #

User archived a player — v1's user-facing delete.

Archive keeps the player's clips visible under 'All players' with an archived badge. True hard-delete is a separate settings action that isn't wired yet; if/when it ships, `player_removed` will join this taxonomy.

Properties

Name Type Description
sport string Archived player's default sport (may be empty).

How it's captured

ios/Plai/Views/Library/PlayerEditorSheet.swift

`PlayerEditorSheet`'s 'Archive player' destructive button.

Read alongside

clip_tagged #

User assigned tags to a clip.

Empty sport/action are kept as empty strings rather than dropped so dashboards can distinguish 'user cleared the tag' from 'user never set one.'

Properties

Name Type Description
clip_id string The clip that was tagged.
sport string Sport tag (may be empty).
action string Action tag (may be empty).

How it's captured

ios/Plai/Views/Library/ClipReviewView.swift

`ClipTagSheet`'s save closure — fires after the write.

Auth

4 events
sign_in_started #

User tapped a provider button on the welcome screen.

Fired before the `AuthService` call goes out so the per-provider denominator includes attempts that fail or get cancelled. Provider strings match FirebaseAuth `providerID` values for clean joins with `signed_in` and `sign_in_failed`.

Properties

Name Type Description
provider enum apple.comgoogle.compasswordpassword_reset Auth path the user picked.

How it's captured

ios/Plai/Views/Onboarding/LoginView.swift

Apple: SignInWithAppleButton's `request` closure. Google: `startGoogleSignIn`. Email: `EmailAuthSheet.submit`.

sign_in_failed #

`AuthService` threw a non-cancel error.

Cancels (`AuthError.userCancelled`) are filtered out — a user dismissing the Apple/Google sheet doesn't count as a failure. Reason is the `AuthError` case name (low cardinality so dashboards can group by failure mode without parsing message strings).

Properties

Name Type Description
provider string Same vocabulary as `sign_in_started`.
reason enum invalid_credentialsemail_already_in_useweak_passwordnetwork_unavailableprovider_failureapple_system_errormissing_credentialno_presenterunknown Low-cardinality failure bucket.

How it's captured

ios/Plai/Views/Onboarding/LoginView.swift

`LoginView.run(provider:)` catch branch, plus Apple completion error branch and EmailAuthSheet catch branches.

signed_in #

Fresh sign-in resolved.

Fires after FirebaseAuth's state-change listener observes a new uid. Fired AFTER `Analytics.setUserID` so the event itself is attributed to the new uid.

Properties

Name Type Description
provider string Original sign_in_provider before Firebase consolidates it (e.g. `apple.com`, `google.com`, `password`).

How it's captured

ios/Plai/Models/AuthState.swift

`Auth.auth().addStateDidChangeListener` callback when the previous uid differs from the new uid.

signed_out #

Sign-out completed.

Fires BEFORE `clearIdentity` so the event itself is still attributed to the user who just signed out, not to nobody.

No properties.

How it's captured

ios/Plai/Models/AuthState.swift

`Auth.auth().addStateDidChangeListener` callback when the user goes from non-nil to nil.

Read alongside

Lifecycle

2 events
app_opened #

App entered the foreground.

Covers cold launches and background-to-foreground resumes. The `was_cold` property distinguishes the two so dashboards can split fresh starts from quick resumes. Pair with `app_backgrounded` to derive session length downstream.

Properties

Name Type Description
was_cold bool True on the first activation after launch; false on resume.

How it's captured

ios/Plai/PlaiApp.swift

SwiftUI `.scenePhase` observer in `PlaiApp.body` fires on every transition to `.active`.

Read alongside

app_backgrounded #

App moved to the background.

Fires exactly once per "user left the app" transition. Pair with the most-recent `app_opened` to bound a session.

No properties.

How it's captured

ios/Plai/PlaiApp.swift

SwiftUI `.scenePhase` observer on transition to `.background`.

Read alongside