FluidVoice Analytics Service Architecture and Event Tracking Implementation

FluidVoice implements a privacy-focused, client-side analytics pipeline using a singleton service facade that dispatches events to an actor-based core, which buffers and flushes data to PostHog in fire-and-forget batches.

FluidVoice is a macOS dictation application that collects usage metrics through a lightweight, entirely client-side architecture. The system leverages Swift's actor model to ensure thread-safe event queuing without blocking the UI thread, while respecting user consent through a persistent opt-out mechanism. This implementation is contained within the altic-dev/FluidVoice repository and utilizes strongly-typed events with a bounded in-memory queue.

Core Architecture Components

The analytics stack consists of several coordinated components that handle everything from consent management to network transmission.

AnalyticsService (Singleton)

The AnalyticsService serves as the public façade throughout the application. Located in Sources/Fluid/Analytics/AnalyticsService.swift, this singleton manages the user's consent status, builds common properties (app version, OS version, hardware snapshot), and forwards events to the core processing layer. It checks the SettingsStore for the "Share Anonymous Analytics" flag before transmitting any data and generates an anonymous install identifier via AnalyticsIdentityStore.

AnalyticsCore (Actor)

Within the same file, a private actor AnalyticsCore runs off the main thread to prevent UI blocking. The actor maintains a bounded in-memory queue with a capacity of 200 events (maxQueuedEvents). It triggers a flush when the queue reaches 20 events (flushAt) or when a periodic timer fires every 30 seconds (flushIntervalSeconds). The core constructs JSON batches and dispatches them to the PostHog endpoint using a fire-and-forget URLSession request with an 8-second timeout.

AnalyticsConfig

The AnalyticsConfig struct, defined in Sources/Fluid/Analytics/AnalyticsConfig.swift, reads PostHog credentials from the app's Info.plist bundle. It provides a default EU-based host and exposes an isConfigured property to verify that valid API keys are present before initialization.

AnalyticsEvent Enum

All event names are strongly typed in AnalyticsEvent (located in Sources/Fluid/Analytics/AnalyticsEvent.swift). This enumeration guarantees low-cardinality schema consistency across the codebase, preventing typos in event strings and enabling compile-time checking.

User preferences are persisted in SettingsStore.swift, which maintains the opt-out toggle. The AnalyticsIdentityStore generates a stable anonymousInstallID for each installation, ensuring unique user tracking without collecting personally identifiable information.

Data Flow from Capture to PostHog

The pipeline operates through four distinct phases from application startup to network transmission.

  1. Bootstrap – During app initialization, AnalyticsService.bootstrap() is called (typically from AppDelegate.swift). This method reads the consent flag, loads the configuration, and starts the core's flush loop only if analytics are enabled.

  2. Capture – Any component calls AnalyticsService.shared.capture(event, properties:). The method constructs common low-cardinality properties, merges caller-provided context, verifies consent, and dispatches an asynchronous task to AnalyticsCore.capture.

  3. Core Handling – The actor stores the event in its bounded queue. When the batch threshold or timer triggers, the core drains the queue and calls sendBatch.

  4. Network Transmission – The sendBatch method constructs a JSON payload containing the API key, host, and event batch, then executes a detached background task to POST to /batch. No response is awaited, keeping the UI completely non-blocking.

Complete List of Tracked Events

The AnalyticsEvent enum enumerates all trackable interactions, organized by functional category.

App Lifecycle Events:

  • app_first_open – Recorded on the application's first launch
  • app_open – Recorded on subsequent launches
  • analytics_consent_changed – Fired when the user toggles the anonymous analytics switch in SettingsView.swift

Dictation and Transcription Events:

  • transcription_completed – A dictation run finishes (captured in ASRService)
  • transcription_chunk_processed – A streaming audio chunk is processed
  • dictation_post_processing_completed – AI post-processing finishes
  • output_delivered – Final text is displayed or copied to clipboard
  • post_transcription_edit – User edits a completed transcription (tracked by PostTranscriptionEditTracker)

Mode-Specific Events:

Error Tracking:

  • error_occurred – Captures unexpected errors from ASR, LLM, or hotkey subsystems

Code Examples: Emitting Events

Developers integrate analytics by calling the singleton with strongly-typed events and optional property dictionaries.

Record a completed dictation with duration and word count buckets:

// From ContentView.swift
AnalyticsService.shared.capture(
    .transcriptionCompleted,
    properties: [
        "duration_seconds": duration,
        "words_bucket": AnalyticsBuckets.bucketWords(wordCount),
        "audio_duration_bucket": AnalyticsBuckets.bucketSeconds(audioDuration)
    ]
)

Track a rewrite operation with latency metrics:

// From RewriteModeService.swift
AnalyticsService.shared.capture(
    .rewriteRunCompleted,
    properties: [
        "mode": AnalyticsMode.rewrite.rawValue,
        "method": AnalyticsOutputMethod.typed.rawValue,
        "latency_bucket": AnalyticsBuckets.bucketSeconds(Date().timeIntervalSince(startTime))
    ]
)

Log consent changes with the new state:

// From SettingsView.swift
AnalyticsService.shared.capture(
    .analyticsConsentChanged,
    properties: ["enabled": enabled]
)

Summary

  • FluidVoice uses a client-side AnalyticsService singleton that dispatches to an actor-based core for thread-safe, non-blocking operation.
  • The system respects user privacy through a SettingsStore-backed opt-out mechanism and anonymousInstallID tracking.
  • Events are strongly typed via the AnalyticsEvent enum and flushed to PostHog in batches of 20 or every 30 seconds.
  • The architecture supports 14 distinct event types covering app lifecycle, dictation flows, command modes, and error conditions.
  • All network requests are fire-and-forget with 8-second timeouts to ensure zero UI latency.

Frequently Asked Questions

The service checks a persistent flag stored in SettingsStore.swift before transmitting any events. When the user toggles the "Share Anonymous Analytics" switch in the settings UI, the app emits an analytics_consent_changed event and immediately respects the new preference, halting or resuming the flush loop accordingly.

What are the queue limits and flush intervals?

The AnalyticsCore actor maintains a bounded queue capable of holding 200 events. It automatically flushes when the queue reaches 20 events or when 30 seconds have elapsed since the last transmission, whichever occurs first. This prevents memory bloat while ensuring timely data delivery.

Which analytics provider does FluidVoice use?

The pipeline sends data to PostHog using the standard /batch endpoint. Configuration including the API key and host URL is read from Info.plist via AnalyticsConfig.swift, with a default EU host provided for GDPR compliance.

How are events structured to maintain data quality?

All event names are defined as cases in the AnalyticsEvent enum, ensuring compile-time safety and low-cardinality schemas. The AnalyticsService automatically injects common properties (app version, OS version, hardware model) into every payload, while callers provide only context-specific data through strongly-typed dictionaries.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →