sitepong 0.2.11 → 0.2.13

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.
@@ -121,10 +121,12 @@ public class SitePongScreenRecorderModule: Module {
121
121
  let sampleRate = config["sampleRate"] as? Double ?? 0.1
122
122
  // RN host stamps platform "react-native-ios" (§1.1/§4.4).
123
123
  let platform = config["platform"] as? String ?? "react-native-ios"
124
+ // Idle grace before a reopen rolls a new session (default 30s).
125
+ let sessionGraceMs = config["sessionGraceMs"] as? Double ?? 30_000
124
126
  guard let endpoint = URL(string: endpointStr) else { return }
125
127
  Watchtower.start(apiKey: apiKey, projectId: projectId,
126
128
  endpoint: endpoint, sampleRate: sampleRate,
127
- platform: platform)
129
+ platform: platform, sessionGraceMs: sessionGraceMs)
128
130
  }
129
131
 
130
132
  Function("watchtowerStop") {
@@ -14,6 +14,16 @@ public struct CaptureEvent: Codable {
14
14
  public var distinct_id: String?
15
15
  public var device_model: String?
16
16
  public var os_version: String?
17
+ /// Persistent per-install id (identifierForVendor) — stamped on every event
18
+ /// so the dashboard can stitch consecutive sessions from the same install
19
+ /// into a journey at read time, even for anonymous users.
20
+ public var install_id: String?
21
+
22
+ // session_start (only on a session that opened after a background→foreground
23
+ // gap): the id of the session this one continues from, and the idle gap in
24
+ // milliseconds. These are the read-time linking hints.
25
+ public var previous_session_id: String?
26
+ public var session_gap_ms: UInt64?
17
27
 
18
28
  // identity traits — only populated on an "identify" event (Watchtower.setUser
19
29
  // with email/name), so PII is sent once per identify, not on every tap.
@@ -13,18 +13,18 @@ public enum Watchtower {
13
13
  /// identity knob — capture logic is identical regardless of platform.
14
14
  public static func start(apiKey: String, projectId: String,
15
15
  endpoint: URL, sampleRate: Double = 0.1,
16
- platform: String = "ios") {
16
+ platform: String = "ios", sessionGraceMs: Double = 30_000) {
17
17
  #if canImport(UIKit)
18
18
  // Ensure UIKit work happens on the main thread.
19
19
  if Thread.isMainThread {
20
20
  WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
21
21
  endpoint: endpoint, sampleRate: sampleRate,
22
- platform: platform)
22
+ platform: platform, sessionGraceMs: sessionGraceMs)
23
23
  } else {
24
24
  DispatchQueue.main.async {
25
25
  WatchtowerEngine.shared.start(apiKey: apiKey, projectId: projectId,
26
26
  endpoint: endpoint, sampleRate: sampleRate,
27
- platform: platform)
27
+ platform: platform, sessionGraceMs: sessionGraceMs)
28
28
  }
29
29
  }
30
30
  #endif
@@ -37,6 +37,22 @@ final class WatchtowerEngine {
37
37
  /// overrides to "react-native-ios" via start(platform:).
38
38
  private var platform: String = "ios"
39
39
 
40
+ /// Persistent per-install id (identifierForVendor), resolved once at start
41
+ /// and stamped on every event so the dashboard can stitch consecutive
42
+ /// sessions from the same install into a journey at read time.
43
+ private var installId: String?
44
+ /// Wall-clock of the last background transition (nil until first background).
45
+ /// Drives the reopen-is-a-new-session decision on the next foreground.
46
+ private var lastBackgroundedAt: Date?
47
+ /// Idle grace: a foreground within this window of the last background resumes
48
+ /// the SAME session (a momentary app-switch shouldn't fragment); beyond it, a
49
+ /// fresh session is rolled. Default 30s; overridable via start(sessionGraceMs:).
50
+ private var sessionGraceMs: Double = 30_000
51
+ /// On a rolled session these carry the prior session id + idle gap (ms); they
52
+ /// ride the new session's session_start, then clear.
53
+ private var pendingPreviousSessionId: String?
54
+ private var pendingSessionGapMs: UInt64?
55
+
40
56
  /// Explicit screen identity override via setScreen (§3.2). Cleared/overridden
41
57
  /// by viewDidAppear unless re-set.
42
58
  private var explicitScreenName: String?
@@ -67,13 +83,18 @@ final class WatchtowerEngine {
67
83
  // MARK: - Lifecycle
68
84
 
69
85
  func start(apiKey: String, projectId: String, endpoint: URL, sampleRate: Double,
70
- platform: String = "ios") {
86
+ platform: String = "ios", sessionGraceMs: Double = 30_000) {
71
87
  guard !isRunning else { return }
72
88
  self.transport = Transport(apiKey: apiKey, projectId: projectId, endpoint: endpoint)
73
89
  self.sessionId = UUID().uuidString
74
90
  self.sequence = 0
75
91
  self.sampleRate = sampleRate
76
92
  self.platform = platform
93
+ self.sessionGraceMs = sessionGraceMs
94
+ self.installId = UIDevice.current.identifierForVendor?.uuidString
95
+ self.lastBackgroundedAt = nil
96
+ self.pendingPreviousSessionId = nil
97
+ self.pendingSessionGapMs = nil
77
98
  self.uploadedScreenFps.removeAll()
78
99
  self.inFlightScreenFps.removeAll()
79
100
  self.fpCache.removeAll()
@@ -130,16 +151,29 @@ final class WatchtowerEngine {
130
151
  /// per-session sequence.
131
152
  private func baseEvent(type: String) -> CaptureEvent {
132
153
  sequence += 1
133
- return CaptureEvent(
154
+ var event = CaptureEvent(
134
155
  type: type, session_id: sessionId, sequence: sequence,
135
156
  timestamp: ISO8601DateFormatter.wt.string(from: Date()),
136
157
  platform: platform, app_version: appVersion,
137
158
  distinct_id: distinctId, device_model: deviceModel,
138
159
  os_version: osVersion
139
160
  )
161
+ event.install_id = installId
162
+ // Link hints ride only the session_start of a rolled session.
163
+ if type == "session_start" {
164
+ event.previous_session_id = pendingPreviousSessionId
165
+ event.session_gap_ms = pendingSessionGapMs
166
+ }
167
+ return event
140
168
  }
141
169
 
170
+ /// Diagnostic seam: when set, receives every emitted event before it is
171
+ /// enqueued for transport. nil in production; used by tests to assert the
172
+ /// session lifecycle + linking fields without a network round-trip.
173
+ var debugOnEmit: ((CaptureEvent) -> Void)?
174
+
142
175
  private func emit(_ event: CaptureEvent) {
176
+ debugOnEmit?(event)
143
177
  transport?.enqueue(event)
144
178
  }
145
179
 
@@ -165,14 +199,20 @@ final class WatchtowerEngine {
165
199
  NotificationCenter.default.addObserver(
166
200
  self, selector: #selector(appDidEnterBackground),
167
201
  name: UIApplication.didEnterBackgroundNotification, object: nil)
202
+ NotificationCenter.default.addObserver(
203
+ self, selector: #selector(appWillEnterForeground),
204
+ name: UIApplication.willEnterForegroundNotification, object: nil)
168
205
  }
169
206
 
170
207
  @objc private func appDidEnterBackground() {
171
208
  // Pending dead-checks can't complete in the background — flush them
172
- // undetermined (dead omitted), then mark the session boundary. The
173
- // session does NOT end here (§1.1): reason "background" lets the
174
- // server close it on timeout if the app never returns.
209
+ // undetermined (dead omitted), then mark the (soft) session boundary and
210
+ // record when we backgrounded so the next foreground can decide whether
211
+ // this was a momentary switch (resume) or a real reopen (roll). The
212
+ // session does NOT end here: reason "background" lets the server close it
213
+ // on timeout if the app never returns.
175
214
  flushPendingTaps()
215
+ lastBackgroundedAt = Date()
176
216
  var end = baseEvent(type: "session_end")
177
217
  end.reason = "background"
178
218
  end.duration_ms = durationMs()
@@ -180,6 +220,42 @@ final class WatchtowerEngine {
180
220
  transport?.flush()
181
221
  }
182
222
 
223
+ /// A reopen. If we were backgrounded longer than the idle grace, the old
224
+ /// session is done — roll a fresh one (linked to the old via prev id + gap).
225
+ /// Within the grace it was just an app-switch, so the same session resumes.
226
+ @objc private func appWillEnterForeground() {
227
+ guard isRunning, let backgroundedAt = lastBackgroundedAt else { return }
228
+ let gapMs = Date().timeIntervalSince(backgroundedAt) * 1000
229
+ lastBackgroundedAt = nil
230
+ guard gapMs > sessionGraceMs else { return }
231
+ rollSession(gapMs: UInt64(max(0, gapMs)))
232
+ }
233
+
234
+ /// Open a brand-new session on the same install. Mirrors start()'s
235
+ /// per-session reset but keeps transport, identity, device, and install id.
236
+ /// The prior session already emitted a session_end (reason "background") when
237
+ /// it backgrounded, so we only open the new one here.
238
+ private func rollSession(gapMs: UInt64) {
239
+ pendingPreviousSessionId = sessionId
240
+ pendingSessionGapMs = gapMs
241
+ sessionId = UUID().uuidString
242
+ sequence = 0
243
+ sessionStartedAt = Date()
244
+ isSampledSession = Double.random(in: 0..<1) < sampleRate
245
+ uploadedScreenFps.removeAll()
246
+ inFlightScreenFps.removeAll()
247
+ fpCache.removeAll()
248
+ lastCaptureAt.removeAll()
249
+ capturesInFlight.removeAll()
250
+ lastFrameHash = nil
251
+ lastEmittedScreenName = nil
252
+ navCounter = 0
253
+ pendingTaps.removeAll()
254
+ emit(baseEvent(type: "session_start"))
255
+ pendingPreviousSessionId = nil
256
+ pendingSessionGapMs = nil
257
+ }
258
+
183
259
  // MARK: - Screen views (§1.1 screen_view)
184
260
 
185
261
  /// Single choke point for screen identity changes — called with the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sitepong",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Official SitePong SDK for error tracking and monitoring",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",