Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
18 changes: 17 additions & 1 deletion DatadogFlags/Sources/Client/FlagsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,27 @@ extension FlagsClient: FlagsClientProtocol {
return FlagDetails(key: key, value: defaultValue, error: .typeMismatch)
}

var metadata: [String: Any] = [:]
// Write extraLogging primitives first; skip "allocationKey" since it is always
// sourced from the typed field below so it cannot clobber the actual allocation key.
Comment thread
typotter marked this conversation as resolved.
Outdated
for (logKey, value) in flagAssignment.extraLogging where logKey != "allocationKey" {
switch value {
case .string(let s): metadata[logKey] = s
case .int(let i): metadata[logKey] = i
case .double(let d): metadata[logKey] = d
case .bool(let b): metadata[logKey] = b
default: break // skip complex types (arrays, dictionaries, null)
Comment thread
sameerank marked this conversation as resolved.
Outdated
}
}
// allocationKey is always written last so it wins over any extraLogging value
metadata["allocationKey"] = 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)
Expand Down
16 changes: 15 additions & 1 deletion DatadogFlags/Sources/Models/FlagAssignment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: FlagValue>(as type: T.Type) -> T? {
switch self.variation {
Expand All @@ -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] = [:]
Comment thread
sameerank marked this conversation as resolved.
) {
self.allocationKey = allocationKey
self.variationKey = variationKey
self.variation = variation
self.reason = reason
self.doLog = doLog
self.extraLogging = extraLogging
}
}

Expand All @@ -56,6 +65,7 @@ extension FlagAssignment: Codable {
case variationValue
case reason
case doLog
case extraLogging
}

public init(from decoder: any Decoder) throws {
Expand All @@ -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)

Expand Down Expand Up @@ -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):
Expand Down
19 changes: 18 additions & 1 deletion DatadogFlags/Sources/Models/FlagDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ public struct FlagDetails<T>: 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`.
public var metadata: [String: Any]
Comment thread
typotter marked this conversation as resolved.
Outdated

/// Creates detailed flag evaluation information.
///
/// - Parameters:
Expand All @@ -79,17 +84,29 @@ public struct FlagDetails<T>: 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: Any] = [:]
Comment thread
typotter marked this conversation as resolved.
Outdated
) {
self.key = key
self.value = value
self.variant = variant
self.reason = reason
self.error = error
self.metadata = metadata
}

public static func == (lhs: FlagDetails<T>, rhs: FlagDetails<T>) -> Bool {
lhs.key == rhs.key &&
lhs.value == rhs.value &&
lhs.variant == rhs.variant &&
lhs.reason == rhs.reason &&
lhs.error == rhs.error &&
NSDictionary(dictionary: lhs.metadata) == NSDictionary(dictionary: rhs.metadata)
}
}
127 changes: 126 additions & 1 deletion DatadogFlags/Tests/Client/FlagsClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,8 @@ final class FlagsClientTests: XCTestCase {
key: "boolean-flag",
value: true,
variant: "variation-124",
reason: "TARGETING_MATCH"
reason: "TARGETING_MATCH",
metadata: ["allocationKey": "allocation-124"]
)
)
XCTAssertEqual(
Expand Down Expand Up @@ -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"] as? String, "alloc-abc")
// Then — extraLogging primitive values are merged into metadata
XCTAssertEqual(details.metadata["experimentName"] as? String, "exp-1")
XCTAssertEqual(details.metadata["sampleRate"] as? Double, 0.5)
XCTAssertEqual(details.metadata["bucketIndex"] as? Int, 7)
XCTAssertEqual(details.metadata["isControl"] as? 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 must be ignored
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; extraLogging "allocationKey" is silently skipped
XCTAssertEqual(details.metadata["allocationKey"] as? String, "real-alloc-key")
XCTAssertEqual(details.metadata["other"] as? String, "value")
}

func testSendFlagEvaluation() {
// Given
let exposureLogger = ExposureLoggerMock()
Expand Down
69 changes: 63 additions & 6 deletions DatadogFlags/Tests/Models/FlagAssignmentsResponseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 = """
Expand Down