-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Anonymous Feed Popularity Tracking in CloudKit
This document describes how to implement anonymous subscriber counting for feeds in Celestra using CloudKit's public database.
Goals
- Track how many users are subscribed to each feed (popularity metric)
- Ensure each user counts only once per feed, regardless of how many devices they use
- Preserve user anonymity—no way to correlate subscriptions across feeds or identify users
Approach: Hashed Subscription Records
Create a FeedSubscription record type in the public database. The record ID is a SHA256 hash of the user's record ID combined with the feed ID.
Why This Works
- Same user + same feed = same record ID → prevents duplicates across devices
- Hash includes feed ID → can't correlate subscriptions across different feeds
- One-way hash → can't reverse to get the original user record ID
Implementation
Generate the Subscription Record ID
import CryptoKit
import CloudKit
func subscriptionRecordID(for feedRecordName: String) async throws -> CKRecord.ID {
let userRecordID = try await CKContainer.default().userRecordID()
// Combine feed + user so the hash is unique per feed
let input = "\(feedRecordName)-\(userRecordID.recordName)"
let hash = SHA256.hash(data: Data(input.utf8))
let recordName = hash.compactMap { String(format: "%02x", $0) }.joined()
return CKRecord.ID(recordName: recordName)
}Subscribe to a Feed
func subscribe(to feedRecordName: String) async throws {
let recordID = try await subscriptionRecordID(for: feedRecordName)
let record = CKRecord(recordType: "FeedSubscription", recordID: recordID)
record["feedRecordName"] = feedRecordName
try await CKContainer.default().publicCloudDatabase.save(record)
// If already subscribed, this overwrites the same record (idempotent)
}Unsubscribe from a Feed
func unsubscribe(from feedRecordName: String) async throws {
let recordID = try await subscriptionRecordID(for: feedRecordName)
try await CKContainer.default().publicCloudDatabase.deleteRecord(withID: recordID)
}Count Subscribers for a Feed
func subscriberCount(for feedRecordName: String) async throws -> Int {
let predicate = NSPredicate(format: "feedRecordName == %@", feedRecordName)
let query = CKQuery(recordType: "FeedSubscription", predicate: predicate)
let (results, _) = try await CKContainer.default().publicCloudDatabase
.records(matching: query)
return results.count
}Note: For feeds with many subscribers, consider caching the count on the
Feedrecord and updating it periodically via a scheduled job rather than counting on every request.
CloudKit Schema
FeedSubscription Record Type
| Field | Type | Description |
|---|---|---|
| feedRecordName | String | References Feed recordName |
The record ID itself is the hashed user+feed identifier—no need to store it as a separate field.
Indexes
- Add a queryable index on
feedRecordNameto support the count query
Important Considerations
iCloud Sign-In Required
CKContainer.default().userRecordID() throws if the user is not signed into iCloud. Handle this gracefully:
func subscribeIfPossible(to feedRecordName: String) async {
do {
try await subscribe(to: feedRecordName)
} catch let error as CKError where error.code == .notAuthenticated {
// User not signed into iCloud—skip popularity tracking
// The subscription still works locally, just doesn't count toward popularity
}
}What to Avoid
- Don't use
identifierForVendor— This is device-specific, so users with multiple devices would count multiple times. - Don't expose
CKRecord.creatorUserRecordID— This is the unhashed user ID and would compromise anonymity. - Don't store the raw user record ID — Always hash it before using in record IDs or fields.
Optional: Aggregate Counts via Scheduled Job
For better performance with popular feeds:
- Clients create/delete
FeedSubscriptionrecords as described above - A scheduled job (e.g., GitHub Action) periodically queries and counts subscriptions
- The job updates a
subscriberCountfield on eachFeedrecord - Clients read the cached count from the
Feedrecord
This provides eventual consistency without requiring clients to count on every request.