diff --git a/DatadogFlags/Sources/Client/FlagsClient.swift b/DatadogFlags/Sources/Client/FlagsClient.swift index cc2f837cd9..ef452fbebb 100644 --- a/DatadogFlags/Sources/Client/FlagsClient.swift +++ b/DatadogFlags/Sources/Client/FlagsClient.swift @@ -248,11 +248,19 @@ extension FlagsClient: FlagsClientProtocol { return FlagDetails(key: key, value: defaultValue, error: .typeMismatch) } + var metadata: [String: AnyValue] = [:] + for (logKey, value) in flagAssignment.extraLogging { + metadata[logKey] = value + } + // Written last so it always wins over any extraLogging value with the same key. + metadata["allocationKey"] = .string(flagAssignment.allocationKey) + let details = FlagDetails( key: key, value: value, variant: flagAssignment.variationKey, - reason: flagAssignment.reason + reason: flagAssignment.reason, + metadata: metadata ) trackEvaluation(key: key, assignment: flagAssignment, context: context) diff --git a/DatadogFlags/Sources/Models/FlagAssignment.swift b/DatadogFlags/Sources/Models/FlagAssignment.swift index a6beadfbef..4729af2ec0 100644 --- a/DatadogFlags/Sources/Models/FlagAssignment.swift +++ b/DatadogFlags/Sources/Models/FlagAssignment.swift @@ -21,6 +21,7 @@ public struct FlagAssignment: Equatable { public var variation: Variation public var reason: String public var doLog: Bool + public var extraLogging: [String: AnyValue] func variation(as type: T.Type) -> T? { switch self.variation { @@ -39,12 +40,20 @@ public struct FlagAssignment: Equatable { } } - public init(allocationKey: String, variationKey: String, variation: Variation, reason: String, doLog: Bool) { + public init( + allocationKey: String, + variationKey: String, + variation: Variation, + reason: String, + doLog: Bool, + extraLogging: [String: AnyValue] = [:] + ) { self.allocationKey = allocationKey self.variationKey = variationKey self.variation = variation self.reason = reason self.doLog = doLog + self.extraLogging = extraLogging } } @@ -56,6 +65,7 @@ extension FlagAssignment: Codable { case variationValue case reason case doLog + case extraLogging } public init(from decoder: any Decoder) throws { @@ -65,6 +75,7 @@ extension FlagAssignment: Codable { self.variationKey = try container.decode(String.self, forKey: .variationKey) self.reason = try container.decode(String.self, forKey: .reason) self.doLog = try container.decode(Bool.self, forKey: .doLog) + self.extraLogging = try container.decodeIfPresent([String: AnyValue].self, forKey: .extraLogging) ?? [:] let variationType = try container.decode(String.self, forKey: .variationType) @@ -99,6 +110,9 @@ extension FlagAssignment: Codable { try container.encode(variationKey, forKey: .variationKey) try container.encode(reason, forKey: .reason) try container.encode(doLog, forKey: .doLog) + if !extraLogging.isEmpty { + try container.encode(extraLogging, forKey: .extraLogging) + } switch variation { case .boolean(let value): diff --git a/DatadogFlags/Sources/Models/FlagDetails.swift b/DatadogFlags/Sources/Models/FlagDetails.swift index 063debddef..1d89f47f50 100644 --- a/DatadogFlags/Sources/Models/FlagDetails.swift +++ b/DatadogFlags/Sources/Models/FlagDetails.swift @@ -4,8 +4,6 @@ * Copyright 2019-Present Datadog, Inc. */ -import Foundation - /// An error tha occurs during feature flag evaluation. /// /// Indicates why a flag evaluation may have failed or returned a default value. @@ -71,6 +69,11 @@ public struct FlagDetails: Equatable where T: Equatable { /// value is from a successful evaluation or a fallback to the default value. public var error: FlagEvaluationError? + /// Additional metadata about the flag evaluation. + /// + /// Contains fields from the flag assignment such as `allocationKey` and `extraLogging` values. + public var metadata: [String: AnyValue] + /// Creates detailed flag evaluation information. /// /// - Parameters: @@ -79,17 +82,20 @@ public struct FlagDetails: Equatable where T: Equatable { /// - variant: The variant key served, if any. /// - reason: The evaluation reason, if available. /// - error: Any error that occurred during evaluation. + /// - metadata: Additional metadata about the evaluation, such as `allocationKey`. public init( key: String, value: T, variant: String? = nil, reason: String? = nil, - error: FlagEvaluationError? = nil + error: FlagEvaluationError? = nil, + metadata: [String: AnyValue] = [:] ) { self.key = key self.value = value self.variant = variant self.reason = reason self.error = error + self.metadata = metadata } } diff --git a/DatadogFlags/Tests/Client/FlagsClientTests.swift b/DatadogFlags/Tests/Client/FlagsClientTests.swift index 4f33d9314f..e523f035dd 100644 --- a/DatadogFlags/Tests/Client/FlagsClientTests.swift +++ b/DatadogFlags/Tests/Client/FlagsClientTests.swift @@ -232,7 +232,8 @@ final class FlagsClientTests: XCTestCase { key: "boolean-flag", value: true, variant: "variation-124", - reason: "TARGETING_MATCH" + reason: "TARGETING_MATCH", + metadata: ["allocationKey": .string("allocation-124")] ) ) XCTAssertEqual( @@ -479,6 +480,130 @@ final class FlagsClientTests: XCTestCase { XCTAssertNil(flagAssignments["missing-flag"]) } + func testGetDetails_includesAllocationKeyInMetadata() { + // Given + let repository = FlagsRepositoryMock( + flagsData: .init( + flags: [ + "feature-flag": .init( + allocationKey: "alloc-abc", + variationKey: "variation-1", + variation: .boolean(true), + reason: "TARGETING_MATCH", + doLog: false, + extraLogging: [ + "experimentName": .string("exp-1"), + "sampleRate": .double(0.5), + "bucketIndex": .int(7), + "isControl": .bool(false), + ] + ) + ], + context: .mockAny(), + date: .mockAny() + ) + ) + let client = FlagsClient( + repository: repository, + exposureLogger: ExposureLoggerMock(), + evaluationLogger: EvaluationLoggerMock(), + rumFlagEvaluationReporter: RUMFlagEvaluationReporterMock() + ) + + // When + let details = client.getDetails(key: "feature-flag", defaultValue: false) + + // Then — typed allocationKey field always present + XCTAssertEqual(details.metadata["allocationKey"], .string("alloc-abc")) + // Then — extraLogging values are merged into metadata + XCTAssertEqual(details.metadata["experimentName"], .string("exp-1")) + XCTAssertEqual(details.metadata["sampleRate"], .double(0.5)) + XCTAssertEqual(details.metadata["bucketIndex"], .int(7)) + XCTAssertEqual(details.metadata["isControl"], .bool(false)) + } + + func testGetDetails_errorPathsHaveEmptyMetadata() { + // Given — a client with no context (providerNotReady) and one string flag (for typeMismatch) + let providerNotReadyClient = FlagsClient( + repository: FlagsRepositoryMock(), + exposureLogger: ExposureLoggerMock(), + evaluationLogger: EvaluationLoggerMock(), + rumFlagEvaluationReporter: RUMFlagEvaluationReporterMock() + ) + + let clientWithFlags = FlagsClient( + repository: FlagsRepositoryMock( + flagsData: .init( + flags: [ + "string-flag": .init( + allocationKey: "allocation-123", + variationKey: "variation-123", + variation: .string("red"), + reason: "TARGETING_MATCH", + doLog: false + ) + ], + context: .mockAny(), + date: .mockAny() + ) + ), + exposureLogger: ExposureLoggerMock(), + evaluationLogger: EvaluationLoggerMock(), + rumFlagEvaluationReporter: RUMFlagEvaluationReporterMock() + ) + + // When + let providerNotReadyDetails = providerNotReadyClient.getDetails(key: "any-flag", defaultValue: false) + let flagNotFoundDetails = clientWithFlags.getDetails(key: "missing-flag", defaultValue: false) + let typeMismatchDetails = clientWithFlags.getDetails(key: "string-flag", defaultValue: false) + + // Then — all error paths return empty metadata + XCTAssertEqual(providerNotReadyDetails.error, .providerNotReady) + XCTAssertTrue(providerNotReadyDetails.metadata.isEmpty) + + XCTAssertEqual(flagNotFoundDetails.error, .flagNotFound) + XCTAssertTrue(flagNotFoundDetails.metadata.isEmpty) + + XCTAssertEqual(typeMismatchDetails.error, .typeMismatch) + XCTAssertTrue(typeMismatchDetails.metadata.isEmpty) + } + + func testGetDetails_extraLoggingAllocationKeyDoesNotOverrideTypedAllocationKey() { + // Given — extraLogging contains an "allocationKey" key that will be overwritten by the typed field + let repository = FlagsRepositoryMock( + flagsData: .init( + flags: [ + "feature-flag": .init( + allocationKey: "real-alloc-key", + variationKey: "variation-1", + variation: .boolean(true), + reason: "TARGETING_MATCH", + doLog: false, + extraLogging: [ + "allocationKey": .string("fake-alloc-key"), + "other": .string("value"), + ] + ) + ], + context: .mockAny(), + date: .mockAny() + ) + ) + let client = FlagsClient( + repository: repository, + exposureLogger: ExposureLoggerMock(), + evaluationLogger: EvaluationLoggerMock(), + rumFlagEvaluationReporter: RUMFlagEvaluationReporterMock() + ) + + // When + let details = client.getDetails(key: "feature-flag", defaultValue: false) + + // Then — typed field wins; allocationKey is written last so it overwrites any extraLogging value + XCTAssertEqual(details.metadata["allocationKey"], .string("real-alloc-key")) + XCTAssertEqual(details.metadata["other"], .string("value")) + } + func testSendFlagEvaluation() { // Given let exposureLogger = ExposureLoggerMock() diff --git a/DatadogFlags/Tests/Models/FlagAssignmentsResponseTests.swift b/DatadogFlags/Tests/Models/FlagAssignmentsResponseTests.swift index c1bee50e64..474c26b3d0 100644 --- a/DatadogFlags/Tests/Models/FlagAssignmentsResponseTests.swift +++ b/DatadogFlags/Tests/Models/FlagAssignmentsResponseTests.swift @@ -110,35 +110,40 @@ final class FlagAssignmentsResponseTests: XCTestCase { variationKey: "variation-123", variation: .string("red"), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), "boolean-flag": .init( allocationKey: "allocation-124", variationKey: "variation-124", variation: .boolean(true), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), "integer-flag": .init( allocationKey: "allocation-125", variationKey: "variation-125", variation: .integer(42), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), "numeric-flag": .init( allocationKey: "allocation-126", variationKey: "variation-126", variation: .double(3.14), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), "legacy-number-flag": .init( allocationKey: "allocation-128", variationKey: "variation-128", variation: .integer(99), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), "json-flag": .init( allocationKey: "allocation-127", @@ -148,13 +153,65 @@ final class FlagAssignmentsResponseTests: XCTestCase { "prop": .int(123), ])), reason: "TARGETING_MATCH", - doLog: true + doLog: true, + extraLogging: ["experiment": .bool(true)] ), ] ) ) } + func testDecodingFlagAssignmentWithExtraLogging() throws { + // Given + let json = """ + { + "allocationKey": "allocation-abc", + "variationKey": "variation-abc", + "variationType": "string", + "variationValue": "blue", + "doLog": true, + "reason": "TARGETING_MATCH", + "extraLogging": { + "experimentName": "exp-42", + "sampleRate": 0.25, + "bucketIndex": 3, + "isControl": false + } + } + """.data(using: .utf8)! + + // When + let assignment = try JSONDecoder().decode(FlagAssignment.self, from: json) + + // Then + XCTAssertEqual(assignment.allocationKey, "allocation-abc") + XCTAssertEqual(assignment.variationKey, "variation-abc") + XCTAssertEqual(assignment.extraLogging["experimentName"], .string("exp-42")) + XCTAssertEqual(assignment.extraLogging["sampleRate"], .double(0.25)) + XCTAssertEqual(assignment.extraLogging["bucketIndex"], .int(3)) + XCTAssertEqual(assignment.extraLogging["isControl"], .bool(false)) + } + + func testDecodingFlagAssignmentWithoutExtraLogging() throws { + // Given — response from an older API that does not include extraLogging + let json = """ + { + "allocationKey": "allocation-xyz", + "variationKey": "variation-xyz", + "variationType": "boolean", + "variationValue": true, + "doLog": false, + "reason": "DEFAULT" + } + """.data(using: .utf8)! + + // When + let assignment = try JSONDecoder().decode(FlagAssignment.self, from: json) + + // Then — extraLogging defaults to empty dict, no error + XCTAssertEqual(assignment.extraLogging, [:]) + } + func testDecodingFlagAssignmentsResponseWithUnknownVariationTypes() throws { // Given let json = """ diff --git a/api-surface-swift b/api-surface-swift index 0fd49404f6..d88410ff6b 100644 --- a/api-surface-swift +++ b/api-surface-swift @@ -1530,7 +1530,8 @@ public struct FlagAssignment: Equatable public var variation: Variation public var reason: String public var doLog: Bool - public init(allocationKey: String, variationKey: String, variation: Variation, reason: String, doLog: Bool) + public var extraLogging: [String: AnyValue] + public init(allocationKey: String,variationKey: String,variation: Variation,reason: String,doLog: Bool,extraLogging: [String: AnyValue] = [:]) public init(from decoder: any Decoder) throws public func encode(to encoder: any Encoder) throws public enum FlagEvaluationError: Error @@ -1543,7 +1544,8 @@ public struct FlagDetails: Equatable where T: Equatable public var variant: String? public var reason: String? public var error: FlagEvaluationError? - public init(key: String,value: T,variant: String? = nil,reason: String? = nil,error: FlagEvaluationError? = nil) + public var metadata: [String: AnyValue] + public init(key: String,value: T,variant: String? = nil,reason: String? = nil,error: FlagEvaluationError? = nil,metadata: [String: AnyValue] = [:]) public protocol FlagValue: Encodable public enum FlagsError: Error case networkError(Error)