-
Notifications
You must be signed in to change notification settings - Fork 163
FFL-1721 Emit flagevaluation EVP Track
#2646
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
Merged
gh-worker-dd-mergequeue-cf854d
merged 27 commits into
develop
from
sameerank/FFL-1721/emit-flagevaluation-evp-track
Feb 6, 2026
+1,699
−24
Merged
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
283f31d
FFL-1721 feat: Add evaluation logging for feature flags
sameerank f63b5ec
FFL-1721 test: Add evaluation logging assertions to client tests
sameerank 7ebed62
FFL-1721 test: Add test skeletons for EVALLOG spec compliance
sameerank b1bdc6a
FFL-1721 test: Implement EvaluationAggregatorTests
sameerank 9eda308
FFL-1721 test: Implement EVALLOG specification tests
sameerank ab61454
FFL-1721 fix: Evaluation logging fixes and cleanup
sameerank 8e70f92
FFL-1721 fix: Hash full context values in evaluation aggregation
sameerank 0a26a49
FFL-1721 feat: Add evaluationFlushInterval configuration option
sameerank 0ff326e
FFL-1721 refactor: Eliminate skipped tests in EvaluationLoggingTests
sameerank 166aae5
FFL-1721 fix: Add evaluation logging files to tvOS target
sameerank a469272
FFL-1721 fix: Change FlagsEvaluationFeature to DatadogRemoteFeature
sameerank b94f1f2
FFL-1721 feat: Log evaluations for error cases per EVALLOG.2
sameerank e3581cd
FFL-1721 refactor: Use standardized normalizedDeviceType mapping
sameerank 855b7ea
FFL-1721 fix: Use guard let for optional context binding
sameerank 4e6b508
FFL-1721 feat: Populate RUM context in evaluation requests
sameerank f61b383
FFL-1721 refactor: Add Flushable conformance to EvaluationAggregator
sameerank 84ca815
FFL-1721 refactor: Use ReadWriteLock instead of DispatchQueue in Eval…
sameerank 4acd6b6
FFL-1721 refactor: Remove Flushable conformance from EvaluationAggreg…
sameerank bf2885f
FFL-1721 fix: Remove unnecessary Thread.sleep calls from tests
sameerank 23c215a
FFL-1721 refactor: Consolidate EVALLOG tests and remove remaining Thr…
sameerank d8f05aa
FFL-1721 fix: Remove deinit flush to avoid Swift exclusivity conflict
sameerank dfcdb55
FFL-1721 feat: Add Flushable conformance to flush evaluations on SDK …
sameerank 1e84ef7
FFL-1721 docs: Add CHANGELOG entry for evaluation logging
sameerank eb68983
FFL-1721 refactor: Extract evaluation error strings into constants
sameerank 52181ac
FFL-1721 chore: Update api-surface-swift for evaluation logging
sameerank 34b4672
FFL-1721 refactor: Rename tests to follow testGivenWhenThen convention
sameerank 31d3a5c
Merge remote-tracking branch 'origin/develop' into sameerank/FFL-1721…
sameerank File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
63 changes: 63 additions & 0 deletions
63
Datadog/IntegrationUnitTests/Flags/FlagsEvaluationIntegrationTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /* | ||
| * 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 | ||
| import TestUtilities | ||
| import DatadogInternal | ||
|
|
||
| @_spi(Internal) | ||
| @testable import DatadogFlags | ||
|
|
||
| /// Covers integration scenarios for flag evaluation logging. | ||
| final class FlagsEvaluationIntegrationTests: XCTestCase { | ||
| private enum Fixtures { | ||
| static let flagsData = FlagsData( | ||
| flags: [ | ||
| "test-flag": .init( | ||
| allocationKey: "allocation-123", | ||
| variationKey: "variation-123", | ||
| variation: .boolean(true), | ||
| reason: "TARGETING_MATCH", | ||
| doLog: true | ||
| ) | ||
| ], | ||
| context: .init( | ||
| targetingKey: "user-123", | ||
| attributes: [:] | ||
| ), | ||
| date: .mockAny() | ||
| ) | ||
| } | ||
|
|
||
| // MARK: - EVALLOG.4: Shutdown Flush | ||
|
|
||
| /// EVALLOG.4: Evaluations are flushed when SDK shuts down via flushAndTearDown() | ||
| func testWhenSDKShutsDown_itFlushesPendingEvaluations() throws { | ||
| // Given | ||
| let core = DatadogCoreProxy(context: .mockWith(trackingConsent: .granted)) | ||
| Flags.enable(with: .init(trackEvaluations: true), in: core) | ||
|
|
||
| let featureScope = core.scope(for: FlagsFeature.self) | ||
| featureScope.flagsDataStore.setFlagsData(Fixtures.flagsData, forClientNamed: FlagsClient.defaultName) | ||
| featureScope.dataStore.flush() | ||
|
|
||
| let client = FlagsClient.create(in: core) | ||
|
|
||
| // When | ||
| _ = client.getBooleanValue(key: "test-flag", defaultValue: false) | ||
|
|
||
| // Then | ||
| try core.flushAndTearDown() | ||
|
|
||
| let events = core.waitAndReturnEvents( | ||
| ofFeature: FlagsEvaluationFeature.name, | ||
| ofType: FlagEvaluationEvent.self | ||
| ) | ||
|
|
||
| XCTAssertEqual(events.count, 1, "Should have flushed pending evaluations on shutdown") | ||
| XCTAssertEqual(events.first?.flag.key, "test-flag") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| /* | ||
| * 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 Foundation | ||
| import DatadogInternal | ||
|
|
||
| internal final class EvaluationAggregator { | ||
| private let flushInterval: TimeInterval | ||
| private let maxAggregations: Int | ||
| private let dateProvider: any DateProvider | ||
| private let featureScope: FeatureScope | ||
| @ReadWriteLock | ||
| private var aggregations: [AggregationKey: AggregatedEvaluation] = [:] | ||
| private var flushTimer: Timer? | ||
|
|
||
| init( | ||
| dateProvider: any DateProvider, | ||
| featureScope: FeatureScope, | ||
| flushInterval: TimeInterval, | ||
| maxAggregations: Int = 1_000 | ||
| ) { | ||
| self.dateProvider = dateProvider | ||
| self.featureScope = featureScope | ||
| self.flushInterval = flushInterval | ||
| self.maxAggregations = maxAggregations | ||
|
|
||
| startFlushTimer() | ||
| } | ||
|
|
||
| deinit { | ||
| flushTimer?.invalidate() | ||
| // Note: We don't flush here due to exclusivity conflict with DatadogCore.stop() | ||
| } | ||
|
|
||
| func recordEvaluation( | ||
| for flagKey: String, | ||
| assignment: FlagAssignment, | ||
| evaluationContext: FlagsEvaluationContext, | ||
| flagError: String? | ||
| ) { | ||
| let errorMessage = flagError | ||
| let now = dateProvider.now.timeIntervalSince1970.dd.toInt64Milliseconds | ||
|
|
||
| let key = AggregationKey( | ||
| flagKey: flagKey, | ||
| variantKey: assignment.variationKey, | ||
| allocationKey: assignment.allocationKey, | ||
| targetingKey: evaluationContext.targetingKey, | ||
| errorMessage: errorMessage, | ||
| context: evaluationContext.attributes | ||
| ) | ||
|
|
||
| var shouldFlush = false | ||
|
|
||
| _aggregations.mutate { aggregations in | ||
| if var existing = aggregations[key] { | ||
| existing.evaluationCount += 1 | ||
| existing.lastEvaluation = now | ||
| aggregations[key] = existing | ||
| } else { | ||
| let runtimeDefaultUsed = assignment.reason == "DEFAULT" || errorMessage != nil | ||
|
|
||
| let aggregated = AggregatedEvaluation( | ||
| flagKey: flagKey, | ||
| variantKey: assignment.variationKey, | ||
| allocationKey: assignment.allocationKey, | ||
| targetingKey: evaluationContext.targetingKey, | ||
| targetingRuleKey: nil, | ||
| errorMessage: errorMessage, | ||
| context: evaluationContext.attributes, | ||
| firstEvaluation: now, | ||
| lastEvaluation: now, | ||
| evaluationCount: 1, | ||
| runtimeDefaultUsed: runtimeDefaultUsed ? true : nil | ||
| ) | ||
|
|
||
| aggregations[key] = aggregated | ||
| } | ||
|
|
||
| shouldFlush = aggregations.count >= self.maxAggregations | ||
| } | ||
|
|
||
| if shouldFlush { | ||
| sendEvaluations() | ||
| } | ||
| } | ||
|
|
||
| func sendEvaluations() { | ||
| var evaluationsToSend: [AggregatedEvaluation] = [] | ||
|
|
||
| _aggregations.mutate { aggregations in | ||
| evaluationsToSend = Array(aggregations.values) | ||
| aggregations.removeAll() | ||
| } | ||
|
|
||
| guard !evaluationsToSend.isEmpty else { | ||
| return | ||
| } | ||
|
|
||
| featureScope.eventWriteContext { _, writer in | ||
| for aggregated in evaluationsToSend { | ||
| let event = aggregated.toFlagEvaluationEvent() | ||
| writer.write(value: event) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func startFlushTimer() { | ||
| flushTimer = Timer.scheduledTimer( | ||
| withTimeInterval: flushInterval, | ||
| repeats: true | ||
| ) { [weak self] _ in | ||
| self?.sendEvaluations() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private struct AggregationKey: Hashable { | ||
| let flagKey: String | ||
| let variantKey: String | ||
| let allocationKey: String | ||
| let targetingKey: String | ||
| let errorMessage: String? | ||
| let contextHash: Int | ||
|
|
||
| init( | ||
| flagKey: String, | ||
| variantKey: String, | ||
| allocationKey: String, | ||
| targetingKey: String, | ||
| errorMessage: String?, | ||
| context: [String: AnyValue] | ||
| ) { | ||
| self.flagKey = flagKey | ||
| self.variantKey = variantKey | ||
| self.allocationKey = allocationKey | ||
| self.targetingKey = targetingKey | ||
| self.errorMessage = errorMessage | ||
| var hasher = Hasher() | ||
| for key in context.keys.sorted() { | ||
| hasher.combine(key) | ||
| hasher.combine(context[key]) | ||
| } | ||
| self.contextHash = hasher.finalize() | ||
| } | ||
| } | ||
|
|
||
| private struct AggregatedEvaluation { | ||
| let flagKey: String | ||
| let variantKey: String | ||
| let allocationKey: String | ||
| let targetingKey: String | ||
| let targetingRuleKey: String? | ||
| let errorMessage: String? | ||
| let context: [String: AnyValue] | ||
|
|
||
| let firstEvaluation: Int64 | ||
| var lastEvaluation: Int64 | ||
| var evaluationCount: Int | ||
| let runtimeDefaultUsed: Bool? | ||
|
|
||
| func toFlagEvaluationEvent() -> FlagEvaluationEvent { | ||
| let eventContext: FlagEvaluationEvent.EvaluationEventContext? = context.isEmpty ? nil : .init( | ||
| evaluation: context, | ||
| dd: nil | ||
| ) | ||
|
|
||
| return FlagEvaluationEvent( | ||
| timestamp: firstEvaluation, | ||
| flag: .init(key: flagKey), | ||
| firstEvaluation: firstEvaluation, | ||
| lastEvaluation: lastEvaluation, | ||
| evaluationCount: evaluationCount, | ||
| variant: runtimeDefaultUsed == true ? nil : .init(key: variantKey), | ||
| allocation: runtimeDefaultUsed == true ? nil : .init(key: allocationKey), | ||
| targetingRule: targetingRuleKey.map { .init(key: $0) }, | ||
| targetingKey: targetingKey, | ||
| runtimeDefaultUsed: runtimeDefaultUsed, | ||
| error: errorMessage.map { .init(message: $0) }, | ||
| context: eventContext | ||
| ) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| /* | ||
| * 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 Foundation | ||
| import DatadogInternal | ||
|
|
||
| internal protocol EvaluationLogging { | ||
| func logEvaluation( | ||
| for flagKey: String, | ||
| assignment: FlagAssignment, | ||
| evaluationContext: FlagsEvaluationContext, | ||
| flagError: String? | ||
| ) | ||
| } | ||
|
|
||
| internal final class EvaluationLogger: EvaluationLogging { | ||
| private let aggregator: EvaluationAggregator | ||
|
|
||
| init(aggregator: EvaluationAggregator) { | ||
| self.aggregator = aggregator | ||
| } | ||
|
|
||
| func logEvaluation( | ||
| for flagKey: String, | ||
| assignment: FlagAssignment, | ||
| evaluationContext: FlagsEvaluationContext, | ||
| flagError: String? | ||
| ) { | ||
| aggregator.recordEvaluation( | ||
| for: flagKey, | ||
| assignment: assignment, | ||
| evaluationContext: evaluationContext, | ||
| flagError: flagError | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| internal final class NOPEvaluationLogger: EvaluationLogging { | ||
| func logEvaluation( | ||
| for flagKey: String, | ||
| assignment: FlagAssignment, | ||
| evaluationContext: FlagsEvaluationContext, | ||
| flagError: String? | ||
| ) { | ||
| // No-op | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the conflict I'm referring to
Also saw it on CI, e.g. here where
FlagsRUMIntegrationTests.testWhenFlagIsEvaluated_itAddsFeatureFlagToRUMView()failed due to the test crashingDuring SDK teardown if we call
sendEvaluations()here, an exclusivity conflict occurs whenEvaluationAggregator.deinittries to flush evaluations whileDatadogCore.stop()is modifying the features dictionary. The chain is:deinit→sendEvaluations()→eventWriteContext→feature(named:type:)→ readsfeatures[name]stop()is writingfeatures = [:]So I ended up needing to bring back
Flushableconformance, so we have access to theFlushable.flush()pre-shutdown hook.dd-sdk-ios/DatadogCore/Sources/Core/DatadogCore.swift
Lines 597 to 598 in f9119f5
which is called before
.stop.