Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
26 changes: 26 additions & 0 deletions Sources/Meeting/MeetingSessionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import TranscriptedCore
@available(macOS 14.0, *)
@MainActor
final class MeetingSessionController: ObservableObject {
static var runtimeDiagnosticsRecorder: RuntimeDiagnostics?

enum StartTrigger: String {
case hotkey = "hotkey"
case menu = "menu"
Expand Down Expand Up @@ -290,6 +292,7 @@ final class MeetingSessionController: ObservableObject {
/// the older transcript continues in the background.
@discardableResult
func startRecording(trigger: StartTrigger = .unknown) async -> Bool {
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "start_requested")
DiagnosticsTrail.record(
engine: "meeting",
event: "meeting_start_requested",
Expand Down Expand Up @@ -329,10 +332,12 @@ final class MeetingSessionController: ObservableObject {
startDecision.errorMessage
?? "Turn on the required permissions in System Settings before recording a meeting."
)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_blocked_permission")
return false
}

guard await ensureModelsReadyForRecording(trigger: trigger) else {
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "models_unavailable")
return false
}

Expand All @@ -359,11 +364,13 @@ final class MeetingSessionController: ObservableObject {
properties: failureProperties
)
state = .error(failureMessage)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "start_failed")
return false
}

activeRecordingTrigger = trigger
state = .recording
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "recording")
let pipelineSnapshot = capture.pipelineDiagnosticsSnapshot()
DiagnosticsTrail.record(
engine: "meeting",
Expand Down Expand Up @@ -495,6 +502,7 @@ final class MeetingSessionController: ObservableObject {
guard !isFinishingRecording else { return }
isFinishingRecording = true
defer { isFinishingRecording = false }
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "stop_requested")
_ = audioInactivityDetector.stopRecording()
audioInactivityWarning = nil

Expand Down Expand Up @@ -524,6 +532,7 @@ final class MeetingSessionController: ObservableObject {
let durationMs = Int(finalRecordingDuration * 1000)
activeRecordingTrigger = .unknown
state = .transcribing
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing")

DiagnosticsTrail.record(
level: finalSystemAudioStatus.isWarning ? .warning : .info,
Expand Down Expand Up @@ -601,6 +610,15 @@ final class MeetingSessionController: ObservableObject {
// from Settings → Meetings, where the pipeline will either succeed
// on a now-finalized file or fail cleanly.
if stopResult.didTimeOut {
Self.runtimeDiagnosticsRecorder?.recordStall(
kind: "meeting",
stage: "recording_stop_timeout",
durationSeconds: finalRecordingDuration,
extra: [
"trigger": recordingTrigger.rawValue,
"reason": reason.rawValue
]
)
failedManager.addFailedTranscription(
micAudioURL: micURL,
systemAudioURL: files.systemURL,
Expand All @@ -614,6 +632,7 @@ final class MeetingSessionController: ObservableObject {
context: baseDiagnosticsContext(extra: ["reason": reason.rawValue])
)
state = .error("Recording didn't close cleanly. Open Settings → Meetings to retry.")
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "stop_timeout")
return
}

Expand Down Expand Up @@ -713,6 +732,7 @@ final class MeetingSessionController: ObservableObject {
activeRecordingTrigger = .unknown
restoreStateAfterRecordingEndedWithoutNewWork()
AppSoundPlayer.shared.play(.dictationCancelled)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "cancelled")

DiagnosticsTrail.record(
engine: "meeting",
Expand Down Expand Up @@ -769,6 +789,7 @@ final class MeetingSessionController: ObservableObject {
message: "Imported meeting transcription requested",
context: baseDiagnosticsContext(extra: ["trigger": StartTrigger.fileImport.rawValue])
)
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "file_import_requested")

if case .idle = state {
await prepareModels()
Expand Down Expand Up @@ -802,6 +823,7 @@ final class MeetingSessionController: ObservableObject {
context: baseDiagnosticsContext(extra: ["error": error.localizedDescription])
)
state = .error(error.localizedDescription)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "file_import_failed")
return false
}

Expand Down Expand Up @@ -833,6 +855,7 @@ final class MeetingSessionController: ObservableObject {
"queue_depth_bucket": AnalyticsReporter.queueDepthBucket(queuedTranscriptionJobs.count),
]
)
Self.runtimeDiagnosticsRecorder?.recordSession(kind: "meeting", stage: "transcribing")
return true
}

Expand Down Expand Up @@ -866,6 +889,7 @@ final class MeetingSessionController: ObservableObject {
message: "Meeting transcription cancelled",
context: baseDiagnosticsContext(extra: ["reason": reason.rawValue])
)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcription_cancelled")
}

func prepareForTermination() async {
Expand Down Expand Up @@ -1348,6 +1372,7 @@ final class MeetingSessionController: ObservableObject {
uniquingKeysWith: { _, new in new }
)
)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_saved")
AppSoundPlayer.shared.play(.meetingTranscriptComplete)
case .failed(let message):
lastTerminalTranscriptionOutcome = .failed(message)
Expand Down Expand Up @@ -1376,6 +1401,7 @@ final class MeetingSessionController: ObservableObject {
uniquingKeysWith: { _, new in new }
)
)
Self.runtimeDiagnosticsRecorder?.clearSession(kind: "meeting", outcome: "transcript_failed")
finalizeBackgroundTranscriptionStateIfNeeded()
case .gettingReady:
if previousStatus.diagnosticName != status.diagnosticName {
Expand Down
90 changes: 80 additions & 10 deletions Sources/Observability/AnalyticsEventPolicy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,67 @@ struct AnalyticsEventPolicy: Equatable {
"voice_processing_active",
]

private static let dictationRouteDiagnosticProperties: Set<String> = [
"default_input_class",
"default_output_class",
"format_ready",
"hfp_suspected",
"input_channels",
"input_device_class",
"input_rate_hz",
"output_channels",
"output_device_class",
"output_rate_hz",
"recovery_latency_bucket",
"recovering",
"route_shape",
"sample_flow_started",
"selection_overrode_default",
"selection_reason",
"selected_input_class",
"was_recording",
]

private static let runtimeDiagnosticProperties: Set<String> = [
"app_version",
"build_version",
"duration_bucket",
"format_ready",
"heartbeat_age_bucket",
"last_event",
"os_major",
"previous_clean_shutdown",
"reason",
"recovering",
"session_active",
"session_kind",
"session_stage",
"stall_kind",
"stall_stage",
"trigger",
]

private static let allowedPolicies: [String: AnalyticsEventPolicy] = [
"app_launched": .init(
name: "app_launched",
allowedProperties: []
),
"app_unclean_shutdown_detected": .init(
name: "app_unclean_shutdown_detected",
allowedProperties: runtimeDiagnosticProperties
),
"app_session_stall_detected": .init(
name: "app_session_stall_detected",
allowedProperties: runtimeDiagnosticProperties
),
"support_diagnostics_copied": .init(
name: "support_diagnostics_copied",
allowedProperties: []
),
"support_diagnostic_event_sent": .init(
name: "support_diagnostic_event_sent",
allowedProperties: []
),
"onboarding_shown": .init(
name: "onboarding_shown",
allowedProperties: [
Expand Down Expand Up @@ -297,40 +353,54 @@ struct AnalyticsEventPolicy: Equatable {
),
"dictation_started": .init(
name: "dictation_started",
allowedProperties: [
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"trigger",
]
]))
),
"dictation_start_failed": .init(
name: "dictation_start_failed",
allowedProperties: [
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"failure_kind",
"trigger",
]
]))
),
"dictation_completed": .init(
name: "dictation_completed",
allowedProperties: [
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"auto_send",
"delivery",
"duration_bucket",
"trigger",
"word_count_bucket",
]
]))
),
"dictation_cancelled": .init(
name: "dictation_cancelled",
allowedProperties: [
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"duration_bucket",
"trigger",
]
]))
),
"dictation_no_speech": .init(
name: "dictation_no_speech",
allowedProperties: [
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"duration_bucket",
"trigger",
]
]))
),
"dictation_audio_route_changed": .init(
name: "dictation_audio_route_changed",
allowedProperties: dictationRouteDiagnosticProperties
),
"dictation_audio_route_recovery_finished": .init(
name: "dictation_audio_route_recovery_finished",
allowedProperties: dictationRouteDiagnosticProperties.union(Set([
"outcome",
]))
),
"dictation_audio_route_recovery_timeout": .init(
name: "dictation_audio_route_recovery_timeout",
allowedProperties: dictationRouteDiagnosticProperties
),
"meeting_recording_started": .init(
name: "meeting_recording_started",
Expand Down
4 changes: 4 additions & 0 deletions Sources/Observability/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ anonymous analytics, and Sparkle update plumbing.
- `JSONLWriter.swift` — shared append-only JSONL writer that reuses file handles and falls back cleanly if log files are rotated or recreated
- `LockedFileAppender.swift` — cross-process-safe file append helper that serializes writes and uses `flock` so concurrent JSONL/debug-log writers do not interleave records
- `DiagnosticsTrail.swift` — lightweight high-signal diagnostics helper
- `RuntimeDiagnostics.swift` — app runtime heartbeat, dirty-shutdown detection, and active session stage tracking for force quits / silent exits
- `RuntimeDiagnosticsStore.swift` — JSON marker persistence and privacy-safe dirty-shutdown context builder
- `CrashReporter.swift` — crash reporting setup
- `CrashReportingPreferences.swift` — Settings-backed crash reporting preference
- `AnalyticsReporter.swift` — privacy-first anonymous usage analytics to PostHog
Expand All @@ -36,6 +38,7 @@ anonymous analytics, and Sparkle update plumbing.
- `SentryRuntimeConfiguration` rejects non-HTTPS DSNs, so insecure local overrides fail closed instead of downgrading crash transport
- PostHog config is read from `Info.plist` (`TranscriptedPostHogAPIKey`, `TranscriptedPostHogHost`) or process environment (`POSTHOG_API_KEY`, `POSTHOG_HOST`), and anonymous analytics must stay event-allowlisted and bucketed rather than sending raw payloads
- Non-fatal error forwarding to Sentry is allowlisted. New `.error` events should not automatically assume they are safe to send off-device.
- `RuntimeDiagnostics` writes only coarse runtime state under app-owned state. Keep it free of transcript text, raw audio, file paths, device names, meeting titles, and speaker names.
- Update telemetry should keep using `UpdateFailureKind` instead of ad hoc string parsing so dashboards stay stable across Sparkle error wording changes.

## Verification
Expand All @@ -57,6 +60,7 @@ Relevant direct coverage:
- `Tests/SentryPayloadSanitizerTests.swift`
- `Tests/SentryRuntimeConfigurationTests.swift`
- `Tests/ObservabilityLogWriterTests.swift`
- `Tests/RuntimeDiagnosticsStoreTests.swift`
- `Tests/UpdateFailureKindTests.swift`

Useful files while testing:
Expand Down
16 changes: 16 additions & 0 deletions Sources/Observability/CrashReporter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ final class CrashReporter {
)
}

@discardableResult
func captureSupportDiagnosticEvent(extra: [String: String]) -> String? {
captureMessageEvent(
level: .warning,
title: "support_diagnostic_event",
message: "Manual support diagnostic event from Transcripted Settings",
tags: [
"source": "support_diagnostics",
"engine": "support",
"event": "diagnostic_event",
],
extra: extra,
fingerprint: ["support", "diagnostic_event"]
)
}

func captureObservabilityEvent(
level: EventLevel,
engine: String,
Expand Down
Loading