Skip to content

Commit bd1c4b0

Browse files
authored
feat: add event enrichment client (#4243)
* feat: add event enrichment client * add more tests * fix swiftformat issue and run tests on CI/CD * add appId to client init and persist client id internally * address review comments * add missing test scheme * update tests * address review comments * address review comment * address review comments * address review changes
1 parent e04d2dd commit bd1c4b0

24 files changed

Lines changed: 2415 additions & 0 deletions

.github/workflows/unit_test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ jobs:
7676
AmplifyKinesisClient,
7777
AmplifyFirehoseClient,
7878
AmplifyConnectClient,
79+
AmplifyEventEnrichmentClient,
7980
AWSLocationGeoPlugin,
8081
AWSPredictionsPlugin,
8182
AWSPinpointAnalyticsPlugin,
@@ -107,6 +108,7 @@ jobs:
107108
{ scheme: AmplifyKinesisClient, flags: 'Kinesis_plugin_unit_test,unit_tests' },
108109
{ scheme: AmplifyFirehoseClient, flags: 'Firehose_plugin_unit_test,unit_tests' },
109110
{ scheme: AmplifyConnectClient, flags: 'Connect_unit_test,unit_tests' },
111+
{ scheme: AmplifyEventEnrichmentClient, flags: 'EventEnrichment_unit_test,unit_tests' },
110112
{ scheme: AWSLocationGeoPlugin, flags: 'Geo_plugin_unit_test,unit_tests' },
111113
{ scheme: AWSPredictionsPlugin, flags: 'Predictions_plugin_unit_test,unit_tests' },
112114
{ scheme: AWSPinpointAnalyticsPlugin, flags: 'Analytics_plugin_unit_test,unit_tests' },
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Unit Tests | Event Enrichment
2+
on:
3+
workflow_dispatch:
4+
inputs:
5+
ios:
6+
description: '📱 iOS'
7+
required: true
8+
default: true
9+
type: boolean
10+
macos:
11+
description: '💻 macOS'
12+
required: true
13+
default: true
14+
type: boolean
15+
tvos:
16+
description: '📺 tvOS'
17+
required: true
18+
default: true
19+
type: boolean
20+
watchos:
21+
description: '⌚️ watchOS'
22+
required: true
23+
default: true
24+
type: boolean
25+
visionos:
26+
description: 'ᯅ visionOS'
27+
required: true
28+
default: true
29+
type: boolean
30+
31+
permissions:
32+
contents: read
33+
34+
jobs:
35+
event-enrichment-unit-tests:
36+
uses: ./.github/workflows/run_unit_tests_platforms.yml
37+
with:
38+
scheme: AmplifyEventEnrichmentClient
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1640"
4+
version = "1.3">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES">
8+
<BuildActionEntries>
9+
<BuildActionEntry
10+
buildForTesting = "YES"
11+
buildForRunning = "YES"
12+
buildForProfiling = "YES"
13+
buildForArchiving = "YES"
14+
buildForAnalyzing = "YES">
15+
<BuildableReference
16+
BuildableIdentifier = "primary"
17+
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
18+
BuildableName = "AmplifyEventEnrichmentClient"
19+
BlueprintName = "AmplifyEventEnrichmentClient"
20+
ReferencedContainer = "container:">
21+
</BuildableReference>
22+
</BuildActionEntry>
23+
</BuildActionEntries>
24+
</BuildAction>
25+
<TestAction
26+
buildConfiguration = "Debug"
27+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
28+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
29+
shouldUseLaunchSchemeArgsEnv = "YES"
30+
codeCoverageEnabled = "YES"
31+
onlyGenerateCoverageForSpecifiedTargets = "YES">
32+
<CodeCoverageTargets>
33+
<BuildableReference
34+
BuildableIdentifier = "primary"
35+
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
36+
BuildableName = "AmplifyEventEnrichmentClient"
37+
BlueprintName = "AmplifyEventEnrichmentClient"
38+
ReferencedContainer = "container:">
39+
</BuildableReference>
40+
</CodeCoverageTargets>
41+
<Testables>
42+
<TestableReference
43+
skipped = "NO">
44+
<BuildableReference
45+
BuildableIdentifier = "primary"
46+
BlueprintIdentifier = "AmplifyEventEnrichmentClientTests"
47+
BuildableName = "AmplifyEventEnrichmentClientTests"
48+
BlueprintName = "AmplifyEventEnrichmentClientTests"
49+
ReferencedContainer = "container:">
50+
</BuildableReference>
51+
</TestableReference>
52+
</Testables>
53+
</TestAction>
54+
<LaunchAction
55+
buildConfiguration = "Debug"
56+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
57+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
58+
launchStyle = "0"
59+
useCustomWorkingDirectory = "NO"
60+
ignoresPersistentStateOnLaunch = "NO"
61+
debugDocumentVersioning = "YES"
62+
debugServiceExtension = "internal"
63+
allowLocationSimulation = "YES">
64+
</LaunchAction>
65+
<ProfileAction
66+
buildConfiguration = "Release"
67+
shouldUseLaunchSchemeArgsEnv = "YES"
68+
savedToolIdentifier = ""
69+
useCustomWorkingDirectory = "NO"
70+
debugDocumentVersioning = "YES">
71+
<MacroExpansion>
72+
<BuildableReference
73+
BuildableIdentifier = "primary"
74+
BlueprintIdentifier = "AmplifyEventEnrichmentClient"
75+
BuildableName = "AmplifyEventEnrichmentClient"
76+
BlueprintName = "AmplifyEventEnrichmentClient"
77+
ReferencedContainer = "container:">
78+
</BuildableReference>
79+
</MacroExpansion>
80+
</ProfileAction>
81+
<AnalyzeAction
82+
buildConfiguration = "Debug">
83+
</AnalyzeAction>
84+
<ArchiveAction
85+
buildConfiguration = "Release"
86+
revealArchiveInOrganizer = "YES">
87+
</ArchiveAction>
88+
</Scheme>
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
//
2+
// Copyright Amazon.com Inc. or its affiliates.
3+
// All Rights Reserved.
4+
//
5+
// SPDX-License-Identifier: Apache-2.0
6+
//
7+
8+
import AmplifyFoundation
9+
import Foundation
10+
11+
/// A client for recording enriched analytics events.
12+
///
13+
/// Collects device, app, session, and SDK metadata and produces
14+
/// ``EnrichedEvent`` instances with Pinpoint-compatible JSON output.
15+
///
16+
/// ## Usage
17+
///
18+
/// ```swift
19+
/// let client = AmplifyEventEnrichmentClient(
20+
/// appId: "my-app",
21+
/// sdkMetadata: SDKMetadata(name: "amplify-swift", version: "2.58.0")
22+
/// )
23+
///
24+
/// let event = try await client.record("button_clicked")
25+
/// print(try event.toJson())
26+
///
27+
/// await client.close()
28+
/// ```
29+
@available(iOS 13.0, macOS 12.0, tvOS 13.0, watchOS 9.0, *)
30+
public actor AmplifyEventEnrichmentClient {
31+
private let appMetadata: AppMetadata
32+
private let deviceMetadata: DeviceMetadata
33+
private let sdkMetadata: SDKMetadata
34+
private let clientId: String
35+
private let sink: (any EventSink)?
36+
private let logger: Logger
37+
private let globalFields: GlobalFieldsManager
38+
private let sessionManager: SessionManager
39+
private var activityTracker: ActivityTracker?
40+
private let autoSessionTracking: Bool
41+
private var userId: String?
42+
private var closed = false
43+
44+
/// Initializes a new event enrichment client.
45+
///
46+
/// The `clientId` is resolved automatically from `UserDefaults` using a
47+
/// read-or-create pattern with the key `com.amplifyframework.device_id`,
48+
/// which is shared with `AmplifyConnectClient`'s `DeviceIdProvider` so both
49+
/// clients report the same identifier for a device.
50+
///
51+
/// When `deviceMetadata` is nil, the client resolves platform, OS version,
52+
/// manufacturer, model, and locale via ``PlatformDeviceMetadataProvider``.
53+
///
54+
/// - Parameters:
55+
/// - appId: Application identifier used in the event envelope and session ID.
56+
/// - sdkMetadata: SDK-level metadata for events.
57+
/// - appMetadata: Application-level metadata. If nil, created from `appId`.
58+
/// - deviceMetadata: Device-level metadata. If nil, resolved via platform APIs.
59+
/// - options: Configuration options.
60+
/// - sink: Optional transport sink for enriched events.
61+
@MainActor
62+
public init(
63+
appId: String,
64+
sdkMetadata: SDKMetadata,
65+
appMetadata: AppMetadata? = nil,
66+
deviceMetadata: DeviceMetadata? = nil,
67+
options: EventEnrichmentClientOptions = EventEnrichmentClientOptions(),
68+
sink: (any EventSink)? = nil
69+
) {
70+
let resolvedAppMetadata = appMetadata ?? AppMetadata(appId: appId)
71+
let resolvedDeviceMetadata = deviceMetadata ?? PlatformDeviceMetadataProvider().getDeviceMetadata()
72+
let resolvedClientId = ClientIDProvider.resolve()
73+
74+
self.appMetadata = resolvedAppMetadata
75+
self.deviceMetadata = resolvedDeviceMetadata
76+
self.sdkMetadata = sdkMetadata
77+
self.clientId = resolvedClientId
78+
self.sink = sink
79+
self.autoSessionTracking = options.autoSessionTracking
80+
self.logger = AmplifyLogging.logger(for: AmplifyEventEnrichmentClient.self)
81+
self.globalFields = GlobalFieldsManager()
82+
let sessionManager = SessionManager(
83+
appId: resolvedAppMetadata.appId,
84+
sessionTimeout: options.sessionTimeout,
85+
generateId: { UUID().uuidString }
86+
)
87+
self.sessionManager = sessionManager
88+
if options.autoSessionTracking {
89+
self.activityTracker = ActivityTracker(
90+
onPause: { Task { await sessionManager.handleAppPaused() } },
91+
onResume: { Task { await sessionManager.handleAppResumed() } }
92+
)
93+
} else {
94+
self.activityTracker = nil
95+
}
96+
}
97+
98+
/// Records an event and returns the enriched result.
99+
///
100+
/// - Parameters:
101+
/// - eventType: The type/name of the event.
102+
/// - attributes: Per-event string attributes (merged with globals).
103+
/// - metrics: Per-event numeric metrics (merged with globals).
104+
/// - Returns: The enriched event.
105+
/// - Throws: ``EventEnrichmentError/clientClosed(_:_:_:)`` if the client has been closed,
106+
/// or ``EventEnrichmentError/noActiveSession(_:_:_:)`` if no session is active.
107+
@discardableResult
108+
public func record(
109+
_ eventType: String,
110+
attributes: [String: String] = [:],
111+
metrics: [String: Double] = [:]
112+
) async throws -> EnrichedEvent {
113+
guard !closed else {
114+
throw EventEnrichmentError.clientClosed(
115+
"Client has been closed",
116+
"Create a new AmplifyEventEnrichmentClient instance."
117+
)
118+
}
119+
120+
if autoSessionTracking {
121+
await sessionManager.startSession()
122+
}
123+
124+
let globalAttributes = await globalFields.attributes
125+
let globalMetrics = await globalFields.metrics
126+
127+
var mergedAttributes = globalAttributes
128+
for (key, value) in attributes {
129+
mergedAttributes[key] = value
130+
}
131+
132+
var mergedMetrics = globalMetrics
133+
for (key, value) in metrics {
134+
mergedMetrics[key] = value
135+
}
136+
137+
guard let session = await sessionManager.activeSession else {
138+
throw EventEnrichmentError.noActiveSession(
139+
"No active session",
140+
"Call startSession() before recording events when autoSessionTracking is disabled."
141+
)
142+
}
143+
144+
let event = EnrichedEvent(
145+
eventId: UUID().uuidString,
146+
eventType: eventType,
147+
eventTimestamp: Int64(Date().timeIntervalSince1970 * 1_000),
148+
session: session,
149+
attributes: mergedAttributes,
150+
metrics: mergedMetrics,
151+
device: deviceMetadata,
152+
app: appMetadata,
153+
sdk: sdkMetadata,
154+
clientId: clientId,
155+
userId: userId
156+
)
157+
158+
await sink?.send(event)
159+
logger.verbose("Recorded event: \(eventType)")
160+
return event
161+
}
162+
163+
/// Starts a new session manually.
164+
public func startSession() async {
165+
await sessionManager.startSession()
166+
}
167+
168+
/// Stops the current session.
169+
public func stopSession() async {
170+
await sessionManager.stopSession()
171+
}
172+
173+
/// Sets the user identifier stamped on subsequent events.
174+
public func setUserId(_ userId: String?) {
175+
self.userId = userId
176+
}
177+
178+
/// Adds a global attribute stamped on every subsequent event.
179+
public func addGlobalAttribute(_ key: String, value: String) async {
180+
await globalFields.addAttribute(key, value: value)
181+
}
182+
183+
/// Removes a global attribute.
184+
public func removeGlobalAttribute(_ key: String) async {
185+
await globalFields.removeAttribute(key)
186+
}
187+
188+
/// Adds a global metric stamped on every subsequent event.
189+
public func addGlobalMetric(_ key: String, value: Double) async {
190+
await globalFields.addMetric(key, value: value)
191+
}
192+
193+
/// Removes a global metric.
194+
public func removeGlobalMetric(_ key: String) async {
195+
await globalFields.removeMetric(key)
196+
}
197+
198+
/// The current session state. Internal, for tests to observe lifecycle handling.
199+
var sessionState: SessionState {
200+
get async { await sessionManager.state }
201+
}
202+
203+
/// Whether lifecycle notifications are still being observed. Internal, for tests.
204+
var isTrackingLifecycle: Bool {
205+
activityTracker != nil
206+
}
207+
208+
/// Releases resources and stops session tracking.
209+
///
210+
/// The client cannot be reused after closing.
211+
public func close() async {
212+
closed = true
213+
// Tear the tracker down before stopping the session: otherwise a lifecycle
214+
// notification arriving in between would start a new session on a closed client.
215+
await activityTracker?.stopTracking()
216+
activityTracker = nil
217+
await sessionManager.stopSession()
218+
logger.info("Client closed")
219+
}
220+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>NSPrivacyTracking</key>
6+
<false/>
7+
<key>NSPrivacyCollectedDataTypes</key>
8+
<array/>
9+
<key>NSPrivacyTrackingDomains</key>
10+
<array/>
11+
<key>NSPrivacyAccessedAPITypes</key>
12+
<array/>
13+
</dict>
14+
</plist>

0 commit comments

Comments
 (0)