From 0d2c04555fac974cf31df83053ac9d0a6a81f286 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 10 Jun 2026 15:17:31 -0400 Subject: [PATCH 1/3] fix(flags): dedupe exposures by latest assignment --- Datadog/Datadog.xcodeproj/project.pbxproj | 4 + .../Sources/Client/ExposureLogger.swift | 3 +- .../Sources/Client/ExposureTracker.swift | 71 +++++++++++----- .../Tests/Client/ExposureLoggerTests.swift | 42 ++++++++++ .../Tests/Client/ExposureTrackerTests.swift | 83 +++++++++++++++++++ 5 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 DatadogFlags/Tests/Client/ExposureTrackerTests.swift diff --git a/Datadog/Datadog.xcodeproj/project.pbxproj b/Datadog/Datadog.xcodeproj/project.pbxproj index 7664fa895a..4e6768905a 100644 --- a/Datadog/Datadog.xcodeproj/project.pbxproj +++ b/Datadog/Datadog.xcodeproj/project.pbxproj @@ -315,6 +315,7 @@ 5B4F37692E93C5C90093778F /* FlagsRUMIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4F37682E93C5B80093778F /* FlagsRUMIntegrationTests.swift */; }; 5B4F376C2E93C5E90093778F /* FlagsEvaluationIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4F376B2E93C5D90093778F /* FlagsEvaluationIntegrationTests.swift */; }; 5B4F552D2E853B3B00A241C3 /* ExposureLoggerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4F552B2E853B2E00A241C3 /* ExposureLoggerTests.swift */; }; + 5B4F55312E85400000A241C3 /* ExposureTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4F55302E85400000A241C3 /* ExposureTrackerTests.swift */; }; 5B4F552F2E853D7700A241C3 /* FlagsClientMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B4F552E2E853D7200A241C3 /* FlagsClientMocks.swift */; }; 5B5B8CB62E8BCA6A00A6740E /* FlagsRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5B8CB52E8BCA5F00A6740E /* FlagsRepositoryTests.swift */; }; 5B5B8CB92E8BD44700A6740E /* FlagsDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B5B8CB82E8BD44000A6740E /* FlagsDataStore.swift */; }; @@ -2070,6 +2071,7 @@ 5B4F37682E93C5B80093778F /* FlagsRUMIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsRUMIntegrationTests.swift; sourceTree = ""; }; 5B4F376B2E93C5D90093778F /* FlagsEvaluationIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsEvaluationIntegrationTests.swift; sourceTree = ""; }; 5B4F552B2E853B2E00A241C3 /* ExposureLoggerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposureLoggerTests.swift; sourceTree = ""; }; + 5B4F55302E85400000A241C3 /* ExposureTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExposureTrackerTests.swift; sourceTree = ""; }; 5B4F552E2E853D7200A241C3 /* FlagsClientMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsClientMocks.swift; sourceTree = ""; }; 5B5B8CB52E8BCA5F00A6740E /* FlagsRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsRepositoryTests.swift; sourceTree = ""; }; 5B5B8CB82E8BD44000A6740E /* FlagsDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsDataStore.swift; sourceTree = ""; }; @@ -3811,6 +3813,7 @@ 5B4F552E2E853D7200A241C3 /* FlagsClientMocks.swift */, 5B16A3722E8434140046A863 /* ExposureMocks.swift */, 5B4F552B2E853B2E00A241C3 /* ExposureLoggerTests.swift */, + 5B4F55302E85400000A241C3 /* ExposureTrackerTests.swift */, 5BF802742E9CD28500097B0E /* RUMFlagEvaluationReporterTests.swift */, 5BB294AF2E82CCA100CAA44B /* ExposureRequestBuilderTests.swift */, 8DADC3C02E7CCF190060F558 /* FlagsClientTests.swift */, @@ -8006,6 +8009,7 @@ 5BF228702E795DF800D79E8A /* FlagsTests.swift in Sources */, 5BB294B02E82CCA400CAA44B /* ExposureRequestBuilderTests.swift in Sources */, 5B4F552D2E853B3B00A241C3 /* ExposureLoggerTests.swift in Sources */, + 5B4F55312E85400000A241C3 /* ExposureTrackerTests.swift in Sources */, 5B4F552F2E853D7700A241C3 /* FlagsClientMocks.swift in Sources */, 8DADC3C92E7CCF190060F558 /* FlagsClientTests.swift in Sources */, 5B1D028E2E8ED10200AB2391 /* FlagAssignmentsResponseTests.swift in Sources */, diff --git a/DatadogFlags/Sources/Client/ExposureLogger.swift b/DatadogFlags/Sources/Client/ExposureLogger.swift index 60d2323395..00ef6cb0f4 100644 --- a/DatadogFlags/Sources/Client/ExposureLogger.swift +++ b/DatadogFlags/Sources/Client/ExposureLogger.swift @@ -46,10 +46,9 @@ internal final class ExposureLogger: ExposureLogging { variationKey: assignment.variationKey ) - guard !loggedExposures.contains(exposure) else { + guard loggedExposures.track(exposure) else { return } - loggedExposures.insert(exposure) let date = dateProvider.now.addingTimeInterval(context.serverTimeOffset) let exposureEvent = ExposureEvent( diff --git a/DatadogFlags/Sources/Client/ExposureTracker.swift b/DatadogFlags/Sources/Client/ExposureTracker.swift index 5f4fb2d9c1..b304353cbc 100644 --- a/DatadogFlags/Sources/Client/ExposureTracker.swift +++ b/DatadogFlags/Sources/Client/ExposureTracker.swift @@ -14,39 +14,68 @@ internal final class ExposureTracker { let variationKey: String } - private class ExposureBox: NSObject { - let value: Exposure + private struct ExposureKey: Hashable { + let targetingKey: String + let flagKey: String - init(_ value: Exposure) { - self.value = value + init(_ exposure: Exposure) { + self.targetingKey = exposure.targetingKey + self.flagKey = exposure.flagKey } + } - override var hash: Int { - value.hashValue - } + private struct Assignment: Equatable { + let allocationKey: String + let variationKey: String - override func isEqual(_ object: Any?) -> Bool { - guard let other = object as? ExposureBox else { - return false - } - return value == other.value + init(_ exposure: Exposure) { + self.allocationKey = exposure.allocationKey + self.variationKey = exposure.variationKey } } - static let defaultCountLimit: Int = 1_000 + // Keep enough latest assignments for the expected mobile case of 2 subjects x 2,500 flags. + // A count-based LRU keeps this bounded without reintroducing NSCache's non-deterministic eviction. + static let defaultCountLimit: Int = 5_000 - private let cache = NSCache() - private let sentinel = NSNumber(value: true) + private var assignmentsByExposureKey: [ExposureKey: Assignment] = [:] + private var exposureKeysByRecency: [ExposureKey] = [] + private let countLimit: Int + private let lock: NSLocking - init(countLimit: Int = defaultCountLimit) { - self.cache.countLimit = countLimit + init( + countLimit: Int = defaultCountLimit, + lock: NSLocking = NSLock() + ) { + self.countLimit = countLimit + self.lock = lock } - func contains(_ exposure: Exposure) -> Bool { - cache.object(forKey: .init(exposure)) != nil + func track(_ exposure: Exposure) -> Bool { + lock.lock() + defer { lock.unlock() } + + let key = ExposureKey(exposure) + let assignment = Assignment(exposure) + guard assignmentsByExposureKey[key] != assignment else { + markRecentlyUsed(key) + return false + } + + assignmentsByExposureKey[key] = assignment + markRecentlyUsed(key) + evictLeastRecentlyUsedEntriesIfNeeded() + return true } - func insert(_ exposure: Exposure) { - cache.setObject(sentinel, forKey: .init(exposure)) + private func markRecentlyUsed(_ key: ExposureKey) { + exposureKeysByRecency.removeAll { $0 == key } + exposureKeysByRecency.append(key) + } + + private func evictLeastRecentlyUsedEntriesIfNeeded() { + while assignmentsByExposureKey.count > countLimit, !exposureKeysByRecency.isEmpty { + assignmentsByExposureKey.removeValue(forKey: exposureKeysByRecency.removeFirst()) + } } } diff --git a/DatadogFlags/Tests/Client/ExposureLoggerTests.swift b/DatadogFlags/Tests/Client/ExposureLoggerTests.swift index 6176973840..4c3628809d 100644 --- a/DatadogFlags/Tests/Client/ExposureLoggerTests.swift +++ b/DatadogFlags/Tests/Client/ExposureLoggerTests.swift @@ -148,6 +148,48 @@ final class ExposureLoggerTests: XCTestCase { XCTAssertEqual(featureScope.eventsWritten.count, 2, "Same flag with different allocation should be not deduplicated") } + func testLogExposureAssignmentCycle() { + // Given + let logger = ExposureLogger( + dateProvider: DateProviderMock(), + featureScope: featureScope + ) + let evaluationContext = FlagsEvaluationContext( + targetingKey: "target-1", + attributes: .mockAny() + ) + var assignmentA = FlagAssignment.mockAnyBoolean() + assignmentA.allocationKey = "allocation-a" + assignmentA.variationKey = "variation-a" + var assignmentB = assignmentA + assignmentB.allocationKey = "allocation-b" + assignmentB.variationKey = "variation-b" + + // When + logger.logExposure( + for: "flag-1", + assignment: assignmentA, + evaluationContext: evaluationContext + ) + logger.logExposure( + for: "flag-1", + assignment: assignmentB, + evaluationContext: evaluationContext + ) + logger.logExposure( + for: "flag-1", + assignment: assignmentA, + evaluationContext: evaluationContext + ) + + // Then + XCTAssertEqual( + featureScope.eventsWritten.count, + 3, + "Assignment changes should emit exposures, including cycles back to a previous assignment" + ) + } + func testLogExposureDifferentFlag() { // Given let logger = ExposureLogger( diff --git a/DatadogFlags/Tests/Client/ExposureTrackerTests.swift b/DatadogFlags/Tests/Client/ExposureTrackerTests.swift new file mode 100644 index 0000000000..d34fb7ec19 --- /dev/null +++ b/DatadogFlags/Tests/Client/ExposureTrackerTests.swift @@ -0,0 +1,83 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2019-Present Datadog, Inc. + */ + +import XCTest + +@_spi(Internal) +@testable import DatadogFlags + +final class ExposureTrackerTests: XCTestCase { + func testItEvictsLeastRecentlyUsedAssignmentWhenCountLimitIsExceeded() { + // Given + let tracker = ExposureTracker(countLimit: 2) + let exposure1 = ExposureTracker.Exposure( + targetingKey: "subject-1", + flagKey: "flag-1", + allocationKey: "allocation-a", + variationKey: "variation-a" + ) + let exposure2 = ExposureTracker.Exposure( + targetingKey: "subject-1", + flagKey: "flag-2", + allocationKey: "allocation-a", + variationKey: "variation-a" + ) + let exposure3 = ExposureTracker.Exposure( + targetingKey: "subject-1", + flagKey: "flag-3", + allocationKey: "allocation-a", + variationKey: "variation-a" + ) + + // When + XCTAssertTrue(tracker.track(exposure1)) + XCTAssertTrue(tracker.track(exposure2)) + XCTAssertFalse(tracker.track(exposure1)) + XCTAssertTrue(tracker.track(exposure3)) + + // Then + XCTAssertFalse(tracker.track(exposure1), "Recently used exposure should remain cached") + XCTAssertTrue(tracker.track(exposure2), "Least recently used exposure should be evicted") + } + + func testItTracksTwoSubjectsAcrossManyFlags() { + // Given + let tracker = ExposureTracker() + let targetingKeys = ["subject-1", "subject-2"] + let flagKeys = (0..<2_500).map { "flag-\($0)" } + + // Then + for targetingKey in targetingKeys { + for flagKey in flagKeys { + XCTAssertTrue( + tracker.track( + .init( + targetingKey: targetingKey, + flagKey: flagKey, + allocationKey: "allocation-a", + variationKey: "variation-a" + ) + ) + ) + } + } + + for targetingKey in targetingKeys { + for flagKey in flagKeys { + XCTAssertFalse( + tracker.track( + .init( + targetingKey: targetingKey, + flagKey: flagKey, + allocationKey: "allocation-a", + variationKey: "variation-a" + ) + ) + ) + } + } + } +} From 27883bc95f93754b6a24f41a5c8c4a9fc3a919c3 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Wed, 10 Jun 2026 15:30:49 -0400 Subject: [PATCH 2/3] fix(flags): use platform exposure cache bound --- .../Sources/Client/ExposureTracker.swift | 50 ++++++++++--------- .../Tests/Client/ExposureTrackerTests.swift | 43 ++++++++++------ 2 files changed, 55 insertions(+), 38 deletions(-) diff --git a/DatadogFlags/Sources/Client/ExposureTracker.swift b/DatadogFlags/Sources/Client/ExposureTracker.swift index b304353cbc..8b78372deb 100644 --- a/DatadogFlags/Sources/Client/ExposureTracker.swift +++ b/DatadogFlags/Sources/Client/ExposureTracker.swift @@ -14,7 +14,7 @@ internal final class ExposureTracker { let variationKey: String } - private struct ExposureKey: Hashable { + private final class ExposureKey: NSObject { let targetingKey: String let flagKey: String @@ -22,9 +22,24 @@ internal final class ExposureTracker { self.targetingKey = exposure.targetingKey self.flagKey = exposure.flagKey } + + override var hash: Int { + var hasher = Hasher() + hasher.combine(targetingKey) + hasher.combine(flagKey) + return hasher.finalize() + } + + override func isEqual(_ object: Any?) -> Bool { + guard let other = object as? ExposureKey else { + return false + } + + return targetingKey == other.targetingKey && flagKey == other.flagKey + } } - private struct Assignment: Equatable { + private final class Assignment { let allocationKey: String let variationKey: String @@ -32,23 +47,26 @@ internal final class ExposureTracker { self.allocationKey = exposure.allocationKey self.variationKey = exposure.variationKey } + + func isEqual(to other: Assignment) -> Bool { + allocationKey == other.allocationKey && variationKey == other.variationKey + } } // Keep enough latest assignments for the expected mobile case of 2 subjects x 2,500 flags. - // A count-based LRU keeps this bounded without reintroducing NSCache's non-deterministic eviction. + // Normal flag keys are typically tens of characters, so entry count is easier to reason about + // than object-size estimates. static let defaultCountLimit: Int = 5_000 - private var assignmentsByExposureKey: [ExposureKey: Assignment] = [:] - private var exposureKeysByRecency: [ExposureKey] = [] - private let countLimit: Int + private let assignmentsByExposureKey = NSCache() private let lock: NSLocking init( countLimit: Int = defaultCountLimit, lock: NSLocking = NSLock() ) { - self.countLimit = countLimit self.lock = lock + self.assignmentsByExposureKey.countLimit = countLimit } func track(_ exposure: Exposure) -> Bool { @@ -57,25 +75,11 @@ internal final class ExposureTracker { let key = ExposureKey(exposure) let assignment = Assignment(exposure) - guard assignmentsByExposureKey[key] != assignment else { - markRecentlyUsed(key) + guard assignmentsByExposureKey.object(forKey: key)?.isEqual(to: assignment) != true else { return false } - assignmentsByExposureKey[key] = assignment - markRecentlyUsed(key) - evictLeastRecentlyUsedEntriesIfNeeded() + assignmentsByExposureKey.setObject(assignment, forKey: key) return true } - - private func markRecentlyUsed(_ key: ExposureKey) { - exposureKeysByRecency.removeAll { $0 == key } - exposureKeysByRecency.append(key) - } - - private func evictLeastRecentlyUsedEntriesIfNeeded() { - while assignmentsByExposureKey.count > countLimit, !exposureKeysByRecency.isEmpty { - assignmentsByExposureKey.removeValue(forKey: exposureKeysByRecency.removeFirst()) - } - } } diff --git a/DatadogFlags/Tests/Client/ExposureTrackerTests.swift b/DatadogFlags/Tests/Client/ExposureTrackerTests.swift index d34fb7ec19..4ff8a234b3 100644 --- a/DatadogFlags/Tests/Client/ExposureTrackerTests.swift +++ b/DatadogFlags/Tests/Client/ExposureTrackerTests.swift @@ -10,37 +10,50 @@ import XCTest @testable import DatadogFlags final class ExposureTrackerTests: XCTestCase { - func testItEvictsLeastRecentlyUsedAssignmentWhenCountLimitIsExceeded() { + func testItSuppressesRepeatedAssignment() { // Given - let tracker = ExposureTracker(countLimit: 2) - let exposure1 = ExposureTracker.Exposure( + let tracker = ExposureTracker() + let exposure = ExposureTracker.Exposure( targetingKey: "subject-1", flagKey: "flag-1", allocationKey: "allocation-a", variationKey: "variation-a" ) - let exposure2 = ExposureTracker.Exposure( + + // When + let firstTrack = tracker.track(exposure) + let secondTrack = tracker.track(exposure) + + // Then + XCTAssertTrue(firstTrack) + XCTAssertFalse(secondTrack) + } + + func testItTracksAssignmentCycle() { + // Given + let tracker = ExposureTracker() + let exposureA = ExposureTracker.Exposure( targetingKey: "subject-1", - flagKey: "flag-2", + flagKey: "flag-1", allocationKey: "allocation-a", variationKey: "variation-a" ) - let exposure3 = ExposureTracker.Exposure( + let exposureB = ExposureTracker.Exposure( targetingKey: "subject-1", - flagKey: "flag-3", - allocationKey: "allocation-a", - variationKey: "variation-a" + flagKey: "flag-1", + allocationKey: "allocation-b", + variationKey: "variation-b" ) // When - XCTAssertTrue(tracker.track(exposure1)) - XCTAssertTrue(tracker.track(exposure2)) - XCTAssertFalse(tracker.track(exposure1)) - XCTAssertTrue(tracker.track(exposure3)) + let firstTrack = tracker.track(exposureA) + let secondTrack = tracker.track(exposureB) + let thirdTrack = tracker.track(exposureA) // Then - XCTAssertFalse(tracker.track(exposure1), "Recently used exposure should remain cached") - XCTAssertTrue(tracker.track(exposure2), "Least recently used exposure should be evicted") + XCTAssertTrue(firstTrack) + XCTAssertTrue(secondTrack) + XCTAssertTrue(thirdTrack) } func testItTracksTwoSubjectsAcrossManyFlags() { From 8417cf281e0a1df7fadfce77a19adb2cd13b7b47 Mon Sep 17 00:00:00 2001 From: Leo Romanovsky Date: Fri, 26 Jun 2026 08:51:01 -0400 Subject: [PATCH 3/3] fix(flags): add exposure cache changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51d58b8ee3..593111219e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased - [FEATURE] Add wildcard host pattern support to WebView tracking via `WebViewTracking.enable(webView:hostPatterns:)`. See [#2963][] +- [FIX] Fix `DatadogFlags` exposure deduplication so assignment changes for the same subject and flag emit new exposure events. See [#2987][] # 3.12.0 / 04-06-2026 @@ -1173,6 +1174,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO [#2968]: https://github.com/DataDog/dd-sdk-ios/pull/2968 [#2969]: https://github.com/DataDog/dd-sdk-ios/pull/2969 [#2963]: https://github.com/DataDog/dd-sdk-ios/pull/2963 +[#2987]: https://github.com/DataDog/dd-sdk-ios/pull/2987 [@00fa9a]: https://github.com/00FA9A [@britton-earnin]: https://github.com/Britton-Earnin