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`.
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.
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.
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.
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 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`.
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).
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.
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.
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.
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.
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(...)`.
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.
/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.
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`.