diff --git a/Sources/Sentry/SentryScope.m b/Sources/Sentry/SentryScope.m index 6b3ab327812..b4772239a79 100644 --- a/Sources/Sentry/SentryScope.m +++ b/Sources/Sentry/SentryScope.m @@ -10,7 +10,7 @@ #import "SentryLogC.h" #import "SentryScope+Private.h" #import "SentryScope+PrivateSwift.h" -#import "SentrySpanInternal+Private.h" +#import "SentrySpanInternal.h" #import "SentrySwift.h" #import "SentryTracer.h" #import "SentryTransactionContext.h" @@ -18,6 +18,8 @@ NS_ASSUME_NONNULL_BEGIN +static NSString *const kSentryScopeSpanStatusSerializationKey = @"status"; + @interface SentryScope () @property (atomic) NSUInteger maxBreadcrumbs; @@ -37,6 +39,7 @@ @implementation SentryScope { NSObject *_spanLock; NSObject *_observersLock; NSObject *_propagationContextLock; + SentryFeatureFlagBufferWrapper *_featureFlagBuffer; } @synthesize span = _span; @@ -56,6 +59,7 @@ - (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs self.attachmentArray = [[NSMutableArray alloc] init]; self.fingerprintArray = [[NSMutableArray alloc] init]; self.attributesDictionary = [[NSMutableDictionary alloc] init]; + _featureFlagBuffer = [SentryFeatureFlagBufferWrapper scopeBuffer]; _spanLock = [[NSObject alloc] init]; _observersLock = [[NSObject alloc] init]; _propagationContextLock = [[NSObject alloc] init]; @@ -70,9 +74,14 @@ - (instancetype)init return [self initWithMaxBreadcrumbs:defaultMaxBreadcrumbs]; } +- (SentryFeatureFlagBufferWrapper *)featureFlagBuffer +{ + return _featureFlagBuffer; +} + - (instancetype)initWithScope:(SentryScope *)scope { - if (self = [self init]) { + if (self = [self initWithMaxBreadcrumbs:scope.maxBreadcrumbs]) { [_extraDictionary addEntriesFromDictionary:[scope extras]]; [_tagDictionary addEntriesFromDictionary:[scope tags]]; [_contextDictionary addEntriesFromDictionary:[scope context]]; @@ -83,6 +92,7 @@ - (instancetype)initWithScope:(SentryScope *)scope [_fingerprintArray addObjectsFromArray:[scope fingerprints]]; [_attachmentArray addObjectsFromArray:[scope attachments]]; [_attributesDictionary addEntriesFromDictionary:[scope attributes]]; + _featureFlagBuffer = [scope.featureFlagBuffer copyBuffer]; self.propagationContext = scope.propagationContext; self.maxBreadcrumbs = scope.maxBreadcrumbs; @@ -216,6 +226,7 @@ - (void)clear @synchronized(_attributesDictionary) { [_attributesDictionary removeAllObjects]; } + [_featureFlagBuffer removeAll]; self.userObject = nil; self.distString = nil; @@ -547,7 +558,11 @@ - (void)removeAttributeForKey:(NSString *)key traceContext = [self buildTraceContext:span]; serializedData[@"traceContext"] = traceContext; - NSDictionary *context = [self context]; + NSMutableDictionary *context = [self context].mutableCopy; + NSDictionary *_Nullable featureFlags = [_featureFlagBuffer serializeForContext]; + if (featureFlags.count > 0) { + context[@"flags"] = featureFlags; + } if (context.count > 0) { [serializedData setValue:context forKey:@"context"]; } @@ -742,7 +757,7 @@ - (NSDictionary *)buildTraceContext:(nullable id)span { if (span != nil) { NSDictionary *dict = [SENTRY_UNWRAP_NULLABLE_VALUE(id, span) serialize]; - if (dict[kSentrySpanStatusSerializationKey] != nil) { + if (dict[kSentryScopeSpanStatusSerializationKey] != nil) { return dict; } @@ -753,7 +768,7 @@ - (NSDictionary *)buildTraceContext:(nullable id)span // to any state other than undefined. Spans first have a default status of OK, but we don't // want to change this for the trace context status. NSMutableDictionary *mutableDict = [dict mutableCopy]; - mutableDict[kSentrySpanStatusSerializationKey] = kSentrySpanStatusNameOk; + mutableDict[kSentryScopeSpanStatusSerializationKey] = kSentrySpanStatusNameOk; return mutableDict; } else { diff --git a/Sources/Sentry/SentrySpanInternal.m b/Sources/Sentry/SentrySpanInternal.m index 9e244450938..c35d3a6b980 100644 --- a/Sources/Sentry/SentrySpanInternal.m +++ b/Sources/Sentry/SentrySpanInternal.m @@ -25,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN @interface SentrySpanInternal () +@property (nonatomic, strong) SentryFeatureFlagBufferWrapper *featureFlagBuffer; @end @implementation SentrySpanInternal { @@ -76,6 +77,7 @@ - (instancetype)initWithContext:(SentrySpanContext *)context #endif // SENTRY_HAS_UIKIT _tags = [[NSMutableDictionary alloc] init]; + self.featureFlagBuffer = [SentryFeatureFlagBufferWrapper spanBuffer]; _stateLock = [[NSObject alloc] init]; _isFinished = NO; @@ -361,6 +363,7 @@ - (NSDictionary *)serialize @synchronized(_data) { NSMutableDictionary *data = _data.mutableCopy; + [data addEntriesFromDictionary:[self.featureFlagBuffer serializeForSpanData]]; if (self.frames && self.frames.count > 0) { NSMutableArray *frames = [[NSMutableArray alloc] initWithCapacity:self.frames.count]; diff --git a/Sources/Sentry/include/SentryScope+PrivateSwift.h b/Sources/Sentry/include/SentryScope+PrivateSwift.h index 077d4468343..49fe6a351d3 100644 --- a/Sources/Sentry/include/SentryScope+PrivateSwift.h +++ b/Sources/Sentry/include/SentryScope+PrivateSwift.h @@ -17,6 +17,8 @@ static NSString *const SENTRY_CONTEXT_APP_KEY = @"app"; @property (atomic, strong) SentryUser *_Nullable userObject; @property (nonatomic, nullable, copy) NSString *currentScreen; +@property (atomic, strong, readonly) SENTRY_SWIFT_MIGRATION_ID(SentryFeatureFlagBufferWrapper) + featureFlagBuffer; - (NSArray *)breadcrumbs; diff --git a/Sources/Sentry/include/SentrySpanInternal.h b/Sources/Sentry/include/SentrySpanInternal.h index 01be9d14dee..3e5c865f5db 100644 --- a/Sources/Sentry/include/SentrySpanInternal.h +++ b/Sources/Sentry/include/SentrySpanInternal.h @@ -90,6 +90,8 @@ SENTRY_NO_INIT * Frames of the stack trace associated with the span. */ @property (nullable, nonatomic, strong) NSArray *frames; +@property (nonatomic, strong, readonly) SENTRY_SWIFT_MIGRATION_ID(SentryFeatureFlagBufferWrapper) + featureFlagBuffer; /** * Init a @c SentrySpan with given transaction and context. diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagBuffer.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagBuffer.swift new file mode 100644 index 00000000000..ddaa20dfe8b --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagBuffer.swift @@ -0,0 +1,130 @@ +// swiftlint:disable missing_docs +import Foundation + +private let maxScopeFeatureFlags = 100 +private let maxSpanFeatureFlags = 10 + +final class SentryFeatureFlagBuffer { + private let maxSize: Int + private let overflowBehavior: SentryFeatureFlagBufferOverflowBehavior + private let lock = NSLock() + private var evaluations: [SentryFeatureFlagEvaluation] + private var indexesByFlag: [String: Int] + + init(maxSize: Int, + overflowBehavior: SentryFeatureFlagBufferOverflowBehavior, + evaluations: [SentryFeatureFlagEvaluation] = []) { + self.maxSize = maxSize + self.overflowBehavior = overflowBehavior + self.evaluations = evaluations + self.indexesByFlag = [:] + let capacity = max(maxSize, 0) + self.evaluations.reserveCapacity(capacity) + self.indexesByFlag.reserveCapacity(capacity) + rebuildIndexes() + } + + static func scopeBuffer() -> SentryFeatureFlagBuffer { + SentryFeatureFlagBuffer( + // Error events record the 100 most recent, unique feature flag evaluations. + // https://develop.sentry.dev/sdk/foundations/client/integrations/feature-flags/#tracking-feature-flag-evaluations + maxSize: maxScopeFeatureFlags, + overflowBehavior: .dropOldest + ) + } + + static func spanBuffer() -> SentryFeatureFlagBuffer { + SentryFeatureFlagBuffer( + // Spans track the first 10 feature flags evaluated within the span's scope. + // https://develop.sentry.dev/sdk/foundations/client/integrations/feature-flags/#tracking-feature-flag-evaluations + maxSize: maxSpanFeatureFlags, + overflowBehavior: .rejectNew + ) + } + + var allEvaluations: [SentryFeatureFlagEvaluation] { + return lock.synchronized { + evaluations + } + } + + func add(name: String, value: Value) { + lock.synchronized { + guard maxSize > 0 else { + return + } + let evaluation = SentryFeatureFlagEvaluation(flag: name, result: value.asSentryFeatureFlagValue) + if let existingIndex = indexesByFlag[evaluation.flag] { + switch overflowBehavior { + case .dropOldest: + evaluations.remove(at: existingIndex) + evaluations.append(evaluation) + rebuildIndexes() + case .rejectNew: + evaluations[existingIndex] = evaluation + } + return + } + + if evaluations.count >= maxSize { + switch overflowBehavior { + case .dropOldest: + evaluations.removeFirst() + rebuildIndexes() + case .rejectNew: + return + } + } + + indexesByFlag[evaluation.flag] = evaluations.count + evaluations.append(evaluation) + } + } + + func remove(name: String) { + lock.synchronized { + guard let index = indexesByFlag[name] else { + return + } + evaluations.remove(at: index) + rebuildIndexes() + } + } + + func removeAll() { + lock.synchronized { + evaluations.removeAll(keepingCapacity: true) + indexesByFlag.removeAll(keepingCapacity: true) + } + } + + func serializeForContext() -> [String: Any]? { + let values = allEvaluations.map { $0.serializeForContext() } + guard !values.isEmpty else { + return nil + } + return ["values": values] + } + + func serializeForSpanData() -> [String: Any] { + return allEvaluations.reduce(into: [String: Any]()) { result, evaluation in + result[evaluation.spanDataKey] = evaluation.result.serializedValue + } + } + + func copy() -> SentryFeatureFlagBuffer { + return SentryFeatureFlagBuffer( + maxSize: maxSize, + overflowBehavior: overflowBehavior, + evaluations: allEvaluations + ) + } + + private func rebuildIndexes() { + indexesByFlag.removeAll(keepingCapacity: true) + for (index, evaluation) in evaluations.enumerated() { + indexesByFlag[evaluation.flag] = index + } + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferOverflowBehavior.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferOverflowBehavior.swift new file mode 100644 index 00000000000..702cee8c43a --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferOverflowBehavior.swift @@ -0,0 +1,6 @@ +// swiftlint:disable missing_docs +enum SentryFeatureFlagBufferOverflowBehavior { + case dropOldest + case rejectNew +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferWrapper.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferWrapper.swift new file mode 100644 index 00000000000..d1bfa77275a --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagBufferWrapper.swift @@ -0,0 +1,48 @@ +// swiftlint:disable missing_docs +@_implementationOnly import _SentryPrivate +import Foundation + +// Objective-C scope/span internals need to own and serialize feature flag buffers, but +// SentryFeatureFlagBuffer is a pure Swift type. Keep this wrapper thin: ObjC gets only the +// serialization/copying methods it needs, while Swift can access the wrapped buffer directly. +@_spi(Private) +@objc(SentryFeatureFlagBufferWrapper) +public final class SentryFeatureFlagBufferWrapper: NSObject { + let buffer: SentryFeatureFlagBuffer + + private init(buffer: SentryFeatureFlagBuffer) { + self.buffer = buffer + super.init() + } + + @objc + public static func scopeBuffer() -> SentryFeatureFlagBufferWrapper { + SentryFeatureFlagBufferWrapper(buffer: SentryFeatureFlagBuffer.scopeBuffer()) + } + + @objc + public static func spanBuffer() -> SentryFeatureFlagBufferWrapper { + SentryFeatureFlagBufferWrapper(buffer: SentryFeatureFlagBuffer.spanBuffer()) + } + + @objc + public func removeAll() { + buffer.removeAll() + } + + @objc + public func copyBuffer() -> SentryFeatureFlagBufferWrapper { + SentryFeatureFlagBufferWrapper(buffer: buffer.copy()) + } + + @objc + public func serializeForContext() -> [String: Any]? { + buffer.serializeForContext() + } + + @objc + public func serializeForSpanData() -> [String: Any] { + buffer.serializeForSpanData() + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagEvaluation.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagEvaluation.swift new file mode 100644 index 00000000000..5f4cec7f1f9 --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagEvaluation.swift @@ -0,0 +1,23 @@ +// swiftlint:disable missing_docs +struct SentryFeatureFlagEvaluation: Equatable { + private static let spanDataKeyPrefix = "flag.evaluation." + + let flag: String + let result: SentryFeatureFlagValueContent + + var spanDataKey: String { + return "\(Self.spanDataKeyPrefix)\(flag)" + } + + func serializeForContext() -> [String: Any] { + return [ + "flag": flag, + "result": result.serializedValue + ] + } + + func serializeForSpanData() -> [String: Any] { + return [spanDataKey: result.serializedValue] + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagValue.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagValue.swift new file mode 100644 index 00000000000..1bad08db3ec --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagValue.swift @@ -0,0 +1,11 @@ +// swiftlint:disable missing_docs +protocol SentryFeatureFlagValue { + var asSentryFeatureFlagValue: SentryFeatureFlagValueContent { get } +} + +extension Bool: SentryFeatureFlagValue { + var asSentryFeatureFlagValue: SentryFeatureFlagValueContent { + return .boolean(self) + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/FeatureFlags/SentryFeatureFlagValueContent.swift b/Sources/Swift/FeatureFlags/SentryFeatureFlagValueContent.swift new file mode 100644 index 00000000000..c141ece3736 --- /dev/null +++ b/Sources/Swift/FeatureFlags/SentryFeatureFlagValueContent.swift @@ -0,0 +1,12 @@ +// swiftlint:disable missing_docs +enum SentryFeatureFlagValueContent: Equatable { + case boolean(Bool) + + var serializedValue: Any { + switch self { + case .boolean(let value): + return value + } + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/Scope.swift b/Sources/Swift/Scope.swift new file mode 100644 index 00000000000..7d00526a6b3 --- /dev/null +++ b/Sources/Swift/Scope.swift @@ -0,0 +1,21 @@ +// swiftlint:disable missing_docs +@_implementationOnly import _SentryPrivate + +// Feature flag APIs live in this file so the eventual public Scope API has a clear home. +// These methods stay SPI while the public surface is being finalized. +extension Scope { + @_spi(Private) public func addFeatureFlag(name: String, result: Bool) { + guard let wrapper = featureFlagBuffer as? SentryFeatureFlagBufferWrapper else { + return + } + wrapper.buffer.add(name: name, value: result) + } + + @_spi(Private) public func removeFeatureFlag(name: String) { + guard let wrapper = featureFlagBuffer as? SentryFeatureFlagBufferWrapper else { + return + } + wrapper.buffer.remove(name: name) + } +} +// swiftlint:enable missing_docs diff --git a/Sources/Swift/Span.swift b/Sources/Swift/Span.swift new file mode 100644 index 00000000000..730cf8c1fa9 --- /dev/null +++ b/Sources/Swift/Span.swift @@ -0,0 +1,27 @@ +// swiftlint:disable missing_docs +@_implementationOnly import _SentryPrivate + +// Feature flag APIs live in this file so the eventual public Span API has a clear home. +// These methods stay SPI while the public surface is being finalized. +extension Span { + @_spi(Private) public func addFeatureFlag(name: String, result: Bool) { + guard let span = self as? SentrySpanInternal else { + return + } + guard let wrapper = span.featureFlagBuffer as? SentryFeatureFlagBufferWrapper else { + return + } + wrapper.buffer.add(name: name, value: result) + } + + @_spi(Private) public func removeFeatureFlag(name: String) { + guard let span = self as? SentrySpanInternal else { + return + } + guard let wrapper = span.featureFlagBuffer as? SentryFeatureFlagBufferWrapper else { + return + } + wrapper.buffer.remove(name: name) + } +} +// swiftlint:enable missing_docs diff --git a/Tests/Perf/metrics-test.yml b/Tests/Perf/metrics-test.yml index 5680f51ff25..0dd2e47d562 100644 --- a/Tests/Perf/metrics-test.yml +++ b/Tests/Perf/metrics-test.yml @@ -11,4 +11,4 @@ startupTimeTest: binarySizeTest: diffMin: 200 KiB - diffMax: 1200 KiB + diffMax: 1210 KiB diff --git a/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferTests.swift b/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferTests.swift new file mode 100644 index 00000000000..ef08f0ddfcf --- /dev/null +++ b/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferTests.swift @@ -0,0 +1,194 @@ +@_spi(Private) @testable import Sentry +import SentryTestUtils +import XCTest + +final class SentryFeatureFlagBufferTests: XCTestCase { + + func testBuffer_whenAddingFeatureFlagValue_shouldSerializeConvertedValue() throws { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + + // -- Act -- + sut.add(name: "checkout", value: TestFeatureFlagValue(value: true)) + + // -- Assert -- + let context = try XCTUnwrap(sut.serializeForContext()) + let values = try XCTUnwrap(context["values"] as? [[String: Any]]) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.element(at: 0)?["flag"] as? String, "checkout") + XCTAssertEqual(values.element(at: 0)?["result"] as? Bool, true) + } + + func testBoolValueConversion_whenValueIsBool_shouldReturnBooleanContent() { + // -- Arrange -- + let value = true + + // -- Act -- + let actual = value.asSentryFeatureFlagValue + + // -- Assert -- + XCTAssertEqual(actual, .boolean(true)) + } + + func testEvaluation_whenSerializingForContext_shouldUseFlagsSchema() throws { + // -- Arrange -- + let evaluation = SentryFeatureFlagEvaluation(flag: "checkout", result: .boolean(true)) + + // -- Act -- + let actual = evaluation.serializeForContext() + + // -- Assert -- + XCTAssertEqual(actual["flag"] as? String, "checkout") + XCTAssertEqual(try XCTUnwrap(actual["result"] as? Bool), true) + } + + func testEvaluation_whenSerializingForSpanData_shouldUseFlagEvaluationKey() throws { + // -- Arrange -- + let evaluation = SentryFeatureFlagEvaluation(flag: "checkout", result: .boolean(true)) + + // -- Act -- + let actual = evaluation.serializeForSpanData() + + // -- Assert -- + XCTAssertEqual(try XCTUnwrap(actual["flag.evaluation.checkout"] as? Bool), true) + } + + func testBuffer_whenSerializingForContext_shouldPreserveInsertionOrder() throws { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + sut.add(name: "first", value: false) + sut.add(name: "second", value: true) + + // -- Act -- + let actual = try XCTUnwrap(sut.serializeForContext()) + let values = try XCTUnwrap(actual["values"] as? [[String: Any]]) + + // -- Assert -- + XCTAssertEqual(values.count, 2) + XCTAssertEqual(values.element(at: 0)?["flag"] as? String, "first") + XCTAssertEqual(values.element(at: 0)?["result"] as? Bool, false) + XCTAssertEqual(values.element(at: 1)?["flag"] as? String, "second") + XCTAssertEqual(values.element(at: 1)?["result"] as? Bool, true) + } + + func testBuffer_whenUpdatingExistingFlag_shouldRefreshAsNewest() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + sut.add(name: "first", value: false) + sut.add(name: "second", value: true) + + // -- Act -- + sut.add(name: "first", value: true) + + // -- Assert -- + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["second", "first"]) + XCTAssertEqual(sut.allEvaluations.map(\.result), [.boolean(true), .boolean(true)]) + } + + func testRemove_whenBufferHasFeatureFlag_shouldRemoveMatchingFlag() throws { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + sut.add(name: "first", value: false) + sut.add(name: "second", value: true) + + // -- Act -- + sut.remove(name: "first") + + // -- Assert -- + let spanData = sut.serializeForSpanData() + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["second"]) + XCTAssertNil(spanData["flag.evaluation.first"]) + XCTAssertEqual(try XCTUnwrap(spanData["flag.evaluation.second"] as? Bool), true) + } + + func testRemove_whenBufferDoesNotHaveFeatureFlag_shouldNotChangeEvaluations() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + sut.add(name: "first", value: false) + sut.add(name: "second", value: true) + + // -- Act -- + sut.remove(name: "missing") + + // -- Assert -- + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["first", "second"]) + } + + func testBuffer_whenDropOldestOverflow_shouldRemoveOldestFlag() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 2, overflowBehavior: .dropOldest) + sut.add(name: "first", value: true) + sut.add(name: "second", value: true) + + // -- Act -- + sut.add(name: "third", value: false) + + // -- Assert -- + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["second", "third"]) + } + + func testBuffer_whenMaxSizeIsZero_shouldStoreNothing() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 0, overflowBehavior: .dropOldest) + + // -- Act -- + sut.add(name: "first", value: true) + + // -- Assert -- + XCTAssertTrue(sut.allEvaluations.isEmpty) + XCTAssertNil(sut.serializeForContext()) + } + + func testBuffer_whenRejectNewOverflow_shouldKeepExistingFlags() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 2, overflowBehavior: .rejectNew) + sut.add(name: "first", value: true) + sut.add(name: "second", value: true) + + // -- Act -- + sut.add(name: "third", value: false) + + // -- Assert -- + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["first", "second"]) + } + + func testBuffer_whenRejectNewOverflow_shouldUpdateExistingFlag() throws { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 2, overflowBehavior: .rejectNew) + sut.add(name: "first", value: true) + sut.add(name: "second", value: true) + sut.add(name: "third", value: false) + + // -- Act -- + sut.add(name: "first", value: false) + + // -- Assert -- + let spanData = sut.serializeForSpanData() + XCTAssertEqual(spanData.count, 2) + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["first", "second"]) + XCTAssertEqual(try XCTUnwrap(spanData["flag.evaluation.first"] as? Bool), false) + XCTAssertEqual(try XCTUnwrap(spanData["flag.evaluation.second"] as? Bool), true) + } + + func testCopy_whenMutatingCopy_shouldNotMutateOriginal() { + // -- Arrange -- + let sut = SentryFeatureFlagBuffer(maxSize: 3, overflowBehavior: .dropOldest) + sut.add(name: "first", value: true) + let copy = sut.copy() + + // -- Act -- + copy.add(name: "second", value: false) + + // -- Assert -- + XCTAssertEqual(sut.allEvaluations.map(\.flag), ["first"]) + XCTAssertEqual(copy.allEvaluations.map(\.flag), ["first", "second"]) + } +} + +private struct TestFeatureFlagValue: SentryFeatureFlagValue { + let value: Bool + + var asSentryFeatureFlagValue: SentryFeatureFlagValueContent { + .boolean(value) + } +} diff --git a/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferWrapperTests.swift b/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferWrapperTests.swift new file mode 100644 index 00000000000..f78915bb577 --- /dev/null +++ b/Tests/SentryTests/FeatureFlags/SentryFeatureFlagBufferWrapperTests.swift @@ -0,0 +1,111 @@ +@_spi(Private) @testable import Sentry +import SentryTestUtils +import XCTest + +final class SentryFeatureFlagBufferWrapperTests: XCTestCase { + + func testScopeBuffer_whenAddingMoreThanLimit_shouldUseDropOldestLimit100() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.scopeBuffer() + + // -- Act -- + for index in 0..<101 { + sut.buffer.add(name: "flag-\(index)", value: true) + } + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.count, 100) + XCTAssertEqual(sut.buffer.allEvaluations.first?.flag, "flag-1") + XCTAssertEqual(sut.buffer.allEvaluations.last?.flag, "flag-100") + } + + func testScopeBuffer_whenLimitReached_shouldUpdateExistingFlagAsNewest() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.scopeBuffer() + for index in 0..<100 { + sut.buffer.add(name: "flag-\(index)", value: true) + } + + // -- Act -- + sut.buffer.add(name: "flag-0", value: false) + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.count, 100) + XCTAssertEqual(sut.buffer.allEvaluations.first?.flag, "flag-1") + XCTAssertEqual(sut.buffer.allEvaluations.last?.flag, "flag-0") + XCTAssertEqual(sut.buffer.allEvaluations.last?.result, .boolean(false)) + } + + func testSpanBuffer_whenAddingMoreThanLimit_shouldUseRejectNewLimit10() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.spanBuffer() + + // -- Act -- + for index in 0..<11 { + sut.buffer.add(name: "flag-\(index)", value: true) + } + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.count, 10) + XCTAssertEqual(sut.buffer.allEvaluations.first?.flag, "flag-0") + XCTAssertEqual(sut.buffer.allEvaluations.last?.flag, "flag-9") + XCTAssertFalse(sut.buffer.allEvaluations.contains { $0.flag == "flag-10" }) + } + + func testSpanBuffer_whenLimitReached_shouldUpdateExistingFlagInPlace() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.spanBuffer() + for index in 0..<10 { + sut.buffer.add(name: "flag-\(index)", value: true) + } + sut.buffer.add(name: "rejected", value: true) + + // -- Act -- + sut.buffer.add(name: "flag-0", value: false) + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.count, 10) + XCTAssertEqual(sut.buffer.allEvaluations.first?.flag, "flag-0") + XCTAssertEqual(sut.buffer.allEvaluations.first?.result, .boolean(false)) + XCTAssertFalse(sut.buffer.allEvaluations.contains { $0.flag == "rejected" }) + } + + func testCopyBuffer_whenMutatingCopy_shouldNotMutateOriginal() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.scopeBuffer() + sut.buffer.add(name: "first", value: true) + + // -- Act -- + let copy = sut.copyBuffer() + copy.buffer.add(name: "second", value: false) + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.map(\.flag), ["first"]) + XCTAssertEqual(copy.buffer.allEvaluations.map(\.flag), ["first", "second"]) + } + + func testRemoveFeatureFlag_whenBufferHasFeatureFlag_shouldRemoveMatchingFlag() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.scopeBuffer() + sut.buffer.add(name: "checkout", value: true) + sut.buffer.add(name: "search", value: false) + + // -- Act -- + sut.buffer.remove(name: "checkout") + + // -- Assert -- + XCTAssertEqual(sut.buffer.allEvaluations.map(\.flag), ["search"]) + } + + func testRemoveAll_whenBufferHasFeatureFlags_shouldClearEvaluations() { + // -- Arrange -- + let sut = SentryFeatureFlagBufferWrapper.scopeBuffer() + sut.buffer.add(name: "checkout", value: true) + + // -- Act -- + sut.removeAll() + + // -- Assert -- + XCTAssertTrue(sut.buffer.allEvaluations.isEmpty) + } +} diff --git a/Tests/SentryTests/SentryScopeSwiftTests.swift b/Tests/SentryTests/SentryScopeSwiftTests.swift index e48e3ed8e96..ba6dfd8a75d 100644 --- a/Tests/SentryTests/SentryScopeSwiftTests.swift +++ b/Tests/SentryTests/SentryScopeSwiftTests.swift @@ -87,6 +87,16 @@ class SentryScopeSwiftTests: XCTestCase { super.setUp() fixture = Fixture() } + + private func serializeFeatureFlags(from scope: Scope) -> [String: Any]? { + let context = scope.serialize()["context"] as? [String: Any] + return context?["flags"] as? [String: Any] + } + + private func serializeFeatureFlagValues(from scope: Scope) throws -> [[String: Any]] { + let featureFlags = try XCTUnwrap(serializeFeatureFlags(from: scope)) + return try XCTUnwrap(featureFlags["values"] as? [[String: Any]]) + } func testSerialize() throws { let scope = fixture.scope @@ -158,6 +168,89 @@ class SentryScopeSwiftTests: XCTestCase { XCTAssertNotEqual(try XCTUnwrap(scope.serialize() as? [String: AnyHashable]), try XCTUnwrap(cloned.serialize() as? [String: AnyHashable])) } + func testInitWithScope_whenFeatureFlagsChanged_shouldNotShareFutureMutations() throws { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: 5) + scope.addFeatureFlag(name: "first", result: true) + + // -- Act -- + let cloned = Scope(scope: scope) + cloned.addFeatureFlag(name: "second", result: false) + + // -- Assert -- + let originalValues = try serializeFeatureFlagValues(from: scope) + XCTAssertEqual(originalValues.count, 1) + XCTAssertEqual(originalValues.element(at: 0)?["flag"] as? String, "first") + + let clonedValues = try serializeFeatureFlagValues(from: cloned) + XCTAssertEqual(clonedValues.count, 2) + XCTAssertEqual(clonedValues.element(at: 0)?["flag"] as? String, "first") + XCTAssertEqual(clonedValues.element(at: 1)?["flag"] as? String, "second") + } + + func testFeatureFlags_whenUpdatingExistingFlag_shouldRefreshAsNewest() throws { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: 5) + scope.addFeatureFlag(name: "first", result: false) + scope.addFeatureFlag(name: "second", result: true) + + // -- Act -- + scope.addFeatureFlag(name: "first", result: true) + + // -- Assert -- + let values = try serializeFeatureFlagValues(from: scope) + XCTAssertEqual(values.count, 2) + XCTAssertEqual(values.element(at: 0)?["flag"] as? String, "second") + XCTAssertEqual(values.element(at: 0)?["result"] as? Bool, true) + XCTAssertEqual(values.element(at: 1)?["flag"] as? String, "first") + XCTAssertEqual(values.element(at: 1)?["result"] as? Bool, true) + } + + func testFeatureFlags_whenRemovingFeatureFlag_shouldRemoveMatchingFlag() throws { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: 5) + scope.addFeatureFlag(name: "first", result: false) + scope.addFeatureFlag(name: "second", result: true) + + // -- Act -- + scope.removeFeatureFlag(name: "first") + + // -- Assert -- + let values = try serializeFeatureFlagValues(from: scope) + XCTAssertEqual(values.count, 1) + XCTAssertEqual(values.element(at: 0)?["flag"] as? String, "second") + XCTAssertEqual(values.element(at: 0)?["result"] as? Bool, true) + } + + func testFeatureFlags_whenRemovingLastFeatureFlag_shouldOmitFlagsContext() { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: 5) + scope.addFeatureFlag(name: "checkout", result: true) + + // -- Act -- + scope.removeFeatureFlag(name: "checkout") + + // -- Assert -- + XCTAssertNil(serializeFeatureFlags(from: scope)) + } + + func testFeatureFlags_whenOverflow_shouldDropOldestFlag() throws { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: 5) + for index in 0..<100 { + scope.addFeatureFlag(name: "flag-\(index)", result: true) + } + + // -- Act -- + scope.addFeatureFlag(name: "flag-100", result: false) + + // -- Assert -- + let values = try serializeFeatureFlagValues(from: scope) + XCTAssertEqual(values.count, 100) + XCTAssertEqual(values.element(at: 0)?["flag"] as? String, "flag-1") + XCTAssertEqual(values.element(at: 99)?["flag"] as? String, "flag-100") + } + func testApplyToEvent() { let actual = fixture.scope.applyTo(event: fixture.event, maxBreadcrumbs: 10) let actualContext = actual?.context as? [String: [String: String]] @@ -173,7 +266,7 @@ class SentryScopeSwiftTests: XCTestCase { XCTAssertEqual(fixture.context["c"], actualContext?["c"]) XCTAssertNotNil(actualContext?["trace"]) } - + func testApplyToEvent_EventWithTags() { let tags = NSMutableDictionary(dictionary: ["my": "tag"]) let event = fixture.event @@ -364,6 +457,18 @@ class SentryScopeSwiftTests: XCTestCase { XCTAssertEqual(0, scope.attachments.count) XCTAssertEqual(0, scope.attributes.count) } + + func testClear_whenScopeHasFeatureFlags_shouldClearFeatureFlags() { + // -- Arrange -- + let scope = Scope(maxBreadcrumbs: fixture.maxBreadcrumbs) + scope.addFeatureFlag(name: "first", result: true) + + // -- Act -- + scope.clear() + + // -- Assert -- + XCTAssertNil(serializeFeatureFlags(from: scope)) + } func testAttachmentsIsACopy() { let scope = fixture.scope @@ -410,6 +515,8 @@ class SentryScopeSwiftTests: XCTestCase { scope.setContext(value: ["some": "value"], key: key) scope.removeContext(key: key) + + scope.addFeatureFlag(name: key, result: true) scope.setExtra(value: 1, key: key) scope.removeExtra(key: key) diff --git a/Tests/SentryTests/Transaction/SentrySpanTests.swift b/Tests/SentryTests/Transaction/SentrySpanTests.swift index 837c25f5d31..554de316dd5 100644 --- a/Tests/SentryTests/Transaction/SentrySpanTests.swift +++ b/Tests/SentryTests/Transaction/SentrySpanTests.swift @@ -409,6 +409,59 @@ class SentrySpanTests: XCTestCase { XCTAssertEqual(span.data.count, 2, "Only expected thread.name and thread.id in data.") XCTAssertNil(span.data[fixture.extraKey]) } + + func testFeatureFlags_whenSerializingEmptySpanBuffer_shouldOmitFlagData() { + // -- Arrange -- + let span = fixture.getSutWithTracer() + + // -- Act -- + let actual = span.serialize()["data"] as? [String: Any] + + // -- Assert -- + XCTAssertNil(actual?["flag.evaluation.checkout"]) + } + + func testFeatureFlags_whenAddingToSpan_shouldSerializeAsSpanData() throws { + // -- Arrange -- + let span = fixture.getSutWithTracer() + + // -- Act -- + span.addFeatureFlag(name: "checkout", result: true) + + // -- Assert -- + let actual = try XCTUnwrap(span.serialize()["data"] as? [String: Any]) + XCTAssertEqual(try XCTUnwrap(actual["flag.evaluation.checkout"] as? Bool), true) + } + + func testFeatureFlags_whenRemovingFromSpan_shouldOmitRemovedFlagData() throws { + // -- Arrange -- + let span = fixture.getSutWithTracer() + span.addFeatureFlag(name: "checkout", result: true) + span.addFeatureFlag(name: "search", result: false) + + // -- Act -- + span.removeFeatureFlag(name: "checkout") + + // -- Assert -- + let actual = try XCTUnwrap(span.serialize()["data"] as? [String: Any]) + XCTAssertNil(actual["flag.evaluation.checkout"]) + XCTAssertEqual(try XCTUnwrap(actual["flag.evaluation.search"] as? Bool), false) + } + + func testFeatureFlags_whenAddingMoreThanSpanLimit_shouldRejectNewFlags() throws { + // -- Arrange -- + let span = fixture.getSutWithTracer() + + // -- Act -- + for index in 0..<11 { + span.addFeatureFlag(name: "flag-\(index)", result: true) + } + + // -- Assert -- + let actual = try XCTUnwrap(span.serialize()["data"] as? [String: Any]) + XCTAssertEqual(actual.keys.filter { $0.hasPrefix("flag.evaluation.") }.count, 10) + XCTAssertNil(actual["flag.evaluation.flag-10"]) + } func testAddAndRemoveTags() { let span = fixture.getSut() diff --git a/Tests/SentryTests/Transaction/SentryTransactionTests.swift b/Tests/SentryTests/Transaction/SentryTransactionTests.swift index f7d109e9883..592c504a66f 100644 --- a/Tests/SentryTests/Transaction/SentryTransactionTests.swift +++ b/Tests/SentryTests/Transaction/SentryTransactionTests.swift @@ -150,7 +150,7 @@ class SentryTransactionTests: XCTestCase { // then XCTAssertEqual(try XCTUnwrap(serializedTransactionExtra[fixture.testKey] as? String), fixture.testValue) } - + func testSerialize_shouldPreserveExtraFromScope() throws { // given let scope = Scope()