This repo now uses three separate observability lanes:
- Sparkle for app updates
- Sentry for crash and reliability diagnostics
- PostHog for anonymous usage statistics
The point of splitting them is to keep product analytics, crash triage, and release delivery from bleeding into each other.
There is no separate beta-only off-device telemetry path anymore. Beta builds keep the updater flow, but diagnostics still go through the same Sentry and PostHog controls described below.
Sentry DSNs should stay on https://. Non-HTTPS overrides are ignored so local
or bundled config cannot silently downgrade crash reports to plaintext
transport.
Sentry release registration is limited to build metadata: release name, dist,
and commit refs. It must not include transcript text, audio
references, meeting titles, speaker names, local paths, or user identifiers.
- never send transcript text
- never send audio data or audio file references
- never send meeting titles
- never send speaker names
- never send source app names or bundle IDs
- never send absolute file paths
- never send free-form context strings
- never send emails, tokens, or raw URLs
- keep analytics to allowlisted events and coarse buckets only (the reviewed exceptions are dictation and meeting speed timings, rounded to 10 ms; see below)
- keep crash reporting separately user-controllable from anonymous analytics
- Sentry automatic app-hang tracking is on in shipped builds (Info.plist
TranscriptedSentryAppHangTrackingEnabled), but only reports a main thread stuck 5+ seconds, andAppHangReportPolicydrops any hang while a modal popup is on screen (a modal run loop doesn't drain the main queue, so those used to be misreported as hangs). The code default with no Info.plist key stays off
- Keep Sparkle as the in-app updater path
- Keep Sparkle wired into
build.shandbuild-beta.sh - Use the official Sentry Cocoa SDK for stronger crash capture
- Keep Sentry scrubbing and allowlisted non-fatal forwarding in app code
- Add a separate anonymous analytics preference
- Replace TelemetryDeck-specific analytics with PostHog-backed transport
- Restrict analytics to explicit, privacy-reviewed events and properties
- Add fast tests for analytics and Sentry sanitization policy
- Create a PostHog project.
- Keep the release PostHog project API key in
Info.plistunderTranscriptedPostHogAPIKey. - Keep
TranscriptedPostHogHostathttps://us.i.posthog.comunless you are using EU or self-hosted PostHog. - For local-only secrets, you can instead create
~/Library/Application Support/Transcripted/observability-overrides.plistand setTranscriptedPostHogAPIKeythere so the token never lands in git history. The app still falls back to the legacy Draft path if you already have~/Library/Application Support/Draft/observability-overrides.plist. - Run
bash build-deps.sh --forceonce to download the pinned Sentry and Sparkle frameworks. - Run
bash build.sh --no-openandbash run-tests.sh. - In the app, verify onboarding shows two separate default-on questions for:
- crash and error reports
- anonymous usage statistics
- Verify Settings also shows separate toggles for:
- crash and error reports
- anonymous usage statistics
- Use About >
Send diagnosticsto verify Sentry wiring (the dedicatedSend Test Sentry Eventsettings row was removed in the settings simplification). - Leave anonymous usage statistics on and verify only allowlisted events arrive in PostHog.
- App-hang tracking is on in release builds (reviewed 2026-09-25, with the
popup filter).
SENTRY_ENABLE_APP_HANG_TRACKING=falseturns it off for a local run. To check the popup filter, open a modal alert or open panel, leave it up for 10+ seconds, and confirm no "App Hanging" event arrives.
This list should match Resources/analytics-events.psv, which
Sources/Observability/AnalyticsEventPolicy.swift compiles into the runtime
allowlist.
usage_digestreliability_failure_observedapp_launchedapp_unclean_shutdown_detectedapp_session_stall_detectedsupport_diagnostic_event_sentonboarding_shownonboarding_step_viewedonboarding_permission_cta_clickedonboarding_permission_status_changedonboarding_model_state_changedonboarding_primary_cta_clickedonboarding_first_dictation_startedonboarding_first_dictation_savedonboarding_first_dictation_stop_clickedonboarding_first_dictation_emptyonboarding_meeting_dry_run_clickedonboarding_agent_cta_clickedonboarding_reporting_toggle_changedonboarding_completedactivation_artifact_action_clickedactivation_first_artifact_savedactivation_second_artifact_savedactivation_habit_loop_actionedactivation_agent_prompt_action_clickedactivation_agent_setup_cta_clickedagent_setup_lifecycle_observedagent_capture_query_observedactivation_return_proxy_observedworkflow_abandonedworkflow_recovery_attemptedworkflow_recovery_failedworkflow_recovery_finishedproduct_friction_observedmenu_bar_openedmenu_bar_action_clickedupdate_action_clickedupdate_setting_changedupdate_check_finishedupdate_download_startedupdate_download_finishedupdate_ready_to_installupdate_relaunchingupdate_installedsettings_openedsettings_page_viewedsettings_feature_discoveredsettings_action_clickedsettings_toggle_changedsettings_permission_cta_clickedsettings_capture_library_changedlaunch_models_warmeddictation_start_requesteddictation_starteddictation_start_faileddictation_start_dropped_for_modifier_combodictation_completeddictation_paste_retry_completeddictation_artifact_saveddictation_stop_latency_measureddictation_cancelleddictation_no_speechdictation_audio_needs_recoverydictation_other_languagedictation_transcription_faileddictation_recording_too_shortdictation_audio_route_changeddictation_audio_route_recovery_finisheddictation_audio_route_recovery_timeoutdictation_zombie_recovery_finisheddictation_pinned_microphone_recording_starteddictation_pinned_microphone_restarteddictation_pinned_microphone_device_switcheddictation_pinned_microphone_fell_back_to_enginedictation_pinned_microphone_silent_inputmeeting_recording_startedmeeting_recording_start_failedmeeting_system_audio_prompt_answeredmeeting_detected_call_endedmeeting_prompt_shownmeeting_prompt_choice_mademeeting_prompt_dismissedmeeting_prompt_outcome_recordedmeeting_prompt_record_selectedmeeting_prompt_suppressedmeeting_mic_boost_prompt_shownmeeting_mic_boost_prompt_actionedmeeting_missed_call_nudgemeeting_recording_stoppedmeeting_capture_health_snapshotmeeting_capture_route_warning_shownmeeting_capture_stopped_under_controllermeeting_recording_cancelledmeeting_file_importedmeeting_file_import_failedmeeting_transcript_savedmeeting_transcript_failedmeeting_speaker_auto_recognizedmeeting_speaker_finalization_failedmeeting_speaker_match_reviewedmeeting_speaker_review_shownmeeting_speaker_review_submittedmeeting_transcript_skippedmeeting_saved_audio_retranscription_requestedwriting_daily_countswriting_setup_completed
- booleans as
"true"/"false" - coarse buckets like
10_29s,50_149,4_plus - stable trigger enums like
hotkey,menu,detected_prompt - retry attempt buckets like
start_attempt_bucket, not raw retry counts - normalized failure kinds like
system_audio,recording_too_short,other - normalized failure-code buckets like
url_-1009,sparkle_2003,other_42 - feature discovery enums like
agent_setup,capture_library,permissions,speaker_review, andupdate_settings - workflow recovery fields limited to
workflow_kind,failure_kind,retry_source,recovery_attempt_bucket,surface,artifact_retained,result, andelapsed_bucket - product friction fields limited to
surface,stage,result,failure_kind,elapsed_bucket,route_shape, andmodel_state - meeting prompt decision/outcome fields limited to
prompt_reason,source,provider,call_state,route_ready,calendar_confidence,choice_kind,outcome_kind,elapsed_bucket, andsuppression_reason - artifact-action analytics limited to
artifact_kind,action_kind,surface,artifact_age_bucket,result,trigger,word_count_bucket, andduration_bucket - paste retry analytics limited to
resultand a coarsereason; never text, capture identifiers, or target-app identifiers - agent capture-query analytics limited to one terminal event with
client_family,tool_kind,capture_kind,result,source_count_bucket,result_count_bucket,latency_bucket, and validated owning-app build identity; never query text, capture IDs, titles, names, transcript text, paths, or user IDs - pinned-device mic rollout fields limited to
mic_backend(pinned_ioproc/av_audio_engine),selection_reason,selected_input_class,selection_overrode_default,start_latency_bucket,restart_trigger,stage,action, the meeting-onlypinned_mic_padded_bucket, andpinned_mic_restart_bucket,pinned_mic_gap_bucket, andpinned_mic_dropped_callback_bucketon meetings and on the dictationempty_takesfallback. That fallback (a built-in or wired mic moved back to the engine after two empty held takes) also carriesinput_channels(1-64) andinput_rate_hz(a fixed rate set, elseother). Thedictation_pinned_microphone_*events are forwarded from localEventReporterevents byAnalyticsEventForwardingPolicy, which rebuilds every value from a fixed set; raw pinned counts and the mic's name and UID stay in local logsrestart_trigger,stage,action, and the meeting-onlypinned_mic_restart_bucket,pinned_mic_gap_bucket,pinned_mic_padded_bucket, andpinned_mic_dropped_callback_bucket. Thedictation_pinned_microphone_*events are forwarded from localEventReporterevents byAnalyticsEventForwardingPolicy, which rebuilds every value from a fixed set; raw pinned counts stay in local logs - Writing analytics limited to
save_enabled,autocomplete_enabled,app_scope(all/picked), andmodel_choice(gemma_e2b/qwen_9b) onwriting_setup_completed, plus, onwriting_daily_counts, the previous local day'ssuggestions_shown,suggestions_accepted, andwords_accepted_bucket.Sources/Writing/WritingAnalytics.swiftbuilds them from the text-free outcome ledger summary, sends at most one daily event, and skips a day with nothing shown or accepted. Never text, app names, bundle IDs, or per-suggestion events Meeting workflow analytics should keep that same stabletriggerenum on later stop/save/fail events so product and reliability reviews can attribute outcomes without joining against any sensitive context.
Anything richer than that should stay local unless there is a new explicit privacy review and a matching allowlist change.
Every analytics change should update Resources/analytics-events.psv,
Resources/analytics-reviewed-properties.psv when new non-bucket properties
are introduced, and this doc in the same PR. Tests/AnalyticsEventPolicyTests.swift
machine-checks the event-name list above and the compiled property taxonomy.
For each new or changed event:
- document the event name in "Allowlisted analytics events"
- keep the registry normalized with
python3 scripts/ops/normalize-analytics-taxonomy.py --check - keep event names stable once released; add a new event instead of changing the meaning of an old one
- use enum, bucket, boolean, or count-bucket properties whenever possible
- use raw numeric diagnostics only for reviewed audio-health shape, such as sample rate, channel count, scalar volume, or peak buckets needed to debug capture reliability
- dictation speed is the other reviewed raw-number case:
dictation_startedcarriesstart_latency_ms, anddictation_stop_latency_measuredcarriesfirst_sound_latency_ms(key press to first audio buffer),decode_latency_ms, andstop_to_paste_latency_ms, all rounded to 10 ms byMachineClassTelemetry.roundedMilliseconds. Both events, anddictation_start_requestedso the attempt funnel stays comparable, also carrystt_model(theTranscriptionModelChoiceraw value),mac_chip(chip family and tier from the CPU brand string, such asm2_pro, elseunknown), andmemory_gb_bucket. These let PostHog compute P50/P95/P99 per model and per kind of Mac; none of them identifies a user or a machine - meeting processing speed is the third:
meeting_transcript_savedcarriesprocessing_ms(job start to saved, wall clock),sleep_ms(the part the Mac slept), and the stage timesmodels_ready_ms,resample_ms,diarize_ms, andstt_ms, all rounded to 10 ms;stt_calls(speech-to-text calls, one per speech segment) andstt_input_seconds(audio seconds fed to them, whole seconds);recording_minutes(whole minutes); plusstt_model,mac_chip, andmemory_gb_bucket.MeetingPipelineTimingscollects them in Core andMeetingProcessingTelemetryformats them. Durations and counts only - Writing usage is the fourth:
writing_daily_countscarries the rawsuggestions_shownandsuggestions_acceptedtotals for one whole local day, read from the text-free outcome ledger. Accepted words go out only aswords_accepted_bucket, on theword_count_bucketboundaries - route activation and return-loop events through
ActivationTelemetrywhen possible so saved-artifact and agent-payoff signals stay coarse - verify
bash run-tests.sh --filter AnalyticsEventPolicyandbash run-tests.sh --filter AnalyticsPayloadSanitizer
Never add analytics properties for:
- transcript text or prompt text
- audio data, audio paths, or audio references
- meeting titles
- speaker names or invitee names
- absolute file paths or filenames derived from user content
- source app names or bundle IDs
- screen content, screenshots, OCR text, app names, window titles, or raw bundle IDs
- emails, tokens, authorization values, or credentials
- raw URLs or referrers
- raw device IDs, advertising IDs, person IDs, user IDs, distinct IDs, or identity-stitching fields
- free-form error strings or free-form context blobs
The nightly security automation should start with the deterministic checker:
python3 scripts/ops/nightly-security-check.py --write-report build/nightly-security-report.jsonThat report is the first pass, not the whole job. It should score the current state, flag repo/release/privacy drift, and only then hand the run off to agent judgment for a small high-confidence patch or a findings note.
When Info.plist has been bumped one patch version ahead for a release
candidate but the matching Git tag does not exist yet, the appcast should stay
on the latest published release. The checker reports that as a watch item, not
a release-integrity failure.
If the run built a fresh app or needs build-output verification, rerun it with:
python3 scripts/ops/nightly-security-check.py --app-bundle build/Transcripted.app --write-report build/nightly-security-report.jsonFor a focused local privacy sweep, run:
python3 scripts/ops/privacy-leak-sweep.py --write-report build/privacy-leak-sweep-report.jsonThat command uses synthetic values only. It covers logs/events/reliability JSONL, Sentry/PostHog payloads, QA and local report text, PR/release text, and the scanner handoff summary shape.
The shared regression corpus for off-device scrubbers lives at
Tests/Fixtures/ObservabilitySanitizerCorpus.json. Both the Sentry and
analytics sanitizer tests should stay pinned to that same corpus so privacy
coverage does not drift quietly between the two lanes.
Meeting capture diagnostics add three fixed, non-identifying descriptors:
capture_health_scope=own_capture: health describes Transcripted's captured buffers and artifacts.cross_app_capture_status=unmeasured: no observation of another app's microphone transmission is made.output_ducking_measurement=hardware_volume_scalars: the legacyoutput_ducking_detectedflag compares sampled hardware volume scalars; it does not measure software attenuation or another app's output.
Existing events, grades, and the legacy flag keep their semantics. An excellent
capture with output_ducking_detected=false cannot certify another app's audio.
Missing descriptors on older events mean legacy data, not measured compatibility.
These descriptors accompany existing capture diagnostics in local logs, support
packets and the already-allowlisted meeting analytics/failure events. They add no
process identity, audio content, hardware reads, or newly forwarded events. Live audio
compatibility requires the receiving-participant checks in
Meeting Audio QA.
The app reuses its persisted anonymous install UUID as PostHog distinct_id and
Sentry user.id. The Sentry sanitizer replaces the full user object with this ID
only. Analytics-enabled captures update a PostHog person through a fixed $set
object: analytics_opt_in, app_version, build_revision, os_major,
install_channel, and first_launch_at (UTC day). For existing installs the first
launch day means first observed by this version, not the original installation.
No email is collected. GeoIP enrichment is disabled on new PostHog requests.
Every app analytics event can carry the common TelemetryContext.keys allowlist:
session_id, correlation_id, app_version, build_revision, os_major,
input_device_class, output_device_class, selection_reason, trigger, and
microphone/screen/accessibility permission booleans. Session and correlation IDs
must be app-generated UUIDs. Missing route observations are explicitly unknown;
a missing observation is not evidence of a healthy route or a denied permission.
Screen permission reflects the app's cached System Audio Recording grant.
Failure, friction, and health events also carry failure_kind and failure_stage.
Health snapshots always carry quality_reason and capture_outcome; cancelled
captures have their own outcome, and a "Record Just My Mic" meeting reports
mic_only_by_choice rather than complete. none means no failure; unknown means missing
measurement. Every allowlisted Sentry hard failure has a matching
reliability_failure_observed PostHog record using the exact same correlation ID
and taxonomy, even when the low-level failure has no product lifecycle event.
Product lifecycle failures remain available for funnel analysis; do not sum them
with their canonical reliability counterparts. Meeting start, transcription,
speaker finalization, and dictation microphone timeout preserve the same operation
ID across their existing lifecycle reports too.
Capture degradation is a local warning and a PostHog health observation. It is never a Sentry error, even if an old producer accidentally requests error level. Hard start, transcript, audio-loss, stop-timeout, and engine-loop failures keep their existing Sentry path. No audio-quality or routing policy changes here.
The existing Usage stats toggle controls analytics collection. A local metadata
ledger supplies the daily digest and recent failure details for support diagnostics.
fair capture grades join degraded; discarded captures do not count as successful
quality outcomes. Missing quality measurements remain explicitly unknown.
The local ledger retains at most 14 local days and three failure summaries in preferences. It consumes reviewed lifecycle metadata, not capture files or logs. It stores aggregate rounded minutes and a histogram of dictation duration buckets, not individual durations. Matching failure kind + correlation ID is counted once across the canonical and lifecycle reports. Copy diagnostics and support drafts include install UUID, release, OS, permissions, coarse runtime/route state, and last failure taxonomy; they no longer append raw event or reliability-log text.
usage_digest is emitted for closed local days on launch or the minute timer,
and for an unsent current day on normal quit. A quit snapshot has
digest_is_partial=true: later activity after a same-day relaunch is still visible
in lifecycle events and the local ledger but does not generate a second digest.
digest_day identifies the activity's local date; timestamps identify delivery.
Calendar arithmetic handles local midnight and DST rather than adding 24 hours.
Digest fields meetings_started, meetings_completed, dictations_completed,
and values inside failures_by_kind / capture_quality_counts use the shared
count buckets 0, 1, 2_3, 4_9, 10_plus. meeting_minutes_bucket uses
0, 1_14m, 15_59m, 1_2h, 3_9h, 10h_plus.
dictation_median_duration_bucket is the lower median bin of the duration
histogram, or none when no dictations completed. No exact count or duration is
sent by the digest. This deliberately follows the brief's bucket-only hard rule.
The two aggregate maps are encoded as JSON objects, never free-form JSON strings.
Each digest is persisted in the existing bounded retry buffer before its day is
marked enqueued. Retries keep the same top-level uuid, event, timestamp, and distinct ID, so a retry after an uncertain
HTTP response does not intentionally count the rollup twice. Transport remains
best effort: digests expire after 14 days, ordinary events after 24 hours.
Up to 14 digests receive priority over lifecycle traffic within the same
100-record / 64 KiB file bound. Off disables new events/person properties/digests,
purges unsent captures, and clears the local usage ledger. Up to 14 date-only
enqueue receipts remain to prevent duplicate same-day digests after re-enabling;
they contain no IDs or activity counts. Crash reporting retains its separate
preference. No vendor, recording behavior, or release configuration changes.
- Exercise a failure with a controlled test install. Match Sentry
user.idto PostHogdistinct_id, andcorrelation_id+failure_kindon the Sentry event,reliability_failure_observed, and the corresponding lifecycle failure. - Break down new-build health snapshots by
quality_reasonandcapture_outcome; both must be present. Separate older releases when assessing null rates. - Complete a degraded capture: the PostHog snapshot and local warning remain,
but
meeting.recording_capture_degradedis absent from Sentry errors. - Compare unique users across the whole selected window; never sum daily DAU. Do not add canonical failure counts to the same lifecycle failures.
- Check Settings, copy diagnostics, then turn Usage stats off. Reopen and quit: there must be no additional analytics/person/digest requests after opt-out.
- Check local-day rollover, repeated quit/relaunch, retry after a network failure, and nested count maps. Synthetic local tests validate plumbing, not production ingestion or physical audio behavior. Fleet changes require deployment.
PostHog's capture API and
person properties
provide the wire contract for $set and event-based anonymous install profiles.
Every capture disables GeoIP enrichment with $geoip_disable=true. Request transport IP handling is a server setting: PostHog's Discard IP data setting should be verified separately; a client-side ip: false option does not provide that guarantee.