Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 9 additions & 1 deletion DatadogFlags/Sources/Client/FlagsClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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
12 changes: 9 additions & 3 deletions DatadogFlags/Sources/Models/FlagDetails.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -71,6 +69,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` and `extraLogging` values.
public var metadata: [String: AnyValue]

/// Creates detailed flag evaluation information.
///
/// - Parameters:
Expand All @@ -79,17 +82,20 @@ 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: AnyValue] = [:]
) {
self.key = key
self.value = value
self.variant = variant
self.reason = reason
self.error = error
self.metadata = 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": .string("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"], .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()
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
6 changes: 4 additions & 2 deletions api-surface-swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There used to be spaces between the function arguments and now there aren't. I wonder if there is some configuration mismatch in the make api-surface run that created the pervious version vs yours?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's still an inconsistency in the formatting here by removing the spaces between the arguments

public init(from decoder: any Decoder) throws
public func encode(to encoder: any Encoder) throws
public enum FlagEvaluationError: Error
Expand All @@ -1543,7 +1544,8 @@ public struct FlagDetails<T>: 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)
Expand Down