@advenue/react-native 1.0.1 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/build.gradle +8 -2
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
- package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -1
- package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +28 -2
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
- package/dist/index.cjs +18 -2
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +18 -2
- package/ios/AdvenueIosModule.swift +4 -1
- package/ios/vendor/Advenue/Advenue.swift +53 -18
- package/ios/vendor/Advenue/AdvenueConfig.swift +17 -3
- package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
- package/ios/vendor/Advenue/DebugLog.swift +46 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
- package/ios/vendor/AdvenueCore/Engine.swift +58 -4
- package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
- package/package.json +18 -9
- package/src/index.ts +11 -1
- package/src/types.ts +13 -3
|
@@ -129,14 +129,11 @@ public enum Advenue {
|
|
|
129
129
|
/// is empty or a backoff window is open.
|
|
130
130
|
public static func flush() { state.submit(.flush) }
|
|
131
131
|
|
|
132
|
-
public static func notifyForeground() { state.
|
|
132
|
+
public static func notifyForeground() { state.notifyForeground() }
|
|
133
133
|
|
|
134
134
|
/// Backgrounding both closes the session and flushes: a batch stranded at the
|
|
135
135
|
/// moment the app leaves the foreground may not be sent for hours.
|
|
136
|
-
public static func notifyBackground() {
|
|
137
|
-
state.submit(.background)
|
|
138
|
-
state.submit(.flush)
|
|
139
|
-
}
|
|
136
|
+
public static func notifyBackground() { state.notifyBackground() }
|
|
140
137
|
|
|
141
138
|
/// Presents the ATT prompt. The app decides when; the SDK never prompts on
|
|
142
139
|
/// its own.
|
|
@@ -167,10 +164,14 @@ final class AcceptedAppId: @unchecked Sendable {
|
|
|
167
164
|
return value
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
|
|
167
|
+
/// True the first time this app id is seen.
|
|
168
|
+
@discardableResult
|
|
169
|
+
func set(_ appId: String) -> Bool {
|
|
171
170
|
lock.lock()
|
|
171
|
+
defer { lock.unlock() }
|
|
172
|
+
let changed = value != appId
|
|
172
173
|
value = appId
|
|
173
|
-
|
|
174
|
+
return changed
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
@@ -209,17 +210,21 @@ final class FacadeState: @unchecked Sendable {
|
|
|
209
210
|
transport: (any EventTransport)? = nil,
|
|
210
211
|
sources: EnrichmentSources? = nil,
|
|
211
212
|
skan skanReporter: (any SkanReporter)? = nil,
|
|
212
|
-
appVersion readVersion: () -> String? = readAppVersion
|
|
213
|
+
appVersion readVersion: () -> String? = readAppVersion,
|
|
214
|
+
launchedInBackground: () -> Bool = readLaunchedInBackground,
|
|
215
|
+
logSink: (any AdvenueLogSink)? = nil
|
|
213
216
|
) {
|
|
214
217
|
// Replace-and-shut-down, never add.
|
|
215
218
|
stop()
|
|
216
219
|
|
|
220
|
+
// `debug`: every swallowed failure goes through onError, so logging there
|
|
221
|
+
// covers all of them — wrapped once, before anything below captures it.
|
|
222
|
+
let (config, log) = withDebugLogging(config, sink: logSink)
|
|
223
|
+
|
|
217
224
|
// The SDK resolves the app version itself (readAppVersion); a config value
|
|
218
225
|
// is ignored — and said so, rather than looking like it took effect.
|
|
219
226
|
if config.appVersion != nil {
|
|
220
|
-
config.onError(
|
|
221
|
-
"config.ignored:appVersion",
|
|
222
|
-
IngestError(status: 0))
|
|
227
|
+
config.onError("config.ignored:appVersion", AdvenueSetupError.appVersionIgnored)
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
// B4: kuyruk blob'u Caches dosyalarına (yedek dışı); UserDefaults'ta
|
|
@@ -230,7 +235,7 @@ final class FacadeState: @unchecked Sendable {
|
|
|
230
235
|
store = CompositeStore(files: files)
|
|
231
236
|
} else {
|
|
232
237
|
store = UserDefaultsStore()
|
|
233
|
-
config.onError("store.cache_unavailable",
|
|
238
|
+
config.onError("store.cache_unavailable", AdvenueSetupError.cacheUnavailable)
|
|
234
239
|
}
|
|
235
240
|
let secure = KeychainStore()
|
|
236
241
|
let uuid = SystemUUIDs()
|
|
@@ -240,7 +245,7 @@ final class FacadeState: @unchecked Sendable {
|
|
|
240
245
|
// Deferred: the Keychain could not be read. Nothing is minted and
|
|
241
246
|
// nothing starts, so no event carries an invented identifier. The next
|
|
242
247
|
// launch after first unlock resolves it.
|
|
243
|
-
config.onError("identity.deferred",
|
|
248
|
+
config.onError("identity.deferred", AdvenueSetupError.identityDeferred)
|
|
244
249
|
return
|
|
245
250
|
}
|
|
246
251
|
|
|
@@ -251,14 +256,19 @@ final class FacadeState: @unchecked Sendable {
|
|
|
251
256
|
osVersion = nil
|
|
252
257
|
#endif
|
|
253
258
|
|
|
254
|
-
let
|
|
259
|
+
let baseTransport =
|
|
255
260
|
transport
|
|
256
261
|
?? HttpTransport(
|
|
257
262
|
endpoint: config.endpoint, apiKey: config.apiKey,
|
|
258
263
|
signingSecret: config.signingSecret,
|
|
259
264
|
// Diagnostics only: it runs after the batch is already accepted, so
|
|
260
265
|
// nothing it does can turn a successful ingest into a failure.
|
|
261
|
-
onAccepted: { [acceptedAppId] appId in
|
|
266
|
+
onAccepted: { [acceptedAppId] appId in
|
|
267
|
+
// A key pasted from the wrong app is otherwise silent.
|
|
268
|
+
if acceptedAppId.set(appId) { log("[Advenue] ingesting into app \(appId)") }
|
|
269
|
+
})
|
|
270
|
+
let eventTransport: any EventTransport =
|
|
271
|
+
config.debug ? LoggingTransport(inner: baseTransport, log: log) : baseTransport
|
|
262
272
|
|
|
263
273
|
let engine = AdvenueEngine(
|
|
264
274
|
config: EngineConfig(
|
|
@@ -267,7 +277,7 @@ final class FacadeState: @unchecked Sendable {
|
|
|
267
277
|
osVersion: osVersion,
|
|
268
278
|
sdkVersion: String((config.sdkVersion ?? AdvenueVersion.current).prefix(32)),
|
|
269
279
|
requireConsent: config.requireConsent, sessionWindowMs: config.sessionWindowMs,
|
|
270
|
-
batchSize: config.batchSize),
|
|
280
|
+
batchSize: config.batchSize, piiScrubEnabled: config.piiScrubEnabled),
|
|
271
281
|
store: store, clock: SystemClock(), scheduler: TimerScheduler(), uuid: uuid,
|
|
272
282
|
transport: eventTransport,
|
|
273
283
|
onError: config.onError)
|
|
@@ -330,13 +340,32 @@ final class FacadeState: @unchecked Sendable {
|
|
|
330
340
|
pendingDeepLinks = []
|
|
331
341
|
lock.unlock()
|
|
332
342
|
|
|
343
|
+
// Before anything else is recorded: a launch that still owes its install
|
|
344
|
+
// stamps it now and holds every flush until the install is enqueued, so
|
|
345
|
+
// the server never sees this launch's session or events ahead of it. The
|
|
346
|
+
// hold outlives the enrichment deadline by a margin and then lapses.
|
|
347
|
+
pipe.submit(.beginInstall(holdMs: INSTALL_WINDOW_MS + INSTALL_HOLD_MARGIN_MS))
|
|
348
|
+
|
|
333
349
|
// Replayed in arrival order, before the first session, so a deferred deep
|
|
334
350
|
// link is attributed to the launch it belongs to.
|
|
335
351
|
for url in buffered { send(url) }
|
|
336
|
-
|
|
352
|
+
// A launch into the background (fetch, silent push, location) is not a
|
|
353
|
+
// session: the first didBecomeActive opens it instead. iOS posts no
|
|
354
|
+
// didEnterBackground for an app that never left the background, so a
|
|
355
|
+
// tracker seeded as foregrounded would swallow the user's real open.
|
|
356
|
+
let inForeground = !launchedInBackground()
|
|
357
|
+
if inForeground { pipe.submit(.foreground) }
|
|
337
358
|
// Seeded to match: the line above IS this launch's foreground, so the
|
|
338
359
|
// activation notification that follows must not open a second session.
|
|
339
|
-
observeLifecycle(seededInForeground:
|
|
360
|
+
observeLifecycle(seededInForeground: inForeground)
|
|
361
|
+
|
|
362
|
+
log(
|
|
363
|
+
"[Advenue] initialized — endpoint \(config.endpoint), "
|
|
364
|
+
+ "sdk \(config.sdkVersion ?? AdvenueVersion.current), "
|
|
365
|
+
// Not "background launch": a scene-based (SwiftUI) app is still
|
|
366
|
+
// reported in the background during didFinishLaunching even when the
|
|
367
|
+
// user opened it. Either way the session opens on activation.
|
|
368
|
+
+ (inForeground ? "launched active" : "not active yet, the session opens on activation"))
|
|
340
369
|
|
|
341
370
|
if config.flushIntervalMs > 0 {
|
|
342
371
|
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
@@ -593,6 +622,12 @@ final class FacadeState: @unchecked Sendable {
|
|
|
593
622
|
#endif
|
|
594
623
|
}
|
|
595
624
|
|
|
625
|
+
/// The public lifecycle calls go through the same tracker as UIKit's signals,
|
|
626
|
+
/// so one forwarded while already foregrounded (or already backgrounded) is a
|
|
627
|
+
/// no-op instead of a session split.
|
|
628
|
+
func notifyForeground() { handle(.didBecomeActive) }
|
|
629
|
+
func notifyBackground() { handle(.didEnterBackground) }
|
|
630
|
+
|
|
596
631
|
/// Applies one lifecycle signal. Public routing lives here rather than in the
|
|
597
632
|
/// observer closure so a test can drive it without UIKit.
|
|
598
633
|
func handle(_ signal: LifecycleSignal) {
|
|
@@ -8,7 +8,7 @@ public struct AdvenueConfig: Sendable {
|
|
|
8
8
|
public var appVersion: String?
|
|
9
9
|
public var requireConsent: Bool
|
|
10
10
|
public var sessionWindowMs: Int64
|
|
11
|
-
/// Events per request. Matches
|
|
11
|
+
/// Events per request. Matches the other SDKs.
|
|
12
12
|
public var batchSize: Int
|
|
13
13
|
/// Auto-flush period. Zero disables the timer, which is what tests want and
|
|
14
14
|
/// no shipping app does.
|
|
@@ -29,6 +29,16 @@ public struct AdvenueConfig: Sendable {
|
|
|
29
29
|
/// Called when the SDK swallows a best-effort failure. Never receives PII.
|
|
30
30
|
public var onError: @Sendable (String, any Error) -> Void
|
|
31
31
|
|
|
32
|
+
/// Development aid: logs the SDK's start, every failure `onError` sees and
|
|
33
|
+
/// each accepted batch to the unified log (subsystem `io.advenue.sdk`) —
|
|
34
|
+
/// Xcode's console and Console.app. Never logs an identifier or a payload.
|
|
35
|
+
/// Leave off in production; route failures through `onError` instead.
|
|
36
|
+
public var debug: Bool
|
|
37
|
+
|
|
38
|
+
/// B1: track/install properties PII scrub. On by default; disable only
|
|
39
|
+
/// explicitly (documented risk — raw PII reaches ingest). Same as Android.
|
|
40
|
+
public var piiScrubEnabled: Bool
|
|
41
|
+
|
|
32
42
|
/// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
|
|
33
43
|
/// by an app.
|
|
34
44
|
///
|
|
@@ -61,7 +71,9 @@ public struct AdvenueConfig: Sendable {
|
|
|
61
71
|
conversionValues: ConversionValueConfig? = nil,
|
|
62
72
|
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
|
|
63
73
|
sdkVersion: String? = nil,
|
|
64
|
-
allowInsecureHttp: Bool = false
|
|
74
|
+
allowInsecureHttp: Bool = false,
|
|
75
|
+
debug: Bool = false,
|
|
76
|
+
piiScrubEnabled: Bool = true
|
|
65
77
|
) {
|
|
66
78
|
precondition(
|
|
67
79
|
allowInsecureHttp || Self.isSecureEndpoint(endpoint),
|
|
@@ -70,6 +82,7 @@ public struct AdvenueConfig: Sendable {
|
|
|
70
82
|
self.endpoint = endpoint
|
|
71
83
|
self.appVersion = appVersion
|
|
72
84
|
self.requireConsent = requireConsent
|
|
85
|
+
self.debug = debug
|
|
73
86
|
self.sessionWindowMs = sessionWindowMs
|
|
74
87
|
self.batchSize = batchSize
|
|
75
88
|
self.flushIntervalMs = flushIntervalMs
|
|
@@ -77,6 +90,7 @@ public struct AdvenueConfig: Sendable {
|
|
|
77
90
|
self.conversionValues = conversionValues
|
|
78
91
|
self.onError = onError
|
|
79
92
|
self.sdkVersion = sdkVersion
|
|
93
|
+
self.piiScrubEnabled = piiScrubEnabled
|
|
80
94
|
}
|
|
81
95
|
}
|
|
82
96
|
|
|
@@ -85,5 +99,5 @@ public struct AdvenueConfig: Sendable {
|
|
|
85
99
|
/// release tag unless something checks. CI does, because the first question
|
|
86
100
|
/// every field report raises is which build produced the event.
|
|
87
101
|
public enum AdvenueVersion {
|
|
88
|
-
public static let current = "1.
|
|
102
|
+
public static let current = "1.1.0"
|
|
89
103
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// A condition met while starting the SDK, reported through `onError` under
|
|
2
|
+
/// the context in parentheses. Not a transport failure — these used to arrive
|
|
3
|
+
/// as `IngestError(status: 0)`, which read as an HTTP error that never
|
|
4
|
+
/// happened and named no cause.
|
|
5
|
+
public enum AdvenueSetupError: Error, Equatable, Sendable, CustomStringConvertible {
|
|
6
|
+
/// `config.ignored:appVersion`
|
|
7
|
+
case appVersionIgnored
|
|
8
|
+
/// `store.cache_unavailable`
|
|
9
|
+
case cacheUnavailable
|
|
10
|
+
/// `identity.deferred`
|
|
11
|
+
case identityDeferred
|
|
12
|
+
|
|
13
|
+
public var description: String {
|
|
14
|
+
switch self {
|
|
15
|
+
case .appVersionIgnored:
|
|
16
|
+
return "appVersion is read from the bundle; the config value has no effect"
|
|
17
|
+
case .cacheUnavailable:
|
|
18
|
+
return "the Caches directory is unavailable; the event queue falls back to UserDefaults"
|
|
19
|
+
case .identityDeferred:
|
|
20
|
+
return "the Keychain is unavailable (not unlocked since boot, or the app is not "
|
|
21
|
+
+ "code-signed); nothing starts until the next launch that can read it"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
/// Where `AdvenueConfig.debug` writes. The unified logging system in an app;
|
|
5
|
+
/// a recorder in tests.
|
|
6
|
+
protocol AdvenueLogSink: Sendable {
|
|
7
|
+
func log(_ message: String)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// `os.Logger`, public privacy: the lines carry failure contexts, counts and
|
|
11
|
+
/// the app id the server resolved — never an identifier or a payload — and a
|
|
12
|
+
/// debug aid that Console.app shows as `<private>` would not be one.
|
|
13
|
+
struct UnifiedLogSink: AdvenueLogSink {
|
|
14
|
+
private let logger = Logger(subsystem: "io.advenue.sdk", category: "Advenue")
|
|
15
|
+
func log(_ message: String) {
|
|
16
|
+
logger.notice("\(message, privacy: .public)")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Logs each accepted batch; failures already reach the log through the
|
|
21
|
+
/// wrapped `onError` (`flush.transport`, `flush.poison`).
|
|
22
|
+
struct LoggingTransport: EventTransport {
|
|
23
|
+
let inner: any EventTransport
|
|
24
|
+
let log: @Sendable (String) -> Void
|
|
25
|
+
|
|
26
|
+
func send(_ events: [ClientEvent]) async throws {
|
|
27
|
+
try await inner.send(events)
|
|
28
|
+
log("[Advenue] sent \(events.count) event(s)")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// With `debug` on: the config with `onError` also writing to the log, and the
|
|
33
|
+
/// log itself. Off: the config unchanged and a log that discards.
|
|
34
|
+
func withDebugLogging(
|
|
35
|
+
_ config: AdvenueConfig, sink: (any AdvenueLogSink)?
|
|
36
|
+
) -> (AdvenueConfig, @Sendable (String) -> Void) {
|
|
37
|
+
guard config.debug else { return (config, { _ in }) }
|
|
38
|
+
let sink = sink ?? UnifiedLogSink()
|
|
39
|
+
var logged = config
|
|
40
|
+
let report = config.onError
|
|
41
|
+
logged.onError = { context, error in
|
|
42
|
+
sink.log("[Advenue] \(context): \(error)")
|
|
43
|
+
report(context, error)
|
|
44
|
+
}
|
|
45
|
+
return (logged, { sink.log($0) })
|
|
46
|
+
}
|
|
@@ -3,9 +3,23 @@ import Foundation
|
|
|
3
3
|
/// A non-2xx ingest response. `isRetryable` separates a transient failure from
|
|
4
4
|
/// a poison payload: retrying a 400 forever would block the queue head, and
|
|
5
5
|
/// dropping a 429 would discard events over a throttle.
|
|
6
|
-
public struct IngestError: Error, Equatable, Sendable {
|
|
6
|
+
public struct IngestError: Error, Equatable, Sendable, CustomStringConvertible {
|
|
7
7
|
public let status: Int
|
|
8
|
-
|
|
8
|
+
/// Set when no response arrived at all — DNS, refused connection, timeout,
|
|
9
|
+
/// no network. `status` is then 408 so the backoff retries it, but that 408
|
|
10
|
+
/// was never sent by a server, and logging it as one sends whoever debugs it
|
|
11
|
+
/// looking at the wrong end (F-SDK-10).
|
|
12
|
+
public let networkCause: String?
|
|
13
|
+
|
|
14
|
+
public init(status: Int, networkCause: String? = nil) {
|
|
15
|
+
self.status = status
|
|
16
|
+
self.networkCause = networkCause
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public var description: String {
|
|
20
|
+
if let networkCause { return "IngestError: no response (network error: \(networkCause))" }
|
|
21
|
+
return "IngestError(status: \(status))"
|
|
22
|
+
}
|
|
9
23
|
|
|
10
24
|
public var isRetryable: Bool {
|
|
11
25
|
status == 408 || status == 429 || status >= 500
|
|
@@ -94,6 +94,14 @@ public actor AdvenueEngine {
|
|
|
94
94
|
attestation: AttestationResult?, challenge: String?)?
|
|
95
95
|
private var skan: SkanStateMachine?
|
|
96
96
|
private var skanReporter: (any SkanReporter)?
|
|
97
|
+
/// F-SDK-3: when this launch began an install that is not recorded yet.
|
|
98
|
+
/// The install is stamped with it — the first open, not the moment the
|
|
99
|
+
/// enrichment that preceded it finished.
|
|
100
|
+
private var installStartedAtMs: Int64?
|
|
101
|
+
/// Flushes are held until the install is enqueued, so nothing reaches the
|
|
102
|
+
/// server ahead of it. Bounded: past this instant the hold lapses, because a
|
|
103
|
+
/// lost enrichment task must never strand the queue.
|
|
104
|
+
private var installHoldUntilMs: Int64?
|
|
97
105
|
private var flushing = false
|
|
98
106
|
private var consecutiveFailures = 0
|
|
99
107
|
private var backoffUntilMs: Int64 = 0
|
|
@@ -159,6 +167,9 @@ public actor AdvenueEngine {
|
|
|
159
167
|
}
|
|
160
168
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(properties) : properties
|
|
161
169
|
queue.enqueue(event)
|
|
170
|
+
// An event is proof of life: a relaunch after an OS kill measures the gap
|
|
171
|
+
// from the last one (F-SDK-7).
|
|
172
|
+
sessions.heartbeat()
|
|
162
173
|
return true
|
|
163
174
|
}
|
|
164
175
|
|
|
@@ -186,6 +197,30 @@ public actor AdvenueEngine {
|
|
|
186
197
|
appInstanceId = id
|
|
187
198
|
}
|
|
188
199
|
|
|
200
|
+
/// Marks the start of a launch that still owes its install. Called by the
|
|
201
|
+
/// platform layer BEFORE the launch's foreground, while enrichment (ATT,
|
|
202
|
+
/// AdServices, attestation) runs. Adjust and AppsFlyer both hold every later
|
|
203
|
+
/// package behind the first one; this does the same, for at most `holdMs`.
|
|
204
|
+
public func beginInstall(holdMs: Int) {
|
|
205
|
+
guard !forgotten, store.string(forKey: INSTALL_SENT_KEY) != "1" else { return }
|
|
206
|
+
let now = clock.nowMs()
|
|
207
|
+
installStartedAtMs = now
|
|
208
|
+
installHoldUntilMs = now + Int64(holdMs)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// Whether a pending install still holds flushes back.
|
|
212
|
+
private func heldForInstall() -> Bool {
|
|
213
|
+
guard let until = installHoldUntilMs else { return false }
|
|
214
|
+
if clock.nowMs() < until { return true }
|
|
215
|
+
installHoldUntilMs = nil
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private func releaseInstallHold() {
|
|
220
|
+
installStartedAtMs = nil
|
|
221
|
+
installHoldUntilMs = nil
|
|
222
|
+
}
|
|
223
|
+
|
|
189
224
|
/// The first-open event, at most once per installation. Returns whether it
|
|
190
225
|
/// was recorded.
|
|
191
226
|
///
|
|
@@ -202,13 +237,21 @@ public actor AdvenueEngine {
|
|
|
202
237
|
if forgotten { return false }
|
|
203
238
|
if config.requireConsent && !consent {
|
|
204
239
|
installDeferred = (adservicesToken, properties, attestation, attestationChallenge)
|
|
240
|
+
// Deferred possibly for good: the events consent does allow must not
|
|
241
|
+
// wait on it. Released on consent, the install is stamped then.
|
|
242
|
+
releaseInstallHold()
|
|
205
243
|
return false
|
|
206
244
|
}
|
|
207
|
-
if store.string(forKey: INSTALL_SENT_KEY) == "1" {
|
|
245
|
+
if store.string(forKey: INSTALL_SENT_KEY) == "1" {
|
|
246
|
+
releaseInstallHold()
|
|
247
|
+
return false
|
|
248
|
+
}
|
|
249
|
+
let installedAtMs = installStartedAtMs ?? clock.nowMs()
|
|
250
|
+
releaseInstallHold()
|
|
208
251
|
|
|
209
252
|
var event = ClientEvent(
|
|
210
253
|
id: uuid.next(), deviceId: config.deviceId, type: "install", name: "install",
|
|
211
|
-
timestamp: EventEncoding.iso8601(ms:
|
|
254
|
+
timestamp: EventEncoding.iso8601(ms: installedAtMs), platform: config.platform)
|
|
212
255
|
event.installationId = config.installationId
|
|
213
256
|
event.appVersion = config.appVersion
|
|
214
257
|
event.osVersion = config.osVersion
|
|
@@ -241,7 +284,9 @@ public actor AdvenueEngine {
|
|
|
241
284
|
merged = properties
|
|
242
285
|
}
|
|
243
286
|
event.properties = config.piiScrubEnabled ? PIIScrub.scrub(merged) : merged
|
|
244
|
-
|
|
287
|
+
// At the head: the session and custom events recorded while enrichment ran
|
|
288
|
+
// are already queued, and the install must reach the server before them.
|
|
289
|
+
queue.enqueueFirst(event)
|
|
245
290
|
|
|
246
291
|
store.set("1", forKey: INSTALL_SENT_KEY)
|
|
247
292
|
return true
|
|
@@ -377,6 +422,7 @@ public actor AdvenueEngine {
|
|
|
377
422
|
/// behind, which is an erasure that does not erase (spec §5).
|
|
378
423
|
public func forgetMe() {
|
|
379
424
|
forgotten = true
|
|
425
|
+
releaseInstallHold()
|
|
380
426
|
queue.clear()
|
|
381
427
|
sessions.reset()
|
|
382
428
|
consent = false
|
|
@@ -421,8 +467,13 @@ public actor AdvenueEngine {
|
|
|
421
467
|
/// throws — a timer-driven call is unawaited, so a transient failure simply
|
|
422
468
|
/// leaves the batch buffered for the next attempt.
|
|
423
469
|
public func flush() async {
|
|
470
|
+
// The auto-flush timer is the foreground tick: it keeps the open
|
|
471
|
+
// sub-session's last activity fresh even when the app records nothing.
|
|
472
|
+
if !forgotten { sessions.heartbeat() }
|
|
424
473
|
guard let transport else { return }
|
|
425
|
-
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs
|
|
474
|
+
if flushing || queue.size == 0 || clock.nowMs() < backoffUntilMs || heldForInstall() {
|
|
475
|
+
return
|
|
476
|
+
}
|
|
426
477
|
flushing = true
|
|
427
478
|
defer { flushing = false }
|
|
428
479
|
|
|
@@ -506,6 +557,7 @@ public enum Command: Sendable {
|
|
|
506
557
|
reporter: any SkanReporter, configVersion: Int)
|
|
507
558
|
case trackInstall(
|
|
508
559
|
adservicesToken: String?, attestation: AttestationResult?, attestationChallenge: String?)
|
|
560
|
+
case beginInstall(holdMs: Int)
|
|
509
561
|
}
|
|
510
562
|
|
|
511
563
|
/// The ordered ingress: a synchronous, non-blocking `submit` feeding one
|
|
@@ -567,6 +619,8 @@ public final class CommandPipe: @unchecked Sendable {
|
|
|
567
619
|
limitAdTracking: limitAdTracking)
|
|
568
620
|
case .setAppInstanceId(let id):
|
|
569
621
|
await engine.setAppInstanceId(id)
|
|
622
|
+
case .beginInstall(let holdMs):
|
|
623
|
+
await engine.beginInstall(holdMs: holdMs)
|
|
570
624
|
case .trackInstall(let token, let attestation, let challenge):
|
|
571
625
|
await engine.trackInstall(
|
|
572
626
|
adservicesToken: token, attestation: attestation, attestationChallenge: challenge)
|
|
@@ -49,6 +49,18 @@ public final class EventQueue {
|
|
|
49
49
|
schedulePersist()
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/// Puts an event at the HEAD of the queue — the install, which is the first
|
|
53
|
+
/// package of an installation and must not be preceded by the session and
|
|
54
|
+
/// custom events recorded while enrichment ran. Over the cap, the oldest
|
|
55
|
+
/// events behind it are dropped, never the event just placed.
|
|
56
|
+
public func enqueueFirst(_ event: ClientEvent) {
|
|
57
|
+
events.insert(event, at: 0)
|
|
58
|
+
if events.count > maxSize {
|
|
59
|
+
events.removeSubrange(1..<(1 + events.count - maxSize))
|
|
60
|
+
}
|
|
61
|
+
schedulePersist()
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
public func peek(_ max: Int) -> [ClientEvent] {
|
|
53
65
|
Array(events.prefix(max))
|
|
54
66
|
}
|
|
@@ -2,6 +2,9 @@ import Foundation
|
|
|
2
2
|
|
|
3
3
|
public let SESSION_STATE_KEY = "advenue.session"
|
|
4
4
|
public let DEFAULT_SESSION_WINDOW_MS: Int64 = 1_800_000 // 30 minutes
|
|
5
|
+
/// The heartbeat persists at most this often: it runs on every recorded event,
|
|
6
|
+
/// and a chatty app must not turn that into a storage write per event.
|
|
7
|
+
public let SESSION_HEARTBEAT_MIN_INTERVAL_MS: Int64 = 1_000
|
|
5
8
|
|
|
6
9
|
/// A session lifecycle event. Values are numbers, strings and bools only.
|
|
7
10
|
public struct SessionEvent: Equatable, Sendable {
|
|
@@ -20,6 +23,10 @@ struct SessionState: Codable {
|
|
|
20
23
|
var activeStart: Int64?
|
|
21
24
|
var firstForegroundAt: Int64
|
|
22
25
|
var timeSpentMs: Int64
|
|
26
|
+
/// The last moment the open sub-session was known to be alive (F-SDK-7).
|
|
27
|
+
/// Absent in state written before it existed, which reads as "alive at
|
|
28
|
+
/// activeStart".
|
|
29
|
+
var lastActiveAt: Int64?
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
/// Platform-agnostic session state machine. Pure: it persists state and
|
|
@@ -30,6 +37,11 @@ public final class SessionTracker {
|
|
|
30
37
|
private let clock: Clock
|
|
31
38
|
private let windowMs: Int64
|
|
32
39
|
private let uuid: UUIDSource
|
|
40
|
+
/// True once THIS process opened the current sub-session (X-SDK-1). Never
|
|
41
|
+
/// persisted: a relaunched process starts false, so its tracks and flushes
|
|
42
|
+
/// before `handleForeground()` cannot keep the dead process's sub-session
|
|
43
|
+
/// alive and move its kill time to the relaunch.
|
|
44
|
+
private var ownsOpenSubSession = false
|
|
33
45
|
|
|
34
46
|
public init(store: KeyValueStore, clock: Clock, windowMs: Int64, uuid: UUIDSource) {
|
|
35
47
|
self.store = store
|
|
@@ -40,7 +52,10 @@ public final class SessionTracker {
|
|
|
40
52
|
|
|
41
53
|
/// Drops the in-memory cache so a wiped store cannot be resurrected by a
|
|
42
54
|
/// later lifecycle call (GDPR erasure).
|
|
43
|
-
public func reset() {
|
|
55
|
+
public func reset() {
|
|
56
|
+
cached = nil
|
|
57
|
+
ownsOpenSubSession = false
|
|
58
|
+
}
|
|
44
59
|
|
|
45
60
|
private func load() -> SessionState? {
|
|
46
61
|
if let cached { return cached }
|
|
@@ -58,14 +73,67 @@ public final class SessionTracker {
|
|
|
58
73
|
store.set(String(decoding: data, as: UTF8.self), forKey: SESSION_STATE_KEY)
|
|
59
74
|
}
|
|
60
75
|
|
|
61
|
-
///
|
|
62
|
-
///
|
|
63
|
-
///
|
|
64
|
-
///
|
|
65
|
-
///
|
|
76
|
+
/// Refreshes the open sub-session's last known activity. Called by the
|
|
77
|
+
/// engine on every recorded event and every flush — the auto-flush timer is
|
|
78
|
+
/// the foreground tick. It is what a relaunch after an OS kill measures the
|
|
79
|
+
/// gap from, the role Adjust's `lastActivity` plays.
|
|
80
|
+
///
|
|
81
|
+
/// A no-op until this process has run `handleForeground()`: only the process
|
|
82
|
+
/// that opened the sub-session may extend it. Otherwise an event or flush in a
|
|
83
|
+
/// relaunched process (iOS `didFinishLaunching` runs before
|
|
84
|
+
/// `didBecomeActive`; push and background wakes) would stamp the killed
|
|
85
|
+
/// sub-session alive "now" and count the dead time as active (X-SDK-1).
|
|
86
|
+
public func heartbeat() {
|
|
87
|
+
guard ownsOpenSubSession else { return }
|
|
88
|
+
guard var state = load(), let activeStart = state.activeStart else { return }
|
|
89
|
+
let now = clock.nowMs()
|
|
90
|
+
if now - (state.lastActiveAt ?? activeStart) < SESSION_HEARTBEAT_MIN_INTERVAL_MS { return }
|
|
91
|
+
state.lastActiveAt = now
|
|
92
|
+
save(state)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Foreground transition, including cold start. Returns the events to emit,
|
|
96
|
+
/// in order:
|
|
97
|
+
///
|
|
98
|
+
/// - a synthetic `session_end` first when a sub-session was still open — the
|
|
99
|
+
/// OS killed the app before it could background. It is closed at its last
|
|
100
|
+
/// heartbeat, not at the relaunch: the time the app was dead is not active
|
|
101
|
+
/// time. A consumer must never see two sessions open, so it comes first.
|
|
102
|
+
/// - then, measured from the background (or that kill time): nothing for a
|
|
103
|
+
/// sub-session inside the window, or a `session_start` for a new session.
|
|
104
|
+
///
|
|
105
|
+
/// A kill is not a session boundary by itself (F-SDK-7). Adjust's rule — a
|
|
106
|
+
/// new session only after the session interval of inactivity — applies to a
|
|
107
|
+
/// relaunch exactly as it does to a return from the background.
|
|
66
108
|
public func handleForeground() -> [SessionEvent] {
|
|
67
|
-
let state = load()
|
|
68
109
|
let now = clock.nowMs()
|
|
110
|
+
guard var state = load(), let activeStart = state.activeStart else {
|
|
111
|
+
return resume(after: load(), now: now)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let killTime = min(now, max(activeStart, state.lastActiveAt ?? activeStart))
|
|
115
|
+
let activeMs = max(0, killTime - activeStart)
|
|
116
|
+
let timeSpentMs = state.timeSpentMs + activeMs
|
|
117
|
+
let end = SessionEvent(
|
|
118
|
+
name: "session_end",
|
|
119
|
+
properties: [
|
|
120
|
+
"sessionId": .string(state.sessionId),
|
|
121
|
+
"sessionNumber": .int(state.sessionNumber),
|
|
122
|
+
"subSession": .int(state.subSessionCount),
|
|
123
|
+
"activeMs": .int(Int(activeMs)),
|
|
124
|
+
"timeSpentMs": .int(Int(timeSpentMs)),
|
|
125
|
+
"sessionLengthMs": .int(Int(max(0, killTime - state.firstForegroundAt))),
|
|
126
|
+
"synthetic": .bool(true),
|
|
127
|
+
])
|
|
128
|
+
state.lastBackgroundAt = killTime
|
|
129
|
+
state.activeStart = nil
|
|
130
|
+
state.lastActiveAt = nil
|
|
131
|
+
state.timeSpentMs = timeSpentMs
|
|
132
|
+
return [end] + resume(after: state, now: now)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/// The foreground decision once no sub-session is open.
|
|
136
|
+
private func resume(after state: SessionState?, now: Int64) -> [SessionEvent] {
|
|
69
137
|
let gap: Int64? = state?.lastBackgroundAt.map { now - $0 }
|
|
70
138
|
|
|
71
139
|
if state == nil || gap == nil || gap! >= windowMs {
|
|
@@ -75,40 +143,27 @@ public final class SessionTracker {
|
|
|
75
143
|
SessionState(
|
|
76
144
|
sessionId: sessionId, sessionNumber: sessionNumber, lastBackgroundAt: nil,
|
|
77
145
|
subSessionCount: 1, activeStart: now, firstForegroundAt: now, timeSpentMs: 0))
|
|
146
|
+
ownsOpenSubSession = true
|
|
78
147
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"sessionId": .string(sessionId),
|
|
83
|
-
"sessionNumber": .int(sessionNumber),
|
|
84
|
-
"subSession": .int(1),
|
|
85
|
-
"isFirstSession": .bool(sessionNumber == 1),
|
|
86
|
-
"timeSinceLastSessionMs": .int(Int(gap ?? 0)),
|
|
87
|
-
])
|
|
88
|
-
|
|
89
|
-
if let prior = state, let activeStart = prior.activeStart {
|
|
90
|
-
let killTime = prior.lastBackgroundAt ?? now
|
|
91
|
-
let activeMs = max(0, killTime - activeStart)
|
|
92
|
-
let end = SessionEvent(
|
|
93
|
-
name: "session_end",
|
|
148
|
+
return [
|
|
149
|
+
SessionEvent(
|
|
150
|
+
name: "session_start",
|
|
94
151
|
properties: [
|
|
95
|
-
"sessionId": .string(
|
|
96
|
-
"sessionNumber": .int(
|
|
97
|
-
"subSession": .int(
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"sessionLengthMs": .int(Int(max(0, killTime - prior.firstForegroundAt))),
|
|
101
|
-
"synthetic": .bool(true),
|
|
152
|
+
"sessionId": .string(sessionId),
|
|
153
|
+
"sessionNumber": .int(sessionNumber),
|
|
154
|
+
"subSession": .int(1),
|
|
155
|
+
"isFirstSession": .bool(sessionNumber == 1),
|
|
156
|
+
"timeSinceLastSessionMs": .int(Int(gap ?? 0)),
|
|
102
157
|
])
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
return [start]
|
|
158
|
+
]
|
|
106
159
|
}
|
|
107
160
|
|
|
108
161
|
var updated = state!
|
|
109
162
|
updated.subSessionCount += 1
|
|
110
163
|
updated.activeStart = now
|
|
164
|
+
updated.lastActiveAt = nil
|
|
111
165
|
save(updated)
|
|
166
|
+
ownsOpenSubSession = true
|
|
112
167
|
return []
|
|
113
168
|
}
|
|
114
169
|
|
|
@@ -123,8 +178,10 @@ public final class SessionTracker {
|
|
|
123
178
|
let timeSpentMs = state.timeSpentMs + activeMs
|
|
124
179
|
state.lastBackgroundAt = now
|
|
125
180
|
state.activeStart = nil
|
|
181
|
+
state.lastActiveAt = nil
|
|
126
182
|
state.timeSpentMs = timeSpentMs
|
|
127
183
|
save(state)
|
|
184
|
+
ownsOpenSubSession = false
|
|
128
185
|
return SessionEvent(
|
|
129
186
|
name: "session_end",
|
|
130
187
|
properties: [
|