@scalebun/react-native 1.2.0 → 1.2.2-beta.0

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.
@@ -6,6 +6,7 @@ import android.os.Build
6
6
  import android.os.Bundle
7
7
  import android.os.Process
8
8
  import android.os.SystemClock
9
+ import android.util.Log
9
10
 
10
11
  /**
11
12
  * Captures app start timing using Android-native hooks.
@@ -23,10 +24,24 @@ import android.os.SystemClock
23
24
  */
24
25
  class AppStartCollector(private val app: Application) {
25
26
 
27
+ companion object {
28
+ private const val TAG = "ScaleBunPerformance"
29
+ /** Ignore an inferred cold-start proxy longer than this (stale/warm process). */
30
+ private const val MAX_INFERRED_DURATION_MS = 60_000L
31
+ }
32
+
26
33
  data class AppStartInfo(
27
34
  val launchType: String, // "cold" | "warm"
28
35
  val durationMs: Long,
29
- val breakdown: Breakdown?
36
+ val breakdown: Breakdown?,
37
+ /**
38
+ * true when the value was NOT captured from Activity lifecycle callbacks
39
+ * but inferred from process-start → query time. Happens on the common RN
40
+ * path where the SDK's native init runs (JS-driven) only after the launch
41
+ * activity has already resumed, so onActivityResumed is never observed.
42
+ * Without this fallback getInfo() returns null and no app_launch is emitted.
43
+ */
44
+ val inferred: Boolean = false
30
45
  )
31
46
 
32
47
  data class Breakdown(
@@ -86,9 +101,11 @@ class AppStartCollector(private val app: Application) {
86
101
  processStartMs = null, // Already at process start
87
102
  activityCreateMs = createDuration,
88
103
  firstFrameMs = resumeDuration
89
- )
104
+ ),
105
+ inferred = false
90
106
  )
91
107
 
108
+ Log.i(TAG, "app start captured from lifecycle: durationMs=$totalDuration")
92
109
  callback?.invoke(info)
93
110
  stop()
94
111
  }
@@ -113,20 +130,51 @@ class AppStartCollector(private val app: Application) {
113
130
  /** Check if app start has been captured */
114
131
  fun isCaptured(): Boolean = captured
115
132
 
116
- /** Get the captured info for on-demand queries */
133
+ /**
134
+ * Get app start info for on-demand queries.
135
+ *
136
+ * Prefers the precise value captured from Activity lifecycle callbacks. When
137
+ * capture never happened — the common RN case, because native init is driven
138
+ * from JS and runs only AFTER the launch activity resumed — falls back to an
139
+ * inferred duration from process start to now. Returning null here (the old
140
+ * behaviour) is exactly what caused App start = 0: AppLaunchCollector.collect()
141
+ * bails on a null/invalid duration, so no app_launch event was ever emitted.
142
+ */
117
143
  fun getInfo(): AppStartInfo? {
118
- if (!captured) return null
119
- val totalDuration = activityResumeUptimeMs - processStartUptimeMs
144
+ if (captured) {
145
+ val totalDuration = activityResumeUptimeMs - processStartUptimeMs
146
+ return AppStartInfo(
147
+ launchType = "cold",
148
+ durationMs = totalDuration,
149
+ breakdown = Breakdown(
150
+ processStartMs = null,
151
+ activityCreateMs = if (activityCreateUptimeMs > 0)
152
+ activityCreateUptimeMs - processStartUptimeMs else null,
153
+ firstFrameMs = if (activityCreateUptimeMs > 0)
154
+ activityResumeUptimeMs - activityCreateUptimeMs else null
155
+ ),
156
+ inferred = false
157
+ )
158
+ }
159
+
160
+ // Fallback: infer from process start → now. Guards against a bad clock
161
+ // (non-positive) and against a long-lived process where this proxy would
162
+ // be meaningless (e.g. queried minutes after a warm start).
163
+ if (processStartUptimeMs <= 0) {
164
+ Log.w(TAG, "app start not captured and no process-start clock — returning null")
165
+ return null
166
+ }
167
+ val inferredDuration = SystemClock.uptimeMillis() - processStartUptimeMs
168
+ if (inferredDuration <= 0 || inferredDuration > MAX_INFERRED_DURATION_MS) {
169
+ Log.w(TAG, "app start not captured; inferred duration out of range ($inferredDuration ms) — returning null")
170
+ return null
171
+ }
172
+ Log.i(TAG, "app start not captured from lifecycle; inferred durationMs=$inferredDuration")
120
173
  return AppStartInfo(
121
174
  launchType = "cold",
122
- durationMs = totalDuration,
123
- breakdown = Breakdown(
124
- processStartMs = null,
125
- activityCreateMs = if (activityCreateUptimeMs > 0)
126
- activityCreateUptimeMs - processStartUptimeMs else null,
127
- firstFrameMs = if (activityCreateUptimeMs > 0)
128
- activityResumeUptimeMs - activityCreateUptimeMs else null
129
- )
175
+ durationMs = inferredDuration,
176
+ breakdown = null,
177
+ inferred = true
130
178
  )
131
179
  }
132
180
  }
@@ -1,6 +1,7 @@
1
1
  package com.scalebun.rn.performance
2
2
 
3
3
  import android.app.Application
4
+ import android.util.Log
4
5
  import com.facebook.react.bridge.*
5
6
  import com.facebook.react.modules.core.DeviceEventManagerModule
6
7
  import com.scalebun.core.performance.AppStartCollector
@@ -33,6 +34,7 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
33
34
 
34
35
  companion object {
35
36
  const val NAME = "ScaleBunPerformance"
37
+ private const val TAG = "ScaleBunPerformance"
36
38
  }
37
39
 
38
40
  override fun getName(): String = NAME
@@ -65,6 +67,7 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
65
67
  putDouble("maxFrameTimeMs", snapshot.maxFrameTimeMs)
66
68
  putDouble("windowMs", snapshot.windowMs.toDouble())
67
69
  }
70
+ Log.i(TAG, "emit FrameMetrics: totalFrames=${snapshot.totalFrames} dropped=${snapshot.droppedFrames}")
68
71
  emit("ScaleBunPerformance_FrameMetrics", params)
69
72
  }
70
73
 
@@ -73,6 +76,7 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
73
76
  putDouble("durationMs", info.durationMs.toDouble())
74
77
  putDouble("timestamp", info.timestamp.toDouble())
75
78
  }
79
+ Log.i(TAG, "emit UiHang: durationMs=${info.durationMs}")
76
80
  emit("ScaleBunPerformance_UiHang", params)
77
81
  }
78
82
  }
@@ -83,12 +87,14 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
83
87
  override fun initialize(promise: Promise) {
84
88
  try {
85
89
  if (core?.isInitialized == true) {
90
+ Log.i(TAG, "initialize() — already initialized, no-op")
86
91
  promise.resolve(true)
87
92
  return
88
93
  }
89
94
 
90
95
  val app = reactApplicationContext.applicationContext as? Application
91
96
  if (app == null) {
97
+ Log.w(TAG, "initialize() — Application context not available")
92
98
  promise.reject("PERF_INIT_ERROR", "Application context not available")
93
99
  return
94
100
  }
@@ -98,8 +104,10 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
98
104
  performanceCore.initialize()
99
105
  core = performanceCore
100
106
 
107
+ Log.i(TAG, "initialize() — native performance core started")
101
108
  promise.resolve(true)
102
109
  } catch (e: Exception) {
110
+ Log.w(TAG, "initialize() — failed: ${e.message}")
103
111
  promise.reject("PERF_INIT_ERROR", e.message, e)
104
112
  }
105
113
  }
@@ -111,9 +119,11 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
111
119
  try {
112
120
  val info = core?.getAppStartInfo()
113
121
  if (info != null) {
122
+ Log.i(TAG, "getAppStartInfo() — launchType=${info.launchType} durationMs=${info.durationMs} inferred=${info.inferred}")
114
123
  val result = Arguments.createMap().apply {
115
124
  putString("launchType", info.launchType)
116
125
  putDouble("durationMs", info.durationMs.toDouble())
126
+ putBoolean("inferred", info.inferred)
117
127
  info.breakdown?.let { breakdown ->
118
128
  val breakdownMap = Arguments.createMap().apply {
119
129
  breakdown.processStartMs?.let { putDouble("processStartMs", it.toDouble()) }
@@ -125,6 +135,7 @@ class PerformanceModule(reactContext: ReactApplicationContext) : PerformanceSpec
125
135
  }
126
136
  promise.resolve(result)
127
137
  } else {
138
+ Log.w(TAG, "getAppStartInfo() — no info available (core=${if (core == null) "null" else "present"})")
128
139
  promise.resolve(null)
129
140
  }
130
141
  } catch (e: Exception) {
@@ -8369,7 +8369,31 @@ var init_PerformanceFeature = __esm({
8369
8369
  static CAPTURE_MAX_RETRIES = 3;
8370
8370
  /** Delay schedule for each attempt (ms) — escalating to let views settle */
8371
8371
  static CAPTURE_DELAYS = [300, 600, 1200];
8372
- // Track event function from SDK pipeline
8372
+ // ─── Early-metric buffering (backend) ───────────────────────────────
8373
+ //
8374
+ // Performance metrics reach the cloud via the SessionManager's backend
8375
+ // transport. But the first metrics — most importantly app_launch — fire
8376
+ // during PerformanceFeature.initialize() (appLaunchCollector.collect()),
8377
+ // which runs from featureRegistry.initializeAll() BEFORE the bootstrap
8378
+ // attaches the backend transport (SDKBootstrapper). In that window
8379
+ // getBackendTransport() is null, so without buffering the metric is sent
8380
+ // only to the desktop debugger and silently dropped from the cloud
8381
+ // ("App start = 0" in Insights). We buffer such metrics locally and drain
8382
+ // them the moment the transport appears (via subsequent events or a
8383
+ // bounded retry). The backend adapter itself buffers again until the
8384
+ // session row is ready, so ordering across the session boundary is safe.
8385
+ /** Metrics captured before the backend transport was available. */
8386
+ _pendingBackendMetrics = [];
8387
+ /** Retry timer that drains the buffer once the transport attaches. */
8388
+ _backendDrainTimer = null;
8389
+ /** Number of drain attempts made for the current buffered batch. */
8390
+ _backendDrainAttempts = 0;
8391
+ /** Hard cap on buffered metrics — bounds memory if a transport never attaches (non-SaaS). */
8392
+ static BACKEND_BUFFER_MAX = 64;
8393
+ /** Max drain retries before giving up (non-SaaS mode never attaches a transport). */
8394
+ static BACKEND_DRAIN_MAX_ATTEMPTS = 6;
8395
+ /** Escalating retry delays (ms); last value repeats. Covers the startup attach race. */
8396
+ static BACKEND_DRAIN_DELAYS = [250, 500, 1e3, 2e3, 4e3];
8373
8397
  constructor(config) {
8374
8398
  this.config = mergePerformanceConfig(config);
8375
8399
  this.transport = new PerformanceTransport(this.config);
@@ -8504,6 +8528,9 @@ var init_PerformanceFeature = __esm({
8504
8528
  clearTimeout(this._captureTimer);
8505
8529
  this._captureTimer = null;
8506
8530
  }
8531
+ this._clearBackendDrainTimer();
8532
+ this._backendDrainAttempts = 0;
8533
+ this._pendingBackendMetrics = [];
8507
8534
  }
8508
8535
  get isActive() {
8509
8536
  return this.active;
@@ -8687,24 +8714,83 @@ var init_PerformanceFeature = __esm({
8687
8714
  // ─── Internal ───────────────────────────────────────────────────────
8688
8715
  _handleEvent(event) {
8689
8716
  this.transport.sendEvent(event);
8717
+ this._forwardMetricToBackend(this._toBackendMetric(event));
8718
+ }
8719
+ /** Map a raw perf event onto the backend metric envelope. */
8720
+ _toBackendMetric(event) {
8721
+ const e = event;
8722
+ const metadata = {
8723
+ id: event.id
8724
+ };
8725
+ if (event.type === "app_launch") {
8726
+ if (e.launchType) metadata.launchType = e.launchType;
8727
+ if (e.breakdown) metadata.breakdown = e.breakdown;
8728
+ if (e.phaseLabels) metadata.phaseLabels = e.phaseLabels;
8729
+ }
8730
+ return {
8731
+ type: event.type,
8732
+ screenName: e.screenName ?? e.context?.screenKey,
8733
+ duration: e.durationMs,
8734
+ value: e.estimatedFps ?? e.value,
8735
+ metadata
8736
+ };
8737
+ }
8738
+ /**
8739
+ * Hand a metric to the backend transport, or buffer it until one attaches.
8740
+ * Every call first drains anything buffered, so a late-arriving transport
8741
+ * flushes the backlog in the order the metrics were produced.
8742
+ */
8743
+ _forwardMetricToBackend(metric) {
8690
8744
  const backendTransport = SessionManager.getExistingInstance()?.getBackendTransport();
8691
8745
  if (backendTransport) {
8692
- const e = event;
8693
- const metadata = {
8694
- id: event.id
8695
- };
8696
- if (event.type === "app_launch") {
8697
- if (e.launchType) metadata.launchType = e.launchType;
8698
- if (e.breakdown) metadata.breakdown = e.breakdown;
8699
- if (e.phaseLabels) metadata.phaseLabels = e.phaseLabels;
8700
- }
8701
- backendTransport.queuePerformanceMetric({
8702
- type: event.type,
8703
- screenName: e.screenName ?? e.context?.screenKey,
8704
- duration: e.durationMs,
8705
- value: e.estimatedFps ?? e.value,
8706
- metadata
8707
- });
8746
+ this._drainPendingBackendMetrics(backendTransport);
8747
+ backendTransport.queuePerformanceMetric(metric);
8748
+ return;
8749
+ }
8750
+ this._pendingBackendMetrics.push(metric);
8751
+ if (this._pendingBackendMetrics.length > _PerformanceFeature.BACKEND_BUFFER_MAX) {
8752
+ this._pendingBackendMetrics.shift();
8753
+ }
8754
+ this._scheduleBackendDrain();
8755
+ }
8756
+ /** Flush all buffered metrics into the transport, preserving order. */
8757
+ _drainPendingBackendMetrics(backendTransport) {
8758
+ if (this._pendingBackendMetrics.length === 0) return;
8759
+ const buffered = this._pendingBackendMetrics;
8760
+ this._pendingBackendMetrics = [];
8761
+ for (const metric of buffered) {
8762
+ backendTransport.queuePerformanceMetric(metric);
8763
+ }
8764
+ this._clearBackendDrainTimer();
8765
+ this._backendDrainAttempts = 0;
8766
+ }
8767
+ /**
8768
+ * Poll for the backend transport a bounded number of times so the sole
8769
+ * early metric (app_launch) still reaches the cloud even when no later
8770
+ * event arrives to trigger a drain. Gives up after BACKEND_DRAIN_MAX_ATTEMPTS
8771
+ * (non-SaaS mode never attaches a transport — the desktop path already has
8772
+ * the data), leaving the bounded buffer to be GC'd on teardown.
8773
+ */
8774
+ _scheduleBackendDrain() {
8775
+ if (this._backendDrainTimer) return;
8776
+ if (this._backendDrainAttempts >= _PerformanceFeature.BACKEND_DRAIN_MAX_ATTEMPTS) return;
8777
+ const delays = _PerformanceFeature.BACKEND_DRAIN_DELAYS;
8778
+ const delay = delays[Math.min(this._backendDrainAttempts, delays.length - 1)];
8779
+ this._backendDrainAttempts++;
8780
+ this._backendDrainTimer = setTimeout(() => {
8781
+ this._backendDrainTimer = null;
8782
+ const backendTransport = SessionManager.getExistingInstance()?.getBackendTransport();
8783
+ if (backendTransport) {
8784
+ this._drainPendingBackendMetrics(backendTransport);
8785
+ } else if (this._pendingBackendMetrics.length > 0) {
8786
+ this._scheduleBackendDrain();
8787
+ }
8788
+ }, delay);
8789
+ }
8790
+ _clearBackendDrainTimer() {
8791
+ if (this._backendDrainTimer) {
8792
+ clearTimeout(this._backendDrainTimer);
8793
+ this._backendDrainTimer = null;
8708
8794
  }
8709
8795
  }
8710
8796
  };
@@ -15663,12 +15749,23 @@ var BackendSessionAdapter = class _BackendSessionAdapter {
15663
15749
  this._triggerNativeFlush();
15664
15750
  }
15665
15751
  queuePerformanceMetric(data) {
15666
- if (this.destroyed || !this.config.clientKey) return;
15752
+ if (this.destroyed || !this.config.clientKey) {
15753
+ logger.warn("[perf.queue] dropped \u2014 no clientKey or adapter destroyed", {
15754
+ type: data.type,
15755
+ destroyed: this.destroyed,
15756
+ hasClientKey: !!this.config.clientKey
15757
+ });
15758
+ return;
15759
+ }
15667
15760
  this.pendingPerformanceMetrics.push({
15668
15761
  ...data,
15669
15762
  clientId: this._clientId(),
15670
15763
  timestamp: (/* @__PURE__ */ new Date()).toISOString()
15671
15764
  });
15765
+ logger.debug("[perf.queue] enqueued performance metric", {
15766
+ type: data.type,
15767
+ pending: this.pendingPerformanceMetrics.length
15768
+ });
15672
15769
  if (this.pendingPerformanceMetrics.length >= 20) {
15673
15770
  this._flushPerformance().catch(() => {
15674
15771
  });
@@ -16235,19 +16332,43 @@ var BackendSessionAdapter = class _BackendSessionAdapter {
16235
16332
  }
16236
16333
  }
16237
16334
  async _flushPerformance() {
16238
- if (this.pendingPerformanceMetrics.length === 0 || !this.currentSessionId || !this.config.clientKey) return;
16335
+ if (this.pendingPerformanceMetrics.length === 0) return;
16336
+ if (!this.currentSessionId || !this.config.clientKey) {
16337
+ logger.debug("[perf.flush] waiting \u2014 not deliverable yet", {
16338
+ pending: this.pendingPerformanceMetrics.length,
16339
+ hasSession: !!this.currentSessionId,
16340
+ hasClientKey: !!this.config.clientKey
16341
+ });
16342
+ return;
16343
+ }
16239
16344
  const batch = this.pendingPerformanceMetrics.splice(0, 50);
16240
16345
  try {
16241
- if (await this._enqueueNative("performance", batch)) return;
16346
+ if (await this._enqueueNative("performance", batch)) {
16347
+ logger.debug("[perf.flush] handed to native outbox", {
16348
+ count: batch.length
16349
+ });
16350
+ return;
16351
+ }
16242
16352
  if (!this._sessionRowReady()) {
16353
+ logger.debug("[perf.flush] session row not ready \u2014 re-buffering", {
16354
+ count: batch.length
16355
+ });
16243
16356
  this.pendingPerformanceMetrics.unshift(...batch);
16244
16357
  return;
16245
16358
  }
16246
16359
  await this._post(ENDPOINTS.INGESTION_PERFORMANCE(this.currentSessionId), {
16247
16360
  metrics: batch
16248
16361
  });
16362
+ logger.info("[perf.flush] POST performance metrics ok", {
16363
+ count: batch.length
16364
+ });
16249
16365
  } catch (err) {
16250
- if (!_BackendSessionAdapter._isPermanentHttpError(err)) this.pendingPerformanceMetrics.unshift(...batch);
16366
+ const permanent = _BackendSessionAdapter._isPermanentHttpError(err);
16367
+ logger.warn("[perf.flush] POST performance metrics failed", {
16368
+ count: batch.length,
16369
+ permanent
16370
+ });
16371
+ if (!permanent) this.pendingPerformanceMetrics.unshift(...batch);
16251
16372
  }
16252
16373
  }
16253
16374
  async _flushLogs() {