diff --git a/ios/DemoApp/Controller/ObjcDetailViewController.m b/ios/DemoApp/Controller/ObjcDetailViewController.m index a65e764ce..a8433c57d 100644 --- a/ios/DemoApp/Controller/ObjcDetailViewController.m +++ b/ios/DemoApp/Controller/ObjcDetailViewController.m @@ -43,6 +43,13 @@ - (void)viewDidLoad { UIView *headerView = [self createTableHeaderView]; tableView.tableHeaderView = headerView; + + NSDictionary *userAttributes = @{ + @"user_name": @"Alice", + @"paid_user": @YES, + @"credit_balance": @1000, + @"latitude": @30.2661403415387}; + [[Measure shared] trackEvent:@"event-name" attributes:userAttributes timestamp:nil]; [self setTitle:@"Objc View Controller"]; diff --git a/ios/DemoApp/Controller/ViewController.swift b/ios/DemoApp/Controller/ViewController.swift index 463803912..cb5d27172 100644 --- a/ios/DemoApp/Controller/ViewController.swift +++ b/ios/DemoApp/Controller/ViewController.swift @@ -44,6 +44,15 @@ import MeasureSDK view.addSubview(tableView) } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + let attributes: [String: AttributeValue] = ["user_name": .string("Alice"), + "paid_user": .boolean(true), + "credit_balance": .int(1000), + "latitude": .double(30.2661403415387)] + Measure.shared.trackEvent(name: "custom_event", attributes: attributes, timestamp: nil) + } + // MARK: - Table Header View with Buttons func createTableHeaderView() -> UIView { diff --git a/ios/MeasureSDK.xcodeproj/project.pbxproj b/ios/MeasureSDK.xcodeproj/project.pbxproj index 436db9251..7bb8bfc2c 100644 --- a/ios/MeasureSDK.xcodeproj/project.pbxproj +++ b/ios/MeasureSDK.xcodeproj/project.pbxproj @@ -47,6 +47,7 @@ 5225D0332D088B7100FD240D /* URLSessionTaskSwizzler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D02C2D088B7100FD240D /* URLSessionTaskSwizzler.swift */; }; 5225D0352D0AEB1A00FD240D /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D0342D0AEB1A00FD240D /* String+Extension.swift */; }; 5225D0502D0FECFF00FD240D /* InputStream+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5225D04F2D0FECFF00FD240D /* InputStream+Extension.swift */; }; + 522532CA2D295F3F001B5D7C /* AttributeValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 522532C92D295F3F001B5D7C /* AttributeValue.swift */; }; 5229D16E2CCB533C00EFFE44 /* RecentSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */; }; 523287692C85E07B000EE268 /* LifecycleObserverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */; }; 523287732C86195E000EE268 /* SessionManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523287722C86195E000EE268 /* SessionManagerTests.swift */; }; @@ -115,6 +116,8 @@ 52AE72032CABAE9000F2830A /* GestureEvents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52AE71FD2CABAE9000F2830A /* GestureEvents.swift */; }; 52AE72082CABAEAB00F2830A /* UIWindow+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52AE72062CABAEAB00F2830A /* UIWindow+Extension.swift */; }; 52AE72092CABAEAB00F2830A /* NSObject+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52AE72072CABAEAB00F2830A /* NSObject+Extension.swift */; }; + 52B2A8772D1A790200C6B5CF /* CustomEventData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B2A8762D1A790200C6B5CF /* CustomEventData.swift */; }; + 52B2A8792D1A89EF00C6B5CF /* CustomEventCollector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52B2A8782D1A89EF00C6B5CF /* CustomEventCollector.swift */; }; 52BCF1DC2CB42026003102DF /* MeasureModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 52BCF1DA2CB42026003102DF /* MeasureModel.xcdatamodeld */; }; 52BCF1DD2CB42383003102DF /* MeasureModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 52BCF1DA2CB42026003102DF /* MeasureModel.xcdatamodeld */; }; 52BCF20A2CB59FBF003102DF /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BCF2092CB59FBF003102DF /* AppDelegate.swift */; }; @@ -177,6 +180,7 @@ 52E303E42D1818EA008FE733 /* NetworkChangeDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E303E32D1818EA008FE733 /* NetworkChangeDetector.swift */; }; 52E303E62D191A05008FE733 /* NetworkChangeCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E303E52D191A05008FE733 /* NetworkChangeCallback.swift */; }; 52EB380C2C8C7334002D63EC /* SignPost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52EB380B2C8C7334002D63EC /* SignPost.swift */; }; + 52F0C6352D2D46980060FD08 /* CustomEventCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F0C6342D2D46980060FD08 /* CustomEventCollectorTests.swift */; }; 52F3773B2CB41DDF006147E8 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F3773A2CB41DDF006147E8 /* AppDelegate.swift */; }; 52F3773D2CB41DE0006147E8 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52F3773C2CB41DDF006147E8 /* SceneDelegate.swift */; }; 52F377422CB41DE0006147E8 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 52F377412CB41DE0006147E8 /* Base */; }; @@ -361,6 +365,7 @@ 5225D02C2D088B7100FD240D /* URLSessionTaskSwizzler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSessionTaskSwizzler.swift; sourceTree = ""; }; 5225D0342D0AEB1A00FD240D /* String+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extension.swift"; sourceTree = ""; }; 5225D04F2D0FECFF00FD240D /* InputStream+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "InputStream+Extension.swift"; sourceTree = ""; }; + 522532C92D295F3F001B5D7C /* AttributeValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributeValue.swift; sourceTree = ""; }; 5229D16D2CCB533C00EFFE44 /* RecentSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentSession.swift; sourceTree = ""; }; 523287682C85E07B000EE268 /* LifecycleObserverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleObserverTests.swift; sourceTree = ""; }; 523287722C86195E000EE268 /* SessionManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManagerTests.swift; sourceTree = ""; }; @@ -430,6 +435,8 @@ 52AE71FD2CABAE9000F2830A /* GestureEvents.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GestureEvents.swift; sourceTree = ""; }; 52AE72062CABAEAB00F2830A /* UIWindow+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIWindow+Extension.swift"; sourceTree = ""; }; 52AE72072CABAEAB00F2830A /* NSObject+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSObject+Extension.swift"; sourceTree = ""; }; + 52B2A8762D1A790200C6B5CF /* CustomEventData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventData.swift; sourceTree = ""; }; + 52B2A8782D1A89EF00C6B5CF /* CustomEventCollector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventCollector.swift; sourceTree = ""; }; 52BCF1DB2CB42026003102DF /* MeasureModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeasureModel.xcdatamodel; sourceTree = ""; }; 52BCF2072CB59FBF003102DF /* TestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 52BCF2092CB59FBF003102DF /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -486,6 +493,7 @@ 52E303E32D1818EA008FE733 /* NetworkChangeDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChangeDetector.swift; sourceTree = ""; }; 52E303E52D191A05008FE733 /* NetworkChangeCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChangeCallback.swift; sourceTree = ""; }; 52EB380B2C8C7334002D63EC /* SignPost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignPost.swift; sourceTree = ""; }; + 52F0C6342D2D46980060FD08 /* CustomEventCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEventCollectorTests.swift; sourceTree = ""; }; 52F377382CB41DDF006147E8 /* DemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 52F3773A2CB41DDF006147E8 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 52F3773C2CB41DDF006147E8 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -572,6 +580,7 @@ 5202BE302C895FC800A3496E /* AppAttributeProcessor.swift */, 5202BE312C895FC800A3496E /* Attribute.swift */, 5202BE322C895FC800A3496E /* AttributeProcessor.swift */, + 522532C92D295F3F001B5D7C /* AttributeValue.swift */, 5202BE332C895FC800A3496E /* ComputeOnceAttributeProcessor.swift */, 5202BE342C895FC800A3496E /* DeviceAttributeProcessor.swift */, 5202BE352C895FC800A3496E /* InstallationIdAttributeProcessor.swift */, @@ -619,6 +628,8 @@ children = ( 5202BE732C8B117900A3496E /* Attachment.swift */, 5202BE742C8B117900A3496E /* AttachmentType.swift */, + 52B2A8782D1A89EF00C6B5CF /* CustomEventCollector.swift */, + 52B2A8762D1A790200C6B5CF /* CustomEventData.swift */, 5202BE752C8B117900A3496E /* Event.swift */, 5202BE762C8B117900A3496E /* EventProcessor.swift */, 5202BE772C8B117900A3496E /* EventType.swift */, @@ -897,6 +908,7 @@ isa = PBXGroup; children = ( 52A1A9612CA592A100461103 /* EventProcessorTests.swift */, + 52F0C6342D2D46980060FD08 /* CustomEventCollectorTests.swift */, ); path = Event; sourceTree = ""; @@ -1412,12 +1424,14 @@ 52AE72022CABAE9000F2830A /* GestureDetector.swift in Sources */, 52CC63C12C9C608E00F7CA0A /* CrashDataPersistence.swift in Sources */, 52EB380C2C8C7334002D63EC /* SignPost.swift in Sources */, + 52B2A8792D1A89EF00C6B5CF /* CustomEventCollector.swift in Sources */, 52A8533E2C994CC200B2A39F /* ExceptionDetail.swift in Sources */, 5225D02F2D088B7100FD240D /* HttpEventCollector.swift in Sources */, 526E30F62CE77BCB00F484B4 /* AppLaunchEvents.swift in Sources */, 52A853402C994D7900B2A39F /* Exception.swift in Sources */, 5222C9E82D14605000B198DA /* NetworkInterceptor.swift in Sources */, 52159E272CC802A800486F54 /* EventSerializer.swift in Sources */, + 522532CA2D295F3F001B5D7C /* AttributeValue.swift in Sources */, 528EAB892C804AA100CB1574 /* SessionManager.swift in Sources */, 52CD91262C7C3D90000189BA /* MeasureInitializer.swift in Sources */, 52A1A94A2CA3CF9B00461103 /* EventStore.swift in Sources */, @@ -1437,6 +1451,7 @@ 524576712CC1128600B288E5 /* BatchCreator.swift in Sources */, 52F93B9E2CAE6B3C00168AB4 /* GestureTargetFinder.swift in Sources */, 52D51ABB2CD0E5C9008F30A6 /* MsrMoniterView.swift in Sources */, + 52B2A8772D1A790200C6B5CF /* CustomEventData.swift in Sources */, 524576732CC116DD00B288E5 /* BatchStore.swift in Sources */, 52816B6A2CCE399B00B160A4 /* LifecycleCollector.swift in Sources */, 528EAB962C84553500CB1574 /* LifecycleObserver.swift in Sources */, @@ -1505,6 +1520,7 @@ 52A1A9662CA5AC9900461103 /* Attachment+Extension.swift in Sources */, 52CC63D72C9EC5FA00F7CA0A /* CrashReportingManagerTests.swift in Sources */, 52D3D7452CC6191B004E404B /* HeartbeatTests.swift in Sources */, + 52F0C6352D2D46980060FD08 /* CustomEventCollectorTests.swift in Sources */, 52BCF1DD2CB42383003102DF /* MeasureModel.xcdatamodeld in Sources */, 52FA6A8F2CE222360091F089 /* MemoryUsageCalculatorTests.swift in Sources */, 52159E292CC8091E00486F54 /* EventSerializerTests.swift in Sources */, diff --git a/ios/MeasureSDK/AppLaunch/AppLaunchCollector.swift b/ios/MeasureSDK/AppLaunch/AppLaunchCollector.swift index ce5d6d9ef..a94c4ce6c 100644 --- a/ios/MeasureSDK/AppLaunch/AppLaunchCollector.swift +++ b/ios/MeasureSDK/AppLaunch/AppLaunchCollector.swift @@ -50,7 +50,8 @@ final class BaseAppLaunchCollector: AppLaunchCollector { type: .coldLaunch, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func onWarmLaunchCallback(_ data: WarmLaunchData) { @@ -59,7 +60,8 @@ final class BaseAppLaunchCollector: AppLaunchCollector { type: .warmLaunch, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func onHotLaunchCallback(_ data: HotLaunchData) { @@ -68,6 +70,7 @@ final class BaseAppLaunchCollector: AppLaunchCollector { type: .hotLaunch, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } } diff --git a/ios/MeasureSDK/Attribute/AttributeValue.swift b/ios/MeasureSDK/Attribute/AttributeValue.swift new file mode 100644 index 000000000..87b76beb2 --- /dev/null +++ b/ios/MeasureSDK/Attribute/AttributeValue.swift @@ -0,0 +1,68 @@ +// +// AttributeValue.swift +// MeasureSDK +// +// Created by Adwin Ross on 04/01/25. +// + +import Foundation + +/// Represents a value of an attribute. It can be a string, boolean, integer, or double. +public enum AttributeValue { + case string(String) + case boolean(Bool) + case int(Int) + case long(Int64) + case float(Float) + case double(Double) + + /// Returns the underlying value as `Any`. + var value: Any { + switch self { + case .string(let value): return value + case .boolean(let value): return value + case .int(let value): return value + case .long(let value): return value + case .float(let value): return value + case .double(let value): return value + } + } + + func serialize() -> Any { + switch self { + case .string(let stringValue): + return "\"\(stringValue)\"" + default: + return self.value + } + } +} + +/// Serializer for `AttributeValue` to handle encoding and decoding. +enum AttributeValueSerializer { + static func serialize(_ value: AttributeValue) -> Any { + switch value { + case .string(let stringValue): + return "\"\(stringValue)\"" + default: + return value.value + } + } + + static func deserialize(from value: Any) -> AttributeValue? { + if let stringValue = value as? String { + return .string(stringValue) + } else if let boolValue = value as? Bool { + return .boolean(boolValue) + } else if let intValue = value as? Int { + return .int(intValue) + } else if let longValue = value as? Int64 { + return .long(longValue) + } else if let floatValue = value as? Float { + return .float(floatValue) + } else if let doubleValue = value as? Double { + return .double(doubleValue) + } + return nil + } +} diff --git a/ios/MeasureSDK/Config/BaseConfigProvider.swift b/ios/MeasureSDK/Config/BaseConfigProvider.swift index 86a867a3e..d63723207 100644 --- a/ios/MeasureSDK/Config/BaseConfigProvider.swift +++ b/ios/MeasureSDK/Config/BaseConfigProvider.swift @@ -33,6 +33,26 @@ final class BaseConfigProvider: ConfigProvider { self.cachedConfig = configLoader.getCachedConfig() } + var maxUserDefinedAttributesPerEvent: Int { + return getMergedConfig(\.maxUserDefinedAttributesPerEvent) + } + + var maxUserDefinedAttributeKeyLength: Int { + return getMergedConfig(\.maxUserDefinedAttributeKeyLength) + } + + var maxUserDefinedAttributeValueLength: Int { + return getMergedConfig(\.maxUserDefinedAttributeValueLength) + } + + var maxEventNameLength: Int { + return getMergedConfig(\.maxEventNameLength) + } + + var customEventNameRegex: String { + return getMergedConfig(\.customEventNameRegex) + } + var maxSessionDurationMs: Number { return getMergedConfig(\.maxSessionDurationMs) } diff --git a/ios/MeasureSDK/Config/Config.swift b/ios/MeasureSDK/Config/Config.swift index fbb856ad1..621ccec86 100644 --- a/ios/MeasureSDK/Config/Config.swift +++ b/ios/MeasureSDK/Config/Config.swift @@ -30,6 +30,11 @@ struct Config: InternalConfig, MeasureConfig { let memoryTrackingIntervalMs: UnsignedNumber let httpContentTypeAllowlist: [String] let defaultHttpHeadersBlocklist: [String] + let customEventNameRegex: String + let maxEventNameLength: Int + let maxUserDefinedAttributeKeyLength: Int + let maxUserDefinedAttributeValueLength: Int + let maxUserDefinedAttributesPerEvent: Int internal init(enableLogging: Bool = DefaultConfig.enableLogging, trackScreenshotOnCrash: Bool = DefaultConfig.trackScreenshotOnCrash, @@ -54,5 +59,10 @@ struct Config: InternalConfig, MeasureConfig { "Proxy-Authorization", "WWW-Authenticate", "X-Api-Key"] + self.customEventNameRegex = "^[a-zA-Z0-9_-]+$" + self.maxEventNameLength = 64 // 64 chars + self.maxUserDefinedAttributeKeyLength = 256 // 256 chars + self.maxUserDefinedAttributeValueLength = 256 // 256 chars + self.maxUserDefinedAttributesPerEvent = 100 } } diff --git a/ios/MeasureSDK/Config/InternalConfig.swift b/ios/MeasureSDK/Config/InternalConfig.swift index 41b93b30e..f32e8276a 100644 --- a/ios/MeasureSDK/Config/InternalConfig.swift +++ b/ios/MeasureSDK/Config/InternalConfig.swift @@ -44,4 +44,19 @@ protocol InternalConfig { /// Default list of HTTP headers to not capture for network request and response. var defaultHttpHeadersBlocklist: [String] { get } + + /// The maximum length of a custom event. Defaults to 64 chars. + var maxEventNameLength: Int { get } + + /// The regex to validate a custom event name. + var customEventNameRegex: String { get } + + /// The maximum length of user defined attribute key. Defaults to 256 chars. + var maxUserDefinedAttributeKeyLength: Int { get } + + /// The maximum length of a user defined attribute value. Defaults to 256 chars. + var maxUserDefinedAttributeValueLength: Int { get } + + /// The maximum number of user defined attributes for an event. Defaults to 100. + var maxUserDefinedAttributesPerEvent: Int { get } } diff --git a/ios/MeasureSDK/CoreData/Entities/EventEntity.swift b/ios/MeasureSDK/CoreData/Entities/EventEntity.swift index b7ec689b5..3a3211ca5 100644 --- a/ios/MeasureSDK/CoreData/Entities/EventEntity.swift +++ b/ios/MeasureSDK/CoreData/Entities/EventEntity.swift @@ -15,6 +15,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length let exception: Data? let attachments: Data? let attributes: Data? + let userDefinedAttributes: String? let gestureClick: Data? let gestureLongClick: Data? let gestureScroll: Data? @@ -32,6 +33,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length let timestampInMillis: Number var batchId: String? let http: Data? + let customEvent: Data? init(_ event: Event) { // swiftlint:disable:this cyclomatic_complexity function_body_length self.id = event.id @@ -42,6 +44,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length self.timestampInMillis = event.timestampInMillis ?? 0 self.attachmentSize = 0 self.batchId = nil + self.userDefinedAttributes = event.userDefinedAttributes if let exception = event.data as? Exception { do { @@ -197,6 +200,17 @@ struct EventEntity { // swiftlint:disable:this type_body_length self.networkChange = nil } + if let customEvent = event.data as? CustomEventData { + do { + let data = try JSONEncoder().encode(customEvent) + self.customEvent = data + } catch { + self.customEvent = nil + } + } else { + self.customEvent = nil + } + if let attributes = event.attributes { do { let data = try JSONEncoder().encode(attributes) @@ -223,6 +237,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length exception: Data?, attachments: Data?, attributes: Data?, + userDefinedAttributes: String?, gestureClick: Data?, gestureLongClick: Data?, gestureScroll: Data?, @@ -239,7 +254,8 @@ struct EventEntity { // swiftlint:disable:this type_body_length warmLaunch: Data?, hotLaunch: Data?, http: Data?, - networkChange: Data?) { + networkChange: Data?, + customEvent: Data?) { self.id = id self.sessionId = sessionId self.timestamp = timestamp @@ -247,6 +263,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length self.exception = exception self.attachments = attachments self.attributes = attributes + self.userDefinedAttributes = userDefinedAttributes self.userTriggered = userTriggered self.gestureClick = gestureClick self.gestureLongClick = gestureLongClick @@ -264,6 +281,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length self.hotLaunch = hotLaunch self.http = http self.networkChange = networkChange + self.customEvent = customEvent } func getEvent() -> Event { // swiftlint:disable:this cyclomatic_complexity function_body_length @@ -382,6 +400,14 @@ struct EventEntity { // swiftlint:disable:this type_body_length decodedData = nil } } + case .custom: + if let customEventData = self.customEvent { + do { + decodedData = try JSONDecoder().decode(T.self, from: customEventData) + } catch { + decodedData = nil + } + } case nil: decodedData = nil } @@ -416,6 +442,7 @@ struct EventEntity { // swiftlint:disable:this type_body_length data: decodedData, attachments: decodedAttachments ?? [Attachment](), attributes: decodedAttributes, - userTriggered: self.userTriggered) + userTriggered: self.userTriggered, + userDefinedAttributes: self.userDefinedAttributes) } } diff --git a/ios/MeasureSDK/CoreData/EventStore.swift b/ios/MeasureSDK/CoreData/EventStore.swift index 46b296822..ba355f82f 100644 --- a/ios/MeasureSDK/CoreData/EventStore.swift +++ b/ios/MeasureSDK/CoreData/EventStore.swift @@ -39,6 +39,7 @@ final class BaseEventStore: EventStore { eventOb.userTriggered = event.userTriggered eventOb.exception = event.exception eventOb.attributes = event.attributes + eventOb.userDefinedAttributes = event.userDefinedAttributes eventOb.attachments = event.attachments eventOb.gestureClick = event.gestureClick eventOb.gestureLongClick = event.gestureLongClick @@ -53,6 +54,7 @@ final class BaseEventStore: EventStore { eventOb.hotLaunch = event.hotLaunch eventOb.http = event.http eventOb.networkChange = event.networkChange + eventOb.customEvent = event.customEvent do { try context.saveIfNeeded() @@ -81,6 +83,7 @@ final class BaseEventStore: EventStore { exception: eventOb.exception, attachments: eventOb.attachments, attributes: eventOb.attributes, + userDefinedAttributes: eventOb.userDefinedAttributes, gestureClick: eventOb.gestureClick, gestureLongClick: eventOb.gestureLongClick, gestureScroll: eventOb.gestureScroll, @@ -97,7 +100,8 @@ final class BaseEventStore: EventStore { warmLaunch: eventOb.warmLaunch, hotLaunch: eventOb.hotLaunch, http: eventOb.http, - networkChange: eventOb.networkChange) + networkChange: eventOb.networkChange, + customEvent: eventOb.customEvent) } } catch { guard let self = self else { return } @@ -124,6 +128,7 @@ final class BaseEventStore: EventStore { exception: eventOb.exception, attachments: eventOb.attachments, attributes: eventOb.attributes, + userDefinedAttributes: eventOb.userDefinedAttributes, gestureClick: eventOb.gestureClick, gestureLongClick: eventOb.gestureLongClick, gestureScroll: eventOb.gestureScroll, @@ -140,7 +145,8 @@ final class BaseEventStore: EventStore { warmLaunch: eventOb.warmLaunch, hotLaunch: eventOb.hotLaunch, http: eventOb.http, - networkChange: eventOb.networkChange) + networkChange: eventOb.networkChange, + customEvent: eventOb.customEvent) } } catch { guard let self = self else { return } @@ -186,6 +192,7 @@ final class BaseEventStore: EventStore { exception: eventOb.exception, attachments: eventOb.attachments, attributes: eventOb.attributes, + userDefinedAttributes: eventOb.userDefinedAttributes, gestureClick: eventOb.gestureClick, gestureLongClick: eventOb.gestureLongClick, gestureScroll: eventOb.gestureScroll, @@ -202,7 +209,8 @@ final class BaseEventStore: EventStore { warmLaunch: eventOb.warmLaunch, hotLaunch: eventOb.hotLaunch, http: eventOb.http, - networkChange: eventOb.networkChange)) + networkChange: eventOb.networkChange, + customEvent: eventOb.customEvent)) } } catch { guard let self = self else { diff --git a/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift b/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift index 02a3e46c3..4cbe2faf4 100644 --- a/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift +++ b/ios/MeasureSDK/CrashReporter/CrashReportingManager.swift @@ -66,7 +66,8 @@ final class CrashReportingManager: CrashReportManager { type: .exception, attributes: attributes, sessionId: sessionId, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } crashReporter.clearCrashData() diff --git a/ios/MeasureSDK/Events/CustomEventCollector.swift b/ios/MeasureSDK/Events/CustomEventCollector.swift new file mode 100644 index 000000000..e3efbcf6e --- /dev/null +++ b/ios/MeasureSDK/Events/CustomEventCollector.swift @@ -0,0 +1,111 @@ +// +// CustomEventCollector.swift +// MeasureSDK +// +// Created by Adwin Ross on 24/12/24. +// + +import Foundation + +protocol CustomEventCollector { + func enable() + func disable() + func trackEvent(name: String, attributes: [String: AttributeValue], timestamp: Int64?) +} + +final class BaseCustomEventCollector: CustomEventCollector { + private let logger: Logger + private let eventProcessor: EventProcessor + private let timeProvider: TimeProvider + private let configProvider: ConfigProvider + private var isEnabled: Bool = false + private lazy var customEventNameRegex: NSRegularExpression? = { + try? NSRegularExpression(pattern: configProvider.customEventNameRegex) + }() + + init(logger: Logger, eventProcessor: EventProcessor, timeProvider: TimeProvider, configProvider: ConfigProvider) { + self.logger = logger + self.eventProcessor = eventProcessor + self.timeProvider = timeProvider + self.configProvider = configProvider + } + + func enable() { + isEnabled = true + } + + func disable() { + isEnabled = false + } + + func trackEvent(name: String, attributes: [String: AttributeValue], timestamp: Int64?) { + guard isEnabled else { return } + guard validateName(name) else { return } + guard validateAttributes(name: name, attributes: attributes) else { return } + + let data = CustomEventData(name: name) + let userDefinedAttributes = EventSerializer.serializeUserDefinedAttribute(attributes) + + eventProcessor.trackUserTriggered(data: data, + timestamp: timestamp ?? timeProvider.now(), + type: .custom, + attributes: nil, + sessionId: nil, + attachments: nil, + userDefinedAttributes: userDefinedAttributes) + } + + private func validateName(_ name: String) -> Bool { + if name.isEmpty { + logger.log(level: .warning, message: "Event name is empty. This event will be dropped.", error: nil, data: nil ) + return false + } + + if name.count > configProvider.maxEventNameLength { + logger.log(level: .warning, message: "Event(\(name)) exceeded max allowed length. This event will be dropped.", error: nil, data: nil) + return false + } + + if let regex = customEventNameRegex, + regex.firstMatch(in: name, options: [], range: NSRange(location: 0, length: name.count)) == nil { + logger.log(level: .warning, message: "Event(\(name)) does not match the allowed pattern. This event will be dropped.", error: nil, data: nil) + return false + } + + return true + } + + private func validateAttributes(name: String, attributes: [String: AttributeValue]) -> Bool { + if attributes.count > configProvider.maxUserDefinedAttributesPerEvent { + logger.log(level: .warning, message: "Event(\(name)) contains more than max allowed attributes. This event will be dropped.", error: nil, data: nil) + return false + } + + return attributes.allSatisfy { key, value in + let isKeyValid = validateKey(key) + let isValueValid = validateValue(value) + + if !isKeyValid { + logger.log(level: .warning, message: "Event(\(name)) contains invalid attribute key: \(key). This event will be dropped.", error: nil, data: nil) + } + if !isValueValid { + logger.log(level: .warning, message: "Event(\(name)) contains invalid attribute value. This event will be dropped.", error: nil, data: nil) + } + + return isKeyValid && isValueValid + } + } + + private func validateKey(_ key: String) -> Bool { + return key.count <= configProvider.maxUserDefinedAttributeKeyLength + } + + private func validateValue(_ value: AttributeValue) -> Bool { + switch value { + case .string(let stringValue): + return stringValue.count <= configProvider.maxUserDefinedAttributeValueLength + default: + return true + } + } +} diff --git a/ios/MeasureSDK/Events/CustomEventData.swift b/ios/MeasureSDK/Events/CustomEventData.swift new file mode 100644 index 000000000..f6a6ae91e --- /dev/null +++ b/ios/MeasureSDK/Events/CustomEventData.swift @@ -0,0 +1,12 @@ +// +// CustomEventData.swift +// MeasureSDK +// +// Created by Adwin Ross on 23/12/24. +// + +import Foundation + +struct CustomEventData: Codable { + let name: String +} diff --git a/ios/MeasureSDK/Events/Event.swift b/ios/MeasureSDK/Events/Event.swift index b739b3278..4ac8762f0 100644 --- a/ios/MeasureSDK/Events/Event.swift +++ b/ios/MeasureSDK/Events/Event.swift @@ -36,7 +36,18 @@ final class Event: Codable { /// A flag to indicate if the event is triggered by the user or the SDK. let userTriggered: Bool - init(id: String, sessionId: String, timestamp: String, timestampInMillis: Number, type: EventType, data: T?, attachments: [Attachment]?, attributes: Attributes?, userTriggered: Bool) { + /// Attributes set by the user in the event. The type of values in the map is set to Any here, however, the allowed values can only be String, Int, Long, Double, Float or Boolean. + let userDefinedAttributes: String? + + init(id: String, + sessionId: String, + timestamp: String, + timestampInMillis: Number, + type: EventType, data: T?, + attachments: [Attachment]?, + attributes: Attributes?, + userTriggered: Bool, + userDefinedAttributes: String? = nil) { self.id = id self.sessionId = sessionId self.timestamp = timestamp @@ -46,6 +57,7 @@ final class Event: Codable { self.attributes = attributes self.userTriggered = userTriggered self.timestampInMillis = timestampInMillis + self.userDefinedAttributes = userDefinedAttributes } enum CodingKeys: String, CodingKey { @@ -58,6 +70,7 @@ final class Event: Codable { case attributes case userTriggered = "user_triggered" case timestampInMillis + case userDefinedAttributes = "user_defined_attributes" } func appendAttributes(_ attributeProcessors: [AttributeProcessor]) { diff --git a/ios/MeasureSDK/Events/EventProcessor.swift b/ios/MeasureSDK/Events/EventProcessor.swift index fee3756c2..7e7404011 100644 --- a/ios/MeasureSDK/Events/EventProcessor.swift +++ b/ios/MeasureSDK/Events/EventProcessor.swift @@ -17,8 +17,17 @@ protocol EventProcessor { type: EventType, attributes: Attributes?, sessionId: String?, - attachments: [Attachment]? - ) + attachments: [Attachment]?, + userDefinedAttributes: String?) + + func trackUserTriggered( // swiftlint:disable:this function_parameter_count + data: T, + timestamp: Number, + type: EventType, + attributes: Attributes?, + sessionId: String?, + attachments: [Attachment]?, + userDefinedAttributes: String?) } /// A concrete implementation of the `EventProcessor` protocol, responsible for tracking and @@ -59,10 +68,37 @@ final class BaseEventProcessor: EventProcessor { type: EventType, attributes: Attributes?, sessionId: String?, - attachments: [Attachment]? - ) { - SignPost.trace(label: "") { - track(data: data, timestamp: timestamp, type: type, attributes: attributes, attachments: attachments, sessionId: sessionId) + attachments: [Attachment]?, + userDefinedAttributes: String?) { + SignPost.trace(label: "track-event") { + track(data: data, + timestamp: timestamp, + type: type, + attributes: attributes, + userTriggered: false, + attachments: attachments, + sessionId: sessionId, + userDefinedAttributes: userDefinedAttributes) + } + } + + func trackUserTriggered( // swiftlint:disable:this function_parameter_count + data: T, + timestamp: Number, + type: EventType, + attributes: Attributes?, + sessionId: String?, + attachments: [Attachment]?, + userDefinedAttributes: String?) { + SignPost.trace(label: "track-event-user-triggered") { + track(data: data, + timestamp: timestamp, + type: type, + attributes: attributes, + userTriggered: true, + attachments: attachments, + sessionId: sessionId, + userDefinedAttributes: userDefinedAttributes) } } @@ -71,8 +107,10 @@ final class BaseEventProcessor: EventProcessor { timestamp: Number, type: EventType, attributes: Attributes?, + userTriggered: Bool, attachments: [Attachment]?, - sessionId: String? + sessionId: String?, + userDefinedAttributes: String? ) { let threadName = OperationQueue.current?.underlyingQueue?.label ?? "unknown" let event = createEvent( @@ -81,8 +119,9 @@ final class BaseEventProcessor: EventProcessor { type: type, attachments: attachments, attributes: attributes ?? Attributes(), - userTriggered: false, - sessionId: sessionId + userTriggered: userTriggered, + sessionId: sessionId, + userDefinedAttributes: userDefinedAttributes ) event.attributes?.threadName = threadName event.attributes?.deviceLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled @@ -104,7 +143,8 @@ final class BaseEventProcessor: EventProcessor { attachments: [Attachment]?, attributes: Attributes?, userTriggered: Bool, - sessionId: String? + sessionId: String?, + userDefinedAttributes: String? ) -> Event { let id = idProvider.createId() let resolvedSessionId = sessionId ?? sessionManager.sessionId @@ -117,7 +157,8 @@ final class BaseEventProcessor: EventProcessor { data: data, attachments: attachments, attributes: attributes, - userTriggered: userTriggered + userTriggered: userTriggered, + userDefinedAttributes: userDefinedAttributes ) } } diff --git a/ios/MeasureSDK/Events/EventType.swift b/ios/MeasureSDK/Events/EventType.swift index 9811263a5..664effde2 100644 --- a/ios/MeasureSDK/Events/EventType.swift +++ b/ios/MeasureSDK/Events/EventType.swift @@ -22,4 +22,5 @@ enum EventType: String, Codable { case hotLaunch = "hot_launch" case http case networkChange = "network_change" + case custom } diff --git a/ios/MeasureSDK/Exporter/EventSerializer.swift b/ios/MeasureSDK/Exporter/EventSerializer.swift index b19f7accf..4eb2b567e 100644 --- a/ios/MeasureSDK/Exporter/EventSerializer.swift +++ b/ios/MeasureSDK/Exporter/EventSerializer.swift @@ -151,6 +151,16 @@ struct EventSerializer { // swiftlint:disable:this type_body_length } } return nil + case .custom: + if let customEventData = event.customEvent { + do { + let decodedData = try JSONDecoder().decode(CustomEventData.self, from: customEventData) + return serialiseCustomEventData(decodedData) + } catch { + return nil + } + } + return nil case nil: return nil } @@ -401,6 +411,13 @@ struct EventSerializer { // swiftlint:disable:this type_body_length return result } + private func serialiseCustomEventData(_ customEventData: CustomEventData) -> String { + var result = "{" + result += "\"name\":\"\(customEventData.name)\"" + result += "}" + return result + } + private func getSerialisedAttributes(for event: EventEntity) -> String? { let decodedAttributes: Attributes? if let attributeData = event.attributes { @@ -431,7 +448,7 @@ struct EventSerializer { // swiftlint:disable:this type_body_length result += "\"app_version\":\"\(decodedAttributes?.appVersion ?? "")\"," result += "\"app_build\":\"\(decodedAttributes?.appBuild ?? "")\"," result += "\"measure_sdk_version\":\"\(decodedAttributes?.measureSdkVersion ?? "")\"," - result += "\"device_low_power_mode\":\"\(decodedAttributes?.deviceLowPowerMode ?? false)\"," + result += "\"device_low_power_mode\":\(decodedAttributes?.deviceLowPowerMode ?? false)," result += "\"app_unique_id\":\"\(decodedAttributes?.appUniqueId ?? "")\"" result += "}" return result @@ -455,8 +472,23 @@ struct EventSerializer { // swiftlint:disable:this type_body_length result += "\"type\":\"\(event.type)\"," result += "\"\(event.type)\":\(serialisedData)," result += "\"attribute\":\(serialisedAttributes)," + if let userDefinedAttributes = event.userDefinedAttributes { + result += "\"user_defined_attribute\":\(userDefinedAttributes)," + } result += "\"user_triggered\":\(event.userTriggered)" result += "}" return result } + + static func serializeUserDefinedAttribute(_ userDefinedAttribute: [String: AttributeValue]?) -> String? { + guard let userDefinedAttribute = userDefinedAttribute else { return nil } + + var result = "{" + for (key, value) in userDefinedAttribute { + result += "\"\(key)\":\(value.serialize())," + } + result = String(result.dropLast()) + result += "}" + return result + } } diff --git a/ios/MeasureSDK/Gestures/GestureCollector.swift b/ios/MeasureSDK/Gestures/GestureCollector.swift index 8d1fb88e5..3118ccc80 100644 --- a/ios/MeasureSDK/Gestures/GestureCollector.swift +++ b/ios/MeasureSDK/Gestures/GestureCollector.swift @@ -69,7 +69,7 @@ final class BaseGestureCollector: GestureCollector { y: FloatNumber32(y), touchDownTime: touchDownTime, touchUpTime: touchUpTime) - eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureClick, attributes: nil, sessionId: nil, attachments: nil) + eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureClick, attributes: nil, sessionId: nil, attachments: nil, userDefinedAttributes: nil) case .longClick(let x, let y, let touchDownTime, let touchUpTime, let target, let targetId, let targetFrame): let gestureTargetFinderData = gestureTargetFinder.findClickable(x: x, y: y, window: window) let width = Number((gestureTargetFinderData.targetFrame?.width ?? targetFrame?.width) ?? 0) @@ -83,7 +83,7 @@ final class BaseGestureCollector: GestureCollector { y: FloatNumber32(y), touchDownTime: touchDownTime, touchUpTime: touchUpTime) - eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureLongClick, attributes: nil, sessionId: nil, attachments: nil) + eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureLongClick, attributes: nil, sessionId: nil, attachments: nil, userDefinedAttributes: nil) case .scroll(let startX, let startY, let endX, let endY, let direction, let touchDownTime, let touchUpTime, let target, let targetId): let startScrollPoint = CGPoint(x: startX, y: startY) let endScrollPoint = CGPoint(x: endX, y: endY) @@ -97,7 +97,7 @@ final class BaseGestureCollector: GestureCollector { direction: direction, touchDownTime: touchDownTime, touchUpTime: touchUpTime) - eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureScroll, attributes: nil, sessionId: nil, attachments: nil) + eventProcessor.track(data: data, timestamp: timeProvider.now(), type: .gestureScroll, attributes: nil, sessionId: nil, attachments: nil, userDefinedAttributes: nil) } } // swiftlint:enable identifier_name diff --git a/ios/MeasureSDK/Http/HttpEventCollector.swift b/ios/MeasureSDK/Http/HttpEventCollector.swift index c3e5d61cd..21d491c95 100644 --- a/ios/MeasureSDK/Http/HttpEventCollector.swift +++ b/ios/MeasureSDK/Http/HttpEventCollector.swift @@ -55,6 +55,7 @@ final class BaseHttpEventCollector: HttpEventCollector { type: .http, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } } diff --git a/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift b/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift index b07256742..ca070f570 100644 --- a/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift +++ b/ios/MeasureSDK/Http/URLSessionTaskInterceptor.swift @@ -43,7 +43,7 @@ final class URLSessionTaskInterceptor { self.defaultHttpHeadersBlocklist = defaultHttpHeadersBlocklist } - func urlSessionTask(_ task: URLSessionTask, setState state: URLSessionTask.State) { + func urlSessionTask(_ task: URLSessionTask, setState state: URLSessionTask.State) { // swiftlint:disable:this cyclomatic_complexity guard !NetworkInterceptor.isEnabled else { return } guard let httpInterceptorCallbacks = self.httpInterceptorCallbacks, diff --git a/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift index aa967b163..4d9200b56 100644 --- a/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift +++ b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift @@ -40,7 +40,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleApp, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func applicationWillEnterForeground() { @@ -49,7 +50,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleApp, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func applicationWillTerminate() { @@ -58,7 +60,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleApp, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func processControllerLifecycleEvent(_ vcLifecycleType: VCLifecycleEventType, for viewController: UIViewController) { @@ -83,8 +86,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleViewController, attributes: nil, sessionId: nil, - attachments: nil - ) + attachments: nil, + userDefinedAttributes: nil) } } @@ -94,6 +97,7 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleSwiftUI, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } } diff --git a/ios/MeasureSDK/Measure.swift b/ios/MeasureSDK/Measure.swift index 08aaecaea..6ea1ad5d3 100644 --- a/ios/MeasureSDK/Measure.swift +++ b/ios/MeasureSDK/Measure.swift @@ -96,4 +96,67 @@ import Foundation return sessionId } + + /// Tracks an event with optional timestamp. + /// + /// Usage Notes: + /// - Event names should be clear and consistent to aid in dashboard searches + /// + /// /// - Example: + /// ```swift + /// Measure.shared.trackEvent(name: "event-name", attributes:["user_name": .string("Alice")], timestamp: nil) + /// ``` + /// - Parameters: + /// - name: Name of the event (max 64 characters) + /// - attributes: Key-value pairs providing additional context + /// - timestamp: Optional timestamp for the event, defaults to current time + /// + public func trackEvent(name: String, attributes: [String: AttributeValue], timestamp: Int64?) { + guard let customEventCollector = measureInternal?.customEventCollector else { return } + + customEventCollector.trackEvent(name: name, attributes: attributes, timestamp: timestamp) + } + + /// Tracks an event with optional timestamp. + /// + /// Usage Notes: + /// - Event names should be clear and consistent to aid in dashboard searches + /// + /// /// - Example: + /// ```objc + /// [[Measure shared] trackEvent:@"event-name" attributes:@{@"user_name": @"Alice"} timestamp:nil]; + /// ``` + /// - Parameters: + /// - name: Name of the event (max 64 characters) + /// - attributes: Key-value pairs providing additional context + /// - timestamp: Optional timestamp for the event, defaults to current time + @objc public func trackEvent(_ name: String, attributes: [String: Any], timestamp: NSNumber?) { + guard let customEventCollector = measureInternal?.customEventCollector, + let logger = measureInternal?.logger else { return } + var transformedAttributes: [String: AttributeValue] = [:] + + for (key, value) in attributes { + if let stringVal = value as? String { + transformedAttributes[key] = .string(stringVal) + } else if let boolVal = value as? Bool { + transformedAttributes[key] = .boolean(boolVal) + } else if let intVal = value as? Int { + transformedAttributes[key] = .int(intVal) + } else if let longVal = value as? Int64 { + transformedAttributes[key] = .long(longVal) + } else if let floatVal = value as? Float { + transformedAttributes[key] = .float(floatVal) + } else if let doubleVal = value as? Double { + transformedAttributes[key] = .double(doubleVal) + } else { + #if DEBUG + fatalError("Attribute value can only be a string, boolean, integer, or double.") + #else + logger.log(level: .fatal, message: "Attribute value can only be a string, boolean, integer, or double.", error: nil, data: nil) + #endif + } + } + + customEventCollector.trackEvent(name: name, attributes: transformedAttributes, timestamp: timestamp?.int64Value) + } } diff --git a/ios/MeasureSDK/MeasureInitializer.swift b/ios/MeasureSDK/MeasureInitializer.swift index e1affecee..8471ef5ab 100644 --- a/ios/MeasureSDK/MeasureInitializer.swift +++ b/ios/MeasureSDK/MeasureInitializer.swift @@ -49,6 +49,7 @@ protocol MeasureInitializer { var appLaunchCollector: AppLaunchCollector { get } var httpEventCollector: HttpEventCollector { get } var networkChangeCollector: NetworkChangeCollector { get } + var customEventCollector: CustomEventCollector { get } } /// `BaseMeasureInitializer` is responsible for setting up the internal configuration @@ -84,6 +85,7 @@ protocol MeasureInitializer { /// - `gestureTargetFinder`: `GestureTargetFinder` object that determines which view is handling the gesture. /// - `cpuUsageCalculator`: `CpuUsageCalculator` object that generates CPU usage data. /// - `memoryUsageCalculator`: `MemoryUsageCalculator` object that generates memory usage data. +/// - `customEventCollector`: `CustomEventCollector` object that triggers custom events. /// - `sysCtl`: `SysCtl` object which provides sysctl functionalities. /// - `httpClient`: `HttpClient` object that handles HTTP requests. /// - `networkClient`: `NetworkClient` object is responsible for initializing the network configuration and executing API requests. @@ -133,6 +135,7 @@ final class BaseMeasureInitializer: MeasureInitializer { let appLaunchCollector: AppLaunchCollector var httpEventCollector: HttpEventCollector let networkChangeCollector: NetworkChangeCollector + let customEventCollector: CustomEventCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -244,6 +247,10 @@ final class BaseMeasureInitializer: MeasureInitializer { self.networkChangeCollector = BaseNetworkChangeCollector(logger: logger, eventProcessor: eventProcessor, timeProvider: timeProvider) + self.customEventCollector = BaseCustomEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + configProvider: configProvider) self.client = client self.httpEventCollector = BaseHttpEventCollector(logger: logger, eventProcessor: eventProcessor, diff --git a/ios/MeasureSDK/MeasureInternal.swift b/ios/MeasureSDK/MeasureInternal.swift index 33b491c1d..8773f4a5b 100644 --- a/ios/MeasureSDK/MeasureInternal.swift +++ b/ios/MeasureSDK/MeasureInternal.swift @@ -14,7 +14,7 @@ import UIKit /// final class MeasureInternal { var measureInitializer: MeasureInitializer - private var logger: Logger { + var logger: Logger { return measureInitializer.logger } private var client: Client { @@ -109,6 +109,9 @@ final class MeasureInternal { private var networkChangeCollector: NetworkChangeCollector { return measureInitializer.networkChangeCollector } + var customEventCollector: CustomEventCollector { + return measureInitializer.customEventCollector + } private let lifecycleObserver: LifecycleObserver init(_ measureInitializer: MeasureInitializer) { @@ -117,6 +120,7 @@ final class MeasureInternal { self.logger.log(level: .info, message: "Starting Measure SDK", error: nil, data: nil) self.sessionManager.setPreviousSessionCrashed(crashReportManager.hasPendingCrashReport) self.sessionManager.start() + self.customEventCollector.enable() self.appLaunchCollector.enable() self.lifecycleObserver.applicationDidEnterBackground = applicationDidEnterBackground self.lifecycleObserver.applicationWillEnterForeground = applicationWillEnterForeground diff --git a/ios/MeasureSDK/NetworkChange/NetworkChangeCollector.swift b/ios/MeasureSDK/NetworkChange/NetworkChangeCollector.swift index a419a2459..6e53e0052 100644 --- a/ios/MeasureSDK/NetworkChange/NetworkChangeCollector.swift +++ b/ios/MeasureSDK/NetworkChange/NetworkChangeCollector.swift @@ -38,6 +38,7 @@ final class BaseNetworkChangeCollector: NetworkChangeCollector { type: .networkChange, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } } diff --git a/ios/MeasureSDK/Performance/CpuUsageCollector.swift b/ios/MeasureSDK/Performance/CpuUsageCollector.swift index 0f17d4022..d115d57eb 100644 --- a/ios/MeasureSDK/Performance/CpuUsageCollector.swift +++ b/ios/MeasureSDK/Performance/CpuUsageCollector.swift @@ -84,7 +84,8 @@ final class BaseCpuUsageCollector: CpuUsageCollector { type: .cpuUsage, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } else { logger.internalLog(level: .error, message: "Could not get CPU usage data.", error: nil, data: nil) } diff --git a/ios/MeasureSDK/Performance/MemoryUsageCollector.swift b/ios/MeasureSDK/Performance/MemoryUsageCollector.swift index 126c6f3f1..e322c348a 100644 --- a/ios/MeasureSDK/Performance/MemoryUsageCollector.swift +++ b/ios/MeasureSDK/Performance/MemoryUsageCollector.swift @@ -75,7 +75,8 @@ final class BaseMemoryUsageCollector: MemoryUsageCollector { type: .memoryUsageAbsolute, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } else { logger.internalLog(level: .error, message: "Could not get memory usage data.", error: nil, data: nil) } diff --git a/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents b/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents index d432ba480..db8ba1580 100644 --- a/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents +++ b/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents @@ -12,6 +12,7 @@ + @@ -28,6 +29,7 @@ + diff --git a/ios/MeasureSDKTests/Event/CustomEventCollectorTests.swift b/ios/MeasureSDKTests/Event/CustomEventCollectorTests.swift new file mode 100644 index 000000000..72ae5e1ff --- /dev/null +++ b/ios/MeasureSDKTests/Event/CustomEventCollectorTests.swift @@ -0,0 +1,127 @@ +// +// CustomEventCollectorTests.swift +// MeasureSDKTests +// +// Created by Adwin Ross on 07/01/25. +// + +import XCTest +@testable import MeasureSDK + +final class BaseCustomEventCollectorTests: XCTestCase { + private var logger: MockLogger! + private var eventProcessor: MockEventProcessor! + private var timeProvider: MockTimeProvider! + private var configProvider: MockConfigProvider! + private var eventCollector: BaseCustomEventCollector! + + override func setUp() { + super.setUp() + + logger = MockLogger() + eventProcessor = MockEventProcessor() + timeProvider = MockTimeProvider() + configProvider = MockConfigProvider() + + configProvider.customEventNameRegex = "^[a-zA-Z0-9_-]+$" + configProvider.maxEventNameLength = 50 + configProvider.maxUserDefinedAttributesPerEvent = 10 + configProvider.maxUserDefinedAttributeKeyLength = 20 + configProvider.maxUserDefinedAttributeValueLength = 100 + + eventCollector = BaseCustomEventCollector( + logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + configProvider: configProvider + ) + } + + func testTrackEvent_whenEnabled_andValidEvent_sendsToProcessor() { + eventCollector.enable() + let attributes: [String: AttributeValue] = [ + "user_name": .string("Alice"), + "is_premium": .boolean(true) + ] + + eventCollector.trackEvent(name: "custom_event", attributes: attributes, timestamp: 123456789) + + XCTAssertNotNil(eventProcessor.data) + XCTAssertEqual(eventProcessor.timestamp, 123456789) + XCTAssertEqual(eventProcessor.type, .custom) + XCTAssertEqual(eventProcessor.userDefinedAttributes, "{\"is_premium\":true,\"user_name\":\"Alice\"}") + } + + func testTrackEvent_whenDisabled_doesNotSendToProcessor() { + eventCollector.disable() + eventCollector.trackEvent(name: "custom_event", attributes: [:], timestamp: nil) + + XCTAssertNil(eventProcessor.data) + } + + func testTrackEvent_whenNameIsEmpty_logsWarning() { + eventCollector.enable() + eventCollector.trackEvent(name: "", attributes: [:], timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("Event name is empty")) + } + + func testTrackEvent_whenNameExceedsMaxLength_logsWarning() { + eventCollector.enable() + let longName = String(repeating: "a", count: configProvider.maxEventNameLength + 1) + eventCollector.trackEvent(name: longName, attributes: [:], timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("exceeded max allowed length")) + } + + func testTrackEvent_whenNameDoesNotMatchRegex_logsWarning() { + eventCollector.enable() + eventCollector.trackEvent(name: "invalid name!", attributes: [:], timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("does not match the allowed pattern")) + } + + func testTrackEvent_whenTooManyAttributes_logsWarning() { + eventCollector.enable() + let attributes = (1...configProvider.maxUserDefinedAttributesPerEvent + 1) + .reduce(into: [String: AttributeValue]()) { $0["key\($1)"] = .int($1) } + + eventCollector.trackEvent(name: "custom_event", attributes: attributes, timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("contains more than max allowed attributes")) + } + + func testTrackEvent_whenAttributeKeyExceedsMaxLength_logsWarning() { + eventCollector.enable() + let attributes: [String: AttributeValue] = [ + String(repeating: "a", count: configProvider.maxUserDefinedAttributeKeyLength + 1): .int(1) + ] + + eventCollector.trackEvent(name: "custom_event", attributes: attributes, timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("contains invalid attribute key")) + } + + func testTrackEvent_whenAttributeValueExceedsMaxLength_logsWarning() { + eventCollector.enable() + let attributes: [String: AttributeValue] = [ + "valid_key": .string(String(repeating: "a", count: configProvider.maxUserDefinedAttributeValueLength + 1)) + ] + + eventCollector.trackEvent(name: "custom_event", attributes: attributes, timestamp: nil) + + XCTAssertNil(eventProcessor.data) + XCTAssertEqual(logger.logs.count, 1) + XCTAssertTrue(logger.logs[0].contains("contains invalid attribute value")) + } +} diff --git a/ios/MeasureSDKTests/Event/EventProcessorTests.swift b/ios/MeasureSDKTests/Event/EventProcessorTests.swift index 044abb587..d3928509d 100644 --- a/ios/MeasureSDKTests/Event/EventProcessorTests.swift +++ b/ios/MeasureSDKTests/Event/EventProcessorTests.swift @@ -134,7 +134,8 @@ final class EventProcessorTests: XCTestCase { type: .exception, attributes: nil, sessionId: nil, - attachments: [Attachment(name: "file-name", type: .screenshot, path: "file-path")]) + attachments: [Attachment(name: "file-name", type: .screenshot, path: "file-path")], + userDefinedAttributes: nil) // Check if latest attributes are saved when an event is tracked XCTAssertEqual(crashDataPersistence.attribute, attributes) @@ -233,7 +234,8 @@ final class EventProcessorTests: XCTestCase { type: .exception, attributes: attributes, sessionId: "session-id-2", - attachments: [Attachment(name: "file-name", type: .screenshot, path: "file-path")]) + attachments: [Attachment(name: "file-name", type: .screenshot, path: "file-path")], + userDefinedAttributes: nil) // Check if latest attributes are saved when an event is tracked XCTAssertEqual(crashDataPersistence.attribute, updatedAttributes) diff --git a/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift b/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift index 7a0d1bca8..df8c011f8 100644 --- a/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift +++ b/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift @@ -723,7 +723,7 @@ final class EventSerializerTests: XCTestCase { // swiftlint:disable:this type_bo } } - func testHttpDataSerialization() { + func testHttpDataSerialization() { // swiftlint:disable:this function_body_length let httpData = HttpData( url: "https://example.com/api/v1/resource", method: "GET", @@ -748,8 +748,7 @@ final class EventSerializerTests: XCTestCase { // swiftlint:disable:this type_bo data: httpData, attachments: [], attributes: TestDataGenerator.generateAttributes(), - userTriggered: false - ) + userTriggered: false) let eventEntity = EventEntity(event) @@ -795,4 +794,67 @@ final class EventSerializerTests: XCTestCase { // swiftlint:disable:this type_bo XCTFail("Invalid JSON object: \(error.localizedDescription)") } } + + func testCustomEventDataSerialization() { // swiftlint:disable:this function_body_length + let customEventData = CustomEventData(name: "TestEvent") + + let event = Event( + id: "customEventId", + sessionId: "sessionId", + timestamp: "2024-10-22T10:02:00Z", + timestampInMillis: 123456791, + type: .custom, + data: customEventData, + attachments: [], + attributes: TestDataGenerator.generateAttributes(), + userTriggered: true) + + let eventEntity = EventEntity(event) + + guard let jsonString = eventSerializer.getSerialisedEvent(for: eventEntity) else { + XCTFail("getSerialisedEvent cannot be nil") + return + } + + let jsonData = Data(jsonString.utf8) + do { + let jsonDict = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] + + if let customDataDict = jsonDict?["custom"] as? [String: String] { + XCTAssertEqual(customDataDict["name"], "TestEvent", "The custom event name should match the expected value.") + } else { + XCTFail("Custom event data is not present in the serialized event.") + } + } catch { + XCTFail("Invalid JSON object: \(error.localizedDescription)") + } + + func testUserDefinedAttributesSerialization() { + let attributes: [String: AttributeValue] = ["string_data": .string("Alice"), + "bool_data": .boolean(true), + "int_data": .int(1000), + "float_data": .float(1001.0), + "long_data": .long(1000000000), + "double_data": .double(30.2661403415387)] + guard let jsonString = EventSerializer.serializeUserDefinedAttribute(attributes) else { + XCTFail("serializeUserDefinedAttribute cannot be nil") + return + } + let jsonData = Data(jsonString.utf8) + do { + if let jsonDict = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { + XCTAssertEqual(jsonDict["string_data"] as? String, "Alice", "The custom event name should match the expected value.") + XCTAssertEqual(jsonDict["bool_data"] as? Bool, true, "The custom event name should match the expected value.") + XCTAssertEqual(jsonDict["int_data"] as? Int, 1000, "The custom event name should match the expected value.") + XCTAssertEqual(jsonDict["float_data"] as? Float, 1001.0, "The custom event name should match the expected value.") + XCTAssertEqual(jsonDict["long_data"] as? Int64, 1000000000, "The custom event name should match the expected value.") + XCTAssertEqual(jsonDict["double_data"] as? Double, 30.2661403415387, "The custom event name should match the expected value.") + } else { + XCTFail("Could not deserizlize user defined attributes.") + } + } catch { + XCTFail("Invalid JSON object: \(error.localizedDescription)") + } + } + } } // swiftlint:disable:this file_length diff --git a/ios/MeasureSDKTests/Helper/TestDataGenerator.swift b/ios/MeasureSDKTests/Helper/TestDataGenerator.swift index 85ef5402e..37ff816ea 100644 --- a/ios/MeasureSDKTests/Helper/TestDataGenerator.swift +++ b/ios/MeasureSDKTests/Helper/TestDataGenerator.swift @@ -73,6 +73,7 @@ struct TestDataGenerator { exception: Data? = nil, attachments: Data? = nil, attributes: Data? = nil, + userDefinedAttributes: String? = nil, gestureClick: Data? = nil, gestureLongClick: Data? = nil, gestureScroll: Data? = nil, @@ -88,8 +89,9 @@ struct TestDataGenerator { coldLaunch: Data? = nil, warmLaunch: Data? = nil, hotLaunch: Data? = nil, - http: Data? = nil - ) -> EventEntity { + http: Data? = nil, + customEvent: Data? = nil, + networkChange: Data? = nil) -> EventEntity { return EventEntity( id: id, sessionId: sessionId, @@ -98,6 +100,7 @@ struct TestDataGenerator { exception: exception, attachments: attachments, attributes: attributes, + userDefinedAttributes: userDefinedAttributes, gestureClick: gestureClick, gestureLongClick: gestureLongClick, gestureScroll: gestureScroll, @@ -113,7 +116,9 @@ struct TestDataGenerator { coldLaunch: coldLaunch, warmLaunch: warmLaunch, hotLaunch: hotLaunch, - http: http + http: http, + networkChange: networkChange, + customEvent: customEvent ) } diff --git a/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift b/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift index 4d2765243..ac869ba9a 100644 --- a/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift +++ b/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift @@ -22,6 +22,13 @@ final class MockConfigProvider: ConfigProvider { var maxAttachmentSizeInEventsBatchInBytes: Number var maxEventsInBatch: Number var timeoutIntervalForRequest: TimeInterval + var customEventNameRegex: String + var maxEventNameLength: Int + var maxUserDefinedAttributeKeyLength: Int + var maxUserDefinedAttributeValueLength: Int + var maxUserDefinedAttributesPerEvent: Int + var httpContentTypeAllowlist: [String] + var defaultHttpHeadersBlocklist: [String] init(enableLogging: Bool = false, trackScreenshotOnCrash: Bool = true, @@ -35,7 +42,19 @@ final class MockConfigProvider: ConfigProvider { timeoutIntervalForRequest: TimeInterval = 30, maxSessionDurationMs: Number = 60 * 60 * 1000, cpuTrackingIntervalMs: UnsignedNumber = 3000, - memoryTrackingIntervalMs: UnsignedNumber = 2000) { + memoryTrackingIntervalMs: UnsignedNumber = 2000, + customEventNameRegex: String = "^[a-zA-Z0-9_-]", + maxEventNameLength: Int = 64, + maxUserDefinedAttributeKeyLength: Int = 256, + maxUserDefinedAttributeValueLength: Int = 256, + maxUserDefinedAttributesPerEvent: Int = 100, + httpContentTypeAllowlist: [String] = ["application/json"], + defaultHttpHeadersBlocklist: [String] = ["Authorization", + "Cookie", + "Set-Cookie", + "Proxy-Authorization", + "WWW-Authenticate", + "X-Api-Key"]) { self.enableLogging = enableLogging self.trackScreenshotOnCrash = trackScreenshotOnCrash self.sessionSamplingRate = sessionSamplingRate @@ -49,6 +68,13 @@ final class MockConfigProvider: ConfigProvider { self.maxSessionDurationMs = maxSessionDurationMs self.cpuTrackingIntervalMs = cpuTrackingIntervalMs self.memoryTrackingIntervalMs = memoryTrackingIntervalMs + self.customEventNameRegex = customEventNameRegex + self.maxEventNameLength = maxEventNameLength + self.maxUserDefinedAttributeKeyLength = maxUserDefinedAttributeKeyLength + self.maxUserDefinedAttributeValueLength = maxUserDefinedAttributeValueLength + self.maxUserDefinedAttributesPerEvent = maxUserDefinedAttributesPerEvent + self.httpContentTypeAllowlist = httpContentTypeAllowlist + self.defaultHttpHeadersBlocklist = defaultHttpHeadersBlocklist } func loadNetworkConfig() {} diff --git a/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift b/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift index d4e966d60..fc642d4da 100644 --- a/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift +++ b/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift @@ -15,18 +15,37 @@ final class MockEventProcessor: EventProcessor { var timestamp: MeasureSDK.Number? var type: MeasureSDK.EventType? var attributes: MeasureSDK.Attributes? + var userDefinedAttributes: String? func track(data: T, // swiftlint:disable:this function_parameter_count timestamp: MeasureSDK.Number, type: MeasureSDK.EventType, attributes: MeasureSDK.Attributes?, sessionId: String?, - attachments: [MeasureSDK.Attachment]?) where T: Codable { + attachments: [MeasureSDK.Attachment]?, + userDefinedAttributes: String? = nil) where T: Codable { self.data = data self.timestamp = timestamp self.type = type self.attributes = attributes self.sessionId = sessionId self.attachments = attachments + self.userDefinedAttributes = userDefinedAttributes + } + + func trackUserTriggered(data: T, // swiftlint:disable:this function_parameter_count + timestamp: MeasureSDK.Number, + type: MeasureSDK.EventType, + attributes: MeasureSDK.Attributes?, + sessionId: String?, + attachments: [MeasureSDK.Attachment]?, + userDefinedAttributes: String? = nil) where T: Codable { + self.data = data + self.timestamp = timestamp + self.type = type + self.attributes = attributes + self.sessionId = sessionId + self.attachments = attachments + self.userDefinedAttributes = userDefinedAttributes } } diff --git a/ios/TestApp/MockMeasureInitializer.swift b/ios/TestApp/MockMeasureInitializer.swift index 11b10206b..72dc7f421 100644 --- a/ios/TestApp/MockMeasureInitializer.swift +++ b/ios/TestApp/MockMeasureInitializer.swift @@ -47,6 +47,8 @@ final class MockMeasureInitializer: MeasureInitializer { let sysCtl: SysCtl let appLaunchCollector: AppLaunchCollector let httpEventCollector: HttpEventCollector + let customEventCollector: CustomEventCollector + let networkChangeCollector: NetworkChangeCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -155,12 +157,20 @@ final class MockMeasureInitializer: MeasureInitializer { sysCtl: sysCtl, userDefaultStorage: userDefaultStorage, currentAppVersion: appVersion) + self.customEventCollector = BaseCustomEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + configProvider: configProvider) + self.networkChangeCollector = BaseNetworkChangeCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider) self.client = client self.httpEventCollector = BaseHttpEventCollector(logger: logger, eventProcessor: eventProcessor, timeProvider: timeProvider, urlSessionTaskSwizzler: URLSessionTaskSwizzler(), httpInterceptorCallbacks: HttpInterceptorCallbacks(), - client: client) + client: client, + configProvider: configProvider) } }