Add iOS Langfuse analytics via backend events endpoint
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:
Krishna Kumar
2026-05-22 21:30:03 -05:00
parent 38cd72416c
commit 18423a7633
4 changed files with 50 additions and 5 deletions

View File

@@ -88,6 +88,7 @@ final class BookmarksViewModel {
restoreAIData(into: &all)
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
nextPageUrl = nil
Analytics.track("search.semantic", ["query": searchQuery, "resultCount": bookmarks.count])
} catch {
self.error = error.localizedDescription
}
@@ -159,6 +160,7 @@ final class BookmarksViewModel {
do {
let allResponse = try await api.fetchBookmarks(limit: 200)
smartCollections = try await claude.generateCollections(from: allResponse.results)
Analytics.track("collections.generated", ["collectionCount": smartCollections.count])
} catch {
self.error = error.localizedDescription
}
@@ -180,6 +182,8 @@ final class BookmarksViewModel {
break
}
}
let enriched = toEnrich.count - bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.count
if enriched > 0 { Analytics.track("enrich.completed", ["count": enriched]) }
enrichmentProgress = 0
}

View File

@@ -59,10 +59,12 @@ final class PodcastPlayerViewModel {
let cached = ClaudeService.cachedPodcastURL(for: articleUrl)
if FileManager.default.fileExists(atPath: cached.path) {
Analytics.track("podcast.started", ["url": articleUrl, "cached": true])
setupPlayer(url: cached, title: "Podcast")
return
}
Analytics.track("podcast.started", ["url": articleUrl, "cached": false])
phase = .generating(progress: 0, label: "Starting…")
pollingTask = Task {
@@ -81,13 +83,16 @@ final class PodcastPlayerViewModel {
return
}
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
}
try await Task.sleep(for: .seconds(3))
}
} catch is CancellationError {
} catch {
Analytics.track("podcast.failed", ["url": articleUrl, "error": error.localizedDescription])
phase = .failed(error.localizedDescription)
}
}
@@ -155,8 +160,14 @@ final class PodcastPlayerViewModel {
let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
timeObserver = p.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] t in
self?.currentTime = t.seconds
self?.updateNowPlaying()
guard let self else { return }
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()