diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7fea28..f5611c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,8 @@ jobs: flutter pub get flutter test --coverage --reporter github - name: Upload Test Report - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: + token: ${{ secrets.CODECOV_TOKEN }} name: report files: coverage/lcov.info \ No newline at end of file diff --git a/README.md b/README.md index 40ac86d..838cb37 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![clickstream-flutter-test](https://github.com/awslabs/clickstream-flutter/actions/workflows/test.yml/badge.svg)](https://github.com/awslabs/clickstream-flutter/actions/workflows/test.yml) [![clickstream-flutter-release](https://github.com/awslabs/clickstream-flutter/actions/workflows/release.yml/badge.svg)](https://github.com/awslabs/clickstream-flutter/actions/workflows/release.yml) [![clickstream-flutter-build-android](https://github.com/awslabs/clickstream-flutter/actions/workflows/build-android.yml/badge.svg)](https://github.com/awslabs/clickstream-flutter/actions/workflows/build-android.yml) [![clickstream-flutter-build-ios](https://github.com/awslabs/clickstream-flutter/actions/workflows/build-ios.yml/badge.svg)](https://github.com/awslabs/clickstream-flutter/actions/workflows/build-ios.yml) [![pub package](https://img.shields.io/pub/v/clickstream_analytics.svg)](https://pub.dev/packages/clickstream_analytics) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) - ## Introduction Clickstream Flutter SDK can help you easily collect and report events from your mobile app to AWS. This SDK is part of an AWS solution - [Clickstream Analytics on AWS](https://github.com/awslabs/clickstream-analytics-on-aws), which provisions data pipeline to ingest and process event data into AWS services such as S3, Redshift. @@ -90,14 +89,30 @@ Current login user's attributes will be cached in disk, so the next time app lau #### Add global attribute -```dart -analytics.addGlobalAttributes({ - "_traffic_source_medium": "Search engine", - "_traffic_source_name": "Summer promotion", - "level": 10 -}); - -// delete global attribute +1. Add global attributes when initializing the SDK + + ```dart + analytics.init({ + appId: "your appId", + endpoint: "https://example.com/collect", + globalAttributes: { + "_traffic_source_medium": "Search engine", + "_traffic_source_name": "Summer promotion", + } + }); + ``` + +2. Add global attributes after initializing the SDK + ```dart + analytics.addGlobalAttributes({ + "_traffic_source_medium": "Search engine", + "_traffic_source_name": "Summer promotion", + "level": 10 + }); + ``` + +#### Delete global attribute +``` analytics.deleteGlobalAttributes(["level"]); ``` @@ -123,13 +138,30 @@ var itemBook = ClickstreamItem( analytics.record( name: "view_item", attributes: { - "currency": 'USD', - "event_category": 'recommended' + "currency": "USD", + "event_category": "recommended" }, items: [itemBook] ); ``` +#### Record Screen View events manually + +By default, SDK will automatically track the preset `_screen_view` event when Android Activity triggers `onResume` or iOS ViewController triggers `viewDidAppear`. + +You can also manually record screen view events whether automatic screen view tracking is enabled, add the following code to record a screen view event with two attributes. + +* `screenName` Required. Your screen's name. +* `screenUniqueId` Optional. Set the id of your Widget. If you do not set, the SDK will set a default value based on the hashcode of the current Activity or ViewController. + +```dart +analytics.recordScreenView( + screenName: 'Main', + screenUniqueId: '123adf', + attributes: { ... } +); +``` + #### Other configurations In addition to the required `appId` and `endpoint`, you can configure other information to get more customized usage: @@ -146,7 +178,10 @@ analytics.init( isTrackUserEngagementEvents: true, isTrackAppExceptionEvents: false, authCookie: "your auth cookie", - sessionTimeoutDuration: 1800000 + sessionTimeoutDuration: 1800000, + globalAttributes: { + "_traffic_source_medium": "Search engine", + }, ); ``` @@ -162,6 +197,7 @@ Here is an explanation of each option: - **isTrackAppExceptionEvents**: whether auto track exception event in app, default is `false` - **authCookie**: your auth cookie for AWS application load balancer auth cookie. - **sessionTimeoutDuration**: the duration for session timeout millisecond, default is 1800000 +- **globalAttributes**: the global attributes when initializing the SDK. #### Configuration update @@ -177,7 +213,6 @@ analytics.updateConfigure( isTrackScreenViewEvents: false isTrackUserEngagementEvents: false, isTrackAppExceptionEvents: false, - sessionTimeoutDuration: 100000, authCookie: "test cookie"); ``` diff --git a/android/build.gradle b/android/build.gradle index 77d4bb3..37cb1ef 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -52,7 +52,7 @@ android { dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' testImplementation 'org.mockito:mockito-core:5.0.0' - implementation 'software.aws.solution:clickstream:0.10.0' + implementation 'software.aws.solution:clickstream:0.12.0' implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.8.10")) } diff --git a/android/src/main/kotlin/software/aws/solution/clickstream_analytics/ClickstreamFlutterPlugin.kt b/android/src/main/kotlin/software/aws/solution/clickstream_analytics/ClickstreamFlutterPlugin.kt index 647a2c9..bd94956 100644 --- a/android/src/main/kotlin/software/aws/solution/clickstream_analytics/ClickstreamFlutterPlugin.kt +++ b/android/src/main/kotlin/software/aws/solution/clickstream_analytics/ClickstreamFlutterPlugin.kt @@ -17,9 +17,7 @@ package software.aws.solution.clickstream_analytics import android.app.Activity import com.amazonaws.logging.Log import com.amazonaws.logging.LogFactory -import com.amplifyframework.AmplifyException import com.amplifyframework.core.Amplify -import com.amplifyframework.core.AmplifyConfiguration import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -27,10 +25,9 @@ import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel.MethodCallHandler import io.flutter.plugin.common.MethodChannel.Result -import org.json.JSONObject -import software.aws.solution.clickstream.AWSClickstreamPlugin import software.aws.solution.clickstream.ClickstreamAnalytics import software.aws.solution.clickstream.ClickstreamAttribute +import software.aws.solution.clickstream.ClickstreamConfiguration import software.aws.solution.clickstream.ClickstreamEvent import software.aws.solution.clickstream.ClickstreamItem import software.aws.solution.clickstream.ClickstreamUserAttribute @@ -64,53 +61,20 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware override fun onMethodCall(call: MethodCall, result: Result) { val arguments = call.arguments() as HashMap? when (call.method) { - "init" -> { - result.success(initSDK(arguments!!)) - } - - "record" -> { - recordEvent(arguments) - } - - "setUserId" -> { - setUserId(arguments) - } - - "setUserAttributes" -> { - setUserAttributes(arguments) - } - - "setGlobalAttributes" -> { - setGlobalAttributes(arguments) - } - - "deleteGlobalAttributes" -> { - deleteGlobalAttributes(arguments) - } - - "updateConfigure" -> { - updateConfigure(arguments) - } - - "flushEvents" -> { - ClickstreamAnalytics.flushEvents() - } - - "disable" -> { - ClickstreamAnalytics.disable() - } - - "enable" -> { - ClickstreamAnalytics.enable() - } - - else -> { - result.notImplemented() - } + "init" -> result.success(initSDK(arguments!!)) + "record" -> recordEvent(arguments) + "setUserId" -> setUserId(arguments) + "setUserAttributes" -> setUserAttributes(arguments) + "addGlobalAttributes" -> addGlobalAttributes(arguments) + "deleteGlobalAttributes" -> deleteGlobalAttributes(arguments) + "updateConfigure" -> updateConfigure(arguments) + "flushEvents" -> ClickstreamAnalytics.flushEvents() + "disable" -> ClickstreamAnalytics.disable() + "enable" -> ClickstreamAnalytics.enable() + else -> result.notImplemented() } } - private fun initSDK(arguments: HashMap): Boolean { if (getIsInitialized()) return false if (mActivity != null) { @@ -119,28 +83,13 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware log.error("Clickstream SDK initialization failed, please initialize in the main thread") return false } - val amplifyObject = JSONObject() - val analyticsObject = JSONObject() - val pluginsObject = JSONObject() - val awsClickstreamPluginObject = JSONObject() - awsClickstreamPluginObject.put("appId", arguments["appId"]) - awsClickstreamPluginObject.put("endpoint", arguments["endpoint"]) - pluginsObject.put("awsClickstreamPlugin", awsClickstreamPluginObject) - analyticsObject.put("plugins", pluginsObject) - amplifyObject.put("analytics", analyticsObject) - val configure = AmplifyConfiguration.fromJson(amplifyObject) - try { - Amplify.addPlugin(AWSClickstreamPlugin(context)) - Amplify.configure(configure, context) - } catch (exception: AmplifyException) { - log.error("Clickstream SDK initialization failed with error: " + exception.message) - return false - } val sessionTimeoutDuration = arguments["sessionTimeoutDuration"] .let { (it as? Int)?.toLong() ?: (it as Long) } val sendEventsInterval = arguments["sendEventsInterval"] .let { (it as? Int)?.toLong() ?: (it as Long) } - ClickstreamAnalytics.getClickStreamConfiguration() + val configuration = ClickstreamConfiguration() + .withAppId(arguments["appId"] as String) + .withEndpoint(arguments["endpoint"] as String) .withLogEvents(arguments["isLogEvents"] as Boolean) .withTrackScreenViewEvents(arguments["isTrackScreenViewEvents"] as Boolean) .withTrackUserEngagementEvents(arguments["isTrackUserEngagementEvents"] as Boolean) @@ -149,7 +98,28 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware .withSessionTimeoutDuration(sessionTimeoutDuration) .withCompressEvents(arguments["isCompressEvents"] as Boolean) .withAuthCookie(arguments["authCookie"] as String) - return true + + (arguments["globalAttributes"] as? HashMap<*, *>)?.takeIf { it.isNotEmpty() } + ?.let { attributes -> + val globalAttributes = ClickstreamAttribute.builder() + for ((key, value) in attributes) { + when (value) { + is String -> globalAttributes.add(key.toString(), value) + is Double -> globalAttributes.add(key.toString(), value) + is Boolean -> globalAttributes.add(key.toString(), value) + is Int -> globalAttributes.add(key.toString(), value) + is Long -> globalAttributes.add(key.toString(), value) + } + } + configuration.withInitialGlobalAttributes(globalAttributes.build()) + } + return try { + ClickstreamAnalytics.init(context, configuration) + true + } catch (exception: Exception) { + log.error("Clickstream SDK initialization failed with error: " + exception.message) + false + } } else { return false } @@ -163,16 +133,12 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware val items = it["items"] as ArrayList<*> val eventBuilder = ClickstreamEvent.builder().name(eventName) for ((key, value) in attributes) { - if (value is String) { - eventBuilder.add(key.toString(), value) - } else if (value is Double) { - eventBuilder.add(key.toString(), value) - } else if (value is Boolean) { - eventBuilder.add(key.toString(), value) - } else if (value is Int) { - eventBuilder.add(key.toString(), value) - } else if (value is Long) { - eventBuilder.add(key.toString(), value) + when (value) { + is String -> eventBuilder.add(key.toString(), value) + is Double -> eventBuilder.add(key.toString(), value) + is Boolean -> eventBuilder.add(key.toString(), value) + is Int -> eventBuilder.add(key.toString(), value) + is Long -> eventBuilder.add(key.toString(), value) } } if (items.size > 0) { @@ -180,16 +146,12 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware for (index in 0 until items.size) { val builder = ClickstreamItem.builder() for ((key, value) in (items[index] as HashMap<*, *>)) { - if (value is String) { - builder.add(key.toString(), value) - } else if (value is Double) { - builder.add(key.toString(), value) - } else if (value is Boolean) { - builder.add(key.toString(), value) - } else if (value is Int) { - builder.add(key.toString(), value) - } else if (value is Long) { - builder.add(key.toString(), value) + when (value) { + is String -> builder.add(key.toString(), value) + is Double -> builder.add(key.toString(), value) + is Boolean -> builder.add(key.toString(), value) + is Int -> builder.add(key.toString(), value) + is Long -> builder.add(key.toString(), value) } } clickstreamItems[index] = builder.build() @@ -217,36 +179,28 @@ class ClickstreamFlutterPlugin : FlutterPlugin, MethodCallHandler, ActivityAware arguments?.let { val builder = ClickstreamUserAttribute.Builder() for ((key, value) in arguments) { - if (value is String) { - builder.add(key, value) - } else if (value is Double) { - builder.add(key, value) - } else if (value is Boolean) { - builder.add(key, value) - } else if (value is Int) { - builder.add(key, value) - } else if (value is Long) { - builder.add(key, value) + when (value) { + is String -> builder.add(key, value) + is Double -> builder.add(key, value) + is Boolean -> builder.add(key, value) + is Int -> builder.add(key, value) + is Long -> builder.add(key, value) } } ClickstreamAnalytics.addUserAttributes(builder.build()) } } - private fun setGlobalAttributes(arguments: java.util.HashMap?) { + private fun addGlobalAttributes(arguments: java.util.HashMap?) { arguments?.let { val builder = ClickstreamAttribute.Builder() for ((key, value) in arguments) { - if (value is String) { - builder.add(key, value) - } else if (value is Double) { - builder.add(key, value) - } else if (value is Boolean) { - builder.add(key, value) - } else if (value is Int) { - builder.add(key, value) - } else if (value is Long) { - builder.add(key, value) + when (value) { + is String -> builder.add(key, value) + is Double -> builder.add(key, value) + is Boolean -> builder.add(key, value) + is Int -> builder.add(key, value) + is Long -> builder.add(key, value) } } ClickstreamAnalytics.addGlobalAttributes(builder.build()) diff --git a/example/lib/main.dart b/example/lib/main.dart index 809788b..51d16e5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -24,6 +24,7 @@ class _MyAppState extends State { @override void initState() { super.initState(); + initClickstream(); } void log(String message) { @@ -32,6 +33,23 @@ class _MyAppState extends State { } } + Future initClickstream() async { + bool result = await analytics.init( + appId: "shopping", + endpoint: testEndpoint, + isLogEvents: true, + isTrackScreenViewEvents: true, + isCompressEvents: false, + sessionTimeoutDuration: 30000, + globalAttributes: { + "channel": "Samsung", + "Class": 5, + "isTrue": true, + "Score": 24.32 + }); + log("init SDK result is:$result"); + } + @override Widget build(BuildContext context) { return MaterialApp( @@ -45,12 +63,7 @@ class _MyAppState extends State { leading: const Icon(Icons.not_started_outlined), title: const Text('initSDK'), onTap: () async { - bool result = await analytics.init( - appId: "shopping", - endpoint: testEndpoint, - isLogEvents: true, - isCompressEvents: false); - log("init SDK result is:$result"); + initClickstream(); }, minLeadingWidth: 0, ), @@ -80,6 +93,18 @@ class _MyAppState extends State { }, minLeadingWidth: 0, ), + ListTile( + leading: const Icon(Icons.remove_red_eye_outlined), + title: const Text('recordCustomScreenViewEvents'), + onTap: () async { + analytics.recordScreenView( + screenName: 'Main', + screenUniqueId: '123adf', + attributes: {'screenClass': "example/lib/main.dart"}); + log("recorded an custom screen view event"); + }, + minLeadingWidth: 0, + ), ListTile( leading: const Icon(Icons.touch_app_outlined), title: const Text('recordEventWithItem'), @@ -155,7 +180,7 @@ class _MyAppState extends State { title: const Text('addGlobalAttributes'), onTap: () async { analytics.addGlobalAttributes({ - "_channel": "Samsung", + "channel": "Samsung", "Class": 5, "isTrue": true, "Score": 24.32 @@ -168,8 +193,8 @@ class _MyAppState extends State { leading: const Icon(Icons.delete_rounded), title: const Text('deleteGlobalAttributes'), onTap: () async { - analytics.deleteGlobalAttributes(["Score", "_channel"]); - log("deleteGlobalAttributes Score and _channel"); + analytics.deleteGlobalAttributes(["Score", "channel"]); + log("deleteGlobalAttributes Score and channel"); }, minLeadingWidth: 0, ), @@ -180,7 +205,6 @@ class _MyAppState extends State { analytics.updateConfigure( isLogEvents: true, isCompressEvents: false, - sessionTimeoutDuration: 100000, isTrackUserEngagementEvents: false, isTrackAppExceptionEvents: false, authCookie: "test cookie", diff --git a/ios/Classes/ClickstreamFlutterPlugin.swift b/ios/Classes/ClickstreamFlutterPlugin.swift index 6d63560..d408180 100644 --- a/ios/Classes/ClickstreamFlutterPlugin.swift +++ b/ios/Classes/ClickstreamFlutterPlugin.swift @@ -18,8 +18,6 @@ public class ClickstreamFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { - case "getPlatformVersion": - result("iOS " + UIDevice.current.systemVersion) case "init": result(initSDK(call.arguments as! [String: Any])) case "record": @@ -47,25 +45,25 @@ public class ClickstreamFlutterPlugin: NSObject, FlutterPlugin { func initSDK(_ arguments: [String: Any]) -> Bool { do { - let plugins: [String: JSONValue] = [ - "awsClickstreamPlugin": [ - "appId": JSONValue.string(arguments["appId"] as! String), - "endpoint": JSONValue.string(arguments["endpoint"] as! String), - "isCompressEvents": JSONValue.boolean(arguments["isCompressEvents"] as! Bool), - "autoFlushEventsInterval": JSONValue.number(arguments["sendEventsInterval"] as! Double), - "isTrackAppExceptionEvents": JSONValue.boolean(arguments["isTrackAppExceptionEvents"] as! Bool) - ] - ] - let analyticsConfiguration = AnalyticsCategoryConfiguration(plugins: plugins) - let config = AmplifyConfiguration(analytics: analyticsConfiguration) - try Amplify.add(plugin: AWSClickstreamPlugin()) - try Amplify.configure(config) - let configure = try ClickstreamAnalytics.getClickstreamConfiguration() - configure.isLogEvents = arguments["isLogEvents"] as! Bool - configure.isTrackScreenViewEvents = arguments["isTrackScreenViewEvents"] as! Bool - configure.isTrackUserEngagementEvents = arguments["isTrackUserEngagementEvents"] as! Bool - configure.sessionTimeoutDuration = arguments["sessionTimeoutDuration"] as! Int64 - configure.authCookie = arguments["authCookie"] as? String + let configuration = ClickstreamConfiguration() + .withAppId(arguments["appId"] as! String) + .withEndpoint(arguments["endpoint"] as! String) + .withLogEvents(arguments["isLogEvents"] as! Bool) + .withTrackScreenViewEvents(arguments["isTrackScreenViewEvents"] as! Bool) + .withTrackUserEngagementEvents(arguments["isTrackUserEngagementEvents"] as! Bool) + .withTrackAppExceptionEvents(arguments["isTrackAppExceptionEvents"] as! Bool) + .withSendEventInterval(arguments["sendEventsInterval"] as! Int) + .withSessionTimeoutDuration(arguments["sessionTimeoutDuration"] as! Int64) + .withCompressEvents(arguments["isCompressEvents"] as! Bool) + .withAuthCookie(arguments["authCookie"] as! String) + if arguments["globalAttributes"] != nil { + let attributes = arguments["globalAttributes"] as! [String: Any] + if attributes.count > 0 { + let globalAttributes = getClickstreamAttributes(attributes) + _ = configuration.withInitialGlobalAttributes(globalAttributes) + } + } + try ClickstreamAnalytics.initSDK(configuration) return true } catch { log.error("Fail to initialize ClickstreamAnalytics: \(error)") diff --git a/ios/Clickstream b/ios/Clickstream index cd9d03c..3fd0573 160000 --- a/ios/Clickstream +++ b/ios/Clickstream @@ -1 +1 @@ -Subproject commit cd9d03c3deb06be12cde936dc545429a79d2b7f3 +Subproject commit 3fd05730311702b82eaf5108b60f73a400667901 diff --git a/lib/clickstream_analytics.dart b/lib/clickstream_analytics.dart index fb1c7f4..4c9dfb0 100644 --- a/lib/clickstream_analytics.dart +++ b/lib/clickstream_analytics.dart @@ -16,6 +16,7 @@ class ClickstreamAnalytics { int sendEventsInterval = 10000, int sessionTimeoutDuration = 1800000, String authCookie = "", + Map? globalAttributes, }) { Map initConfig = { 'appId': appId, @@ -27,7 +28,8 @@ class ClickstreamAnalytics { 'isTrackAppExceptionEvents': isTrackAppExceptionEvents, 'sendEventsInterval': sendEventsInterval, 'sessionTimeoutDuration': sessionTimeoutDuration, - 'authCookie': authCookie + 'authCookie': authCookie, + 'globalAttributes': globalAttributes }; return ClickstreamInterface.instance.init(initConfig); } @@ -49,6 +51,17 @@ class ClickstreamAnalytics { }); } + Future recordScreenView( + {required String screenName, + String? screenUniqueId, + Map? attributes}) { + return record(name: '_screen_view', attributes: { + '_screen_name': screenName, + if (screenUniqueId != null) '_screen_unique_id': screenUniqueId, + if (attributes != null) ...attributes, + }); + } + Future setUserId(String? userId) { return ClickstreamInterface.instance.setUserId({"userId": userId}); } diff --git a/release.sh b/release.sh index f45292d..322925c 100755 --- a/release.sh +++ b/release.sh @@ -5,3 +5,4 @@ echo ${version} regex="[0-9]\+\.[0-9]\+\.[0-9]\+" sed -i "s/version: ${regex}/version: ${version}/g" pubspec.yaml +sed -i "s/s.version = '${regex}'/s.version = '${version}'/g" ios/clickstream_analytics.podspec diff --git a/test/clickstream_flutter_test.dart b/test/clickstream_flutter_test.dart index 01a75a9..86cdd1f 100644 --- a/test/clickstream_flutter_test.dart +++ b/test/clickstream_flutter_test.dart @@ -67,6 +67,37 @@ void main() { expect(result, true); }); + test('init SDK with global attributes', () async { + var result = await analytics.init( + appId: 'testApp', + endpoint: "https://example.com/collect", + globalAttributes: { + "channel": "Samsung", + "Class": 5, + "isTrue": true, + "Score": 24.32 + }); + expect(result, true); + }); + + test('init SDK with all configuration', () async { + var result = await analytics.init( + appId: 'testApp', + endpoint: "https://example.com/collect", + isLogEvents: true, + isCompressEvents: false, + sessionTimeoutDuration: 150000, + sendEventsInterval: 60000, + authCookie: 'test auth cookie', + isTrackScreenViewEvents: false, + isTrackUserEngagementEvents: false, + isTrackAppExceptionEvents: true, + globalAttributes: { + "channel": "Samsung", + }); + expect(result, true); + }); + test('record event', () async { var result = analytics.record(name: "testEvent"); expect(result, isNotNull); @@ -101,6 +132,14 @@ void main() { expect(result, isNotNull); }); + test('record custom screen view event', () async { + var result = analytics.recordScreenView( + screenName: "MainPage", + screenUniqueId: 'a13cfe', + attributes: {"category": "shoes", "currency": "CNY", "value": 279.9}); + expect(result, isNotNull); + }); + test('setUserId', () async { var result = analytics.setUserId("11234"); expect(result, isNotNull);