From c25f9ce81a715d163aef0c9a383bcf7204e578b6 Mon Sep 17 00:00:00 2001 From: Adwin Ronald Ross Date: Tue, 7 Jan 2025 18:15:41 +0530 Subject: [PATCH] chore(ios): add custom event tracking --- .../Controller/ObjcDetailViewController.m | 7 + ios/DemoApp/Controller/ViewController.swift | 9 ++ ios/MeasureSDK.xcodeproj/project.pbxproj | 16 +++ .../AppLaunch/AppLaunchCollector.swift | 9 +- ios/MeasureSDK/Attribute/AttributeValue.swift | 68 ++++++++++ .../Config/BaseConfigProvider.swift | 20 +++ ios/MeasureSDK/Config/Config.swift | 10 ++ ios/MeasureSDK/Config/InternalConfig.swift | 15 +++ .../CoreData/Entities/EventEntity.swift | 31 ++++- ios/MeasureSDK/CoreData/EventStore.swift | 14 +- .../CrashReporter/CrashReportingManager.swift | 3 +- .../Events/CustomEventCollector.swift | 111 +++++++++++++++ ios/MeasureSDK/Events/CustomEventData.swift | 12 ++ ios/MeasureSDK/Events/Event.swift | 15 ++- ios/MeasureSDK/Events/EventProcessor.swift | 49 +++++-- ios/MeasureSDK/Events/EventType.swift | 1 + ios/MeasureSDK/Exporter/EventSerializer.swift | 34 ++++- .../Gestures/GestureCollector.swift | 6 +- .../Lifecycle/LifecycleCollector.swift | 13 +- ios/MeasureSDK/Measure.swift | 63 +++++++++ ios/MeasureSDK/MeasureInitializer.swift | 7 + ios/MeasureSDK/MeasureInternal.swift | 6 +- .../Performance/CpuUsageCollector.swift | 3 +- .../Performance/MemoryUsageCollector.swift | 3 +- .../MeasureModel.xcdatamodel/contents | 2 + .../Event/CustomEventCollectorTests.swift | 127 ++++++++++++++++++ .../Event/EventProcessorTests.swift | 6 +- .../Exporter/EventSerializerTests.swift | 63 +++++++++ .../Helper/TestDataGenerator.swift | 8 +- .../Mocks/MockConfigProvider.swift | 18 ++- .../Mocks/MockEventProcessor.swift | 5 +- ios/TestApp/MockMeasureInitializer.swift | 5 + 32 files changed, 720 insertions(+), 39 deletions(-) create mode 100644 ios/MeasureSDK/Attribute/AttributeValue.swift create mode 100644 ios/MeasureSDK/Events/CustomEventCollector.swift create mode 100644 ios/MeasureSDK/Events/CustomEventData.swift create mode 100644 ios/MeasureSDKTests/Event/CustomEventCollectorTests.swift 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 63a683a90..0950f4af8 100644 --- a/ios/MeasureSDK.xcodeproj/project.pbxproj +++ b/ios/MeasureSDK.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 52159E2D2CC8FAA500486F54 /* TimeProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52159E2C2CC8FAA500486F54 /* TimeProviderTests.swift */; }; 5224ECE02C88057A00D1B1F7 /* FatalErrorUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5224ECDF2C88057A00D1B1F7 /* FatalErrorUtil.swift */; }; 5224ECE32C880FA400D1B1F7 /* XCTextCase+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5224ECE22C880FA300D1B1F7 /* XCTextCase+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 */; }; @@ -106,6 +107,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 */; }; @@ -164,6 +167,7 @@ 52D51ABB2CD0E5C9008F30A6 /* MsrMoniterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D51ABA2CD0E5C9008F30A6 /* MsrMoniterView.swift */; }; 52D51ABD2CD2060A008F30A6 /* LifecycleCollectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D51ABC2CD2060A008F30A6 /* LifecycleCollectorTests.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 */; }; @@ -339,6 +343,7 @@ 52159E2C2CC8FAA500486F54 /* TimeProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeProviderTests.swift; sourceTree = ""; }; 5224ECDF2C88057A00D1B1F7 /* FatalErrorUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FatalErrorUtil.swift; sourceTree = ""; }; 5224ECE22C880FA300D1B1F7 /* XCTextCase+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTextCase+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 = ""; }; @@ -408,6 +413,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 = ""; }; @@ -460,6 +467,7 @@ 52D51ABA2CD0E5C9008F30A6 /* MsrMoniterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsrMoniterView.swift; sourceTree = ""; }; 52D51ABC2CD2060A008F30A6 /* LifecycleCollectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LifecycleCollectorTests.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 = ""; }; @@ -546,6 +554,7 @@ 5202BE302C895FC800A3496E /* AppAttributeProcessor.swift */, 5202BE312C895FC800A3496E /* Attribute.swift */, 5202BE322C895FC800A3496E /* AttributeProcessor.swift */, + 522532C92D295F3F001B5D7C /* AttributeValue.swift */, 5202BE332C895FC800A3496E /* ComputeOnceAttributeProcessor.swift */, 5202BE342C895FC800A3496E /* DeviceAttributeProcessor.swift */, 5202BE352C895FC800A3496E /* InstallationIdAttributeProcessor.swift */, @@ -591,6 +600,8 @@ children = ( 5202BE732C8B117900A3496E /* Attachment.swift */, 5202BE742C8B117900A3496E /* AttachmentType.swift */, + 52B2A8782D1A89EF00C6B5CF /* CustomEventCollector.swift */, + 52B2A8762D1A790200C6B5CF /* CustomEventData.swift */, 5202BE752C8B117900A3496E /* Event.swift */, 5202BE762C8B117900A3496E /* EventProcessor.swift */, 5202BE772C8B117900A3496E /* EventType.swift */, @@ -853,6 +864,7 @@ isa = PBXGroup; children = ( 52A1A9612CA592A100461103 /* EventProcessorTests.swift */, + 52F0C6342D2D46980060FD08 /* CustomEventCollectorTests.swift */, ); path = Event; sourceTree = ""; @@ -1353,10 +1365,12 @@ 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 */, 526E30F62CE77BCB00F484B4 /* AppLaunchEvents.swift in Sources */, 52A853402C994D7900B2A39F /* Exception.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 */, @@ -1375,6 +1389,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 */, @@ -1437,6 +1452,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..52722a1dc --- /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 "\"\(value.value)\"" + 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 b78988b97..25fcd397a 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 c15d2f9e9..ce3d4bb78 100644 --- a/ios/MeasureSDK/Config/Config.swift +++ b/ios/MeasureSDK/Config/Config.swift @@ -28,6 +28,11 @@ struct Config: InternalConfig, MeasureConfig { let maxSessionDurationMs: Number let cpuTrackingIntervalMs: UnsignedNumber let memoryTrackingIntervalMs: UnsignedNumber + 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, @@ -45,5 +50,10 @@ struct Config: InternalConfig, MeasureConfig { self.maxSessionDurationMs = 6 * 60 * 60 * 1000 // 6 hours self.cpuTrackingIntervalMs = 3 * 1000 // 3 seconds self.memoryTrackingIntervalMs = 2 * 1000 // 2 seconds + 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 f01c05cd8..3156f5412 100644 --- a/ios/MeasureSDK/Config/InternalConfig.swift +++ b/ios/MeasureSDK/Config/InternalConfig.swift @@ -38,4 +38,19 @@ protocol InternalConfig { /// The interval at which memory related data is collected. Defaults to 2 seconds. var memoryTrackingIntervalMs: UnsignedNumber { 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 e9657659d..91c3fd110 100644 --- a/ios/MeasureSDK/CoreData/Entities/EventEntity.swift +++ b/ios/MeasureSDK/CoreData/Entities/EventEntity.swift @@ -15,6 +15,7 @@ struct EventEntity { let exception: Data? let attachments: Data? let attributes: Data? + let userDefinedAttributes: String? let gestureClick: Data? let gestureLongClick: Data? let gestureScroll: Data? @@ -30,6 +31,7 @@ struct EventEntity { let attachmentSize: Number let timestampInMillis: Number var batchId: String? + let customEvent: Data? init(_ event: Event) { // swiftlint:disable:this cyclomatic_complexity function_body_length self.id = event.id @@ -40,6 +42,7 @@ struct EventEntity { self.timestampInMillis = event.timestampInMillis ?? 0 self.attachmentSize = 0 self.batchId = nil + self.userDefinedAttributes = event.userDefinedAttributes if let exception = event.data as? Exception { do { @@ -173,6 +176,17 @@ struct EventEntity { self.hotLaunch = 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) @@ -199,6 +213,7 @@ struct EventEntity { exception: Data?, attachments: Data?, attributes: Data?, + userDefinedAttributes: String?, gestureClick: Data?, gestureLongClick: Data?, gestureScroll: Data?, @@ -213,7 +228,8 @@ struct EventEntity { memoryUsage: Data?, coldLaunch: Data?, warmLaunch: Data?, - hotLaunch: Data?) { + hotLaunch: Data?, + customEvent: Data?) { self.id = id self.sessionId = sessionId self.timestamp = timestamp @@ -221,6 +237,7 @@ struct EventEntity { self.exception = exception self.attachments = attachments self.attributes = attributes + self.userDefinedAttributes = userDefinedAttributes self.userTriggered = userTriggered self.gestureClick = gestureClick self.gestureLongClick = gestureLongClick @@ -236,6 +253,7 @@ struct EventEntity { self.coldLaunch = coldLaunch self.warmLaunch = warmLaunch self.hotLaunch = hotLaunch + self.customEvent = customEvent } func getEvent() -> Event { // swiftlint:disable:this cyclomatic_complexity function_body_length @@ -338,6 +356,14 @@ struct EventEntity { 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 } @@ -372,6 +398,7 @@ struct EventEntity { 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 d6bd4607e..ea415873b 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 @@ -51,6 +52,7 @@ final class BaseEventStore: EventStore { eventOb.coldLaunch = event.coldLaunch eventOb.warmLaunch = event.warmLaunch eventOb.hotLaunch = event.hotLaunch + eventOb.customEvent = event.customEvent do { try context.saveIfNeeded() @@ -79,6 +81,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, @@ -93,7 +96,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch) + hotLaunch: eventOb.hotLaunch, + customEvent: eventOb.customEvent) } } catch { guard let self = self else { return } @@ -120,6 +124,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, @@ -134,7 +139,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch) + hotLaunch: eventOb.hotLaunch, + customEvent: eventOb.customEvent) } } catch { guard let self = self else { return } @@ -180,6 +186,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, @@ -194,7 +201,8 @@ final class BaseEventStore: EventStore { memoryUsage: eventOb.memoryUsage, coldLaunch: eventOb.coldLaunch, warmLaunch: eventOb.warmLaunch, - hotLaunch: eventOb.hotLaunch)) + hotLaunch: eventOb.hotLaunch, + 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..a84e0b1c7 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,23 @@ 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 +93,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 +105,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 +129,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 +143,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 35972a3b6..66afdd7b6 100644 --- a/ios/MeasureSDK/Events/EventType.swift +++ b/ios/MeasureSDK/Events/EventType.swift @@ -20,4 +20,5 @@ enum EventType: String, Codable { case coldLaunch = "cold_launch" case warmLaunch = "warm_launch" case hotLaunch = "hot_launch" + case custom } diff --git a/ios/MeasureSDK/Exporter/EventSerializer.swift b/ios/MeasureSDK/Exporter/EventSerializer.swift index 20f639be2..a2a47c544 100644 --- a/ios/MeasureSDK/Exporter/EventSerializer.swift +++ b/ios/MeasureSDK/Exporter/EventSerializer.swift @@ -131,6 +131,16 @@ struct EventSerializer { } } 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 } @@ -321,6 +331,13 @@ struct EventSerializer { 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 { @@ -351,7 +368,7 @@ struct EventSerializer { 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 @@ -375,8 +392,23 @@ struct EventSerializer { 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/Lifecycle/LifecycleCollector.swift b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift index 6c229c7c5..4e1161f36 100644 --- a/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift +++ b/ios/MeasureSDK/Lifecycle/LifecycleCollector.swift @@ -39,7 +39,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleApp, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func applicationWillEnterForeground() { @@ -48,7 +49,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleApp, attributes: nil, sessionId: nil, - attachments: nil) + attachments: nil, + userDefinedAttributes: nil) } func processControllerLifecycleEvent(_ vcLifecycleType: VCLifecycleEventType, for viewController: UIViewController) { @@ -73,8 +75,8 @@ class BaseLifecycleCollector: LifecycleCollector { type: .lifecycleViewController, attributes: nil, sessionId: nil, - attachments: nil - ) + attachments: nil, + userDefinedAttributes: nil) } } @@ -84,6 +86,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 b705d6e18..b0f8ffdb8 100644 --- a/ios/MeasureSDK/Measure.swift +++ b/ios/MeasureSDK/Measure.swift @@ -82,4 +82,67 @@ import Foundation } } } + + /// 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 876ea4ba8..8dcff7fe1 100644 --- a/ios/MeasureSDK/MeasureInitializer.swift +++ b/ios/MeasureSDK/MeasureInitializer.swift @@ -47,6 +47,7 @@ protocol MeasureInitializer { var memoryUsageCalculator: MemoryUsageCalculator { get } var sysCtl: SysCtl { get } var appLaunchCollector: AppLaunchCollector { get } + var customEventCollector: CustomEventCollector { get } } /// `BaseMeasureInitializer` is responsible for setting up the internal configuration @@ -81,6 +82,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. @@ -128,6 +130,7 @@ final class BaseMeasureInitializer: MeasureInitializer { let memoryUsageCalculator: MemoryUsageCalculator let sysCtl: SysCtl let appLaunchCollector: AppLaunchCollector + let customEventCollector: CustomEventCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -236,6 +239,10 @@ final class BaseMeasureInitializer: MeasureInitializer { sysCtl: sysCtl, userDefaultStorage: userDefaultStorage, currentAppVersion: appVersion) + self.customEventCollector = BaseCustomEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + configProvider: configProvider) self.client = client } } diff --git a/ios/MeasureSDK/MeasureInternal.swift b/ios/MeasureSDK/MeasureInternal.swift index df5886522..314b30d01 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 { @@ -103,6 +103,9 @@ final class MeasureInternal { private var appLaunchCollector: AppLaunchCollector { return measureInitializer.appLaunchCollector } + var customEventCollector: CustomEventCollector { + return measureInitializer.customEventCollector + } private let lifecycleObserver: LifecycleObserver init(_ measureInitializer: MeasureInitializer) { @@ -111,6 +114,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/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 354b416fe..216279297 100644 --- a/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents +++ b/ios/MeasureSDK/XCDataModel/MeasureModel.xcdatamodeld/MeasureModel.xcdatamodel/contents @@ -12,6 +12,7 @@ + @@ -26,6 +27,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 e30a6a8b2..20b9a450c 100644 --- a/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift +++ b/ios/MeasureSDKTests/Exporter/EventSerializerTests.swift @@ -723,4 +723,67 @@ final class EventSerializerTests: XCTestCase { // swiftlint:disable:this type_bo } } + func testCustomEventDataSerialization() { + 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)") + } + } } diff --git a/ios/MeasureSDKTests/Helper/TestDataGenerator.swift b/ios/MeasureSDKTests/Helper/TestDataGenerator.swift index fdd1f3717..62c011bf4 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, @@ -87,7 +88,8 @@ struct TestDataGenerator { memoryUsage: Data? = nil, coldLaunch: Data? = nil, warmLaunch: Data? = nil, - hotLaunch: Data? = nil + hotLaunch: Data? = nil, + customEvent: Data? = nil ) -> EventEntity { return EventEntity( id: id, @@ -97,6 +99,7 @@ struct TestDataGenerator { exception: exception, attachments: attachments, attributes: attributes, + userDefinedAttributes: userDefinedAttributes, gestureClick: gestureClick, gestureLongClick: gestureLongClick, gestureScroll: gestureScroll, @@ -111,7 +114,8 @@ struct TestDataGenerator { memoryUsage: memoryUsage, coldLaunch: coldLaunch, warmLaunch: warmLaunch, - hotLaunch: hotLaunch + hotLaunch: hotLaunch, + customEvent: customEvent ) } diff --git a/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift b/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift index 4d2765243..a92ec790d 100644 --- a/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift +++ b/ios/MeasureSDKTests/Mocks/MockConfigProvider.swift @@ -22,6 +22,11 @@ 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 init(enableLogging: Bool = false, trackScreenshotOnCrash: Bool = true, @@ -35,7 +40,13 @@ 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 + ) { self.enableLogging = enableLogging self.trackScreenshotOnCrash = trackScreenshotOnCrash self.sessionSamplingRate = sessionSamplingRate @@ -49,6 +60,11 @@ 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 } func loadNetworkConfig() {} diff --git a/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift b/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift index d4e966d60..2c32606cf 100644 --- a/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift +++ b/ios/MeasureSDKTests/Mocks/MockEventProcessor.swift @@ -15,18 +15,21 @@ 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 } } diff --git a/ios/TestApp/MockMeasureInitializer.swift b/ios/TestApp/MockMeasureInitializer.swift index dd4314f5f..c7f8c3587 100644 --- a/ios/TestApp/MockMeasureInitializer.swift +++ b/ios/TestApp/MockMeasureInitializer.swift @@ -46,6 +46,7 @@ final class MockMeasureInitializer: MeasureInitializer { let memoryUsageCalculator: MemoryUsageCalculator let sysCtl: SysCtl let appLaunchCollector: AppLaunchCollector + let customEventCollector: CustomEventCollector init(config: MeasureConfig, // swiftlint:disable:this function_body_length client: Client) { @@ -154,6 +155,10 @@ final class MockMeasureInitializer: MeasureInitializer { sysCtl: sysCtl, userDefaultStorage: userDefaultStorage, currentAppVersion: appVersion) + self.customEventCollector = BaseCustomEventCollector(logger: logger, + eventProcessor: eventProcessor, + timeProvider: timeProvider, + configProvider: configProvider) self.client = client } }