Skip to content

Commit 2c4d593

Browse files
ZhaoChaoqunclaude
andcommitted
feat: add fine-grained telemetry with per-stage latency tracking
Integrate TelemetryDeck SDK and implement v1 analytics optimized for free-tier quota (max 4 events per recording session). Key changes: - Add AnalyticsService with elapsedMs() helper and privacy-safe buckets - Enhance PostProcessing.Completed with per-stage precise ms latency (TermNorm, ITN, CSC, Punctuation, CloudRewrite) - Add ASR.FlushCompleted event for core ASR flush performance - Add Model.LoadCompleted event for model initialization tracking - Add App.Launched and Session.Completed baseline events - Add analytics opt-out toggle in Settings UI - Update telemetry schema design doc to v1.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ace89a9 commit 2c4d593

8 files changed

Lines changed: 502 additions & 4 deletions

File tree

Sources/ASREngineFactory.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ enum ASREngineFactory {
3333
recognitionQueue: DispatchQueue
3434
) async -> Result {
3535
logger.info("开始加载语音识别模型 (\(modelType.displayName, privacy: .public))")
36+
let loadStart = ContinuousClock.now
3637

3738
// Clear previous pipeline state
3839
pipeline.reset()
@@ -57,6 +58,19 @@ enum ASREngineFactory {
5758
}
5859
pipeline.termNormalizer = termNormalizer
5960

61+
// Analytics: track model load result
62+
let loadMs = AnalyticsService.elapsedMs(since: loadStart)
63+
let success = engine != nil
64+
var params: [String: String] = [
65+
"engine": modelType.rawValue,
66+
"latencyMs": "\(loadMs)",
67+
"success": "\(success)",
68+
]
69+
if !success {
70+
params["errorType"] = "load_failed"
71+
}
72+
AnalyticsService.track("Model.LoadCompleted", parameters: params)
73+
6074
return Result(engine: engine)
6175
}
6276

Sources/AnalyticsService.swift

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import Foundation
2+
import os
3+
import TelemetryDeck
4+
5+
private let analyticsLogger = Logger(subsystem: "com.typeless.app", category: "Analytics")
6+
7+
/// Lightweight wrapper around TelemetryDeck for privacy-first analytics.
8+
///
9+
/// Design principles:
10+
/// - All numeric values are bucketed (e.g. "500-1000ms" not "743ms") to prevent re-identification
11+
/// - Text content is NEVER sent
12+
/// - Users can opt out via Settings toggle (UserDefaults "analyticsEnabled", default true)
13+
/// - DEBUG builds are automatically tagged as test signals by TelemetryDeck
14+
enum AnalyticsService {
15+
16+
private static let appID = "6EB06114-0222-4BE1-9DDC-BB283B640436"
17+
18+
/// Initialize TelemetryDeck. Call once from AppDelegate.applicationDidFinishLaunching.
19+
static func initialize() {
20+
guard isEnabled else {
21+
analyticsLogger.info("Analytics disabled by user preference")
22+
return
23+
}
24+
25+
let config = TelemetryDeck.Config(appID: appID)
26+
TelemetryDeck.initialize(config: config)
27+
analyticsLogger.info("Analytics initialized (TelemetryDeck)")
28+
}
29+
30+
/// Whether analytics is enabled (opt-out model: default true).
31+
static var isEnabled: Bool {
32+
// UserDefaults.standard.object returns nil for unset keys;
33+
// treat nil as true (opt-out default).
34+
if let value = UserDefaults.standard.object(forKey: "analyticsEnabled") as? Bool {
35+
return value
36+
}
37+
return true
38+
}
39+
40+
/// Update enabled state and reinitialize if needed.
41+
static func setEnabled(_ enabled: Bool) {
42+
UserDefaults.standard.set(enabled, forKey: "analyticsEnabled")
43+
if enabled {
44+
let config = TelemetryDeck.Config(appID: appID)
45+
TelemetryDeck.initialize(config: config)
46+
analyticsLogger.info("Analytics re-enabled")
47+
}
48+
// TelemetryDeck doesn't have a teardown; disabled state is checked in track()
49+
}
50+
51+
/// Track an analytics event with optional parameters.
52+
static func track(_ event: String, parameters: [String: String] = [:]) {
53+
guard isEnabled else { return }
54+
55+
// Attach default parameters
56+
var params = parameters
57+
params["appVersion"] = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
58+
params["selectedEngine"] = UserDefaults.standard.string(forKey: "selectedASRModel") ?? "streamingParaformer"
59+
params["cloudRewriteEnabled"] = "\(!((UserDefaults.standard.string(forKey: "cloudRewriteAPIKey") ?? "").isEmpty) || GeneratedSecrets.cloudRewriteAPIKey != nil)"
60+
61+
TelemetryDeck.signal(event, parameters: params)
62+
}
63+
64+
// MARK: - Timing Helpers
65+
66+
/// Convert a `ContinuousClock.Duration` to integer milliseconds.
67+
///
68+
/// Usage:
69+
/// ```
70+
/// let start = ContinuousClock.now
71+
/// // ... work ...
72+
/// let ms = AnalyticsService.elapsedMs(since: start)
73+
/// ```
74+
static func elapsedMs(since start: ContinuousClock.Instant) -> Int {
75+
let elapsed = start.duration(to: .now)
76+
return Int(elapsed.components.seconds * 1000) + Int(elapsed.components.attoseconds / 1_000_000_000_000_000)
77+
}
78+
79+
// MARK: - Bucketing Helpers
80+
81+
/// Map millisecond latency to a privacy-safe bucket string.
82+
static func latencyBucket(ms: Int) -> String {
83+
switch ms {
84+
case ..<500: return "0-500ms"
85+
case 500..<1000: return "500-1000ms"
86+
case 1000..<1500: return "1000-1500ms"
87+
case 1500..<2000: return "1500-2000ms"
88+
default: return "2000ms+"
89+
}
90+
}
91+
92+
/// Fine-grained latency bucket for fast pipeline stages (TermNorm, ITN, etc.).
93+
static func fineLatencyBucket(ms: Int) -> String {
94+
switch ms {
95+
case ..<10: return "0-10ms"
96+
case 10..<50: return "10-50ms"
97+
case 50..<100: return "50-100ms"
98+
case 100..<200: return "100-200ms"
99+
case 200..<500: return "200-500ms"
100+
case 500..<1000: return "500-1000ms"
101+
default: return "1000ms+"
102+
}
103+
}
104+
105+
/// Map recording duration (seconds) to a privacy-safe bucket.
106+
static func durationBucket(seconds: Double) -> String {
107+
switch seconds {
108+
case ..<5: return "0-5s"
109+
case 5..<15: return "5-15s"
110+
case 15..<30: return "15-30s"
111+
case 30..<60: return "30-60s"
112+
default: return "60s+"
113+
}
114+
}
115+
116+
/// Map text length (character count) to a privacy-safe bucket.
117+
static func lengthBucket(count: Int) -> String {
118+
switch count {
119+
case 0: return "0"
120+
case 1...10: return "1-10"
121+
case 11...50: return "11-50"
122+
case 51...200: return "51-200"
123+
default: return "200+"
124+
}
125+
}
126+
}

Sources/PostProcessingPipeline.swift

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ final class PostProcessingPipeline {
5050
func process(rawText: String, engine: (any ASREngine)?, completion: @escaping (String?) -> Void) {
5151
processingQueue.async { [weak self] in
5252
guard let self = self else { return }
53+
let pipelineStart = ContinuousClock.now
5354

5455
guard !rawText.isEmpty else {
5556
logger.info("最终识别结果: (无)")
@@ -58,26 +59,53 @@ final class PostProcessingPipeline {
5859
}
5960

6061
// Stage 1: Term normalization (domain-specific)
62+
let termNormStart = ContinuousClock.now
6163
let normalizedText = self.termNormalizer?.normalize(rawText) ?? rawText
62-
if normalizedText != rawText {
64+
let termNormMs = AnalyticsService.elapsedMs(since: termNormStart)
65+
let termNormChanged = normalizedText != rawText
66+
if termNormChanged {
6367
logger.info("TermNormalizer: \(rawText, privacy: .public)\(normalizedText, privacy: .public)")
6468
}
6569

6670
// Stage 2: ITN (inverse text normalization) for engines that need it
6771
var processedText = normalizedText
72+
let itnApplied: Bool
73+
let itnMs: Int
6874
if let engine = engine, engine.needsITN, let itn = self.itn {
75+
let itnStart = ContinuousClock.now
76+
let before = processedText
6977
processedText = itn.normalize(text: processedText)
70-
if processedText != normalizedText {
78+
itnMs = AnalyticsService.elapsedMs(since: itnStart)
79+
itnApplied = processedText != before
80+
if itnApplied {
7181
logger.info("ITN: \(normalizedText, privacy: .public)\(processedText, privacy: .public)")
7282
}
83+
} else {
84+
itnApplied = false
85+
itnMs = 0
7386
}
7487

7588
// Branch: engines that don't need punctuation skip CSC + punctuation
7689
guard let engine = engine, engine.needsPunctuation else {
7790
Task { [weak self] in
7891
guard let self else { return }
92+
let cloudRewriteStart = ContinuousClock.now
7993
let rewrittenText = await self.cloudRewriteService.rewriteOrPassthrough(processedText)
94+
let cloudRewriteMs = AnalyticsService.elapsedMs(since: cloudRewriteStart)
8095
self.processingQueue.async {
96+
let totalMs = AnalyticsService.elapsedMs(since: pipelineStart)
97+
AnalyticsService.track("PostProcessing.Completed", parameters: [
98+
"itnApplied": "\(itnApplied)",
99+
"cscApplied": "false",
100+
"punctuationApplied": "false",
101+
"totalLatencyMs": "\(totalMs)",
102+
"termNormLatencyMs": "\(termNormMs)",
103+
"itnLatencyMs": "\(itnMs)",
104+
"cscLatencyMs": "0",
105+
"punctLatencyMs": "0",
106+
"cloudRewriteLatencyMs": "\(cloudRewriteMs)",
107+
"termNormChanged": "\(termNormChanged)",
108+
])
81109
logger.info("最终结果: \(rewrittenText, privacy: .public)")
82110
completion(rewrittenText)
83111
}
@@ -86,23 +114,44 @@ final class PostProcessingPipeline {
86114
}
87115

88116
// Stage 3: CSC (Chinese spelling correction)
117+
let cscStart = ContinuousClock.now
89118
let correctedText = self.corrector?.correctSpelling(processedText) ?? processedText
119+
let cscMs = AnalyticsService.elapsedMs(since: cscStart)
120+
let cscApplied = correctedText != processedText
90121
logger.info("原始文本: \(processedText, privacy: .public)")
91-
if correctedText != processedText {
122+
if cscApplied {
92123
logger.info("CSC 纠正: \(correctedText, privacy: .public)")
93124
} else {
94125
logger.info("CSC 未修改文本")
95126
}
96127

97128
// Stage 4: Punctuation
129+
let punctStart = ContinuousClock.now
98130
let punctuatedText = self.punctuator?.addPunctuation(text: correctedText) ?? correctedText
131+
let punctMs = AnalyticsService.elapsedMs(since: punctStart)
132+
let punctuationApplied = punctuatedText != correctedText
99133
logger.info("标点处理后: \(punctuatedText, privacy: .public)")
100134

101135
// Stage 5: Cloud rewrite
102136
Task { [weak self] in
103137
guard let self else { return }
138+
let cloudRewriteStart = ContinuousClock.now
104139
let rewrittenText = await self.cloudRewriteService.rewriteOrPassthrough(punctuatedText)
140+
let cloudRewriteMs = AnalyticsService.elapsedMs(since: cloudRewriteStart)
105141
self.processingQueue.async {
142+
let totalMs = AnalyticsService.elapsedMs(since: pipelineStart)
143+
AnalyticsService.track("PostProcessing.Completed", parameters: [
144+
"itnApplied": "\(itnApplied)",
145+
"cscApplied": "\(cscApplied)",
146+
"punctuationApplied": "\(punctuationApplied)",
147+
"totalLatencyMs": "\(totalMs)",
148+
"termNormLatencyMs": "\(termNormMs)",
149+
"itnLatencyMs": "\(itnMs)",
150+
"cscLatencyMs": "\(cscMs)",
151+
"punctLatencyMs": "\(punctMs)",
152+
"cloudRewriteLatencyMs": "\(cloudRewriteMs)",
153+
"termNormChanged": "\(termNormChanged)",
154+
])
106155
completion(rewrittenText)
107156
}
108157
}

Sources/RecordingManager.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class RecordingManager {
2525
private let stateQueue = DispatchQueue(label: "com.typeless.state")
2626
private var state: RecordingState = .idle
2727

28+
/// Recording session start time for analytics duration tracking
29+
private var sessionStartTime: ContinuousClock.Instant?
30+
2831
/// 计算密集型操作的队列(ASR decoding + post-processing 共用)
2932
private let recognitionQueue = DispatchQueue(label: "com.typeless.recognition", qos: .userInitiated)
3033

@@ -121,6 +124,7 @@ class RecordingManager {
121124

122125
// 开始录音
123126
case (.ready, .recording):
127+
self.sessionStartTime = .now
124128
DispatchQueue.main.async { self.onRecordingStarted?() }
125129
self.startRecording()
126130

@@ -145,6 +149,18 @@ class RecordingManager {
145149
// 后处理完成
146150
case (.postProcessing, .ready):
147151
if case .postProcessComplete(let finalText) = event {
152+
// Analytics: track completed session
153+
if let startTime = self.sessionStartTime {
154+
let elapsed = startTime.duration(to: .now)
155+
let durationSeconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18
156+
AnalyticsService.track("Session.Completed", parameters: [
157+
"engine": self.currentModel.rawValue,
158+
"durationBucket": AnalyticsService.durationBucket(seconds: durationSeconds),
159+
"hasResult": "\(finalText != nil && !(finalText?.isEmpty ?? true))",
160+
"resultLengthBucket": AnalyticsService.lengthBucket(count: finalText?.count ?? 0),
161+
])
162+
self.sessionStartTime = nil
163+
}
148164
DispatchQueue.main.async { self.onFinalResult?(finalText) }
149165
}
150166

@@ -185,9 +201,17 @@ class RecordingManager {
185201
return
186202
}
187203

204+
let flushStart = ContinuousClock.now
188205
engine.flush { [weak self] rawText in
189206
guard let self = self else { return }
207+
let flushMs = AnalyticsService.elapsedMs(since: flushStart)
190208
let text = rawText.isEmpty ? fallbackText : rawText
209+
AnalyticsService.track("ASR.FlushCompleted", parameters: [
210+
"engine": self.currentModel.rawValue,
211+
"latencyMs": "\(flushMs)",
212+
"success": "\(!rawText.isEmpty)",
213+
"rawTextLengthBucket": AnalyticsService.lengthBucket(count: text.count),
214+
])
191215
self.handleEvent(.flushComplete(rawText: text))
192216
}
193217
}

Sources/SettingsGeneralView.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import SwiftUI
22

3-
/// 通用设置视图(快捷键、关于、权限)
3+
/// 通用设置视图(快捷键、关于、权限、数据分析
44
struct SettingsGeneralView: View {
5+
@AppStorage("analyticsEnabled") private var analyticsEnabled = true
6+
57
var body: some View {
68
Section("快捷键") {
79
Text("长按 Fn 键开始录音")
@@ -21,5 +23,16 @@ struct SettingsGeneralView: View {
2123
} footer: {
2224
Text("Nano Typeless 需要辅助功能权限来监听全局按键,需要麦克风权限来录制语音。")
2325
}
26+
27+
Section {
28+
Toggle("发送匿名使用统计", isOn: $analyticsEnabled)
29+
} header: {
30+
Text("数据分析")
31+
} footer: {
32+
Text("帮助改进 Nano Typeless。不会收集任何文本内容或个人信息。")
33+
}
34+
.onChange(of: analyticsEnabled) { _, newValue in
35+
AnalyticsService.setEnabled(newValue)
36+
}
2437
}
2538
}

Sources/TypelessApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
2525

2626
func applicationDidFinishLaunching(_ notification: Notification) {
2727
NSApp.setActivationPolicy(.accessory)
28+
AnalyticsService.initialize()
2829
setupStatusBar()
2930

3031
_ = RecordingManager.shared
@@ -45,6 +46,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
4546
checkPermissions()
4647
autoDownloadDefaultModelIfNeeded()
4748
showOnboardingIfNeeded()
49+
50+
AnalyticsService.track("App.Launched")
4851
}
4952

5053
private func showOnboardingIfNeeded() {

0 commit comments

Comments
 (0)