Add iOS Langfuse analytics via backend events endpoint
Some checks failed
CI / build-and-deploy (push) Failing after 8s
Some checks failed
CI / build-and-deploy (push) Failing after 8s
AnalyticsService.track() is fire-and-forget — posts to POST /v1/marks/events which records a Langfuse trace. No external SDK, no Sentry on iOS side. Events tracked: - podcast.started (url, cached) - podcast.completed (url, durationSec) — fires at 97% playback - podcast.failed (url, error) - search.semantic (query, resultCount) - enrich.completed (count) - collections.generated (collectionCount) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
0479C0AA16E3AFDBD4414AD0 /* PodcastIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */; };
|
||||||
|
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */; };
|
||||||
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||||
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
D3A4B1E764CC88A774AF8EA5 /* EditBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditBookmarkView.swift; sourceTree = "<group>"; };
|
||||||
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||||
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsService.swift; sourceTree = "<group>"; };
|
||||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndex.swift; sourceTree = "<group>"; };
|
||||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksAuth.swift; sourceTree = "<group>"; };
|
||||||
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||||
@@ -125,7 +127,8 @@
|
|||||||
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
A1B2C3D4E5F60002A2B3C4D5 /* AnalyticsService.swift */,
|
||||||
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||||
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||||
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
F78AA3450BDFAC24591EE407 /* MarksAuth.swift */,
|
||||||
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
F75DE8BE4B3A3E0E60B8BB03 /* PodcastIndex.swift */,
|
||||||
@@ -279,7 +282,8 @@
|
|||||||
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||||
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||||
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
706CCE40744D7F382AFFE429 /* BrowserView.swift in Sources */,
|
||||||
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
A1B2C3D4E5F60001A2B3C4D5 /* AnalyticsService.swift in Sources */,
|
||||||
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||||
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||||
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
C3189071834E0F8898408C37 /* EditBookmarkView.swift in Sources */,
|
||||||
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
|
|||||||
26
Marks/Services/AnalyticsService.swift
Normal file
26
Marks/Services/AnalyticsService.swift
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Analytics {
|
||||||
|
static func track(_ event: String, _ properties: [String: Any] = [:]) {
|
||||||
|
Task.detached {
|
||||||
|
do {
|
||||||
|
let token = try await MarksAuth.validToken()
|
||||||
|
guard let url = URL(string: MarksAuth.baseURL + "/v1/marks/events") else { return }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
var body: [String: Any] = ["event": event]
|
||||||
|
if !properties.isEmpty { body["properties"] = properties }
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
let (_, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
if status < 200 || status > 299 {
|
||||||
|
print("[Analytics] \(event) → HTTP \(status)")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[Analytics] \(event) failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ final class BookmarksViewModel {
|
|||||||
restoreAIData(into: &all)
|
restoreAIData(into: &all)
|
||||||
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
|
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
|
||||||
nextPageUrl = nil
|
nextPageUrl = nil
|
||||||
|
Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count])
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -159,6 +160,7 @@ final class BookmarksViewModel {
|
|||||||
do {
|
do {
|
||||||
let allResponse = try await api.fetchBookmarks(limit: 200)
|
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||||
smartCollections = try await claude.generateCollections(from: allResponse.results)
|
smartCollections = try await claude.generateCollections(from: allResponse.results)
|
||||||
|
Analytics.track("collections.generated", ["collectionCount": smartCollections.count])
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error.localizedDescription
|
self.error = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -180,6 +182,8 @@ final class BookmarksViewModel {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count
|
||||||
|
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
|
||||||
enrichmentProgress = 0
|
enrichmentProgress = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ final class PodcastPlayerViewModel {
|
|||||||
|
|
||||||
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
|
||||||
if FileManager.default.fileExists(atPath: cached.path) {
|
if FileManager.default.fileExists(atPath: cached.path) {
|
||||||
|
Analytics.track("podcast.started", ["url": articleUrl, "cached": true])
|
||||||
setupPlayer(url: cached, title: "Podcast")
|
setupPlayer(url: cached, title: "Podcast")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Analytics.track("podcast.started", ["url": articleUrl, "cached": false])
|
||||||
phase = .generating(progress: 0, label: "Starting…")
|
phase = .generating(progress: 0, label: "Starting…")
|
||||||
|
|
||||||
pollingTask = Task {
|
pollingTask = Task {
|
||||||
@@ -81,13 +83,16 @@ final class PodcastPlayerViewModel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if job.isFailed {
|
if job.isFailed {
|
||||||
phase = .failed(job.error ?? "Generation failed")
|
let errMsg = job.error ?? "Generation failed"
|
||||||
|
Analytics.track("podcast.failed", ["url": articleUrl, "error": errMsg])
|
||||||
|
phase = .failed(errMsg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try await Task.sleep(for: .seconds(3))
|
try await Task.sleep(for: .seconds(3))
|
||||||
}
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
} catch {
|
} catch {
|
||||||
|
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
|
||||||
phase = .failed(error.localizedDescription)
|
phase = .failed(error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,8 +160,14 @@ final class PodcastPlayerViewModel {
|
|||||||
|
|
||||||
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
|
||||||
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
|
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
|
||||||
self?.currentTime = t.seconds
|
guard let self else { return }
|
||||||
self?.updateNowPlaying()
|
let prev = self.currentTime
|
||||||
|
self.currentTime = t.seconds
|
||||||
|
self.updateNowPlaying()
|
||||||
|
// Track completion when crossing 97%
|
||||||
|
if self.duration > 1 && prev / self.duration < 0.97 && t.seconds / self.duration >= 0.97 {
|
||||||
|
Analytics.track("podcast.completed", ["url": self.currentArticleUrl, "durationSec": Int(self.duration)])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupRemoteCommands()
|
setupRemoteCommands()
|
||||||
|
|||||||
Reference in New Issue
Block a user