Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/unit_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
AmplifyKinesisClient,
AmplifyFirehoseClient,
AmplifyConnectClient,
AmplifyEventEnrichmentClient,
AWSLocationGeoPlugin,
AWSPredictionsPlugin,
AWSPinpointAnalyticsPlugin,
Expand Down Expand Up @@ -107,6 +108,7 @@ jobs:
{ scheme: AmplifyKinesisClient, flags: 'Kinesis_plugin_unit_test,unit_tests' },
{ scheme: AmplifyFirehoseClient, flags: 'Firehose_plugin_unit_test,unit_tests' },
{ scheme: AmplifyConnectClient, flags: 'Connect_unit_test,unit_tests' },
{ scheme: AmplifyEventEnrichmentClient, flags: 'EventEnrichment_unit_test,unit_tests' },
{ scheme: AWSLocationGeoPlugin, flags: 'Geo_plugin_unit_test,unit_tests' },
{ scheme: AWSPredictionsPlugin, flags: 'Predictions_plugin_unit_test,unit_tests' },
{ scheme: AWSPinpointAnalyticsPlugin, flags: 'Analytics_plugin_unit_test,unit_tests' },
Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/unit_test_event_enrichment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Unit Tests | Event Enrichment
on:
workflow_dispatch:
inputs:
ios:
description: '📱 iOS'
required: true
default: true
type: boolean
macos:
description: '💻 macOS'
required: true
default: true
type: boolean
tvos:
description: '📺 tvOS'
required: true
default: true
type: boolean
watchos:
description: '⌚️ watchOS'
required: true
default: true
type: boolean
visionos:
description: 'ᯅ visionOS'
required: true
default: true
type: boolean

permissions:
contents: read

jobs:
event-enrichment-unit-tests:
uses: ./.github/workflows/run_unit_tests_platforms.yml
with:
scheme: AmplifyEventEnrichmentClient
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
BuildableName = "AmplifyEventEnrichmentClient"
BlueprintName = "AmplifyEventEnrichmentClient"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "YES"
onlyGenerateCoverageForSpecifiedTargets = "YES">
<CodeCoverageTargets>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
BuildableName = "AmplifyEventEnrichmentClient"
BlueprintName = "AmplifyEventEnrichmentClient"
ReferencedContainer = "container:">
</BuildableReference>
</CodeCoverageTargets>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AmplifyEventEnrichmentClientTests"
BuildableName = "AmplifyEventEnrichmentClientTests"
BlueprintName = "AmplifyEventEnrichmentClientTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
BuildableName = "AmplifyEventEnrichmentClient"
BlueprintName = "AmplifyEventEnrichmentClient"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
//
// Copyright Amazon.com Inc. or its affiliates.
// All Rights Reserved.
//
// SPDX-License-Identifier: Apache-2.0
//

import AmplifyFoundation
import Foundation

/// A client for recording enriched analytics events.
///
/// Collects device, app, session, and SDK metadata and produces
/// ``EnrichedEvent`` instances with Pinpoint-compatible JSON output.
///
/// ## Usage
///
/// ```swift
/// let client = AmplifyEventEnrichmentClient(
/// appId: "my-app",
/// sdkMetadata: SDKMetadata(name: "amplify-swift", version: "2.58.0")
/// )
///
/// let event = try await client.record("button_clicked")
/// print(try event.toJson())
///
/// await client.close()
/// ```
@available(iOS 13.0, macOS 12.0, tvOS 13.0, watchOS 9.0, *)
public actor AmplifyEventEnrichmentClient {
private let appMetadata: AppMetadata
private let deviceMetadata: DeviceMetadata
private let sdkMetadata: SDKMetadata
private let clientId: String
private let sink: (any EventSink)?
private let logger: Logger
private let globalFields: GlobalFieldsManager
private let sessionManager: SessionManager
private var activityTracker: ActivityTracker?
private let autoSessionTracking: Bool
private var userId: String?
private var closed = false

/// Initializes a new event enrichment client.
///
/// The `clientId` is resolved automatically from `UserDefaults` using a
/// read-or-create pattern with the key `com.amplifyframework.device_id`,
/// which is shared with `AmplifyConnectClient`'s `DeviceIdProvider` so both
/// clients report the same identifier for a device.
///
/// When `deviceMetadata` is nil, the client resolves platform, OS version,
/// manufacturer, model, and locale via ``PlatformDeviceMetadataProvider``.
///
/// - Parameters:
/// - appId: Application identifier used in the event envelope and session ID.
/// - sdkMetadata: SDK-level metadata for events.
/// - appMetadata: Application-level metadata. If nil, created from `appId`.
/// - deviceMetadata: Device-level metadata. If nil, resolved via platform APIs.
/// - options: Configuration options.
/// - sink: Optional transport sink for enriched events.
@MainActor
public init(
appId: String,
sdkMetadata: SDKMetadata,
appMetadata: AppMetadata? = nil,
deviceMetadata: DeviceMetadata? = nil,
options: EventEnrichmentClientOptions = EventEnrichmentClientOptions(),
sink: (any EventSink)? = nil
) {
let resolvedAppMetadata = appMetadata ?? AppMetadata(appId: appId)
let resolvedDeviceMetadata = deviceMetadata ?? PlatformDeviceMetadataProvider().getDeviceMetadata()
let resolvedClientId = ClientIDProvider.resolve()

self.appMetadata = resolvedAppMetadata
self.deviceMetadata = resolvedDeviceMetadata
self.sdkMetadata = sdkMetadata
self.clientId = resolvedClientId
self.sink = sink
self.autoSessionTracking = options.autoSessionTracking
self.logger = AmplifyLogging.logger(for: AmplifyEventEnrichmentClient.self)
self.globalFields = GlobalFieldsManager()
let sessionManager = SessionManager(
appId: resolvedAppMetadata.appId,
sessionTimeout: options.sessionTimeout,
generateId: { UUID().uuidString }
)
self.sessionManager = sessionManager
if options.autoSessionTracking {
self.activityTracker = ActivityTracker(
onPause: { Task { await sessionManager.handleAppPaused() } },
onResume: { Task { await sessionManager.handleAppResumed() } }
)
} else {
self.activityTracker = nil
}
}

/// Records an event and returns the enriched result.
///
/// - Parameters:
/// - eventType: The type/name of the event.
/// - attributes: Per-event string attributes (merged with globals).
/// - metrics: Per-event numeric metrics (merged with globals).
/// - Returns: The enriched event.
/// - Throws: ``EventEnrichmentError/clientClosed(_:_:_:)`` if the client has been closed,
/// or ``EventEnrichmentError/noActiveSession(_:_:_:)`` if no session is active.
@discardableResult
public func record(
_ eventType: String,
attributes: [String: String] = [:],
metrics: [String: Double] = [:]
) async throws -> EnrichedEvent {
guard !closed else {
throw EventEnrichmentError.clientClosed(
"Client has been closed",
"Create a new AmplifyEventEnrichmentClient instance."
)
}

if autoSessionTracking {
await sessionManager.startSession()
}

let globalAttributes = await globalFields.attributes
let globalMetrics = await globalFields.metrics

var mergedAttributes = globalAttributes
for (key, value) in attributes {
mergedAttributes[key] = value
}

var mergedMetrics = globalMetrics
for (key, value) in metrics {
mergedMetrics[key] = value
}

guard let session = await sessionManager.activeSession else {
throw EventEnrichmentError.noActiveSession(
"No active session",
"Call startSession() before recording events when autoSessionTracking is disabled."
)
}

let event = EnrichedEvent(
eventId: UUID().uuidString,
eventType: eventType,
eventTimestamp: Int64(Date().timeIntervalSince1970 * 1_000),
session: session,
attributes: mergedAttributes,
metrics: mergedMetrics,
device: deviceMetadata,
app: appMetadata,
sdk: sdkMetadata,
clientId: clientId,
userId: userId
)

await sink?.send(event)
logger.verbose("Recorded event: \(eventType)")
return event
}

/// Starts a new session manually.
public func startSession() async {
await sessionManager.startSession()
}

/// Stops the current session.
public func stopSession() async {
await sessionManager.stopSession()
}

/// Sets the user identifier stamped on subsequent events.
public func setUserId(_ userId: String?) {
self.userId = userId
}

/// Adds a global attribute stamped on every subsequent event.
public func addGlobalAttribute(_ key: String, value: String) async {
await globalFields.addAttribute(key, value: value)
}

/// Removes a global attribute.
public func removeGlobalAttribute(_ key: String) async {
await globalFields.removeAttribute(key)
}

/// Adds a global metric stamped on every subsequent event.
public func addGlobalMetric(_ key: String, value: Double) async {
await globalFields.addMetric(key, value: value)
}

/// Removes a global metric.
public func removeGlobalMetric(_ key: String) async {
await globalFields.removeMetric(key)
}

/// The current session state. Internal, for tests to observe lifecycle handling.
var sessionState: SessionState {
get async { await sessionManager.state }
}

/// Whether lifecycle notifications are still being observed. Internal, for tests.
var isTrackingLifecycle: Bool {
activityTracker != nil
}

/// Releases resources and stops session tracking.
///
/// The client cannot be reused after closing.
public func close() async {
closed = true
// Tear the tracker down before stopping the session: otherwise a lifecycle
// notification arriving in between would start a new session on a closed client.
await activityTracker?.stopTracking()
activityTracker = nil
await sessionManager.stopSession()
logger.info("Client closed")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array/>
</dict>
</plist>
Loading
Loading