Skip to content
4 changes: 4 additions & 0 deletions Fluid.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
803000000000000000000003 /* MediaRemoteAdapter in Frameworks */ = {isa = PBXBuildFile; productRef = 803000000000000000000004 /* MediaRemoteAdapter */; };
B51800000000000000000002 /* SpokenSendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51800000000000000000001 /* SpokenSendTests.swift */; };
B51900000000000000000002 /* PrivateAIProviderPromptFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51900000000000000000001 /* PrivateAIProviderPromptFormatTests.swift */; };
FEED67600000000000000002 /* PasteDeliveryCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED67600000000000000001 /* PasteDeliveryCoordinatorTests.swift */; };
7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; };
7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; };
7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; };
Expand Down Expand Up @@ -70,6 +71,7 @@
DA7100010000000000000001 /* DirectAudioReliabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DirectAudioReliabilityTests.swift; sourceTree = "<group>"; };
B51800000000000000000001 /* SpokenSendTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpokenSendTests.swift; sourceTree = "<group>"; };
B51900000000000000000001 /* PrivateAIProviderPromptFormatTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateAIProviderPromptFormatTests.swift; sourceTree = "<group>"; };
FEED67600000000000000001 /* PasteDeliveryCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteDeliveryCoordinatorTests.swift; sourceTree = "<group>"; };
7C078D8F2E3B339200FB7CAC /* FluidVoice Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "FluidVoice Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; };
7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyShortcutTests.swift; sourceTree = "<group>"; };
7CDB0A202F3C4D5600FB7CAD /* FluidDictationIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FluidDictationIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand Down Expand Up @@ -161,6 +163,7 @@
803000000000000000000001 /* MediaPlaybackServiceTests.swift */,
B51800000000000000000001 /* SpokenSendTests.swift */,
B51900000000000000000001 /* PrivateAIProviderPromptFormatTests.swift */,
FEED67600000000000000001 /* PasteDeliveryCoordinatorTests.swift */,
);
path = FluidDictationIntegrationTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -332,6 +335,7 @@
803000000000000000000002 /* MediaPlaybackServiceTests.swift in Sources */,
B51800000000000000000002 /* SpokenSendTests.swift in Sources */,
B51900000000000000000002 /* PrivateAIProviderPromptFormatTests.swift in Sources */,
FEED67600000000000000002 /* PasteDeliveryCoordinatorTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
151 changes: 150 additions & 1 deletion Sources/Fluid/Analytics/AnalyticsDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,72 @@ final class AnalyticsDatabase {
}
}

func recordInsertionLatency(
path: AnalyticsInsertionPath,
outcome: AnalyticsInsertionOutcome,
requestMilliseconds: Int,
readyMilliseconds: Int?,
toggleStopMilliseconds: Int? = nil,
at date: Date
) throws {
try self.finalizeDays(before: date)
let requestMilliseconds = max(0, requestMilliseconds)
let readyMilliseconds = readyMilliseconds.map { max(0, $0) }
let readyCount = readyMilliseconds == nil ? 0 : 1
let readyValue = readyMilliseconds ?? 0
let day = self.dayString(date)

try self.transaction {
try self.run(
"INSERT INTO daily_insertion_latency (" +
"day, delivery_path, outcome, request_count, request_total_ms, request_min_ms, request_max_ms, " +
"ready_count, ready_total_ms, ready_min_ms, ready_max_ms" +
") VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?) " +
"ON CONFLICT(day, delivery_path, outcome) DO UPDATE SET " +
"request_count = request_count + 1, " +
"request_total_ms = request_total_ms + excluded.request_total_ms, " +
"request_min_ms = MIN(request_min_ms, excluded.request_min_ms), " +
"request_max_ms = MAX(request_max_ms, excluded.request_max_ms), " +
"ready_count = ready_count + excluded.ready_count, " +
"ready_total_ms = ready_total_ms + excluded.ready_total_ms, " +
"ready_min_ms = CASE " +
"WHEN excluded.ready_count = 0 THEN ready_min_ms " +
"WHEN ready_count = 0 THEN excluded.ready_min_ms " +
"ELSE MIN(ready_min_ms, excluded.ready_min_ms) END, " +
"ready_max_ms = CASE " +
"WHEN excluded.ready_count = 0 THEN ready_max_ms " +
"WHEN ready_count = 0 THEN excluded.ready_max_ms " +
"ELSE MAX(ready_max_ms, excluded.ready_max_ms) END",
bindings: [
.text(day), .text(path.rawValue), .text(outcome.rawValue),
.integer(requestMilliseconds), .integer(requestMilliseconds), .integer(requestMilliseconds),
.integer(readyCount), .integer(readyValue), .integer(readyValue), .integer(readyValue),
]
)

if let toggleStopMilliseconds,
outcome == .dispatched,
path == .clipboard || path == .clipboardFallback
{
let milliseconds = max(0, toggleStopMilliseconds)
try self.run(
"INSERT INTO daily_clipboard_toggle_latency " +
"(day, delivery_path, sample_count, total_ms, min_ms, max_ms) " +
"VALUES (?, ?, 1, ?, ?, ?) " +
"ON CONFLICT(day, delivery_path) DO UPDATE SET " +
"sample_count = sample_count + 1, " +
"total_ms = total_ms + excluded.total_ms, " +
"min_ms = MIN(min_ms, excluded.min_ms), " +
"max_ms = MAX(max_ms, excluded.max_ms)",
bindings: [
.text(day), .text(path.rawValue),
.integer(milliseconds), .integer(milliseconds), .integer(milliseconds),
]
)
}
}
}

func recordOnboardingStarted(origin: AnalyticsOnboardingOrigin, at date: Date) throws {
try self.finalizeDays(before: date)
try self.transaction {
Expand Down Expand Up @@ -273,8 +339,20 @@ final class AnalyticsDatabase {
"WHERE day < ? ORDER BY day, role, mode, provider, model",
bindings: [.text(today)]
)
let insertionRows = try self.query(
"SELECT day, delivery_path, outcome, request_count, request_total_ms, request_min_ms, " +
"request_max_ms, ready_count, ready_total_ms, ready_min_ms, ready_max_ms " +
"FROM daily_insertion_latency WHERE day < ? ORDER BY day, delivery_path, outcome",
bindings: [.text(today)]
)
let clipboardToggleRows = try self.query(
"SELECT day, delivery_path, sample_count, total_ms, min_ms, max_ms " +
"FROM daily_clipboard_toggle_latency WHERE day < ? ORDER BY day, delivery_path",
bindings: [.text(today)]
)

guard !usageRows.isEmpty || !modelRows.isEmpty else { return }
guard !usageRows.isEmpty || !modelRows.isEmpty || !insertionRows.isEmpty || !clipboardToggleRows.isEmpty
else { return }
try self.transaction {
for row in usageRows where row.count == 5 {
try self.enqueue(.usageDailySummary, at: date, properties: [
Expand All @@ -295,8 +373,49 @@ final class AnalyticsDatabase {
"use_count": Int(row[5]) ?? 0,
])
}
for row in insertionRows where row.count == 11 {
let requestCount = Int(row[3]) ?? 0
let requestTotal = Int(row[4]) ?? 0
let readyCount = Int(row[7]) ?? 0
let readyTotal = Int(row[8]) ?? 0
let toggleRow = clipboardToggleRows.first {
$0.count == 6 &&
row[2] == AnalyticsInsertionOutcome.dispatched.rawValue &&
$0[0] == row[0] &&
$0[1] == row[1]
}
var properties: [String: Any] = [
"latency_date": row[0],
"delivery_path": row[1],
"outcome": row[2],
"request_count": requestCount,
"request_total_ms": requestTotal,
"request_average_ms": Self.average(total: requestTotal, count: requestCount),
"request_min_ms": Int(row[5]) ?? 0,
"request_max_ms": Int(row[6]) ?? 0,
"ready_count": readyCount,
]
if readyCount > 0 {
properties["ready_total_ms"] = readyTotal
properties["ready_average_ms"] = Self.average(total: readyTotal, count: readyCount)
properties["ready_min_ms"] = Int(row[9]) ?? 0
properties["ready_max_ms"] = Int(row[10]) ?? 0
}
if let toggleRow {
let count = Int(toggleRow[2]) ?? 0
let total = Int(toggleRow[3]) ?? 0
properties["toggle_stop_to_dispatch_count"] = count
properties["toggle_stop_to_dispatch_total_ms"] = total
properties["toggle_stop_to_dispatch_average_ms"] = Self.average(total: total, count: count)
properties["toggle_stop_to_dispatch_min_ms"] = Int(toggleRow[4]) ?? 0
properties["toggle_stop_to_dispatch_max_ms"] = Int(toggleRow[5]) ?? 0
}
try self.enqueue(.insertionLatencyDailySummary, at: date, properties: properties)
}
try self.run("DELETE FROM daily_usage WHERE day < ?", bindings: [.text(today)])
try self.run("DELETE FROM daily_model_usage WHERE day < ?", bindings: [.text(today)])
try self.run("DELETE FROM daily_insertion_latency WHERE day < ?", bindings: [.text(today)])
try self.run("DELETE FROM daily_clipboard_toggle_latency WHERE day < ?", bindings: [.text(today)])
}
}

Expand Down Expand Up @@ -364,6 +483,8 @@ final class AnalyticsDatabase {
try self.execute("DELETE FROM outbox")
try self.execute("DELETE FROM daily_usage")
try self.execute("DELETE FROM daily_model_usage")
try self.execute("DELETE FROM daily_insertion_latency")
try self.execute("DELETE FROM daily_clipboard_toggle_latency")
try self.execute("DELETE FROM event_dedupe")
try self.execute("DELETE FROM onboarding_flows")
try self.execute("DELETE FROM model_download_attempts")
Expand Down Expand Up @@ -398,6 +519,29 @@ final class AnalyticsDatabase {
use_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day, role, mode, provider, model)
);
CREATE TABLE IF NOT EXISTS daily_insertion_latency (
day TEXT NOT NULL,
delivery_path TEXT NOT NULL,
outcome TEXT NOT NULL,
request_count INTEGER NOT NULL DEFAULT 0,
request_total_ms INTEGER NOT NULL DEFAULT 0,
request_min_ms INTEGER NOT NULL DEFAULT 0,
request_max_ms INTEGER NOT NULL DEFAULT 0,
ready_count INTEGER NOT NULL DEFAULT 0,
ready_total_ms INTEGER NOT NULL DEFAULT 0,
ready_min_ms INTEGER NOT NULL DEFAULT 0,
ready_max_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day, delivery_path, outcome)
);
CREATE TABLE IF NOT EXISTS daily_clipboard_toggle_latency (
day TEXT NOT NULL,
delivery_path TEXT NOT NULL,
sample_count INTEGER NOT NULL DEFAULT 0,
total_ms INTEGER NOT NULL DEFAULT 0,
min_ms INTEGER NOT NULL DEFAULT 0,
max_ms INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(day, delivery_path)
);
CREATE TABLE IF NOT EXISTS event_dedupe (
dedupe_key TEXT PRIMARY KEY,
created_at REAL NOT NULL
Expand Down Expand Up @@ -526,6 +670,11 @@ final class AnalyticsDatabase {
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}

private static func average(total: Int, count: Int) -> Double {
guard count > 0 else { return 0 }
return (Double(total) / Double(count) * 10).rounded() / 10
}

private func purgeDeletedPages() throws {
try self.execute("PRAGMA incremental_vacuum")
try self.execute("PRAGMA wal_checkpoint(TRUNCATE)")
Expand Down
19 changes: 19 additions & 0 deletions Sources/Fluid/Analytics/AnalyticsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ enum AnalyticsEvent: String {
case activeUser = "active_user"
case usageDailySummary = "usage_daily_summary"
case modelUsageDailySummary = "model_usage_daily_summary"
case insertionLatencyDailySummary = "insertion_latency_daily_summary"
case onboardingStarted = "onboarding_started"
case onboardingStepViewed = "onboarding_step_viewed"
case onboardingStepCompleted = "onboarding_step_completed"
Expand All @@ -30,6 +31,24 @@ enum AnalyticsModelRole: String {
case aiPostProcessing = "ai_post_processing"
}

enum AnalyticsInsertionPath: String {
case clipboard
case direct
case clipboardFallback = "clipboard_fallback"
case notAttempted = "not_attempted"
}

enum AnalyticsInsertionOutcome: String {
case dispatched
case emptyText = "empty_text"
case accessibilityNotTrusted = "accessibility_not_trusted"
case clipboardSnapshotFailed = "clipboard_snapshot_failed"
case clipboardWriteFailed = "clipboard_write_failed"
case pasteCommandFailed = "paste_command_failed"
case targetUnavailable = "target_unavailable"
case targetRestoreFailed = "target_restore_failed"
}

struct AnalyticsModelDescriptor: Equatable {
let provider: String
let model: String
Expand Down
39 changes: 39 additions & 0 deletions Sources/Fluid/Analytics/AnalyticsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ final class AnalyticsService {
}
}

func recordInsertionLatency(
path: AnalyticsInsertionPath,
outcome: AnalyticsInsertionOutcome,
requestMilliseconds: Int,
readyMilliseconds: Int?,
toggleStopMilliseconds: Int?
) {
self.submit { core, context in
await core.recordInsertionLatency(
path: path,
outcome: outcome,
requestMilliseconds: requestMilliseconds,
readyMilliseconds: readyMilliseconds,
toggleStopMilliseconds: toggleStopMilliseconds,
context: context
)
}
}

func recordOnboardingStarted(origin: AnalyticsOnboardingOrigin) {
self.submit { core, context in
await core.recordOnboardingStarted(origin: origin, context: context)
Expand Down Expand Up @@ -251,6 +270,26 @@ private actor AnalyticsCore {
}
}

func recordInsertionLatency(
path: AnalyticsInsertionPath,
outcome: AnalyticsInsertionOutcome,
requestMilliseconds: Int,
readyMilliseconds: Int?,
toggleStopMilliseconds: Int?,
context: AnalyticsContext
) async {
await self.write(context: context) { database, date in
try database.recordInsertionLatency(
path: path,
outcome: outcome,
requestMilliseconds: requestMilliseconds,
readyMilliseconds: readyMilliseconds,
toggleStopMilliseconds: toggleStopMilliseconds,
at: date
)
}
}

func recordOnboardingStarted(origin: AnalyticsOnboardingOrigin, context: AnalyticsContext) async {
await self.write(context: context) { database, date in
try database.recordOnboardingStarted(origin: origin, at: date)
Expand Down
Loading
Loading