Skip to content

Commit b82d341

Browse files
authored
Merge pull request #20 from ZhaoChaoqun/feat/telemetry-v1-fine-grained
feat: fine-grained telemetry with per-stage latency tracking
2 parents ace89a9 + fb4ea4a commit b82d341

8 files changed

Lines changed: 514 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: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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+
/// - Latency values use precise milliseconds for maximum diagnostic value
11+
/// - Text content is NEVER sent; text lengths are bucketed (e.g. "11-50" not "37")
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+
///
20+
/// Always initializes the SDK regardless of enabled state, so that toggling
21+
/// analytics on later doesn't require re-initialization. The `track()` method
22+
/// checks `isEnabled` before sending any signals.
23+
static func initialize() {
24+
let config = TelemetryDeck.Config(appID: appID)
25+
TelemetryDeck.initialize(config: config)
26+
27+
if isEnabled {
28+
analyticsLogger.info("Analytics initialized (TelemetryDeck)")
29+
} else {
30+
analyticsLogger.info("Analytics initialized but disabled by user preference")
31+
}
32+
}
33+
34+
/// Whether analytics is enabled (opt-out model: default true).
35+
static var isEnabled: Bool {
36+
// UserDefaults.standard.object returns nil for unset keys;
37+
// treat nil as true (opt-out default).
38+
if let value = UserDefaults.standard.object(forKey: "analyticsEnabled") as? Bool {
39+
return value
40+
}
41+
return true
42+
}
43+
44+
/// Update enabled state. TelemetryDeck is already initialized;
45+
/// this only controls whether `track()` sends signals.
46+
static func setEnabled(_ enabled: Bool) {
47+
UserDefaults.standard.set(enabled, forKey: "analyticsEnabled")
48+
analyticsLogger.info("Analytics \(enabled ? "enabled" : "disabled")")
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"] = "\(hasCloudRewriteAPIKey)"
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+
/// Map recording duration (seconds) to a privacy-safe bucket.
93+
static func durationBucket(seconds: Double) -> String {
94+
switch seconds {
95+
case ..<5: return "0-5s"
96+
case 5..<15: return "5-15s"
97+
case 15..<30: return "15-30s"
98+
case 30..<60: return "30-60s"
99+
default: return "60s+"
100+
}
101+
}
102+
103+
/// Map text length (character count) to a privacy-safe bucket.
104+
static func lengthBucket(count: Int) -> String {
105+
switch count {
106+
case 0: return "0"
107+
case 1...10: return "1-10"
108+
case 11...50: return "11-50"
109+
case 51...200: return "51-200"
110+
default: return "200+"
111+
}
112+
}
113+
114+
// MARK: - Private Helpers
115+
116+
/// Whether a Cloud Rewrite API key is configured (user-provided or built-in).
117+
private static var hasCloudRewriteAPIKey: Bool {
118+
let userKey = UserDefaults.standard.string(forKey: "cloudRewriteAPIKey") ?? ""
119+
return !userKey.isEmpty || GeneratedSecrets.cloudRewriteAPIKey != nil
120+
}
121+
}

Sources/PostProcessingPipeline.swift

Lines changed: 69 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,48 @@ 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+
Self.trackPostProcessingCompleted(
97+
pipelineStart: pipelineStart,
98+
termNormMs: termNormMs, termNormChanged: termNormChanged,
99+
itnMs: itnMs, itnApplied: itnApplied,
100+
cscMs: 0, cscApplied: false,
101+
punctMs: 0, punctuationApplied: false,
102+
cloudRewriteMs: cloudRewriteMs
103+
)
81104
logger.info("最终结果: \(rewrittenText, privacy: .public)")
82105
completion(rewrittenText)
83106
}
@@ -86,26 +109,69 @@ final class PostProcessingPipeline {
86109
}
87110

88111
// Stage 3: CSC (Chinese spelling correction)
112+
let cscStart = ContinuousClock.now
89113
let correctedText = self.corrector?.correctSpelling(processedText) ?? processedText
114+
let cscMs = AnalyticsService.elapsedMs(since: cscStart)
115+
let cscApplied = correctedText != processedText
90116
logger.info("原始文本: \(processedText, privacy: .public)")
91-
if correctedText != processedText {
117+
if cscApplied {
92118
logger.info("CSC 纠正: \(correctedText, privacy: .public)")
93119
} else {
94120
logger.info("CSC 未修改文本")
95121
}
96122

97123
// Stage 4: Punctuation
124+
let punctStart = ContinuousClock.now
98125
let punctuatedText = self.punctuator?.addPunctuation(text: correctedText) ?? correctedText
126+
let punctMs = AnalyticsService.elapsedMs(since: punctStart)
127+
let punctuationApplied = punctuatedText != correctedText
99128
logger.info("标点处理后: \(punctuatedText, privacy: .public)")
100129

101130
// Stage 5: Cloud rewrite
102131
Task { [weak self] in
103132
guard let self else { return }
133+
let cloudRewriteStart = ContinuousClock.now
104134
let rewrittenText = await self.cloudRewriteService.rewriteOrPassthrough(punctuatedText)
135+
let cloudRewriteMs = AnalyticsService.elapsedMs(since: cloudRewriteStart)
105136
self.processingQueue.async {
137+
Self.trackPostProcessingCompleted(
138+
pipelineStart: pipelineStart,
139+
termNormMs: termNormMs, termNormChanged: termNormChanged,
140+
itnMs: itnMs, itnApplied: itnApplied,
141+
cscMs: cscMs, cscApplied: cscApplied,
142+
punctMs: punctMs, punctuationApplied: punctuationApplied,
143+
cloudRewriteMs: cloudRewriteMs
144+
)
106145
completion(rewrittenText)
107146
}
108147
}
109148
}
110149
}
150+
151+
// MARK: - Analytics Helper
152+
153+
/// Build and send the PostProcessing.Completed event with per-stage latency.
154+
/// Single source of truth — called from both pipeline branches.
155+
private static func trackPostProcessingCompleted(
156+
pipelineStart: ContinuousClock.Instant,
157+
termNormMs: Int, termNormChanged: Bool,
158+
itnMs: Int, itnApplied: Bool,
159+
cscMs: Int, cscApplied: Bool,
160+
punctMs: Int, punctuationApplied: Bool,
161+
cloudRewriteMs: Int
162+
) {
163+
let totalMs = AnalyticsService.elapsedMs(since: pipelineStart)
164+
AnalyticsService.track("PostProcessing.Completed", parameters: [
165+
"itnApplied": "\(itnApplied)",
166+
"cscApplied": "\(cscApplied)",
167+
"punctuationApplied": "\(punctuationApplied)",
168+
"totalLatencyMs": "\(totalMs)",
169+
"termNormLatencyMs": "\(termNormMs)",
170+
"itnLatencyMs": "\(itnMs)",
171+
"cscLatencyMs": "\(cscMs)",
172+
"punctLatencyMs": "\(punctMs)",
173+
"cloudRewriteLatencyMs": "\(cloudRewriteMs)",
174+
"termNormChanged": "\(termNormChanged)",
175+
])
176+
}
111177
}

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)