-
Notifications
You must be signed in to change notification settings - Fork 30
feature: Added Filtering and Rate Limiting #1073
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d94dc8e
a4c26b2
9201e05
520720a
3d8da98
d0f1ef4
ce1168f
d70e5d1
1579e6e
d2c2bfb
98d2ef7
b8502a6
2ef90b8
aff42b0
de4272a
43b4501
46177f0
fca8019
a8570d1
b78fda6
9061e1e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import Foundation | ||
| import SyncSqlCipher | ||
|
|
||
| public extension DIGraphShared { | ||
| /// Creates, migrates, and registers the ``StorageManager`` for a given CDP API key. | ||
| /// | ||
| /// Follows the same pattern as ``registerPendingPushDeliveryStore(appGroupId:)`` | ||
| /// — call this once from ``DataPipeline.initialize(moduleConfig:)`` before | ||
| /// creating ``DataPipelineImplementation``, so the graph has a live instance | ||
| /// ready for any code that calls ``storageManager``. | ||
| /// | ||
| /// Returns `nil` and logs an error if the database cannot be opened or | ||
| /// migrations fail. Callers that depend on storage must handle a nil result | ||
| /// gracefully; the aggregation engine will be a no-op in that case. | ||
| @discardableResult | ||
| func registerStorageManager(cdpApiKey: String) -> StorageManager? { | ||
| if let existing: StorageManager = getOptional(StorageManager.self) { | ||
| return existing | ||
| } | ||
|
|
||
| do { | ||
| let path = Self.databasePath(for: cdpApiKey) | ||
| let db = try Database(path: path, key: cdpApiKey) | ||
| let storage = StorageManager(db: db) | ||
| try storage.runMigrations() | ||
| register(storage, forType: StorageManager.self) | ||
| return storage | ||
| } catch { | ||
| logger.error("CIO StorageManager failed to initialize: \(error)") | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// The registered ``StorageManager``, or `nil` if ``registerStorageManager(cdpApiKey:)`` | ||
| /// has not been called yet or failed. | ||
| var storageManager: StorageManager? { | ||
| getOptional(StorageManager.self) | ||
| } | ||
|
|
||
| private static func databasePath(for cdpApiKey: String) -> String { | ||
| let appSupport = FileManager.default | ||
| .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] | ||
| let dir = appSupport | ||
| .appendingPathComponent("io.customer", isDirectory: true) | ||
| .appendingPathComponent(cdpApiKey, isDirectory: true) | ||
| try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) | ||
| return dir.appendingPathComponent("cio.db").path | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| import Foundation | ||
| @preconcurrency import SyncSqlCipher | ||
|
|
||
| /// Gateway for all encrypted on-disk storage used by the aggregation engine. | ||
| /// | ||
| /// Implemented as a `struct` because `SyncSqlCipher.Database` is internally | ||
| /// thread-safe (DispatchQueue-serialized), so `StorageManager` carries no | ||
| /// mutable state of its own. | ||
| public struct StorageManager: Sendable { | ||
| // public so per-module StorageManager+X.swift extensions can reach it directly. | ||
| // Restrict to `package` once CocoaPods support is dropped. | ||
| public let db: Database | ||
|
|
||
| public init(db: Database) { | ||
| self.db = db | ||
| } | ||
|
|
||
| /// Apply schema migrations. Must be called once before any other method. | ||
| /// Pass `extra` migrations from modules that extend the schema. | ||
| public func runMigrations(extra: [any Migration] = []) throws { | ||
| try db.migrate([CreateAggregationSchema()] + extra) | ||
| } | ||
|
|
||
| // MARK: - sdk_meta (utility key/value table) | ||
|
|
||
| public func getMetaValue(_ key: String) throws -> String? { | ||
| try db.fetchOne(SdkMetaRecord.self, id: key)?.value | ||
| } | ||
|
|
||
| public func setMetaValue(_ value: String?, for key: String) throws { | ||
| if let value { | ||
| _ = try db.save(SdkMetaRecord(key: key, value: value)) | ||
| } else { | ||
| try db.execute("DELETE FROM sdk_meta WHERE key = ?", key) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Entity Records | ||
|
|
||
| private struct SdkMetaRecord: Entity { | ||
| static let tableName = TableName("sdk_meta") | ||
| static let primaryKeyName = "key" | ||
| static let primaryKey: WritableKeyPath<SdkMetaRecord, String> & Sendable = \.key | ||
|
|
||
| var key: String | ||
| var value: String | ||
| } | ||
|
|
||
| // MARK: - Schema | ||
|
|
||
| private struct CreateAggregationSchema: Migration { | ||
| let id = "001-create-aggregation-schema" | ||
|
|
||
| func up(_ ctx: MigrationContext) throws { | ||
| // Server-fetched aggregation ruleset (single row, id always = 1). | ||
| try ctx.execute( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS aggregation_rules ( | ||
| id INTEGER PRIMARY KEY CHECK (id = 1), | ||
| payload TEXT NOT NULL, | ||
| fetched_at TEXT NOT NULL | ||
| ) | ||
| """ | ||
| ) | ||
| // Per-rule accumulator state (counters for rate limiting). | ||
| try ctx.execute( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS aggregation_state ( | ||
| rule_id TEXT NOT NULL PRIMARY KEY, | ||
| state_json TEXT NOT NULL, | ||
| last_flushed_at INTEGER NOT NULL DEFAULT 0, | ||
| scope TEXT NOT NULL DEFAULT 'profile' | ||
| ) | ||
| """ | ||
| ) | ||
| // General-purpose SDK key/value metadata. | ||
| try ctx.execute( | ||
| """ | ||
| CREATE TABLE IF NOT EXISTS sdk_meta ( | ||
| key TEXT NOT NULL PRIMARY KEY, | ||
| value TEXT NOT NULL | ||
| ) | ||
| """ | ||
| ) | ||
| } | ||
|
|
||
| func down(_ ctx: MigrationContext) throws {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import Foundation | ||
|
|
||
| enum RuleScope: String, Codable, Sendable { | ||
| case profile | ||
| case device | ||
|
|
||
| init(from decoder: Decoder) throws { | ||
| let raw = try decoder.singleValueContainer().decode(String.self) | ||
| self = RuleScope(rawValue: raw) ?? .profile | ||
| } | ||
| } | ||
|
|
||
| struct FilterEntry: Codable, Sendable { | ||
| let eventType: String | ||
| let name: String | ||
| let scope: RuleScope | ||
|
|
||
| enum CodingKeys: String, CodingKey { case eventType, name, scope } | ||
|
|
||
| init(from decoder: Decoder) throws { | ||
| let c = try decoder.container(keyedBy: CodingKeys.self) | ||
| self.eventType = try c.decode(String.self, forKey: .eventType) | ||
| self.name = try c.decode(String.self, forKey: .name) | ||
| self.scope = try c.decodeIfPresent(RuleScope.self, forKey: .scope) ?? .profile | ||
| } | ||
| } | ||
|
|
||
| struct RateLimitEntry: Codable, Sendable { | ||
| let eventType: String | ||
| let name: String | ||
| let windowSeconds: Int | ||
| let scope: RuleScope | ||
|
|
||
| enum CodingKeys: String, CodingKey { case eventType, name, windowSeconds, scope } | ||
|
|
||
| init(from decoder: Decoder) throws { | ||
| let c = try decoder.container(keyedBy: CodingKeys.self) | ||
| self.eventType = try c.decode(String.self, forKey: .eventType) | ||
| self.name = try c.decode(String.self, forKey: .name) | ||
| self.windowSeconds = try c.decode(Int.self, forKey: .windowSeconds) | ||
| self.scope = try c.decodeIfPresent(RuleScope.self, forKey: .scope) ?? .profile | ||
| } | ||
| } | ||
|
|
||
| struct AggregationRuleset: Codable, Sendable { | ||
| let filters: [FilterEntry]? | ||
| let rateLimits: [RateLimitEntry]? | ||
| // rules reserved for next aggregation phase | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import CioInternalCommon | ||
| import Foundation | ||
|
|
||
| /// Synchronous, thread-safe engine that decides whether an event should be | ||
| /// allowed through or dropped based on the active ``AggregationRuleset``. | ||
| /// | ||
| /// `@unchecked Sendable` is safe here: all mutable state (`processed`) is | ||
| /// protected by `Synchronized<>`, and `StorageManager.db` is serialized | ||
| /// internally via a private DispatchQueue. | ||
| final class EventPolicyEngine: Sendable { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm finding myself reverse understanding how this engine would work, it would be very helpful if we can create a spec that describes the intended behavior
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Given the issues we've seen, what makes sense to me is something like:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The code is pretty clear so I'm not sure what you are not unerstanding. De-duplication is not in scope for this. Segment has 5 basic event types: Filter rules have a event name and type. When the engine gets asked if an event should be passed on, it uses the name and type to check against a look up table (O(1)) in memory to see if that particular event type and name combination is filtered or rate limited. If the event is neither, it goes on. If it is filtered, the event is discarded immediately. If it is rate limited, we check and set (atomically) the event's database record to see when it was seen last. If it has not been seen within the window, the last seen time is updated and the event is sent on. If it has been seen within the time window, the record is not modified and the event is destroyed. |
||
| /// Wire-format ruleset pre-processed into O(1) lookup structures. | ||
| private struct ProcessedRuleset { | ||
| /// Keys of the form `"\(eventType):\(name)"` for blocked events. | ||
| let filterKeys: Set<String> | ||
| /// Rate-limit rules keyed by `"\(eventType):\(name)"`. First rule wins on duplicates. | ||
| let rateLimitsByKey: [String: RateLimitEntry] | ||
|
|
||
| init(_ ruleset: AggregationRuleset) { | ||
| self.filterKeys = Set((ruleset.filters ?? []).map { "\($0.eventType):\($0.name)" }) | ||
| self.rateLimitsByKey = Dictionary( | ||
| (ruleset.rateLimits ?? []).map { ("\($0.eventType):\($0.name)", $0) }, | ||
| uniquingKeysWith: { first, _ in first } | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private let storage: StorageManager | ||
| private let processed = Synchronized<ProcessedRuleset?>(nil) | ||
|
|
||
| init(storage: StorageManager) { | ||
| self.storage = storage | ||
| } | ||
|
|
||
| /// Replace the active ruleset. Call this after receiving updated config from the server. | ||
| func load(ruleset: AggregationRuleset?) { | ||
| processed.wrappedValue = ruleset.map { ProcessedRuleset($0) } | ||
| } | ||
|
|
||
| /// Returns `true` if the event is allowed through, `false` if it should be dropped. | ||
| func shouldAllow(eventType: String, name: String) -> Bool { | ||
| shouldAllow(eventType: eventType, name: name, now: Int64(Date().timeIntervalSince1970)) | ||
| } | ||
|
|
||
| /// Time-injectable overload used by tests. | ||
| func shouldAllow(eventType: String, name: String, now: Int64) -> Bool { | ||
| guard let rs = processed.wrappedValue else { return true } | ||
|
|
||
| let exactKey = "\(eventType):\(name)" | ||
| let wildcardKey = "\(eventType):*" | ||
|
|
||
| if rs.filterKeys.contains(exactKey) || rs.filterKeys.contains(wildcardKey) { | ||
| return false | ||
| } | ||
|
|
||
| if let rl = rs.rateLimitsByKey[exactKey] ?? rs.rateLimitsByKey[wildcardKey] { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does rate limiting |
||
| // Fail open on DB error so a storage failure doesn't silently discard events. | ||
| return (try? storage.checkAndUpdateRateLimit( | ||
| key: exactKey, | ||
| now: now, | ||
| windowSeconds: Int64(rl.windowSeconds), | ||
| scope: rl.scope.rawValue | ||
| )) ?? true | ||
| } | ||
|
|
||
| return true | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.