Skip to content
Merged
Show file tree
Hide file tree
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 Jan 20, 2026
f63b5ec
FFL-1721 test: Add evaluation logging assertions to client tests
sameerank Jan 20, 2026
7ebed62
FFL-1721 test: Add test skeletons for EVALLOG spec compliance
sameerank Jan 21, 2026
b1bdc6a
FFL-1721 test: Implement EvaluationAggregatorTests
sameerank Jan 21, 2026
9eda308
FFL-1721 test: Implement EVALLOG specification tests
sameerank Jan 21, 2026
ab61454
FFL-1721 fix: Evaluation logging fixes and cleanup
sameerank Jan 21, 2026
8e70f92
FFL-1721 fix: Hash full context values in evaluation aggregation
sameerank Jan 22, 2026
0a26a49
FFL-1721 feat: Add evaluationFlushInterval configuration option
sameerank Jan 22, 2026
0ff326e
FFL-1721 refactor: Eliminate skipped tests in EvaluationLoggingTests
sameerank Jan 22, 2026
166aae5
FFL-1721 fix: Add evaluation logging files to tvOS target
sameerank Jan 22, 2026
a469272
FFL-1721 fix: Change FlagsEvaluationFeature to DatadogRemoteFeature
sameerank Jan 23, 2026
b94f1f2
FFL-1721 feat: Log evaluations for error cases per EVALLOG.2
sameerank Jan 26, 2026
e3581cd
FFL-1721 refactor: Use standardized normalizedDeviceType mapping
sameerank Jan 26, 2026
855b7ea
FFL-1721 fix: Use guard let for optional context binding
sameerank Jan 26, 2026
4e6b508
FFL-1721 feat: Populate RUM context in evaluation requests
sameerank Jan 26, 2026
f61b383
FFL-1721 refactor: Add Flushable conformance to EvaluationAggregator
sameerank Jan 26, 2026
84ca815
FFL-1721 refactor: Use ReadWriteLock instead of DispatchQueue in Eval…
sameerank Jan 31, 2026
4acd6b6
FFL-1721 refactor: Remove Flushable conformance from EvaluationAggreg…
sameerank Jan 31, 2026
bf2885f
FFL-1721 fix: Remove unnecessary Thread.sleep calls from tests
sameerank Jan 31, 2026
23c215a
FFL-1721 refactor: Consolidate EVALLOG tests and remove remaining Thr…
sameerank Feb 1, 2026
d8f05aa
FFL-1721 fix: Remove deinit flush to avoid Swift exclusivity conflict
sameerank Feb 2, 2026
dfcdb55
FFL-1721 feat: Add Flushable conformance to flush evaluations on SDK …
sameerank Feb 3, 2026
1e84ef7
FFL-1721 docs: Add CHANGELOG entry for evaluation logging
sameerank Feb 3, 2026
eb68983
FFL-1721 refactor: Extract evaluation error strings into constants
sameerank Feb 4, 2026
52181ac
FFL-1721 chore: Update api-surface-swift for evaluation logging
sameerank Feb 4, 2026
34b4672
FFL-1721 refactor: Rename tests to follow testGivenWhenThen convention
sameerank Feb 4, 2026
31d3a5c
Merge remote-tracking branch 'origin/develop' into sameerank/FFL-1721…
sameerank Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Unreleased

- [IMPROVEMENT] Skip malformed Logs attributes individually instead of dropping the entire
event, and log clear error messages. See [#2665][]
- [FEATURE] Add evaluation logging to `DatadogFlags` module. See [#2646][]
- [IMPROVEMENT] Skip malformed Logs attributes individually instead of dropping the entire event, and log clear error messages. See [#2665][]

# 3.6.1 / 02-02-2026

- [FIX] Prevent crashes related to swapping the `__cxa_throw` function. See [#2661][]

# 3.6.0 / 28-01-2026

- [FEATURE] Add `DatadogProfiling` module to profile app launches. See [#2654][]
Expand Down Expand Up @@ -1038,6 +1038,7 @@ Release `2.0` introduces breaking changes. Follow the [Migration Guide](MIGRATIO
[#2633]: https://github.com/DataDog/dd-sdk-ios/pull/2633
[#2647]: https://github.com/DataDog/dd-sdk-ios/pull/2647
[#2640]: https://github.com/DataDog/dd-sdk-ios/pull/2640
[#2646]: https://github.com/DataDog/dd-sdk-ios/pull/2646
[#2639]: https://github.com/DataDog/dd-sdk-ios/pull/2639
[#2654]: https://github.com/DataDog/dd-sdk-ios/pull/2654
[#2665]: https://github.com/DataDog/dd-sdk-ios/pull/2665
Expand Down
68 changes: 54 additions & 14 deletions Datadog/Datadog.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

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")
}
}
186 changes: 186 additions & 0 deletions DatadogFlags/Sources/Client/EvaluationAggregator.swift
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()
Copy link
Contributor Author

@sameerank sameerank Feb 3, 2026

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

Screenshot 2026-02-02 at 4 26 42 PM

Also saw it on CI, e.g. here where FlagsRUMIntegrationTests.testWhenFlagIsEvaluated_itAddsFeatureFlagToRUMView() failed due to the test crashing

Test Suite 'FlagsRUMIntegrationTests' started at 2026-01-31 08:28:26.829.
note: [DatadogSDKTesting] Crash detected! Exiting...

During SDK teardown if we call sendEvaluations() here, an exclusivity conflict occurs when EvaluationAggregator.deinit tries to flush evaluations while DatadogCore.stop() is modifying the features dictionary. The chain is:

  1. deinitsendEvaluations()eventWriteContextfeature(named:type:) → reads features[name]
  2. Meanwhile, stop() is writing features = [:]

So I ended up needing to bring back Flushable conformance, so we have access to the Flushable.flush() pre-shutdown hook.

// Next, flush flushable Features - finish current data collection to open "event write contexts":
features.forEach { $0.flush() }

which is called before .stop.

    func flushAndTearDown() {
        flush()
        ...
        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
)
}
}
50 changes: 50 additions & 0 deletions DatadogFlags/Sources/Client/EvaluationLogger.swift
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
}
}
Loading