diff --git a/CHANGELOG.md b/CHANGELOG.md index 338dbe88e97..725f7231122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Features + +- Add attributes data to `SentryScope` (#6830) + ## 9.0.0 This changelog lists every breaking change. For a high-level overview and upgrade guidance, see the [migration guide](https://docs.sentry.io/platforms/apple/migration/). diff --git a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift index 0316c08831c..9846e1ba4a1 100644 --- a/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift +++ b/Samples/SentrySampleShared/SentrySampleShared/SentrySDKWrapper.swift @@ -196,6 +196,10 @@ public struct SentrySDKWrapper { } let data = Data("hello".utf8) scope.addAttachment(Attachment(data: data, filename: "log.txt")) + + scope.setAttribute(value: "\(Bundle.main.bundleIdentifier ?? "")-custom-attribute", key: "custom-attribute-text") + scope.setAttribute(value: Date().timeIntervalSince1970, key: "custom-attribute-numeric") + scope.setAttribute(value: true, key: "custom-attribute-boolean") return scope } diff --git a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard index 884e2330df8..e123514827f 100644 --- a/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard +++ b/Samples/iOS-Swift/iOS-Swift/Base.lproj/Main.storyboard @@ -1256,6 +1256,16 @@ + @@ -1830,6 +1840,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Samples/iOS-Swift/iOS-Swift/ViewControllers/ScopeViewController.swift b/Samples/iOS-Swift/iOS-Swift/ViewControllers/ScopeViewController.swift new file mode 100644 index 00000000000..721c78831c1 --- /dev/null +++ b/Samples/iOS-Swift/iOS-Swift/ViewControllers/ScopeViewController.swift @@ -0,0 +1,61 @@ +import Sentry +import UIKit + +class ScopeViewController: UIViewController { + + @IBOutlet var attributesTextView: UITextView! + @IBOutlet var attributeNameField: UITextField! + @IBOutlet var attributeValueField: UITextField! + + override func viewDidLoad() { + super.viewDidLoad() + + updateAttributesTextView() + } + + @IBAction func setAttribute(_ sender: Any?) { + guard let attributeName = attributeNameField.text, let attributeValue = attributeValueField.text else { + return + } + + SentrySDK.configureScope { scope in + scope.setAttribute(value: attributeValue, key: attributeName) + } + + updateAttributesTextView() + } + + @IBAction func removeAttribute(_ sender: Any?) { + guard let attributeName = attributeNameField.text else { + return + } + + SentrySDK.configureScope { scope in + scope.removeAttribute(key: attributeName) + } + + updateAttributesTextView() + } + + @IBAction func updateAttributesTextView(_ sender: Any?) { + updateAttributesTextView() + } + + private func updateAttributesTextView() { + SentrySDK.configureScope { [weak self] scope in + guard let self else { return } + + guard let jsonData = try? JSONSerialization.data(withJSONObject: scope.attributes, options: [.prettyPrinted]) else { + self.attributesTextView.text = "Error serializing attributes to JSON" + return + } + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + self.attributesTextView.text = "Error converting data to JSON text" + return + } + + self.attributesTextView.text = jsonString + } + } +} diff --git a/Sources/Sentry/Public/SentryScope.h b/Sources/Sentry/Public/SentryScope.h index 6017ee5b1ee..7e304ab478f 100644 --- a/Sources/Sentry/Public/SentryScope.h +++ b/Sources/Sentry/Public/SentryScope.h @@ -41,6 +41,11 @@ NS_SWIFT_NAME(Scope) */ @property (nonatomic, readonly, copy) NSDictionary *tags; +/** + * Gets the dictionary of currently set attributes. + */ +@property (nonatomic, readonly, copy) NSDictionary *attributes; + - (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs NS_DESIGNATED_INITIALIZER; - (instancetype)init; - (instancetype)initWithScope:(SentryScope *)scope; @@ -137,6 +142,24 @@ NS_SWIFT_NAME(Scope) */ - (void)addAttachment:(SentryAttachment *)attachment NS_SWIFT_NAME(addAttachment(_:)); +/** + * Set global attributes. Attributes are searchable key/value string pairs attached to every log + * message. + * @note The SDK only applies attributes to Logs. The SDK doesn't apply the attributes to + * Events, Transactions, Spans, Profiles, Session Replay. + * @param value Supported values are string, integers, boolean and double + * @param key The key to store, cannot be an empty string + */ +- (void)setAttributeValue:(id)value forKey:(NSString *)key NS_SWIFT_NAME(setAttribute(value:key:)); + +/** + * Remove the attribute for the specified key. + * @note The SDK only applies attributes to Logs. The SDK doesn't apply the attributes to + * Events, Transactions, Spans, Profiles, Session Replay. + * @param key The key to remove + */ +- (void)removeAttributeForKey:(NSString *)key NS_SWIFT_NAME(removeAttribute(key:)); + /** * Clears all attachments in the scope. */ diff --git a/Sources/Sentry/SentryCrashIntegration.m b/Sources/Sentry/SentryCrashIntegration.m index b58332ecd56..5c8288a18db 100644 --- a/Sources/Sentry/SentryCrashIntegration.m +++ b/Sources/Sentry/SentryCrashIntegration.m @@ -239,6 +239,10 @@ - (void)configureScope userInfo[@"release"] = self.options.releaseName; userInfo[@"dist"] = self.options.dist; + // Crashes don't use the attributes field, we remove them to avoid uploading them + // unnecessarily. + [userInfo removeObjectForKey:@"attributes"]; + [SentryDependencyContainer.sharedInstance.crashReporter setUserInfo:userInfo]; [outerScope addObserver:self.scopeObserver]; diff --git a/Sources/Sentry/SentryCrashScopeObserver.m b/Sources/Sentry/SentryCrashScopeObserver.m index b226b044763..6363aad9862 100644 --- a/Sources/Sentry/SentryCrashScopeObserver.m +++ b/Sources/Sentry/SentryCrashScopeObserver.m @@ -91,6 +91,11 @@ - (void)setLevel:(enum SentryLevel)level sentrycrash_scopesync_setLevel([json bytes]); } +- (void)setAttributes:(nullable NSDictionary *)attributes +{ + // Nothing to do here, crash events don't support attributes +} + - (void)addSerializedBreadcrumb:(NSDictionary *)crumb { NSData *json = [self toJSONEncodedCString:crumb]; diff --git a/Sources/Sentry/SentryScope.m b/Sources/Sentry/SentryScope.m index e1bc15dbdb3..1afaa67e7c8 100644 --- a/Sources/Sentry/SentryScope.m +++ b/Sources/Sentry/SentryScope.m @@ -29,6 +29,8 @@ @interface SentryScope () @property (atomic, strong) NSMutableArray *breadcrumbArray; +@property (atomic, strong) NSMutableDictionary *attributesDictionary; + @end @implementation SentryScope { @@ -50,6 +52,7 @@ - (instancetype)initWithMaxBreadcrumbs:(NSInteger)maxBreadcrumbs self.contextDictionary = [NSMutableDictionary new]; self.attachmentArray = [NSMutableArray new]; self.fingerprintArray = [NSMutableArray new]; + self.attributesDictionary = [NSMutableDictionary new]; _spanLock = [[NSObject alloc] init]; self.observers = [NSMutableArray new]; self.propagationContext = [[SentryPropagationContext alloc] init]; @@ -74,6 +77,7 @@ - (instancetype)initWithScope:(SentryScope *)scope [_breadcrumbArray addObjectsFromArray:crumbs]; [_fingerprintArray addObjectsFromArray:[scope fingerprints]]; [_attachmentArray addObjectsFromArray:[scope attachments]]; + [_attributesDictionary addEntriesFromDictionary:[scope attributes]]; self.propagationContext = scope.propagationContext; self.maxBreadcrumbs = scope.maxBreadcrumbs; @@ -191,6 +195,9 @@ - (void)clear @synchronized(_spanLock) { _span = nil; } + @synchronized(_attributesDictionary) { + [_attributesDictionary removeAllObjects]; + } self.userObject = nil; self.distString = nil; @@ -467,6 +474,40 @@ - (void)clearAttachments } } +- (NSDictionary *)attributes +{ + @synchronized(_attributesDictionary) { + return _attributesDictionary.copy; + } +} + +- (void)setAttributeValue:(id)value forKey:(NSString *)key +{ + if (key == nil || key.length == 0) { + SENTRY_LOG_ERROR(@"Attribute's key cannot be nil nor empty"); + return; + } + + @synchronized(_attributesDictionary) { + _attributesDictionary[key] = value; + + for (id observer in self.observers) { + [observer setAttributes:_attributesDictionary]; + } + } +} + +- (void)removeAttributeForKey:(NSString *)key +{ + @synchronized(_attributesDictionary) { + [_attributesDictionary removeObjectForKey:key]; + + for (id observer in self.observers) { + [observer setAttributes:_attributesDictionary]; + } + } +} + - (NSDictionary *)serialize { NSMutableDictionary *serializedData = [NSMutableDictionary new]; @@ -509,6 +550,9 @@ - (void)clearAttachments if (crumbs.count > 0) { [serializedData setValue:crumbs forKey:@"breadcrumbs"]; } + if (self.attributes.count > 0) { + [serializedData setValue:[self attributes] forKey:@"attributes"]; + } return serializedData; } diff --git a/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationScopeObserver.swift b/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationScopeObserver.swift index 349516abfdf..8a97dd31f76 100644 --- a/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationScopeObserver.swift +++ b/Sources/Swift/Integrations/WatchdogTerminations/SentryWatchdogTerminationScopeObserver.swift @@ -54,6 +54,10 @@ class SentryWatchdogTerminationScopeObserver: NSObject, SentryScopeObserver { // Nothing to do here, watchdog termination events are always Fatal } + func setAttributes(_ attributes: [String: Any]?) { + // Nothing to do here, watchdog termination events don't support attributes + } + func addSerializedBreadcrumb(_ serializedBreadcrumb: [String: Any]) { breadcrumbProcessor.addSerializedBreadcrumb(serializedBreadcrumb) } diff --git a/Sources/Swift/State/SentryScopeObserver.swift b/Sources/Swift/State/SentryScopeObserver.swift index 83fae2e3d0e..afa77ac9885 100644 --- a/Sources/Swift/State/SentryScopeObserver.swift +++ b/Sources/Swift/State/SentryScopeObserver.swift @@ -8,6 +8,7 @@ func setEnvironment(_ environment: String?) func setFingerprint(_ fingerprint: [String]?) func setLevel(_ level: SentryLevel) + func setAttributes(_ attributes: [String: Any]?) func addSerializedBreadcrumb(_ serializedBreadcrumb: [String: Any]) func clearBreadcrumbs() func clear() diff --git a/Tests/SentryTests/Integrations/SentryCrash/SentryCrashIntegrationTests.swift b/Tests/SentryTests/Integrations/SentryCrash/SentryCrashIntegrationTests.swift index 30fbf657c75..f0619b7e7d2 100644 --- a/Tests/SentryTests/Integrations/SentryCrash/SentryCrashIntegrationTests.swift +++ b/Tests/SentryTests/Integrations/SentryCrash/SentryCrashIntegrationTests.swift @@ -624,6 +624,32 @@ class SentryCrashIntegrationTests: NotificationCenterTestCase { XCTAssertEqual("transaction", envelope.items.first?.header.type) } + func testAttributesAreNotPassedToSentryCrash() throws { + // Start the SDK without any integration + SentrySDK.start { options in + options.dsn = SentryCrashIntegrationTests.dsnAsString + options.removeAllIntegrations() + } + + // Configure some attributes + SentrySDK.configureScope { scope in + scope.setAttribute(value: "value", key: "key") + scope.setEnvironment("test-attributes") + } + + // UserInfo is set when installing the integration, so let's manually install it again to use the new scope values + let sentryCrash = fixture.sentryCrash + let sut = SentryCrashIntegration(crashAdapter: sentryCrash, andDispatchQueueWrapper: fixture.dispatchQueueWrapper) + + sut.install(with: Options()) + + let userInfo = try XCTUnwrap(SentryDependencyContainer.sharedInstance().crashReporter.userInfo) + // Double check the environment just set is in the user info + assertUserInfoField(userInfo: userInfo, key: "environment", expected: "test-attributes") + // Validate there is no attributes in the user info + XCTAssertNil(userInfo["attributes"]) + } + private func givenCurrentSession() -> SentrySession { // serialize sets the timestamp let session = SentrySession(jsonObject: fixture.session.serialize())! diff --git a/Tests/SentryTests/SentryScope+Equality.m b/Tests/SentryTests/SentryScope+Equality.m index bd2c40f9122..eb1ef9eb7e6 100644 --- a/Tests/SentryTests/SentryScope+Equality.m +++ b/Tests/SentryTests/SentryScope+Equality.m @@ -49,6 +49,9 @@ - (BOOL)isEqualToScope:(SentryScope *)scope if (self.attachmentArray != scope.attachmentArray && ![self.attachmentArray isEqualToArray:scope.attachmentArray]) return NO; + if (self.attributes != scope.attributes + && ![self.attributes isEqualToDictionary:scope.attributes]) + return NO; return YES; } @@ -65,6 +68,7 @@ - (NSUInteger)hash hash = hash * 23 + (NSUInteger)self.levelEnum; hash = hash * 23 + self.maxBreadcrumbs; hash = hash * 23 + [self.attachmentArray hash]; + hash = hash * 23 + [self.attributes hash]; return hash; } diff --git a/Tests/SentryTests/SentryScopeSwiftTests.swift b/Tests/SentryTests/SentryScopeSwiftTests.swift index 6e07570fa4f..e595b2cd7c7 100644 --- a/Tests/SentryTests/SentryScopeSwiftTests.swift +++ b/Tests/SentryTests/SentryScopeSwiftTests.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length @_spi(Private) import Sentry import SentryTestUtils import XCTest @@ -63,6 +64,8 @@ class SentryScopeSwiftTests: XCTestCase { scope.addAttachment(TestData.fileAttachment) + scope.setAttribute(value: "my-value", key: "my-attribute-key") + event = Event() event.message = SentryMessage(formatted: "message") @@ -131,6 +134,7 @@ class SentryScopeSwiftTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(cloned.serialize() as? [String: AnyHashable]), snapshot) XCTAssertEqual(scope.propagationContext.spanId, cloned.propagationContext.spanId) XCTAssertEqual(scope.propagationContext.traceId, cloned.propagationContext.traceId) + XCTAssertEqual(scope.attributes as NSDictionary, cloned.attributes as NSDictionary) let (event1, event2) = (Event(), Event()) (event1.timestamp, event2.timestamp) = (fixture.date, fixture.date) @@ -358,6 +362,7 @@ class SentryScopeSwiftTests: XCTestCase { let expected = Scope(maxBreadcrumbs: fixture.maxBreadcrumbs) XCTAssertEqual(expected, scope) XCTAssertEqual(0, scope.attachments.count) + XCTAssertEqual(0, scope.attributes.count) } func testAttachmentsIsACopy() { @@ -562,6 +567,24 @@ class SentryScopeSwiftTests: XCTestCase { XCTAssertEqual(level, observer.level) } + func testScopeObserver_setAttributes() { + let sut = Scope() + let observer = fixture.observer + sut.add(observer) + + sut.setAttribute(value: "my-attribute", key: "key-string") + sut.setAttribute(value: false, key: "key-bool") + sut.setAttribute(value: 1.5, key: "key-double") + sut.setAttribute(value: 4, key: "key-integer") + + XCTAssertEqual([ + "key-string": "my-attribute", + "key-bool": false, + "key-double": 1.5, + "key-integer": 4 + ] as [String: AnyHashable], try XCTUnwrap(sut.attributes as? [String: AnyHashable])) + } + func testScopeObserver_addBreadcrumb() { let sut = Scope() let observer = fixture.observer @@ -856,6 +879,71 @@ class SentryScopeSwiftTests: XCTestCase { // -- Assert -- XCTAssertNil(actualSpan) } + + func testSetStringAttribute() { + let scope = Scope() + + scope.setAttribute(value: "test-string", key: "a-string-key") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-string-key"] as? String), "test-string") + } + + func testSetStringAttributeAgainChangesValue() { + let scope = Scope() + + scope.setAttribute(value: "test-string", key: "a-string-key") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-string-key"] as? String), "test-string") + + scope.setAttribute(value: "another-string", key: "a-string-key") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-string-key"] as? String), "another-string") + } + + func testSetBoolAttribute() { + let scope = Scope() + + scope.setAttribute(value: true, key: "a-bool-key") + scope.setAttribute(value: false, key: "a-bool-key-false") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-bool-key-false"] as? Bool), false) + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-bool-key"] as? Bool), true) + } + + func testSetDoubleAttribute() { + let scope = Scope() + + scope.setAttribute(value: 1.4728, key: "a-double-key") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["a-double-key"] as? Double), 1.4728) + } + + func testSetIntegerAttribute() { + let scope = Scope() + + scope.setAttribute(value: 4, key: "an-integer-key") + + XCTAssertEqual(try XCTUnwrap(scope.attributes["an-integer-key"] as? Int), 4) + } + + func testRemoveAttribute() { + let scope = Scope() + + scope.setAttribute(value: "test-string", key: "a-key") + + scope.removeAttribute(key: "a-key") + + XCTAssertNil(scope.attributes["a-key"]) + } + + func testRemoveNotExistingAttributeDoesNotCrash() { + let scope = Scope() + + // This should not crash + scope.removeAttribute(key: "an-invalid-key") + + XCTAssertTrue(scope.attributes.isEmpty) + } private class TestScopeObserver: NSObject, SentryScopeObserver { var tags: [String: String]? @@ -920,6 +1008,11 @@ class SentryScopeSwiftTests: XCTestCase { func setUser(_ user: User?) { self.user = user } + + var attributes: [String: Any]? + func setAttributes(_ attributes: [String: Any]?) { + self.attributes = attributes + } } } @@ -965,3 +1058,4 @@ private final class NotOfTypeSpan: NSObject, Span { } private final class SubClassOfSentrySpan: SentrySpan {} +// swiftlint:enable file_length diff --git a/sdk_api.json b/sdk_api.json index 32385e9f07a..8bcb2cfc873 100644 --- a/sdk_api.json +++ b/sdk_api.json @@ -15140,6 +15140,80 @@ } ] }, + { + "kind": "Var", + "name": "attributes", + "printedName": "attributes", + "children": [ + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Any]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "ProtocolComposition", + "printedName": "Any" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Var", + "usr": "c:objc(cs)SentryScope(py)attributes", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "attributes", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "accessors": [ + { + "kind": "Accessor", + "name": "Get", + "printedName": "Get()", + "children": [ + { + "kind": "TypeNominal", + "name": "Dictionary", + "printedName": "[Swift.String : Any]", + "children": [ + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + }, + { + "kind": "TypeNominal", + "name": "ProtocolComposition", + "printedName": "Any" + } + ], + "usr": "s:SD" + } + ], + "declKind": "Accessor", + "usr": "c:objc(cs)SentryScope(im)attributes", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "attributes", + "declAttributes": [ + "DiscardableResult", + "ObjC", + "Dynamic" + ], + "accessorKind": "get" + } + ] + }, { "kind": "Constructor", "name": "init", @@ -15933,6 +16007,81 @@ ], "funcSelfKind": "NonMutating" }, + { + "kind": "Function", + "name": "setAttribute", + "printedName": "setAttribute(value:key:)", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "ProtocolComposition", + "printedName": "Any" + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "c:objc(cs)SentryScope(im)setAttributeValue:forKey:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "setAttributeValue:forKey:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "funcSelfKind": "NonMutating" + }, + { + "kind": "Function", + "name": "removeAttribute", + "printedName": "removeAttribute(key:)", + "children": [ + { + "kind": "TypeNameAlias", + "name": "Void", + "printedName": "Swift.Void", + "children": [ + { + "kind": "TypeNominal", + "name": "Void", + "printedName": "()" + } + ] + }, + { + "kind": "TypeNominal", + "name": "String", + "printedName": "Swift.String", + "usr": "s:SS" + } + ], + "declKind": "Func", + "usr": "c:objc(cs)SentryScope(im)removeAttributeForKey:", + "moduleName": "Sentry", + "isOpen": true, + "objc_name": "removeAttributeForKey:", + "declAttributes": [ + "ObjC", + "Dynamic" + ], + "funcSelfKind": "NonMutating" + }, { "kind": "Function", "name": "clearAttachments",