diff --git a/.github/composite_actions/log_cw_metric/dist/main.cjs b/.github/composite_actions/log_cw_metric/dist/main.cjs index 47c8bac01a8..c499c35289a 100644 --- a/.github/composite_actions/log_cw_metric/dist/main.cjs +++ b/.github/composite_actions/log_cw_metric/dist/main.cjs @@ -7544,7 +7544,7 @@ throw A.wrapException(A.Exception_Exception("test-type input of " + testType + " must be one of: canary, e2e, unit")); workingDirectory = A.Core_getRequiredInput(A._asJSObject(t1.core), "working-directory"); t3 = type$.String; - category = A.IterableExtension_firstWhereOrNull(A._setArrayType(["canaries", "analytics", "api", "auth", "authenticator", "core", "datastore", "db_common", "push", "secure_storage", "storage", "aws_common", "aws_signature_v4", "smithy", "worker_bee", "amplify_flutter", "amplify_foundation_dart_bridge", "amplify_foundation_dart", "amplify_lints", "amplify_native_legacy_wrapper", "kinesis", "connect_client", "pub_server"], t2), B.JSString_methods.get$contains(workingDirectory), t3); + category = A.IterableExtension_firstWhereOrNull(A._setArrayType(["canaries", "analytics", "api", "auth", "authenticator", "core", "datastore", "db_common", "push", "secure_storage", "storage", "aws_common", "aws_signature_v4", "smithy", "worker_bee", "amplify_flutter", "amplify_foundation_dart_bridge", "amplify_foundation_dart", "amplify_lints", "amplify_native_legacy_wrapper", "kinesis", "connect_client", "event_enrichment", "pub_server"], t2), B.JSString_methods.get$contains(workingDirectory), t3); if (category == null) throw A.wrapException(A.Exception_Exception("WorkingDirectory input of " + workingDirectory + " must contain a valid category.")); t4 = A._asString(A._asJSObject(A._asJSObject(t1.github).context).workflow); @@ -7702,7 +7702,7 @@ }, IterableExtension_firstWhereOrNull(_this, test, $T) { var _i, element; - for (_i = 0; _i < 23; ++_i) { + for (_i = 0; _i < 24; ++_i) { element = _this[_i]; if (test.call$1(element)) return element; diff --git a/.github/workflows/amplify_event_enrichment.yaml b/.github/workflows/amplify_event_enrichment.yaml new file mode 100644 index 00000000000..9d687d8fb47 --- /dev/null +++ b/.github/workflows/amplify_event_enrichment.yaml @@ -0,0 +1,64 @@ +# Generated with aft. To update, run: `aft generate workflows` +name: amplify_event_enrichment +on: + push: + branches: + - main + - stable + paths: + - '.github/workflows/amplify_event_enrichment.yaml' + - '.github/workflows/flutter_vm.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/**/*.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/lib/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/test/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/pubspec.yaml' + - 'packages/amplify_foundation/amplify_foundation_dart/lib/**/*.dart' + - 'packages/amplify_foundation/amplify_foundation_dart/pubspec.yaml' + - 'packages/amplify_lints/lib/**/*.yaml' + - 'packages/amplify_lints/pubspec.yaml' + - 'packages/aws_common/lib/**/*.dart' + - 'packages/aws_common/pubspec.yaml' + pull_request: + paths: + - '.github/workflows/amplify_event_enrichment.yaml' + - '.github/workflows/flutter_vm.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/**/*.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/lib/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment/test/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/pubspec.yaml' + - 'packages/amplify_foundation/amplify_foundation_dart/lib/**/*.dart' + - 'packages/amplify_foundation/amplify_foundation_dart/pubspec.yaml' + - 'packages/amplify_lints/lib/**/*.yaml' + - 'packages/amplify_lints/pubspec.yaml' + - 'packages/aws_common/lib/**/*.dart' + - 'packages/aws_common/pubspec.yaml' + schedule: + - cron: "0 13 * * 1" # Every Monday at 06:00 PST + workflow_dispatch: +defaults: + run: + shell: bash + +# These permissions are needed to interact with GitHub's OIDC Token endpoint. +permissions: + id-token: write + contents: read + +# Cancels in-progress job when there is another push to same ref. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-only-cancel-in-progress-jobs-or-runs-for-the-current-workflow +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + uses: ./.github/workflows/flutter_vm.yaml + secrets: inherit + with: + package-name: amplify_event_enrichment + working-directory: packages/amplify_event_enrichment/amplify_event_enrichment + has-goldens: false diff --git a/.github/workflows/amplify_event_enrichment_dart.yaml b/.github/workflows/amplify_event_enrichment_dart.yaml new file mode 100644 index 00000000000..7c72fd4e290 --- /dev/null +++ b/.github/workflows/amplify_event_enrichment_dart.yaml @@ -0,0 +1,59 @@ +# Generated with aft. To update, run: `aft generate workflows` +name: amplify_event_enrichment_dart +on: + push: + branches: + - main + - stable + paths: + - '.github/workflows/amplify_event_enrichment_dart.yaml' + - '.github/workflows/dart_vm.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/**/*.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/test/**/*' + - 'packages/amplify_foundation/amplify_foundation_dart/lib/**/*.dart' + - 'packages/amplify_foundation/amplify_foundation_dart/pubspec.yaml' + - 'packages/amplify_lints/lib/**/*.yaml' + - 'packages/amplify_lints/pubspec.yaml' + - 'packages/aws_common/lib/**/*.dart' + - 'packages/aws_common/pubspec.yaml' + pull_request: + paths: + - '.github/workflows/amplify_event_enrichment_dart.yaml' + - '.github/workflows/dart_vm.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/**/*.dart' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/**/*.yaml' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/**/*' + - 'packages/amplify_event_enrichment/amplify_event_enrichment_dart/test/**/*' + - 'packages/amplify_foundation/amplify_foundation_dart/lib/**/*.dart' + - 'packages/amplify_foundation/amplify_foundation_dart/pubspec.yaml' + - 'packages/amplify_lints/lib/**/*.yaml' + - 'packages/amplify_lints/pubspec.yaml' + - 'packages/aws_common/lib/**/*.dart' + - 'packages/aws_common/pubspec.yaml' + schedule: + - cron: "0 13 * * 1" # Every Monday at 06:00 PST + workflow_dispatch: +defaults: + run: + shell: bash + +# These permissions are needed to interact with GitHub's OIDC Token endpoint. +permissions: + id-token: write + contents: read + +# Cancels in-progress job when there is another push to same ref. +# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-only-cancel-in-progress-jobs-or-runs-for-the-current-workflow +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + uses: ./.github/workflows/dart_vm.yaml + secrets: inherit + with: + package-name: amplify_event_enrichment_dart + working-directory: packages/amplify_event_enrichment/amplify_event_enrichment_dart diff --git a/actions/bin/log_cw_metric.dart b/actions/bin/log_cw_metric.dart index 77639d894dc..a138b64b575 100644 --- a/actions/bin/log_cw_metric.dart +++ b/actions/bin/log_cw_metric.dart @@ -95,6 +95,7 @@ Future logMetric() async { 'amplify_native_legacy_wrapper', 'kinesis', 'connect_client', + 'event_enrichment', 'pub_server', ]; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/CHANGELOG.md b/packages/amplify_event_enrichment/amplify_event_enrichment/CHANGELOG.md new file mode 100644 index 00000000000..ba73f29ec4f --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release ([#7029](https://github.com/aws-amplify/amplify-flutter/pull/7029)) diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/LICENSE b/packages/amplify_event_enrichment/amplify_event_enrichment/LICENSE new file mode 100644 index 00000000000..67db8588217 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/README.md b/packages/amplify_event_enrichment/amplify_event_enrichment/README.md new file mode 100644 index 00000000000..36bcf56d011 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/README.md @@ -0,0 +1,74 @@ +# amplify_event_enrichment + +A Flutter implementation for on-device event enrichment with automatic session lifecycle tracking, device metadata collection, and a persistent client ID. + +## Category / Platform Support + +| Category | Android | iOS | Web | Windows | MacOS | Linux | +| ---------------- | :-----: | :-: | :-: | :-----: | :---: | :---: | +| Event Enrichment | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | + +## Getting Started + +Create a client and record events. The client tracks session lifecycle +automatically, collects basic device metadata, and stamps a persistent client +ID on every event. `record` returns a `Result`, so failures surface through the +`Ok`/`Error` contract rather than as thrown exceptions. + +```dart +import 'dart:convert'; + +import 'package:amplify_event_enrichment/amplify_event_enrichment.dart'; + +final client = await EventEnrichmentClientFlutter.create( + appId: 'my-app-id', + sdkMetadata: SdkMetadata(name: 'amplify-flutter', version: '2.0.0'), +); + +final result = await client.record( + 'button_clicked', + attributes: {'screen': 'home'}, +); +switch (result) { + case Ok(:final value): + print(jsonEncode(value.toJson())); + case Error(:final error): + print('Failed to record event: $error'); +} +``` + +For richer device information (manufacturer, model), supply a custom +`DeviceMetadataProvider` to `EventEnrichmentClientFlutter.create`. A custom +`ClientIdProvider` can be supplied the same way to source the client ID from +your own store. + +## Sessions + +Every event carries a session. Sessions follow app foreground/background +transitions by default, and a session that has been backgrounded longer than +`EventEnrichmentClientOptions.sessionTimeout` ends rather than resuming. + +Session boundaries are emitted through the configured `Sender` as events, using +the event types legacy Amplify Analytics used for the same signals: + +- `zSessionStartEventType` (`_session.start`) when a session starts, carrying + its id and start timestamp +- `zSessionStopEventType` (`_session.stop`) when it ends, carrying its stop + timestamp and duration as well + +Both get the same enrichment every other event gets. A start is emitted when the +client is constructed with `autoSessionTracking` on, on an explicit +`startSession()`, on the first `record()` if no session is running, and when a +resume follows a session timeout. A stop is emitted on an explicit +`stopSession()`, on the session timeout expiring, on `close()`, and when +`startSession()` displaces a running session. A displacement emits the stop +before the start, and backgrounding and foregrounding inside the timeout window +emits nothing, since it is the same session throughout. Each session produces +exactly one start and at most one stop. + +`startSession()`, `stopSession()` and `close()` return a `Future` that completes +once those events have been handed to the sender, so awaiting them means the +boundary has been delivered. Sender failures on these paths are logged, never +thrown. + +### Visit our [Web Site](https://docs.amplify.aws/) to learn more about AWS Amplify. diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/analysis_options.yaml b/packages/amplify_event_enrichment/amplify_event_enrichment/analysis_options.yaml new file mode 100644 index 00000000000..7f605f74d4d --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/analysis_options.yaml @@ -0,0 +1 @@ +include: package:amplify_lints/library.yaml diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/lib/amplify_event_enrichment.dart b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/amplify_event_enrichment.dart new file mode 100644 index 00000000000..78b55b948c7 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/amplify_event_enrichment.dart @@ -0,0 +1,13 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// Amplify Event Enrichment client for Flutter. +library; + +// Re-export everything from the Dart package. +export 'package:amplify_event_enrichment_dart/amplify_event_enrichment.dart'; + +// Export Flutter-specific implementations. +export 'src/event_enrichment_client_flutter.dart'; +export 'src/flutter_lifecycle_observer.dart'; +export 'src/shared_preferences_client_id_provider.dart'; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/constants.dart b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/constants.dart new file mode 100644 index 00000000000..3d7dd64248f --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/constants.dart @@ -0,0 +1,12 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// SharedPreferences key for the persistent client/device ID. +/// +/// This value is a cross-package integration contract: the Connect client +/// reads and writes the same key in the same native store (SharedPreferences +/// on Android, NSUserDefaults on iOS) so a device resolves to ONE id +/// everywhere, including the Phase 2 native packages. Changing it re-mints an +/// id for every existing install, which registers those devices a second time +/// on the backend, so it must stay in lockstep across packages and platforms. +const zClientIdStorageKey = 'com.amplifyframework.device_id'; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/event_enrichment_client_flutter.dart b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/event_enrichment_client_flutter.dart new file mode 100644 index 00000000000..47c729cb36c --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/event_enrichment_client_flutter.dart @@ -0,0 +1,226 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment/src/flutter_lifecycle_observer.dart'; +import 'package:amplify_event_enrichment/src/shared_preferences_client_id_provider.dart'; +import 'package:amplify_event_enrichment_dart/amplify_event_enrichment.dart'; +import 'package:amplify_foundation_dart/amplify_foundation_dart.dart' + show AmplifyLogging, Result; +import 'package:uuid/uuid.dart'; + +/// {@template amplify_event_enrichment.event_enrichment_client_flutter} +/// Flutter convenience wrapper that creates an [EventEnrichmentClient] with +/// platform-specific providers wired automatically. +/// +/// Uses: +/// - [PlatformDeviceMetadataProvider] for device metadata (OS name/version) +/// - [SharedPreferencesClientIdProvider] for persistent client ID +/// - [FlutterLifecycleObserver] for automatic session lifecycle tracking +/// +/// Both providers are injectable: pass `deviceMetadataProvider` for richer +/// device info (manufacturer, model), or `clientIdProvider` to source the +/// client ID from your own store. +/// +/// ## Sessions +/// +/// Every event carries a session, and this wrapper exposes the same manual +/// controls as the underlying client ([startSession], [stopSession], +/// [handleAppPaused], [handleAppResumed]). +/// +/// You do not have to call them. With the default +/// [EventEnrichmentClientOptions.autoSessionTracking] of `true`, a session +/// starts up front and follows app foreground/background transitions. With +/// `autoSessionTracking` set to `false`, no session starts up front and no +/// lifecycle observer is installed, but recording still works: the first +/// [record] call lazily starts a session. Use the manual controls when you +/// want to define session boundaries yourself, or to feed lifecycle +/// transitions in when the observer is not installed. +/// +/// [stopSession] is an explicit end to tracking: the lifecycle observer will +/// not start a new session on the next foreground after it. Recording an event +/// starts one again. +/// +/// When a session starts, a [zSessionStartEventType] event is emitted through +/// the configured [Sender]; when it ends, a [zSessionStopEventType] event +/// carrying the ended session's stop timestamp and duration. Both use the event +/// types legacy Analytics used. See [startSession] and [stopSession]. +/// +/// ## Usage +/// +/// ```dart +/// final client = await EventEnrichmentClientFlutter.create( +/// appId: 'my-app-id', +/// sdkMetadata: SdkMetadata(name: 'amplify-flutter', version: '2.0.0'), +/// ); +/// +/// final result = await client.record('button_clicked'); +/// ``` +/// {@endtemplate} +class EventEnrichmentClientFlutter { + EventEnrichmentClientFlutter._(this._delegate, this._lifecycleObserver); + + /// {@macro amplify_event_enrichment.event_enrichment_client_flutter} + /// + /// Resolves device metadata and client ID from the platform, then creates + /// an [EventEnrichmentClient] with automatic lifecycle tracking. + static Future create({ + required String appId, + required SdkMetadata sdkMetadata, + AppMetadata? appMetadata, + DeviceMetadataProvider? deviceMetadataProvider, + ClientIdProvider? clientIdProvider, + EventEnrichmentClientOptions? options, + Sender? sender, + }) async { + assert( + appMetadata == null || appMetadata.appId == appId, + 'appMetadata.appId ("${appMetadata.appId}") does not match the appId ' + '"$appId". When both are provided they must be the same value.', + ); + final metadataProvider = + deviceMetadataProvider ?? const PlatformDeviceMetadataProvider(); + final idProvider = + clientIdProvider ?? const SharedPreferencesClientIdProvider(); + final logger = AmplifyLogging.logger('EventEnrichmentClientFlutter'); + + // Resolve device metadata and client ID independently so a failure in + // either provider degrades gracefully rather than throwing out of create(). + var deviceMetadata = const DeviceMetadata(); + try { + deviceMetadata = await metadataProvider.getDeviceMetadata(); + } on Object catch (e, st) { + logger.warn( + 'Failed to resolve device metadata; continuing with empty metadata.', + e, + st, + ); + } + + String clientId; + try { + clientId = await idProvider.getClientId(); + } on Object catch (e, st) { + clientId = const Uuid().v4(); + logger.warn( + 'Failed to resolve a persistent client ID; using an ephemeral one.', + e, + st, + ); + } + + final delegate = EventEnrichmentClient( + appMetadata: appMetadata ?? AppMetadata(appId: appId), + deviceMetadata: deviceMetadata, + sdkMetadata: sdkMetadata, + clientId: clientId, + options: options, + sender: sender, + ); + + FlutterLifecycleObserver? lifecycleObserver; + final opts = options ?? const EventEnrichmentClientOptions(); + if (opts.autoSessionTracking) { + lifecycleObserver = FlutterLifecycleObserver( + sessionManager: delegate.sessionManager, + ); + } + + return EventEnrichmentClientFlutter._(delegate, lifecycleObserver); + } + + final EventEnrichmentClient _delegate; + final FlutterLifecycleObserver? _lifecycleObserver; + + /// Whether the client has been closed. + bool get isClosed => _delegate.isClosed; + + /// Records an event and returns the enriched result. + /// + /// Awaits the configured sender and never throws; delivery failures come + /// back as an error [Result]. See `EventEnrichmentClient.record`. + Future> record( + String eventType, { + Map? attributes, + Map? metrics, + }) => _delegate.record(eventType, attributes: attributes, metrics: metrics); + + /// Starts a new session manually and emits a [zSessionStartEventType] event + /// for it. + /// + /// Only needed when you want an explicit session boundary. Recording works + /// without it: see [EventEnrichmentClientOptions.autoSessionTracking]. + /// + /// A session already running is ended first, which emits a + /// [zSessionStopEventType] event for it before the start. The returned future + /// completes once both events have been handed to the [Sender]. + Future startSession() => _delegate.startSession(); + + /// Stops the current session and emits a [zSessionStopEventType] event for + /// it. + /// + /// The emitted event's session section carries the stopped session's id, + /// start timestamp, stop timestamp and duration, so session length reaches + /// the [Sender] rather than being computed and dropped. The returned future + /// completes once the event has been handed to the sender; a sender failure + /// is logged and never thrown. + /// + /// This is also an explicit end to session tracking. The lifecycle observer + /// will not start a new session when the app next returns to the foreground, + /// so a session you ended stays ended. Recording an event still lazily starts + /// a fresh session, and [startSession] resumes normal lifecycle behaviour. + Future stopSession() => _delegate.stopSession(); + + /// Called when the app moves to background. + /// + /// Only needed when [EventEnrichmentClientOptions.autoSessionTracking] is + /// `false`; otherwise [FlutterLifecycleObserver] calls this for you. + /// + /// If the session timeout expires before the app returns, the session ends + /// and its [zSessionStopEventType] event is emitted from the timer. Nothing + /// is awaiting a timer, so a sender failure on that path surfaces only in + /// the logs. + void handleAppPaused() => _delegate.handleAppPaused(); + + /// Called when the app returns to foreground. + /// + /// Only needed when [EventEnrichmentClientOptions.autoSessionTracking] is + /// `false`; otherwise [FlutterLifecycleObserver] calls this for you. + /// + /// Resumes a paused session, or starts a new one if the session timeout + /// expired while backgrounded. Does nothing after an explicit + /// [stopSession]. + /// + /// Resuming a paused session emits nothing, since it is the same session. A + /// restart after a timeout emits a [zSessionStartEventType] event, with no + /// caller to await it, so a sender failure there surfaces only in the logs. + void handleAppResumed() => _delegate.handleAppResumed(); + + /// Sets the user identifier stamped on subsequent events. + void setUserId(String? userId) => _delegate.setUserId(userId); + + /// Adds a global attribute stamped on every subsequent event. + void addGlobalAttribute(String key, String value) => + _delegate.addGlobalAttribute(key, value); + + /// Removes a global attribute. + void removeGlobalAttribute(String key) => + _delegate.removeGlobalAttribute(key); + + /// Adds a global metric stamped on every subsequent event. + void addGlobalMetric(String key, double value) => + _delegate.addGlobalMetric(key, value); + + /// Removes a global metric. + void removeGlobalMetric(String key) => _delegate.removeGlobalMetric(key); + + /// Releases resources, stops session tracking, and removes lifecycle observer. + /// + /// A session still running is ended first, so its [zSessionStopEventType] + /// event reaches the [Sender] before the client goes away; the returned + /// future completes once that event has been sent. A session that already + /// ended does not emit a second one. + Future close() async { + _lifecycleObserver?.dispose(); + await _delegate.close(); + } +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/flutter_lifecycle_observer.dart b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/flutter_lifecycle_observer.dart new file mode 100644 index 00000000000..a206abfc8cd --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/flutter_lifecycle_observer.dart @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment_dart/amplify_event_enrichment.dart'; +import 'package:flutter/widgets.dart'; + +/// {@template amplify_event_enrichment.flutter_lifecycle_observer} +/// Flutter implementation of [LifecycleObserver] using [WidgetsBindingObserver]. +/// +/// Listens to [AppLifecycleState] changes and calls the session manager's +/// pause/resume methods accordingly. +/// {@endtemplate} +class FlutterLifecycleObserver + with WidgetsBindingObserver + implements LifecycleObserver { + /// {@macro amplify_event_enrichment.flutter_lifecycle_observer} + FlutterLifecycleObserver({required SessionManager sessionManager}) + : _sessionManager = sessionManager { + WidgetsBinding.instance.addObserver(this); + } + + final SessionManager _sessionManager; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + onPause(); + case AppLifecycleState.resumed: + onResume(); + case _: + break; + } + } + + @override + void onPause() => _sessionManager.handleAppPaused(); + + @override + void onResume() => _sessionManager.handleAppResumed(); + + @override + void dispose() => WidgetsBinding.instance.removeObserver(this); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/shared_preferences_client_id_provider.dart b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/shared_preferences_client_id_provider.dart new file mode 100644 index 00000000000..e966a1b0311 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/lib/src/shared_preferences_client_id_provider.dart @@ -0,0 +1,45 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment/src/constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +/// {@template amplify_event_enrichment.client_id_provider} +/// Provides the persistent client ID stamped on every enriched event. +/// +/// Implement this to source the ID from somewhere other than the default +/// [SharedPreferencesClientIdProvider] — for example a keychain-backed store, +/// or a fixed value in tests. +/// +/// The ID is expected to be stable for the lifetime of the install: returning +/// a new value on each call makes one device look like many. +/// {@endtemplate} +// ignore: one_member_abstracts +abstract interface class ClientIdProvider { + /// Returns the persistent client ID, creating one if it doesn't exist. + Future getClientId(); +} + +/// {@template amplify_event_enrichment.shared_preferences_client_id_provider} +/// Reads or creates a persistent client ID from SharedPreferences. +/// +/// Uses read-or-create semantics: if a non-empty value exists at +/// [zClientIdStorageKey], it is returned. Otherwise a new UUID v4 is +/// generated, persisted, and returned. Whichever of enrichment/Connect +/// initializes first generates the UUID; the other reads it. +/// {@endtemplate} +class SharedPreferencesClientIdProvider implements ClientIdProvider { + /// {@macro amplify_event_enrichment.shared_preferences_client_id_provider} + const SharedPreferencesClientIdProvider(); + + @override + Future getClientId() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(zClientIdStorageKey); + if (existing != null && existing.isNotEmpty) return existing; + final id = const Uuid().v4(); + await prefs.setString(zClientIdStorageKey, id); + return id; + } +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment/pubspec.yaml b/packages/amplify_event_enrichment/amplify_event_enrichment/pubspec.yaml new file mode 100644 index 00000000000..f2022efe991 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment/pubspec.yaml @@ -0,0 +1,38 @@ +name: amplify_event_enrichment +description: A Flutter implementation for on-device event enrichment with automatic lifecycle tracking, device metadata collection, and persistent client ID. +version: 0.1.0 +homepage: https://docs.amplify.aws/lib/q/platform/flutter/ +repository: https://github.com/aws-amplify/amplify-flutter/tree/main/packages/amplify_event_enrichment/amplify_event_enrichment +issue_tracker: https://github.com/aws-amplify/amplify-flutter/issues + +topics: + - aws + - analytics + - event-enrichment + - aws-amplify + +environment: + sdk: ^3.11.0 + flutter: ">=3.41.0" + +# Helps `pana` since we do not use Flutter plugins for most platforms +platforms: + ios: + android: + macos: + windows: + linux: + web: + +dependencies: + amplify_event_enrichment_dart: ">=0.1.0 <0.2.0" + amplify_foundation_dart: ">=2.12.1 <2.13.0" + flutter: + sdk: flutter + shared_preferences: ^2.0.15 + uuid: ^4.5.1 + +dev_dependencies: + amplify_lints: ">=3.1.6 <3.2.0" + flutter_test: + sdk: flutter diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/CHANGELOG.md b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/CHANGELOG.md new file mode 100644 index 00000000000..ba73f29ec4f --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.1.0 + +- Initial release ([#7029](https://github.com/aws-amplify/amplify-flutter/pull/7029)) diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/LICENSE b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/LICENSE new file mode 100644 index 00000000000..67db8588217 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/README.md b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/README.md new file mode 100644 index 00000000000..1bc4dd866db --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/README.md @@ -0,0 +1,11 @@ +# AWS Event Enrichment for Dart + +Dart-only implementation for on-device event enrichment. It collects device, app, session, and SDK context and produces a structured analytics JSON envelope. For internal use in the `amplify_event_enrichment` Flutter library. + +## Code generation + +The event envelope is serialized with `json_serializable`. After changing any of the envelope classes in `lib/src/enriched_event.dart`, regenerate `enriched_event.g.dart` rather than editing it: + +```bash +dart run build_runner build +``` diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/analysis_options.yaml b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/analysis_options.yaml new file mode 100644 index 00000000000..01538d576cf --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:amplify_lints/library.yaml + +analyzer: + exclude: + - '**/*.g.dart' diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/example/amplify_event_enrichment_dart_example.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/example/amplify_event_enrichment_dart_example.dart new file mode 100644 index 00000000000..b19e266a7e9 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/example/amplify_event_enrichment_dart_example.dart @@ -0,0 +1,70 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// ignore_for_file: avoid_print + +import 'dart:convert'; + +import 'package:amplify_event_enrichment_dart/amplify_event_enrichment.dart'; +import 'package:amplify_foundation_dart/amplify_foundation_dart.dart'; + +/// A [Sender] that prints each enriched envelope, standing in for a real +/// transport such as the Kinesis or Firehose client. +/// +/// Sending is asynchronous, so a transport that suspends can be awaited and its +/// failures observed. This one does no async work and still satisfies the +/// contract with an `async` body. +class PrintingSender implements Sender { + @override + Future send(EnrichedEvent event) async { + print(jsonEncode(event.toJson())); + } +} + +Future main() async { + // Pure Dart has no platform providers, so app, device and SDK metadata and + // the client id are supplied directly. The amplify_event_enrichment Flutter + // package resolves all four for you and tracks the app lifecycle. + // + // Constructing the client starts a session, which prints a + // zSessionStartEventType ("_session.start") envelope before anything below + // runs. + final client = + EventEnrichmentClient( + appMetadata: const AppMetadata(appId: 'my-app', versionName: '1.0.0'), + deviceMetadata: const DeviceMetadata( + platform: 'macOS', + locale: 'en_US', + ), + sdkMetadata: const SdkMetadata( + name: 'amplify-flutter', + version: '2.0.0', + ), + clientId: 'a-stable-per-install-id', + sender: PrintingSender(), + ) + // Stamped on every event from here on, including the session stop below. + // The session start has already gone out, so it does not carry this one. + ..addGlobalAttribute('env', 'prod'); + + // record() never throws: a sender failure comes back as an error Result. + final result = await client.record( + 'button_clicked', + attributes: {'screen': 'home'}, + ); + switch (result) { + case Ok(:final value): + print('recorded ${value.eventType} in session ${value.session.id}'); + case Error(:final error): + print('failed to record: $error'); + } + + // Ends the session, printing a zSessionStopEventType ("_session.stop") + // envelope that carries the session's stop timestamp and duration. Awaiting + // it means the event has reached the sender. + await client.stopSession(); + + // The session already ended, so closing emits nothing further. Closing while + // a session is still running would emit its stop here instead. + await client.close(); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/amplify_event_enrichment.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/amplify_event_enrichment.dart new file mode 100644 index 00000000000..1d19fe759fd --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/amplify_event_enrichment.dart @@ -0,0 +1,20 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// Standalone on-device event enrichment client. +library; + +export 'src/enriched_event.dart'; +export 'src/event_enrichment_client.dart'; +export 'src/event_enrichment_client_options.dart'; +export 'src/exception/event_enrichment_exception.dart'; +export 'src/global_fields_manager.dart'; +export 'src/lifecycle_observer.dart'; +export 'src/metadata/app_metadata.dart'; +export 'src/metadata/device_metadata.dart'; +export 'src/metadata/device_metadata_provider.dart'; +export 'src/metadata/sdk_metadata.dart'; +export 'src/sender.dart'; +export 'src/session/session.dart'; +export 'src/session/session_event_types.dart'; +export 'src/session/session_manager.dart'; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.dart new file mode 100644 index 00000000000..b7dd4a23f00 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.dart @@ -0,0 +1,256 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment_dart/src/metadata/app_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/metadata/sdk_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/session/session.dart'; +import 'package:amplify_event_enrichment_dart/src/util/serializable.dart'; +import 'package:meta/meta.dart'; + +part 'enriched_event.g.dart'; + +/// {@template amplify_event_enrichment.enriched_event} +/// An analytics event enriched with device, app, session, and SDK metadata. +/// +/// Use [toJson] to produce the structured analytics envelope as a +/// JSON-compatible map. +/// {@endtemplate} +@immutable +final class EnrichedEvent { + /// {@macro amplify_event_enrichment.enriched_event} + const EnrichedEvent({ + required this.eventId, + required this.eventType, + required this.eventTimestamp, + required this.session, + required this.attributes, + required this.metrics, + required this.device, + required this.app, + required this.sdk, + required this.clientId, + this.userId, + }); + + /// Unique event identifier (UUID). + final String eventId; + + /// Type of the event. + final String eventType; + + /// Milliseconds since epoch when the event was recorded. + final int eventTimestamp; + + /// Session active at the time of recording. + final Session session; + + /// Merged attributes (globals + per-event). + final Map attributes; + + /// Merged metrics (globals + per-event). + final Map metrics; + + /// Device metadata. + final DeviceMetadata device; + + /// Application metadata. + final AppMetadata app; + + /// SDK metadata. + final SdkMetadata sdk; + + /// Persistent client/device identifier. + final String clientId; + + /// Optional user identifier. + final String? userId; + + /// Version of the analytics event envelope schema, emitted as + /// `event_version`. + /// + /// "3.1" identifies the layout of the envelope (the event, application, + /// client, device, and session sections and their field names) so + /// downstream consumers can tell which schema they are parsing. Bump this + /// only when the envelope structure changes in a way consumers must handle; + /// it is independent of the package version. + static const _eventVersion = '3.1'; + + /// Serializes to the analytics event envelope as a JSON-compatible map. + /// + /// Encode with `jsonEncode` where a JSON string is needed. + /// + /// The envelope is not a field-for-field projection of this class — it + /// regroups platform name/version, renames `manufacturer` to `make`, wraps + /// the locale, and drops empty sections — so it is modelled as its own set + /// of generated classes in this file that this method builds and delegates + /// to. + Map toJson() => _envelope().toJson(); + + _Envelope _envelope() { + final platform = device.platform == null && device.platformVersion == null + ? null + : _Platform(name: device.platform, version: device.platformVersion); + final locale = device.locale; + return _Envelope( + eventType: eventType, + eventTimestamp: eventTimestamp, + // On-device enrichment has no server ingestion step, so this reflects + // client-side arrival and mirrors event_timestamp. Retained for + // envelope compatibility. + arrivalTimestamp: eventTimestamp, + eventVersion: _eventVersion, + application: _Application( + appId: app.appId, + packageName: app.packageName, + versionName: app.versionName, + versionCode: app.versionCode, + title: app.title, + sdk: _Sdk(name: sdk.name, version: sdk.version), + ), + client: _Client(clientId: clientId, userId: userId), + device: _Device( + platform: platform, + make: device.manufacturer, + model: device.model, + locale: locale == null ? null : _Locale(code: locale), + ), + session: _Session( + id: session.id, + startTimestamp: session.startTimestamp, + stopTimestamp: session.stopTimestamp, + duration: session.duration, + ), + // Empty rather than absent globals still mean "nothing to report", and + // the envelope omits the section in that case. + attributes: attributes.isEmpty ? null : attributes, + metrics: metrics.isEmpty ? null : metrics, + ); + } +} + +/// The top level of the analytics event envelope. +@zEventEnrichmentSerializable +final class _Envelope { + const _Envelope({ + required this.eventType, + required this.eventTimestamp, + required this.arrivalTimestamp, + required this.eventVersion, + required this.application, + required this.client, + required this.device, + required this.session, + this.attributes, + this.metrics, + }); + + final String eventType; + final int eventTimestamp; + final int arrivalTimestamp; + final String eventVersion; + final _Application application; + final _Client client; + final _Device device; + final _Session session; + final Map? attributes; + final Map? metrics; + + Map toJson() => _$EnvelopeToJson(this); +} + +/// The `application` section, with the SDK nested inside it. +@zEventEnrichmentSerializable +final class _Application { + const _Application({ + required this.appId, + required this.sdk, + this.packageName, + this.versionName, + this.versionCode, + this.title, + }); + + final String appId; + final String? packageName; + final String? versionName; + final String? versionCode; + final String? title; + final _Sdk sdk; + + Map toJson() => _$ApplicationToJson(this); +} + +/// The `application.sdk` section. +@zEventEnrichmentSerializable +final class _Sdk { + const _Sdk({required this.name, required this.version}); + + final String name; + final String version; + + Map toJson() => _$SdkToJson(this); +} + +/// The `client` section. +@zEventEnrichmentSerializable +final class _Client { + const _Client({required this.clientId, this.userId}); + + final String clientId; + final String? userId; + + Map toJson() => _$ClientToJson(this); +} + +/// The `device` section. Emitted even when empty. +@zEventEnrichmentSerializable +final class _Device { + const _Device({this.platform, this.make, this.model, this.locale}); + + final _Platform? platform; + final String? make; + final String? model; + final _Locale? locale; + + Map toJson() => _$DeviceToJson(this); +} + +/// The `device.platform` section, absent when neither field is known. +@zEventEnrichmentSerializable +final class _Platform { + const _Platform({this.name, this.version}); + + final String? name; + final String? version; + + Map toJson() => _$PlatformToJson(this); +} + +/// The `device.locale` section, which wraps the locale code in an object. +@zEventEnrichmentSerializable +final class _Locale { + const _Locale({required this.code}); + + final String code; + + Map toJson() => _$LocaleToJson(this); +} + +/// The `session` section. +@zEventEnrichmentSerializable +final class _Session { + const _Session({ + required this.id, + required this.startTimestamp, + this.stopTimestamp, + this.duration, + }); + + final String id; + final String startTimestamp; + final String? stopTimestamp; + final int? duration; + + Map toJson() => _$SessionToJson(this); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.g.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.g.dart new file mode 100644 index 00000000000..66e472507a2 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/enriched_event.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'enriched_event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$EnvelopeToJson(_Envelope instance) => { + 'event_type': instance.eventType, + 'event_timestamp': instance.eventTimestamp, + 'arrival_timestamp': instance.arrivalTimestamp, + 'event_version': instance.eventVersion, + 'application': instance.application.toJson(), + 'client': instance.client.toJson(), + 'device': instance.device.toJson(), + 'session': instance.session.toJson(), + 'attributes': ?instance.attributes, + 'metrics': ?instance.metrics, +}; + +Map _$ApplicationToJson(_Application instance) => + { + 'app_id': instance.appId, + 'package_name': ?instance.packageName, + 'version_name': ?instance.versionName, + 'version_code': ?instance.versionCode, + 'title': ?instance.title, + 'sdk': instance.sdk.toJson(), + }; + +Map _$SdkToJson(_Sdk instance) => { + 'name': instance.name, + 'version': instance.version, +}; + +Map _$ClientToJson(_Client instance) => { + 'client_id': instance.clientId, + 'user_id': ?instance.userId, +}; + +Map _$DeviceToJson(_Device instance) => { + 'platform': ?instance.platform?.toJson(), + 'make': ?instance.make, + 'model': ?instance.model, + 'locale': ?instance.locale?.toJson(), +}; + +Map _$PlatformToJson(_Platform instance) => { + 'name': ?instance.name, + 'version': ?instance.version, +}; + +Map _$LocaleToJson(_Locale instance) => { + 'code': instance.code, +}; + +Map _$SessionToJson(_Session instance) => { + 'id': instance.id, + 'start_timestamp': instance.startTimestamp, + 'stop_timestamp': ?instance.stopTimestamp, + 'duration': ?instance.duration, +}; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client.dart new file mode 100644 index 00000000000..19d9b5deb45 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client.dart @@ -0,0 +1,320 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:async'; + +import 'package:amplify_event_enrichment_dart/src/enriched_event.dart'; +import 'package:amplify_event_enrichment_dart/src/event_enrichment_client_options.dart'; +import 'package:amplify_event_enrichment_dart/src/exception/event_enrichment_exception.dart'; +import 'package:amplify_event_enrichment_dart/src/global_fields_manager.dart'; +import 'package:amplify_event_enrichment_dart/src/metadata/app_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/metadata/sdk_metadata.dart'; +import 'package:amplify_event_enrichment_dart/src/sender.dart'; +import 'package:amplify_event_enrichment_dart/src/session/session.dart'; +import 'package:amplify_event_enrichment_dart/src/session/session_event_types.dart'; +import 'package:amplify_event_enrichment_dart/src/session/session_manager.dart'; +import 'package:amplify_foundation_dart/amplify_foundation_dart.dart'; +import 'package:uuid/uuid.dart'; + +/// {@template amplify_event_enrichment.event_enrichment_client} +/// Client for recording enriched analytics events. +/// +/// Collects device, app, session, and SDK metadata and produces +/// [EnrichedEvent] instances that serialize to a structured analytics +/// JSON envelope. +/// +/// ## Sessions +/// +/// Every event carries a session. When a session starts the client emits a +/// [zSessionStartEventType] event for it, and when it ends a +/// [zSessionStopEventType] event carrying the stop timestamp and duration — +/// both through the configured [Sender], and both named the way legacy +/// Analytics named them. See [startSession] and [stopSession]. +/// +/// ## Usage +/// +/// ```dart +/// final client = EventEnrichmentClient( +/// appMetadata: AppMetadata(appId: 'my-app'), +/// deviceMetadata: DeviceMetadata(platform: 'iOS'), +/// sdkMetadata: SdkMetadata(name: 'amplify-flutter', version: '2.0.0'), +/// clientId: 'device-uuid', +/// ); +/// +/// final result = await client.record('button_clicked'); +/// if (result case Ok(:final value)) { +/// print(jsonEncode(value.toJson())); +/// } +/// +/// await client.close(); +/// ``` +/// {@endtemplate} +class EventEnrichmentClient { + /// {@macro amplify_event_enrichment.event_enrichment_client} + EventEnrichmentClient({ + required AppMetadata appMetadata, + required DeviceMetadata deviceMetadata, + required SdkMetadata sdkMetadata, + required String clientId, + EventEnrichmentClientOptions? options, + Sender? sender, + }) : _appMetadata = appMetadata, + _deviceMetadata = deviceMetadata, + _sdkMetadata = sdkMetadata, + _clientId = clientId, + _sender = sender, + _logger = AmplifyLogging.logger('EventEnrichmentClient') { + final opts = options ?? const EventEnrichmentClientOptions(); + _sessionManager = SessionManager( + appId: appMetadata.appId, + sessionTimeout: opts.sessionTimeout, + generateId: () => const Uuid().v4(), + onSessionStarted: _emitSessionStart, + onSessionEnded: _emitSessionStop, + ); + if (opts.autoSessionTracking) { + // A constructor has nothing to await, so this eager start's + // zSessionStartEventType event is fired the same way the timeout path + // fires its own: the emission's guard logs sender failures, and this + // catches anything else so no start report can escape as an unhandled + // async error. The event still reaches the sender before any event a + // caller records straight after construction, because SessionManager + // reports a start with nothing to displace synchronously. + _sessionManager + .startSession() + .onError( + (e, st) => _logger.error('Failed to start a session', e, st), + ) + .ignore(); + } + } + + final AppMetadata _appMetadata; + final DeviceMetadata _deviceMetadata; + final SdkMetadata _sdkMetadata; + final String _clientId; + final Sender? _sender; + final Logger _logger; + final GlobalFieldsManager _globalFields = GlobalFieldsManager(); + late final SessionManager _sessionManager; + + /// The session manager for this client. + /// + /// Exposed for use by platform-specific lifecycle observers. + SessionManager get sessionManager => _sessionManager; + + String? _userId; + bool _closed = false; + + /// Whether the client has been closed. + bool get isClosed => _closed; + + /// Records an event and returns the enriched result. + /// + /// Awaits the configured sender, so the returned future completes only once + /// the event has been handed to the transport. + /// + /// Never throws. Returns [Result.error] with an + /// [EventEnrichmentClosedException] if the client has been closed, or an + /// [EventEnrichmentRecordException] if recording fails unexpectedly — which + /// includes a sender that throws synchronously *or* whose future completes + /// with an error. Sender failures are logged before being returned. + Future> record( + String eventType, { + Map? attributes, + Map? metrics, + }) async { + if (_closed) return const Result.error(EventEnrichmentClosedException()); + + try { + // A stopped session is still exposed by the manager for inspection, so + // start a fresh one instead of stamping the stopped session (which + // carries a stop_timestamp) onto a new event. The start is awaited, so + // its zSessionStartEventType event reaches the sender ahead of this + // event. A session that already stopped emitted its stop then, so no + // stop is emitted here. + if (_sessionManager.session == null || + _sessionManager.state == SessionState.stopped) { + await _sessionManager.startSession(); + } + + final event = _buildEvent( + eventType, + _sessionManager.session!, + attributes: attributes, + metrics: metrics, + ); + + // Awaited inside the try so a sender whose future completes with an + // error is caught here rather than surfacing as an unhandled async error. + await _sender?.send(event); + _logger.verbose('Recorded event: $eventType'); + return Result.ok(event); + } on Object catch (e, st) { + _logger.error('Failed to record event: $eventType', e, st); + return Result.error(EventEnrichmentRecordException(cause: e)); + } + } + + /// Enriches [eventType] against [session] and the client's current metadata. + /// + /// Shared by [record] and the session-boundary emissions so a session event + /// is enriched exactly like any other event. + EnrichedEvent _buildEvent( + String eventType, + Session session, { + Map? attributes, + Map? metrics, + }) => EnrichedEvent( + eventId: const Uuid().v4(), + eventType: eventType, + eventTimestamp: DateTime.now().millisecondsSinceEpoch, + session: session, + attributes: {..._globalFields.attributes, ...?attributes}, + metrics: {..._globalFields.metrics, ...?metrics}, + device: _deviceMetadata, + app: _appMetadata, + sdk: _sdkMetadata, + clientId: _clientId, + userId: _userId, + ); + + /// Emits a [zSessionStartEventType] event for a session that just started. + /// + /// Wired into [SessionManager] as its session-started callback, so every + /// start path reports the session exactly once: the eager start at + /// construction when [EventEnrichmentClientOptions.autoSessionTracking] is + /// on, an explicit [startSession], [record]'s lazy start, the restart when a + /// resume follows a session timeout, and the new session in a displacement. + /// + /// The event's session section carries the new session's id and start + /// timestamp, with no stop fields, since the session has not ended. + Future _emitSessionStart(Session session) => + _emitSessionEvent(zSessionStartEventType, session); + + /// Emits a [zSessionStopEventType] event for a session that just ended. + /// + /// Wired into [SessionManager] as its session-ended callback, so every end + /// path reports the session exactly once: an explicit [stopSession], the + /// session timeout expiring while backgrounded, [close], and the implicit + /// stop when [startSession] displaces a running session. + /// + /// The event's session section carries the *ended* session — its id, start + /// timestamp, stop timestamp and duration. + Future _emitSessionStop(Session session) => + _emitSessionEvent(zSessionStopEventType, session); + + /// Sends a session-boundary event through the same enrichment as [record]: + /// app, client, device and SDK metadata, the current user id, and the global + /// attributes and metrics. Legacy Pinpoint also stamped its globals on + /// `_session.start` and `_session.stop`, so including them keeps that + /// behaviour. + /// + /// Never throws. A sender that fails is logged and swallowed, because a + /// session boundary is a lifecycle transition rather than a caller's attempt + /// to record something: there is no `Result` to hand back, and the timeout + /// and lifecycle paths have no caller at all. + Future _emitSessionEvent(String eventType, Session session) async { + final sender = _sender; + if (sender == null) return; + try { + await sender.send(_buildEvent(eventType, session)); + _logger.verbose('Recorded event: $eventType'); + } on Object catch (e, st) { + _logger.error('Failed to record event: $eventType', e, st); + } + } + + /// Starts a new session manually and emits a [zSessionStartEventType] event + /// for it. + /// + /// A session already running is ended first, which emits a + /// [zSessionStopEventType] event for it before the start — legacy Pinpoint + /// reported the same stop-then-start pair when a new session displaced an old + /// one. The returned future completes once both events have been handed to + /// the [Sender]. + Future startSession() => _sessionManager.startSession(); + + /// Stops the current session and emits a [zSessionStopEventType] event for + /// it. + /// + /// The emitted event's session section carries the stopped session's id, + /// start timestamp, stop timestamp and duration, so session length reaches + /// the [Sender] rather than being computed and dropped. The returned future + /// completes once the event has been handed to the sender; a sender failure + /// is logged and never thrown. Nothing is emitted, and the returned future + /// is already complete, when no session is running. + /// + /// This is also an explicit end to session tracking: a later + /// [handleAppResumed] will not start a new session. Recording an event + /// still lazily starts one, and [startSession] resumes normal lifecycle + /// behaviour. + Future stopSession() => _sessionManager.stopSession(); + + /// Called when the app moves to background. + /// + /// If the session timeout expires before the app returns, the session ends + /// and its [zSessionStopEventType] event is emitted from the timer. Nothing + /// is awaiting a timer, so a sender failure on that path surfaces only in + /// the logs. + void handleAppPaused() => _sessionManager.handleAppPaused(); + + /// Called when the app returns to foreground. + /// + /// Resumes a paused session, or starts a new one if the session timeout + /// expired while backgrounded. Does nothing after an explicit + /// [stopSession] — a session the customer ended is not resurrected by a + /// lifecycle transition. + /// + /// Resuming a paused session is the same session, so nothing is emitted for + /// it. A restart after a timeout emits a [zSessionStartEventType] event, with + /// no caller to await it, so a sender failure there surfaces only in the + /// logs. + void handleAppResumed() => _sessionManager.handleAppResumed(); + + /// Sets the user identifier stamped on subsequent events. + // Kept as an imperative method (rather than a setter) to stay consistent + // with the other mutators on this client and the equivalent native APIs. + // ignore: use_setters_to_change_properties + void setUserId(String? userId) => _userId = userId; + + /// Adds a global attribute stamped on every subsequent event. + void addGlobalAttribute(String key, String value) => + _globalFields.addAttribute(key, value); + + /// Removes a global attribute. + void removeGlobalAttribute(String key) => _globalFields.removeAttribute(key); + + /// Adds a global metric stamped on every subsequent event. + void addGlobalMetric(String key, double value) => + _globalFields.addMetric(key, value); + + /// Removes a global metric. + void removeGlobalMetric(String key) => _globalFields.removeMetric(key); + + /// Releases resources and stops session tracking. + /// + /// A session still running is ended first, so its [zSessionStopEventType] + /// event reaches the [Sender] before the client goes away; the returned + /// future completes once that event has been sent. A session that already + /// ended emitted its stop then, so closing after [stopSession] or after a + /// session timeout does not emit a second one, and closing with no session + /// emits nothing. + /// + /// The client cannot be reused after closing. Calling this more than once is + /// a no-op. + Future close() async { + if (_closed) return; + // Closed first so no further record() call can slip in behind the final + // session-stop event. _emitSessionStop deliberately does not consult this + // flag, since the close path's whole point is to emit one last event. + _closed = true; + await _sessionManager.stopSession(); + // clearSession rather than leaving the stopped session readable: the stop + // has already been recorded and emitted, and this drops the session so + // none is readable after close. + _sessionManager.clearSession(); + _logger.info('Client closed'); + } +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client_options.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client_options.dart new file mode 100644 index 00000000000..02aa742ef24 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/event_enrichment_client_options.dart @@ -0,0 +1,30 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// {@template amplify_event_enrichment.event_enrichment_client_options} +/// Configuration options for `EventEnrichmentClient`. +/// {@endtemplate} +final class EventEnrichmentClientOptions { + /// {@macro amplify_event_enrichment.event_enrichment_client_options} + const EventEnrichmentClientOptions({ + this.autoSessionTracking = true, + this.sessionTimeout = const Duration(seconds: 5), + }); + + /// Whether to automatically start a session at construction and drive it + /// from the app lifecycle. + /// + /// When `true` (the default), a session starts as soon as the client is + /// constructed and follows app foreground/background transitions. + /// + /// When `false`, no session is started at construction. This does not + /// disable session creation: the first `record()` call lazily starts a + /// session, and manual session and lifecycle calls still work. It only opts + /// out of eagerly starting a session up front. + final bool autoSessionTracking; + + /// Duration the app can remain backgrounded before a new session starts. + /// + /// Defaults to 5 seconds. + final Duration sessionTimeout; +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/exception/event_enrichment_exception.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/exception/event_enrichment_exception.dart new file mode 100644 index 00000000000..f2fca58cbfe --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/exception/event_enrichment_exception.dart @@ -0,0 +1,44 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_foundation_dart/amplify_foundation_dart.dart'; + +/// {@template amplify_event_enrichment.event_enrichment_exception} +/// Base exception for Amplify Event Enrichment errors. +/// {@endtemplate} +sealed class EventEnrichmentException extends AmplifyException { + /// {@macro amplify_event_enrichment.event_enrichment_exception} + const EventEnrichmentException({ + required super.message, + required super.recoverySuggestion, + super.cause, + }); +} + +/// {@template amplify_event_enrichment.client_closed_exception} +/// Thrown when an operation is attempted on a closed client. +/// {@endtemplate} +final class EventEnrichmentClosedException extends EventEnrichmentException { + /// {@macro amplify_event_enrichment.client_closed_exception} + const EventEnrichmentClosedException() + : super( + message: 'Client has been closed', + recoverySuggestion: 'Create a new EventEnrichmentClient instance.', + ); +} + +/// {@template amplify_event_enrichment.record_exception} +/// Returned as [Result.error] when recording an event fails unexpectedly. +/// +/// The underlying failure is available via [cause]. +/// {@endtemplate} +final class EventEnrichmentRecordException extends EventEnrichmentException { + /// {@macro amplify_event_enrichment.record_exception} + const EventEnrichmentRecordException({required super.cause}) + : super( + message: 'Failed to record event', + recoverySuggestion: + 'Inspect the underlying cause. If a custom Sender was ' + 'provided, ensure its send method does not throw.', + ); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/global_fields_manager.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/global_fields_manager.dart new file mode 100644 index 00000000000..f50656dd000 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/global_fields_manager.dart @@ -0,0 +1,37 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:collection'; + +/// {@template amplify_event_enrichment.global_fields_manager} +/// Manages global attributes and metrics that are stamped on every event. +/// +/// Values are in-memory only and not persisted between sessions. +/// {@endtemplate} +class GlobalFieldsManager { + /// {@macro amplify_event_enrichment.global_fields_manager} + GlobalFieldsManager(); + + final Map _attributes = {}; + final Map _metrics = {}; + + /// Current global attributes (unmodifiable view). + UnmodifiableMapView get attributes => + UnmodifiableMapView(_attributes); + + /// Current global metrics (unmodifiable view). + UnmodifiableMapView get metrics => + UnmodifiableMapView(_metrics); + + /// Adds a global attribute. + void addAttribute(String key, String value) => _attributes[key] = value; + + /// Removes a global attribute by key. + void removeAttribute(String key) => _attributes.remove(key); + + /// Adds a global metric. + void addMetric(String key, double value) => _metrics[key] = value; + + /// Removes a global metric by key. + void removeMetric(String key) => _metrics.remove(key); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/lifecycle_observer.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/lifecycle_observer.dart new file mode 100644 index 00000000000..c4303dee2b7 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/lifecycle_observer.dart @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// {@template amplify_event_enrichment.lifecycle_observer} +/// Abstract interface for observing app lifecycle transitions. +/// +/// Implement this to bridge platform-specific lifecycle events +/// (e.g. Flutter's `WidgetsBindingObserver`) to the session manager. +/// {@endtemplate} +abstract interface class LifecycleObserver { + /// Called when the app moves to the background. + void onPause(); + + /// Called when the app returns to the foreground. + void onResume(); + + /// Stops observing lifecycle events and releases resources. + void dispose(); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/app_metadata.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/app_metadata.dart new file mode 100644 index 00000000000..18f62f0aed3 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/app_metadata.dart @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:meta/meta.dart'; + +/// {@template amplify_event_enrichment.app_metadata} +/// Application-level metadata stamped on every event. +/// {@endtemplate} +@immutable +final class AppMetadata { + /// {@macro amplify_event_enrichment.app_metadata} + const AppMetadata({ + required this.appId, + this.packageName, + this.versionName, + this.versionCode, + this.title, + }); + + /// Application identifier used in the event envelope. + final String appId; + + /// Application package name. + final String? packageName; + + /// Application version name. + final String? versionName; + + /// Application version code. + final String? versionCode; + + /// Application display title. + final String? title; +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata.dart new file mode 100644 index 00000000000..45a5541998f --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata.dart @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:meta/meta.dart'; + +/// {@template amplify_event_enrichment.device_metadata} +/// Device-level metadata stamped on every event. +/// {@endtemplate} +@immutable +final class DeviceMetadata { + /// {@macro amplify_event_enrichment.device_metadata} + const DeviceMetadata({ + this.platform, + this.platformVersion, + this.manufacturer, + this.model, + this.locale, + }); + + /// Platform name (e.g. "iOS", "Android"). + final String? platform; + + /// Platform OS version. + final String? platformVersion; + + /// Device manufacturer (e.g. "Apple", "Samsung"). + final String? manufacturer; + + /// Device model (e.g. "iPhone", "SM-G900F"). + final String? model; + + /// Device locale code (e.g. "en_US"). + final String? locale; +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_io.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_io.dart new file mode 100644 index 00000000000..37e4c67d8c0 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_io.dart @@ -0,0 +1,15 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:io' show Platform; + +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata.dart'; + +/// Resolves basic device metadata from `dart:io Platform` on native platforms. +DeviceMetadata resolvePlatformDeviceMetadata() { + return DeviceMetadata( + platform: Platform.operatingSystem, + platformVersion: Platform.operatingSystemVersion, + locale: Platform.localeName, + ); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_provider.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_provider.dart new file mode 100644 index 00000000000..4ed19ba68a5 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_provider.dart @@ -0,0 +1,38 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata.dart'; +// Selects the native (`dart:io`) resolver when available and falls back to a +// web-safe stub otherwise, so this library compiles on web. +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata_stub.dart' + if (dart.library.io) 'package:amplify_event_enrichment_dart/src/metadata/device_metadata_io.dart'; + +/// {@template amplify_event_enrichment.device_metadata_provider} +/// Provides device metadata for event enrichment. +/// +/// Implement this to supply custom device information (e.g. via +/// `device_info_plus` for model/manufacturer). The default +/// [PlatformDeviceMetadataProvider] reads basic OS info from the platform on +/// native, and returns empty metadata on web. +/// {@endtemplate} +abstract interface class DeviceMetadataProvider { + /// Returns device metadata for the current platform. + Future getDeviceMetadata(); +} + +/// {@template amplify_event_enrichment.platform_device_metadata_provider} +/// Default [DeviceMetadataProvider] backed by the platform. +/// +/// On native platforms it reports OS name, version, and locale. On web, where +/// `dart:io` is unavailable, it returns empty metadata. It does not provide +/// manufacturer or model — wire a custom provider (e.g. using +/// `device_info_plus`) if those are needed. +/// {@endtemplate} +class PlatformDeviceMetadataProvider implements DeviceMetadataProvider { + /// {@macro amplify_event_enrichment.platform_device_metadata_provider} + const PlatformDeviceMetadataProvider(); + + @override + Future getDeviceMetadata() async => + resolvePlatformDeviceMetadata(); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_stub.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_stub.dart new file mode 100644 index 00000000000..a355d448a4a --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/device_metadata_stub.dart @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment_dart/src/metadata/device_metadata.dart'; + +/// Fallback used on platforms without `dart:io` (e.g. web). +/// +/// Returns empty device metadata. Supply a custom `DeviceMetadataProvider` +/// (for example one backed by browser APIs) if richer web metadata is needed. +DeviceMetadata resolvePlatformDeviceMetadata() => const DeviceMetadata(); diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/sdk_metadata.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/sdk_metadata.dart new file mode 100644 index 00000000000..769b512a652 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/metadata/sdk_metadata.dart @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:meta/meta.dart'; + +/// {@template amplify_event_enrichment.sdk_metadata} +/// SDK-level metadata stamped on every event. +/// {@endtemplate} +@immutable +final class SdkMetadata { + /// {@macro amplify_event_enrichment.sdk_metadata} + const SdkMetadata({required this.name, required this.version}); + + /// SDK name (e.g. "amplify-flutter"). + final String name; + + /// SDK version string. + final String version; +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/sender.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/sender.dart new file mode 100644 index 00000000000..d4bb4ddb4e9 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/sender.dart @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:amplify_event_enrichment_dart/src/enriched_event.dart'; + +/// {@template amplify_event_enrichment.sender} +/// Interface for transporting enriched events to a destination. +/// +/// Implement this to pipe events to Kinesis, Firehose, or any custom +/// transport. Named after the `Sender` abstraction the kinesis and firehose +/// clients sit behind, and deliberately not `EventSink`, which would collide +/// with `dart:async`'s `EventSink` in any file importing both. +/// +/// Sending is asynchronous: [send] returns a `Future` so the client can await +/// delivery and observe failures. Implementations that do no async work can +/// still return an already-completed future (an `async` method body with no +/// `await` is enough). +/// {@endtemplate} +abstract interface class Sender { + /// Sends an enriched event to the configured destination. + /// + /// May throw, or return a future that completes with an error, if delivery + /// fails. `EventEnrichmentClient.record` awaits this call and converts a + /// failure into an error `Result` after logging it, so a throwing sender + /// never crashes the caller of `record`. + Future send(EnrichedEvent event); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session.dart new file mode 100644 index 00000000000..cae3ba7a18f --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session.dart @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:aws_common/aws_common.dart'; +import 'package:meta/meta.dart'; + +/// {@template amplify_event_enrichment.session} +/// Represents an app session with start/stop timestamps and duration. +/// {@endtemplate} +@immutable +final class Session with AWSEquatable { + /// {@macro amplify_event_enrichment.session} + const Session({ + required this.id, + required this.startTimestamp, + this.stopTimestamp, + this.duration, + }); + + /// Unique session identifier. + final String id; + + /// ISO 8601 UTC timestamp when the session started. + final String startTimestamp; + + /// ISO 8601 UTC timestamp when the session stopped. + final String? stopTimestamp; + + /// Duration of the session in milliseconds. + final int? duration; + + @override + List get props => [id, startTimestamp, stopTimestamp, duration]; +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_event_types.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_event_types.dart new file mode 100644 index 00000000000..008638f8301 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_event_types.dart @@ -0,0 +1,25 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/// The event type of the event emitted when a session starts. +/// +/// This is the same string legacy Amplify Analytics used for the equivalent +/// Pinpoint event, so downstream analytics that already key on Pinpoint's +/// naming keep working after moving onto this client. +/// +/// See `EventEnrichmentClient.startSession` for when the event is emitted and +/// what it contains. +const zSessionStartEventType = '_session.start'; + +/// The event type of the event emitted when a session ends. +/// +/// This is the same string legacy Amplify Analytics used for the equivalent +/// Pinpoint event, so downstream analytics that already key on Pinpoint's +/// naming keep working after moving onto this client. +/// +/// Unlike a start event, this one carries the session's stop timestamp and +/// duration, which exist nowhere else. +/// +/// See `EventEnrichmentClient.stopSession` for when the event is emitted and +/// what it contains. +const zSessionStopEventType = '_session.stop'; diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_manager.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_manager.dart new file mode 100644 index 00000000000..6cd03d8045c --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/session/session_manager.dart @@ -0,0 +1,315 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'dart:async'; + +import 'package:amplify_event_enrichment_dart/src/session/session.dart'; +import 'package:amplify_foundation_dart/amplify_foundation_dart.dart'; +import 'package:meta/meta.dart'; + +/// {@template amplify_event_enrichment.on_session_started} +/// Called when a session starts, with the newly created session. +/// +/// Invoked exactly once per session, from every start path: the eager start at +/// construction, an explicit start, the lazy start when an event is recorded +/// without a session, the restart when a resume follows a session timeout, and +/// the new session in a displacement. +/// +/// In a displacement this is called after [OnSessionEnded] has completed for +/// the session being replaced, so a stop is always reported before the start +/// that replaced it. +/// +/// Implementations must not throw, and should complete their own failures +/// rather than propagating them: the paths that start a session are lifecycle +/// transitions, not a caller's attempt to record anything. +/// {@endtemplate} +typedef OnSessionStarted = Future Function(Session session); + +/// {@template amplify_event_enrichment.on_session_ended} +/// Called when a session ends, after its stop timestamp and duration have +/// been recorded on it. +/// +/// The [Session] passed is the session that just ended, so implementations see +/// its stop timestamp and duration. Invoked exactly once per session, from +/// every end path: an explicit stop, the session timeout expiring, and the +/// implicit stop when a new session displaces a running one. +/// +/// Implementations must not throw, and should complete their own failures +/// rather than propagating them: the paths that end a session are lifecycle +/// transitions, not a caller's attempt to record anything. +/// {@endtemplate} +typedef OnSessionEnded = Future Function(Session session); + +/// The state of the session manager. +enum SessionState { + /// No active session. + stopped, + + /// Session is active (app in foreground). + active, + + /// Session is paused (app backgrounded, within timeout). + paused, +} + +/// {@template amplify_event_enrichment.session_manager} +/// Manages session lifecycle with Active/Paused/Stopped states. +/// +/// When the app backgrounds, the session enters [SessionState.paused]. +/// If the app returns to foreground within the configured timeout, the same +/// session resumes. If the timeout expires, a new session starts on next +/// foreground. +/// +/// An explicit [stopSession] or [clearSession] call is treated differently +/// from a timeout: it means tracking was ended on purpose, so +/// [handleAppResumed] will not start a new session afterwards. See +/// [handleAppResumed]. +/// +/// Every session start and end is reported to the optional [OnSessionStarted] +/// and [OnSessionEnded] callbacks supplied at construction, which is how the +/// client turns a session boundary into an emitted event. The manager itself +/// knows nothing about senders or events. +/// {@endtemplate} +class SessionManager { + /// {@macro amplify_event_enrichment.session_manager} + /// + /// [onSessionStarted] and [onSessionEnded] are each invoked once per session + /// boundary, after the state transition has been applied. + SessionManager({ + required String appId, + required Duration sessionTimeout, + required String Function() generateId, + OnSessionStarted? onSessionStarted, + OnSessionEnded? onSessionEnded, + }) : _appId = appId, + _sessionTimeout = sessionTimeout, + _generateId = generateId, + _onSessionStarted = onSessionStarted, + _onSessionEnded = onSessionEnded; + + final String _appId; + final Duration _sessionTimeout; + final String Function() _generateId; + final OnSessionStarted? _onSessionStarted; + final OnSessionEnded? _onSessionEnded; + final Logger _logger = AmplifyLogging.logger('EventEnrichmentSessionManager'); + + SessionState _state = SessionState.stopped; + Session? _session; + Timer? _pauseTimer; + DateTime? _sessionStart; + + /// Whether the stopped state was reached by an explicit [stopSession] or + /// [clearSession] call rather than by the session timeout expiring. + /// + /// A timeout stop is something the app comes back from, so a later + /// foreground starts a fresh session. An explicit stop is the customer + /// ending tracking, so a later foreground leaves it ended. + bool _stoppedExplicitly = false; + + /// Current session state. + SessionState get state => _state; + + /// Current session, or `null` if none has started yet or [clearSession] has + /// dropped it. + /// + /// A session stopped by [stopSession] or by the timeout expiring stays + /// readable here, carrying its stop timestamp and duration, so it can still + /// be inspected. Only [clearSession] nulls it. + Session? get session => _session; + + /// Visible for testing — allows injecting a custom timer factory. + @visibleForTesting + Timer Function(Duration, void Function()) timerFactory = Timer.new; + + /// Starts a new session. If one is running, ends it first. + /// + /// Also clears the explicit-stop flag, so lifecycle transitions resume + /// managing sessions again after this call. + /// + /// Both state changes are applied synchronously, before anything is awaited: + /// [session] and [state] describe the new session as soon as this returns, + /// whether or not the caller awaits. The returned future completes once the + /// boundary has been reported — a displaced session's end first, then the new + /// session's start, in that order. + /// + /// When there is no session to displace the start is reported synchronously + /// rather than after a microtask, so a caller that records an event + /// immediately after cannot get its event reported ahead of the session's + /// start. + Future startSession() { + final ended = _endCurrent(); + final started = _startFresh(); + if (ended == null) return _reportStart(started); + return ended.then((_) => _reportStart(started)); + } + + /// Stops the current session, recording stop time and duration, and reports + /// it to [OnSessionEnded]. + /// + /// This is an explicit end to tracking: [handleAppResumed] will not start a + /// new session afterwards. Call [startSession], or record an event on the + /// client, to begin tracking again. + /// + /// The state change is synchronous; the returned future completes once the + /// end has been reported. Ending an already-stopped session does nothing and + /// reports nothing, so this is safe to call more than once. + Future stopSession() { + final ended = _endCurrent(); + _stoppedExplicitly = true; + return ended ?? Future.value(); + } + + /// Clears the current session without recording stop metadata. + /// + /// Unlike [stopSession], this records no stop timestamp or duration, reports + /// nothing to [OnSessionEnded], and drops the session entirely so none + /// remains readable. Used when disposing the client, after the session has + /// already been ended and reported. + /// + /// Like [stopSession], this is an explicit end to tracking, so + /// [handleAppResumed] will not start a new session afterwards. + void clearSession() { + _cancelTimer(); + _session = null; + _sessionStart = null; + _state = SessionState.stopped; + _stoppedExplicitly = true; + } + + /// Called when the app moves to background. + void handleAppPaused() { + if (_state != SessionState.active) return; + _state = SessionState.paused; + _pauseTimer = timerFactory(_sessionTimeout, _onTimeoutExpired); + } + + /// Called when the app returns to foreground. + /// + /// Resumes a paused session, or starts a new one if the session timeout + /// expired while backgrounded. Does nothing if tracking was stopped + /// explicitly via [stopSession] or [clearSession] — a session the customer + /// ended is not resurrected by a lifecycle transition. + /// Called when the app returns to foreground. + /// + /// Resumes a paused session, or starts a new one if the session timeout + /// expired while backgrounded. Does nothing if tracking was stopped + /// explicitly via [stopSession] or [clearSession] — a session the customer + /// ended is not resurrected by a lifecycle transition. + /// + /// Resuming a paused session is not a session boundary, so nothing is + /// reported for it. A restart after a timeout is, and there is no caller to + /// await it: the start report is fired the same way the timeout's end report + /// is, so a failure surfaces only in the log. + void handleAppResumed() { + switch (_state) { + case SessionState.paused: + _cancelTimer(); + _state = SessionState.active; + case SessionState.stopped: + if (_stoppedExplicitly) return; + // Nothing is running, so this starts a session without ending one: + // there is no end to report and no caller to await the start. + _reportStartUnawaited(_startFresh()); + case SessionState.active: + break; + } + } + + /// Records stop metadata on the current session, moves to the stopped state, + /// and reports the ended session to [OnSessionEnded]. + /// + /// Returns `null` when there was no session to end, which is what keeps a + /// session from being ended — or reported — twice: whichever end path runs + /// first takes it to [SessionState.stopped], and every later one is a no-op. + /// That covers stopping twice, closing after a stop, and closing after a + /// timeout. + /// + /// Deliberately does not touch the explicit-stop flag, so the timeout path + /// and [startSession]'s implicit stop stay restartable by + /// [handleAppResumed]. + Future? _endCurrent() { + _cancelTimer(); + if (_state == SessionState.stopped || _session == null) return null; + final now = DateTime.now(); + final ended = Session( + id: _session!.id, + startTimestamp: _session!.startTimestamp, + stopTimestamp: now.toUtc().toIso8601String(), + duration: now.difference(_sessionStart!).inMilliseconds, + ); + _session = ended; + _state = SessionState.stopped; + return _onSessionEnded?.call(ended) ?? Future.value(); + } + + /// Creates and activates a new session, clearing the explicit-stop flag. + /// + /// Returns the new session so the caller can report it. Each call produces a + /// distinct session, so a start is reported at most once per session. + Session _startFresh() { + _stoppedExplicitly = false; + _sessionStart = DateTime.now(); + final started = Session( + id: _generateSessionId(), + startTimestamp: _sessionStart!.toUtc().toIso8601String(), + ); + _session = started; + _state = SessionState.active; + return started; + } + + Future _reportStart(Session started) => + _onSessionStarted?.call(started) ?? Future.value(); + + /// Reports a start that nothing can await, logging anything that escapes the + /// callback's own handling. + void _reportStartUnawaited(Session started) { + unawaited( + _reportStart(started).onError( + (e, st) => + _logger.error('Failed to report the start of a session', e, st), + ), + ); + } + + void _onTimeoutExpired() { + // A timer has no caller, so nothing can await this. OnSessionEnded is + // contracted not to throw; if one does anyway, the log is the only place + // the failure can surface. + final ended = _endCurrent(); + if (ended == null) return; + unawaited( + ended.onError( + (e, st) => _logger.error( + 'Failed to report the end of a session that timed out', + e, + st, + ), + ), + ); + } + + void _cancelTimer() { + _pauseTimer?.cancel(); + _pauseTimer = null; + } + + String _generateSessionId() { + var prefix = _appId; + if (prefix.length > 8) { + prefix = prefix.substring(0, 8); + } else { + prefix = prefix.padLeft(8, '_'); + } + final uniqueId = _generateId().substring(0, 8); + final now = DateTime.now().toUtc(); + final date = + '${now.year}${_pad(now.month)}${_pad(now.day)}' + '-${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}' + '${now.millisecond.toString().padLeft(3, '0')}'; + return '$prefix-$uniqueId-$date'; + } + + static String _pad(int n) => n.toString().padLeft(2, '0'); +} diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/util/serializable.dart b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/util/serializable.dart new file mode 100644 index 00000000000..212beedd772 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/lib/src/util/serializable.dart @@ -0,0 +1,19 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import 'package:json_annotation/json_annotation.dart'; + +/// Serialization options for the analytics event envelope. +/// +/// The envelope is snake_case, omits absent fields and sections entirely, and +/// nests its sections as objects, so `fieldRename`, `includeIfNull` and +/// `explicitToJson` are all fixed here rather than repeated on every class. +/// +/// `createFactory` is off because the envelope is only ever written. Nothing +/// in this package parses it back; consumers of the emitted JSON own that. +const zEventEnrichmentSerializable = JsonSerializable( + fieldRename: FieldRename.snake, + includeIfNull: false, + explicitToJson: true, + createFactory: false, +); diff --git a/packages/amplify_event_enrichment/amplify_event_enrichment_dart/pubspec.yaml b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/pubspec.yaml new file mode 100644 index 00000000000..22a50fb0ce2 --- /dev/null +++ b/packages/amplify_event_enrichment/amplify_event_enrichment_dart/pubspec.yaml @@ -0,0 +1,29 @@ +name: amplify_event_enrichment_dart +description: A standalone Dart client that enriches analytics events with device, app, and session context, producing a structured JSON envelope. +version: 0.1.0 +homepage: https://docs.amplify.aws/lib/q/platform/flutter/ +repository: https://github.com/aws-amplify/amplify-flutter/tree/main/packages/amplify_event_enrichment/amplify_event_enrichment_dart +issue_tracker: https://github.com/aws-amplify/amplify-flutter/issues + +topics: + - aws + - analytics + - event-enrichment + - aws-amplify + +environment: + sdk: ^3.11.0 + +dependencies: + amplify_foundation_dart: ">=2.12.1 <2.13.0" + aws_common: ">=0.7.15 <0.8.0" + json_annotation: ^4.9.0 + meta: ^1.16.0 + uuid: ^4.5.1 + +dev_dependencies: + amplify_lints: ">=3.1.6 <3.2.0" + build_runner: ^2.4.15 + fake_async: ^1.3.0 + json_serializable: ^6.11.0 + test: ^1.22.1 diff --git a/pubspec.yaml b/pubspec.yaml index 67762d15878..63f1f146e1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -116,6 +116,12 @@ aft: packages: - amplify_connect_client - amplify_connect_client_dart + - name: Event Enrichment + summary: amplify_event_enrichment + propagate: none + packages: + - amplify_event_enrichment + - amplify_event_enrichment_dart - name: AWS Common summary: aws_common propagate: none