From 06429d2c41f1b945e30ad956f787f19dfd19d6c9 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 5 Oct 2026 21:47:47 +0000 Subject: [PATCH 1/2] Dictation telemetry: time Cmd+V, the confirm wait, and the recorder stop_to_paste_latency_ms includes the paste confirmation wait (up to 0.35 s after the text has usually landed), and nothing says which recorder a take used, so slow mic stops can't be split by backend. dictation_stop_latency_measured now also carries: - stop_to_paste_dispatch_latency_ms: stop to Cmd+V sent (10 ms rounded) - paste_confirm_bucket, checkpoint_bucket, resample_bucket - mic_backend: pinned_ioproc or engine, read before the mic stops Registry, reviewed-properties list, privacy doc and the policy test move in lockstep. No behavior change. Claude-Session: https://claude.ai/code/session_01DEL5vVKCBRYCvxaT5GS1nA --- Resources/analytics-events.psv | 2 +- Resources/analytics-reviewed-properties.psv | 1 + Sources/Speech/ParakeetRecordingTeardown.swift | 5 +++++ .../UI/Overlay/DictationSessionController+Stop.swift | 1 + .../Overlay/DictationSessionController+Telemetry.swift | 9 +++++++++ Sources/UI/Overlay/DictationSessionDeliveryTypes.swift | 2 ++ Tests/AnalyticsEventPolicyTests.swift | 10 ++++++++++ docs/privacy-first-observability.md | 4 +++- 8 files changed, 32 insertions(+), 2 deletions(-) diff --git a/Resources/analytics-events.psv b/Resources/analytics-events.psv index 8b270b9ae..ff39466dc 100644 --- a/Resources/analytics-events.psv +++ b/Resources/analytics-events.psv @@ -44,7 +44,7 @@ dictation_start_dropped_for_modifier_combo|duration_bucket,trigger dictation_start_failed|default_input_class,default_output_class,failure_kind,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,start_attempt_bucket,trigger,was_recording dictation_start_requested|default_input_class,default_output_class,first_since_launch,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,mac_chip,memory_gb_bucket,model_state,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,start_retry,stt_model,trigger,was_recording dictation_started|default_input_class,default_output_class,first_since_launch,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,mac_chip,memory_gb_bucket,output_channels,output_device_class,output_rate_hz,recovering,recovery_latency_bucket,route_shape,sample_flow_started,selected_input_class,selection_overrode_default,selection_reason,start_latency_bucket,start_latency_ms,stt_model,trigger,was_recording -dictation_stop_latency_measured|auto_enter_bucket,auto_send,auto_send_block_reason,auto_send_expected,auto_send_key,cleanup_bucket,cleanup_changed,cleanup_enabled,copy_reason,decode_bucket,decode_latency_ms,default_input_class,default_output_class,delivery,first_sound_latency_bucket,first_sound_latency_ms,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,mac_chip,memory_gb_bucket,mic_stop_bucket,model_wait_bucket,outcome,output_channels,output_device_class,output_rate_hz,paste_bucket,recovering,recovery_latency_bucket,route_shape,sample_flow_started,save_bucket,save_outcome,selected_input_class,selection_overrode_default,selection_reason,stop_to_done_bucket,stop_to_paste_bucket,stop_to_paste_latency_ms,stt_model,target_confirmation_mode,trigger,was_recording,word_count_bucket +dictation_stop_latency_measured|auto_enter_bucket,auto_send,auto_send_block_reason,auto_send_expected,auto_send_key,checkpoint_bucket,cleanup_bucket,cleanup_changed,cleanup_enabled,copy_reason,decode_bucket,decode_latency_ms,default_input_class,default_output_class,delivery,first_sound_latency_bucket,first_sound_latency_ms,format_ready,hfp_suspected,input_channels,input_device_class,input_rate_hz,mac_chip,memory_gb_bucket,mic_backend,mic_stop_bucket,model_wait_bucket,outcome,output_channels,output_device_class,output_rate_hz,paste_bucket,paste_confirm_bucket,recovering,recovery_latency_bucket,resample_bucket,route_shape,sample_flow_started,save_bucket,save_outcome,selected_input_class,selection_overrode_default,selection_reason,stop_to_done_bucket,stop_to_paste_bucket,stop_to_paste_dispatch_latency_ms,stop_to_paste_latency_ms,stt_model,target_confirmation_mode,trigger,was_recording,word_count_bucket dictation_transcription_failed|duration_bucket,route_shape,trigger dictation_zombie_recovery_finished|failure_kind,hfp_suspected,input_device_class,output_device_class,result,route_shape,stage launch_models_warmed|dictation_ready,meeting_recording_ready,warmup_latency_bucket diff --git a/Resources/analytics-reviewed-properties.psv b/Resources/analytics-reviewed-properties.psv index da4e96040..95026ba1c 100644 --- a/Resources/analytics-reviewed-properties.psv +++ b/Resources/analytics-reviewed-properties.psv @@ -222,6 +222,7 @@ state step_id step_index stop_timed_out +stop_to_paste_dispatch_latency_ms stop_to_paste_latency_ms stt_calls stt_input_seconds diff --git a/Sources/Speech/ParakeetRecordingTeardown.swift b/Sources/Speech/ParakeetRecordingTeardown.swift index 471174c21..40d4c1aa7 100644 --- a/Sources/Speech/ParakeetRecordingTeardown.swift +++ b/Sources/Speech/ParakeetRecordingTeardown.swift @@ -234,6 +234,11 @@ extension ParakeetEngine: ParakeetStopRecordingHost { var hasSharedMeetingMicClaim: Bool { sharedMeetingMicClaim != nil } var isSharedMeetingMicResumeInProgress: Bool { sharedMeetingMicTransition.isResumeInProgress } var hasPinnedDictationRecording: Bool { pinnedDictationRecording != nil } + /// The recorder the current take uses, for stop timing (`pinned_ioproc` + /// or `engine`). Read it before the stop clears the pinned recording. + var dictationMicBackendName: String { + hasPinnedDictationRecording ? PinnedMicrophoneCapture.diagnosticBackendName : "engine" + } var activeConfigRecoveryGeneration: UInt64? { recoveryState.isRecovering ? recoveryState.generation : nil diff --git a/Sources/UI/Overlay/DictationSessionController+Stop.swift b/Sources/UI/Overlay/DictationSessionController+Stop.swift index a469ac97a..676e35b7c 100644 --- a/Sources/UI/Overlay/DictationSessionController+Stop.swift +++ b/Sources/UI/Overlay/DictationSessionController+Stop.swift @@ -195,6 +195,7 @@ extension DictationSessionController { Task { await checkpointSignal.complete() } } var stopTiming = DictationStopTiming(requestedAt: stopRequestedAt) + stopTiming.micBackend = appState.sttRouter.parakeetEngine.dictationMicBackendName // runStopUntilTranscribed (DictationSessionPipeline.swift) owns the // order: the stale-task fence, then DictationStopCheckpoint (stop // the mic, play the stop click, save the take to a private WAV), diff --git a/Sources/UI/Overlay/DictationSessionController+Telemetry.swift b/Sources/UI/Overlay/DictationSessionController+Telemetry.swift index 4951cc536..286160b18 100644 --- a/Sources/UI/Overlay/DictationSessionController+Telemetry.swift +++ b/Sources/UI/Overlay/DictationSessionController+Telemetry.swift @@ -228,10 +228,13 @@ extension DictationSessionController { ).rawValue let timingBuckets: [(metric: String, bucket: String)] = [ ("stop_to_mic_stop_ms", "mic_stop_bucket"), + ("snapshot_resample_ms", "resample_bucket"), + ("recovery_checkpoint_ms", "checkpoint_bucket"), ("model_wait_ms", "model_wait_bucket"), ("decode_ms", "decode_bucket"), ("cleanup_ms", "cleanup_bucket"), ("paste_ms", "paste_bucket"), + ("paste_confirmation_wait_ms", "paste_confirm_bucket"), ("auto_enter_ms", "auto_enter_bucket"), ("save_ms", "save_bucket"), ("stop_to_paste_ms", "stop_to_paste_bucket"), @@ -246,6 +249,9 @@ extension DictationSessionController { let exactTimings: [(metric: String, key: String)] = [ ("decode_ms", "decode_latency_ms"), ("stop_to_paste_ms", "stop_to_paste_latency_ms"), + // When Cmd+V went out: the text usually shows here, before the + // confirmation wait that stop_to_paste includes. + ("stop_to_paste_dispatch_ms", "stop_to_paste_dispatch_latency_ms"), ] for exactTiming in exactTimings { guard let milliseconds = measurements[exactTiming.metric] else { continue } @@ -255,6 +261,9 @@ extension DictationSessionController { analyticsProperties["first_sound_latency_bucket"] = AnalyticsReporter.latencyBucket(milliseconds: firstSoundMs) analyticsProperties["first_sound_latency_ms"] = MachineClassTelemetry.roundedMilliseconds(firstSoundMs) } + if let micBackend = timing.micBackend { + analyticsProperties["mic_backend"] = micBackend + } analyticsProperties.merge(dictationSpeedContext(appState: appState)) { current, _ in current } AnalyticsReporter.track( diff --git a/Sources/UI/Overlay/DictationSessionDeliveryTypes.swift b/Sources/UI/Overlay/DictationSessionDeliveryTypes.swift index 858728082..d6e4642bb 100644 --- a/Sources/UI/Overlay/DictationSessionDeliveryTypes.swift +++ b/Sources/UI/Overlay/DictationSessionDeliveryTypes.swift @@ -5,6 +5,8 @@ import AppKit struct DictationStopTiming { let requestedAt: CFAbsoluteTime + /// Which recorder this take used, read before the mic stops. + var micBackend: String? var micStoppedAt: CFAbsoluteTime? var snapshotStartedAt: CFAbsoluteTime? var snapshotFinishedAt: CFAbsoluteTime? diff --git a/Tests/AnalyticsEventPolicyTests.swift b/Tests/AnalyticsEventPolicyTests.swift index 508b11bc4..f689cc5a7 100644 --- a/Tests/AnalyticsEventPolicyTests.swift +++ b/Tests/AnalyticsEventPolicyTests.swift @@ -1088,6 +1088,11 @@ func testAnalyticsEventPolicy() { "stop_to_paste_bucket": "500_999ms", "stop_to_paste_ms": "621", "stop_to_paste_latency_ms": "620", + "stop_to_paste_dispatch_latency_ms": "270", + "paste_confirm_bucket": "250_499ms", + "checkpoint_bucket": "lt_100ms", + "resample_bucket": "lt_100ms", + "mic_backend": "pinned_ioproc", "stt_model": "parakeet-tdt-v3", "target_confirmation_mode": "clipboard_read_only", "trigger": "physical_key", @@ -1108,6 +1113,11 @@ func testAnalyticsEventPolicy() { assertEqual(sanitized["decode_latency_ms"], "420", "10 ms decode timing should survive for per-model percentiles") assertEqual(sanitized["stop_to_paste_latency_ms"], "620", "10 ms stop-to-paste timing should survive") assertEqual(sanitized["first_sound_latency_ms"], "200", "10 ms key-to-first-sound timing should survive") + assertEqual(sanitized["stop_to_paste_dispatch_latency_ms"], "270", "10 ms stop-to-Cmd+V timing should survive") + assertEqual(sanitized["paste_confirm_bucket"], "250_499ms", "the bucketed paste confirmation wait should survive") + assertEqual(sanitized["checkpoint_bucket"], "lt_100ms", "the bucketed recovery checkpoint should survive") + assertEqual(sanitized["resample_bucket"], "lt_100ms", "the bucketed stop resample should survive") + assertEqual(sanitized["mic_backend"], "pinned_ioproc", "which recorder the take used should survive") assertEqual(sanitized["first_sound_latency_bucket"], "100_249ms", "bucketed key-to-first-sound timing should survive") assertEqual(sanitized["stt_model"], "parakeet-tdt-v3", "the speech model id should survive") assertEqual(sanitized["mac_chip"], "m2_pro", "the coarse chip family should survive") diff --git a/docs/privacy-first-observability.md b/docs/privacy-first-observability.md index fb15c3a83..25e843882 100644 --- a/docs/privacy-first-observability.md +++ b/docs/privacy-first-observability.md @@ -281,7 +281,9 @@ For each new or changed event: - dictation speed is the other reviewed raw-number case: `dictation_started` carries `start_latency_ms`, and `dictation_stop_latency_measured` carries `first_sound_latency_ms` (key press to first audio buffer), - `decode_latency_ms`, and `stop_to_paste_latency_ms`, all rounded to 10 ms + `decode_latency_ms`, `stop_to_paste_latency_ms`, and + `stop_to_paste_dispatch_latency_ms` (stop to Cmd+V sent, before the paste + confirmation wait), all rounded to 10 ms by `MachineClassTelemetry.roundedMilliseconds`. Both events, and `dictation_start_requested` so the attempt funnel stays comparable, also carry `stt_model` (the `TranscriptionModelChoice` raw value), `mac_chip` (chip From 8894a796b921d79bc85d040ec537b01ae2300898 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Mon, 5 Oct 2026 22:59:02 +0000 Subject: [PATCH 2/2] Dictation telemetry: tag takes on the shared meeting mic A dictation during a meeting rides the meeting's mic, not the engine, so mic_backend now says shared_meeting_mic for those takes instead of engine. Claude-Session: https://claude.ai/code/session_01DEL5vVKCBRYCvxaT5GS1nA --- Sources/Speech/ParakeetRecordingTeardown.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Speech/ParakeetRecordingTeardown.swift b/Sources/Speech/ParakeetRecordingTeardown.swift index 40d4c1aa7..86c3ccff8 100644 --- a/Sources/Speech/ParakeetRecordingTeardown.swift +++ b/Sources/Speech/ParakeetRecordingTeardown.swift @@ -234,10 +234,11 @@ extension ParakeetEngine: ParakeetStopRecordingHost { var hasSharedMeetingMicClaim: Bool { sharedMeetingMicClaim != nil } var isSharedMeetingMicResumeInProgress: Bool { sharedMeetingMicTransition.isResumeInProgress } var hasPinnedDictationRecording: Bool { pinnedDictationRecording != nil } - /// The recorder the current take uses, for stop timing (`pinned_ioproc` - /// or `engine`). Read it before the stop clears the pinned recording. + /// The recorder the current take uses, for stop timing (`pinned_ioproc`, + /// `shared_meeting_mic` or `engine`). Read it before the stop clears them. var dictationMicBackendName: String { - hasPinnedDictationRecording ? PinnedMicrophoneCapture.diagnosticBackendName : "engine" + if hasPinnedDictationRecording { return PinnedMicrophoneCapture.diagnosticBackendName } + return hasSharedMeetingMicClaim ? "shared_meeting_mic" : "engine" } var activeConfigRecoveryGeneration: UInt64? {