@dynatrace/react-native-plugin 2.341.1 → 2.343.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.
Files changed (39) hide show
  1. package/README.md +37 -9
  2. package/android/build.gradle +11 -2
  3. package/android/src/main/java/com/dynatrace/android/agent/DynatraceAppStartModule.kt +9 -31
  4. package/android/src/main/java/com/dynatrace/android/agent/DynatraceConfigurationModule.kt +1 -0
  5. package/android/src/main/java/com/dynatrace/android/agent/DynatraceRNBridgeImpl.kt +41 -0
  6. package/android/src/main/java/com/dynatrace/android/agent/DynatraceUtils.kt +1 -0
  7. package/android/src/main/java/com/dynatrace/android/agent/ScreenshotSelfMonitor.kt +175 -0
  8. package/android/src/main/java/com/dynatrace/android/agent/UIChangeScreenshotListener.kt +124 -0
  9. package/android/src/new/java/com/dynatrace/android/agent/DynatraceRNBridge.kt +33 -0
  10. package/android/src/old/java/com/dynatrace/android/agent/DynatraceRNBridge.kt +40 -0
  11. package/files/plugin.gradle +1 -1
  12. package/instrumentation/libs/UserInteraction.js +1 -1
  13. package/ios/DTXScreenshotSelfMonitor.h +29 -0
  14. package/ios/DTXScreenshotSelfMonitor.mm +213 -0
  15. package/ios/DTXScreenshotSelfMonitorSwizzler.h +15 -0
  16. package/ios/DTXScreenshotSelfMonitorSwizzler.mm +68 -0
  17. package/ios/DynatraceRNBridge.h +6 -0
  18. package/ios/DynatraceRNBridge.mm +53 -0
  19. package/lib/core/Dynatrace.js +2 -0
  20. package/lib/core/logging/LogMessages.js +42 -0
  21. package/lib/features/ui-interaction/IUserInteractionEvent.js +1 -1
  22. package/lib/features/ui-interaction/Runtime.js +1 -1
  23. package/lib/next/Dynatrace.js +26 -1
  24. package/lib/next/events/EventPipeline.js +48 -31
  25. package/lib/next/events/HttpRequestEventData.js +1 -1
  26. package/lib/next/events/spec/EventSpecContstants.js +1 -1
  27. package/lib/next/userAction/NullUserAction.js +15 -0
  28. package/lib/next/userAction/UserAction.js +2 -0
  29. package/lib/next/userAction/UserActionConfiguration.js +10 -0
  30. package/lib/next/userAction/UserActionImpl.js +70 -0
  31. package/lib/next/util/TraceContextUtils.js +8 -14
  32. package/lib/next/util/Utils.js +13 -0
  33. package/package.json +7 -3
  34. package/public.js +3 -1
  35. package/react-native-dynatrace.podspec +10 -3
  36. package/scripts/Config.js +17 -3
  37. package/scripts/core/InstrumentCall.js +12 -4
  38. package/src/lib/core/interface/NativeDynatraceBridge.ts +34 -1
  39. package/types.d.ts +29 -2
package/README.md CHANGED
@@ -23,20 +23,20 @@ If you want to start using this plugin and are not a Dynatrace customer yet, hea
23
23
  * React v16.8 or newer
24
24
  * React Native v0.60 or newer
25
25
  * For Android users:
26
- * SDK version 21+
27
- * Gradle version 7.0+ ([How to update?](#updating-to-gradle-7))
26
+ * SDK version 23+
27
+ * Gradle version 8.1.1+ ([How to update?](#updating-to-gradle-7))
28
28
  * Android Gradle plugin version 7.0+
29
- * Java 11
29
+ * Java 17
30
30
  * Kotlin 2.0.21 - see [Kotlin Compatibility Note](#kotlin-compatibility-note)
31
31
  * Jetpack Compose 1.4 - 1.10 - see [Compose Compatibility Note](#compose-compatibility-note)
32
- * For iOS users: Minimum iOS 12
32
+ * For iOS users: Minimum iOS 15
33
33
  * NodeJS 16.0.0+ since our dependencies require NodeJS 16.0.0
34
34
 
35
35
  ## Agent Versions
36
36
  These agent versions are configured in this plugin:
37
37
 
38
- * Android Agent: 8.341.1.1004
39
- * iOS Agent: 8.341.1.1010
38
+ * Android Agent: 8.343.1.1038
39
+ * iOS Agent: 8.343.1.1007
40
40
 
41
41
  ## Quick Setup
42
42
 
@@ -1049,7 +1049,7 @@ If masking is active for an interaction, the event still contains the same struc
1049
1049
  "ui_element.components": ["Pressable"],
1050
1050
  "ui_element.id": "App/View/Pressable",
1051
1051
  "ui_element.name_origin": "component",
1052
- "interaction.name": "touch",
1052
+ "interaction.type": "touch",
1053
1053
  "positions": [{ "x": 120, "y": 460 }],
1054
1054
  "ui_element.responder.detected_name": "Pressable",
1055
1055
  "ui_element.responder.components": ["Pressable"],
@@ -1160,6 +1160,7 @@ npx instrumentDynatrace [optional: config=... gradle=... plist=...]
1160
1160
  * `gradle=C:\MyReactAndroidProject\build.gradle`: The location of the root build.gradle file. We will assume that the other gradle file resides in `/app/build.gradle`. This will add the whole agent dependencies automatically for you and will update the configuration.
1161
1161
  * `plist=C:\MyReactIOSProject\projectName\info.plist`: Tell the script where your info.plist file is. The plist file is used for updating the configuration for the agent.
1162
1162
  * `config=C:\SpecialFolderForDynatrace\dynatrace.config.js`: If you have not got your config file in the root folder of the React Native project but somewhere else.
1163
+ * `--reset`: Clear any previously-persisted custom config path and revert the Metro/Babel transformer to the default `dynatrace.config.js` lookup. Useful after switching between build flavors.
1163
1164
 
1164
1165
  If `plist=...` is not provided, the plugin tries to resolve `Info.plist` automatically in this order:
1165
1166
 
@@ -1188,6 +1189,22 @@ Our scripts assumes that the usual React Native project structure is given. The
1188
1189
  * `plist=C:\MyReactIOSProject\projectName\info.plist`: Tell the script where your info.plist file is. The plist file is used for updating the configuration for the agent.
1189
1190
  * `config=C:\SpecialFolderForDynatrace\dynatrace.config.js`: If you have not got your config file in the root folder of the React Native project but somewhere else.
1190
1191
 
1192
+ ### DYNATRACE_CONFIG environment variable
1193
+
1194
+ As an alternative to `config=`, you can set the `DYNATRACE_CONFIG` environment variable to specify the config file path. This is particularly useful in CI pipelines and Xcode schemes where multiple build flavors each need their own config file, because it does not require changing the instrumentation script — only the environment of the build step.
1195
+
1196
+ ```
1197
+ DYNATRACE_CONFIG=dynatrace.config.prod.js npx instrumentDynatrace
1198
+ ```
1199
+
1200
+ **Precedence (highest to lowest):**
1201
+
1202
+ 1. `config=` CLI argument — always wins
1203
+ 2. `DYNATRACE_CONFIG` environment variable
1204
+ 3. Default `dynatrace.config.js` in the project root
1205
+
1206
+ > **Note:** `DYNATRACE_CONFIG` applies to the **Metro/Babel transformer** as well as `npx instrumentDynatrace`. This means you can set it in your Xcode scheme's environment variables to ensure the correct config is used during iOS archive/bundling, independently of what was passed to the instrumentation step.
1207
+
1191
1208
  Examples:
1192
1209
 
1193
1210
  * React Native v0.60.1 or newer:
@@ -2071,7 +2088,19 @@ If you are struggling with a problem, submit a support ticket to Dynatrace (supp
2071
2088
  <br/><br/>
2072
2089
  ## Changelog
2073
2090
 
2074
- In Progress
2091
+ 2.343.1
2092
+ * Updated Android (8.343.1.1038) & iOS Agent (8.343.1.1007)
2093
+ * Added User Actions feature
2094
+ * Raised minimum platform requirements for Android and iOS
2095
+ * Android API level: 21 → 23
2096
+ * Java: 11 → 17
2097
+ * AGP: 7.0+ → 8.1.1+
2098
+ * iOS SDK: 12 → 15
2099
+ * Fixed iOS archive/bundle crash (`ENOENT: no such file or directory`) in projects that use app-specific config files (e.g. `dynatrace.config.prod.js`) instead of the default `dynatrace.config.js` at the repo root. The config resolution code was unconditionally reading the config file before `require()`, causing a hard crash in the Metro/Babel transformer instead of a graceful "config not found" fallback. Discovered via external report from a monorepo setup with multiple build flavors.
2100
+ * Fixed custom config path (`config=` argument to `npx instrumentDynatrace`) being lost between build steps: previously, running `npx instrumentDynatrace` without arguments in a subsequent build step (e.g. a generic iOS build phase) would silently delete the persisted custom path, causing Metro bundler to fall back to the hardcoded `dynatrace.config.js` and crash. The custom path is now preserved until explicitly overridden.
2101
+ * Added `DYNATRACE_CONFIG` environment variable as an alternative way to specify the config file path for the Metro/Babel transformer.
2102
+ * Added `npx instrumentDynatrace --reset` flag to clear a previously-persisted custom config path and revert the Metro/Babel transformer to the default `dynatrace.config.js` lookup. Useful after switching between build flavors that each use a different config file.
2103
+ * Fixed incorrect `start_time` on Android app start events when `3rd_gen_enabled` is false. The `EventPipeline` was suppressing the `forwardAppStartEvent` timing-correction call along with all other 3rd-gen events, causing the native Dynatrace SDK to report its own stale process-creation timestamp instead of the actual JS bundle load time.
2075
2104
 
2076
2105
  2.341.1
2077
2106
  * Updated Android (8.341.1.1004) & iOS Agent (8.341.1.1010)
@@ -2084,7 +2113,6 @@ In Progress
2084
2113
  * Updated `@babel/runtime` to latest major version
2085
2114
  * Fixed security vulnerabilities in `ws` and `brace-expansion` dependencies
2086
2115
 
2087
-
2088
2116
  2.339.1
2089
2117
  * Updated Android (8.339.1.1004) & iOS Agent (8.339.1.1011)
2090
2118
  * Enabled User Interaction Feature by default
@@ -8,6 +8,14 @@ def isNewArchitectureEnabled() {
8
8
  return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
9
9
  }
10
10
 
11
+ // Build-time master switch for the screenshot self-monitoring (replay metrics) feature.
12
+ // Defaults to enabled; disable for a build with -PdynatraceReplayMetricsEnabled=false
13
+ // (or set dynatraceReplayMetricsEnabled=false in gradle.properties). When false, the monitor is
14
+ // never registered, regardless of the remote `replay_metrics_enabled` flag.
15
+ def isReplayMetricsBuildEnabled() {
16
+ return !project.hasProperty("dynatraceReplayMetricsEnabled") || project.dynatraceReplayMetricsEnabled == "true"
17
+ }
18
+
11
19
  buildscript {
12
20
  repositories {
13
21
  google()
@@ -43,12 +51,13 @@ android {
43
51
  }
44
52
 
45
53
  defaultConfig {
46
- minSdkVersion safeExtGet('minSdkVersion', 21)
54
+ minSdkVersion safeExtGet('minSdkVersion', 23)
47
55
  targetSdkVersion safeExtGet('targetSdkVersion', 31)
48
56
  versionCode 1
49
57
  versionName "1.0"
50
58
 
51
59
  buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
60
+ buildConfigField "boolean", "REPLAY_METRICS_ENABLED", isReplayMetricsBuildEnabled().toString()
52
61
  }
53
62
 
54
63
  lintOptions {
@@ -72,7 +81,7 @@ repositories {
72
81
  }
73
82
 
74
83
  dependencies {
75
- implementation 'com.dynatrace.agent:agent-android:8.341.1.1004'
84
+ implementation 'com.dynatrace.agent:agent-android:8.343.1.1038'
76
85
  implementation "com.facebook.react:react-native:${safeExtGet('reactNative', '+')}"
77
86
  }
78
87
 
@@ -1,6 +1,5 @@
1
1
  package com.dynatrace.android.agent
2
2
 
3
- import android.os.SystemClock
4
3
  import com.dynatrace.android.agent.model.AppStartMeasurement
5
4
  import com.dynatrace.android.agent.model.AppStartMeasurementType
6
5
  import com.facebook.react.bridge.Arguments
@@ -31,52 +30,31 @@ private val buffer: Queue<AppStartMeasurement> = ConcurrentLinkedQueue()
31
30
  * Setup to register all time points which we want to use to report correct app startup time.
32
31
  */
33
32
  fun setupAppStartListener() {
34
- val initTimestamp = System.currentTimeMillis() - SystemClock.elapsedRealtime();
35
33
  ReactMarker.addListener { name: ReactMarkerConstants?, _: String?, _: Int ->
36
34
  when (name) {
37
35
  ReactMarkerConstants.RELOAD -> {
38
36
  buffer.clear()
39
- buffer.add(
40
- AppStartMeasurement(
41
- AppStartMeasurementType.RELOAD.value,
42
- initTimestamp + SystemClock.elapsedRealtime()
43
- )
44
- )
37
+ buffer.add(AppStartMeasurement(AppStartMeasurementType.RELOAD.value, TimeLineProvider.getSystemTime()))
45
38
  }
46
39
 
47
- ReactMarkerConstants.CONTENT_APPEARED -> buffer.add(
48
- AppStartMeasurement(
49
- AppStartMeasurementType.CONTENT_APPEARED.value,
50
- initTimestamp + SystemClock.elapsedRealtime()
51
- )
52
- )
40
+ ReactMarkerConstants.CONTENT_APPEARED -> {
41
+ buffer.add(AppStartMeasurement(AppStartMeasurementType.CONTENT_APPEARED.value, TimeLineProvider.getSystemTime()))
42
+ }
53
43
 
54
44
  ReactMarkerConstants.DOWNLOAD_END -> buffer.add(
55
- AppStartMeasurement(
56
- AppStartMeasurementType.DOWNLOAD_END.value,
57
- initTimestamp + SystemClock.elapsedRealtime()
58
- )
45
+ AppStartMeasurement(AppStartMeasurementType.DOWNLOAD_END.value, TimeLineProvider.getSystemTime())
59
46
  )
60
47
 
61
48
  ReactMarkerConstants.DOWNLOAD_START -> buffer.add(
62
- AppStartMeasurement(
63
- AppStartMeasurementType.DOWNLOAD_START.value,
64
- initTimestamp + SystemClock.elapsedRealtime()
65
- )
49
+ AppStartMeasurement(AppStartMeasurementType.DOWNLOAD_START.value, TimeLineProvider.getSystemTime())
66
50
  )
67
51
 
68
52
  ReactMarkerConstants.RUN_JS_BUNDLE_END -> buffer.add(
69
- AppStartMeasurement(
70
- AppStartMeasurementType.RUN_JS_BUNDLE_END.value,
71
- initTimestamp + SystemClock.elapsedRealtime()
72
- )
53
+ AppStartMeasurement(AppStartMeasurementType.RUN_JS_BUNDLE_END.value, TimeLineProvider.getSystemTime())
73
54
  )
74
55
 
75
56
  ReactMarkerConstants.RUN_JS_BUNDLE_START -> buffer.add(
76
- AppStartMeasurement(
77
- AppStartMeasurementType.RUN_JS_BUNDLE_START.value,
78
- initTimestamp + SystemClock.elapsedRealtime()
79
- )
57
+ AppStartMeasurement(AppStartMeasurementType.RUN_JS_BUNDLE_START.value, TimeLineProvider.getSystemTime())
80
58
  )
81
59
 
82
60
  else -> {
@@ -126,4 +104,4 @@ internal class DynatraceAppStartModule(reactContext: ReactApplicationContext?) :
126
104
  override fun getName(): String {
127
105
  return APP_START_MODULE
128
106
  }
129
- }
107
+ }
@@ -25,6 +25,7 @@ internal class DynatraceConfigurationModule(reactContext: ReactApplicationContex
25
25
 
26
26
  init {
27
27
  setupInternalListener()
28
+ reactApplicationContext?.let { ScreenshotSelfMonitor.initialize(it) }
28
29
  }
29
30
 
30
31
  /**
@@ -308,6 +308,47 @@ class DynatraceRNBridgeImpl(
308
308
  HybridBridge.forwardAppStartEvent(JSONObject(DynatraceUtils.toHashMap(attributes)), appStartKeys.toArrayList() as ArrayList<String>)
309
309
  }
310
310
 
311
+ fun setAutomaticUserActionDetection(enabled: Boolean) {
312
+ HybridBridge.setAutomaticUserActionDetection(enabled)
313
+ }
314
+
315
+ fun createUserAction(
316
+ actionId: String,
317
+ name: String,
318
+ completeAutomatically: Boolean,
319
+ properties: ReadableMap?
320
+ ) {
321
+ val jsonProperties = properties?.let {
322
+ JSONObject(DynatraceUtils.toHashMap(it))
323
+ }
324
+ HybridBridge.createUserAction(
325
+ actionId,
326
+ name,
327
+ completeAutomatically,
328
+ jsonProperties
329
+ )
330
+ }
331
+
332
+ fun addEventStringPropertyToUserAction(actionId: String, key: String, value: String) {
333
+ HybridBridge.addEventPropertyToUserAction(actionId, key, value)
334
+ }
335
+
336
+ fun addEventDoublePropertyToUserAction(actionId: String, key: String, value: Double) {
337
+ HybridBridge.addEventPropertyToUserAction(actionId, key, value)
338
+ }
339
+
340
+ fun addEventBooleanPropertyToUserAction(actionId: String, key: String, value: Boolean) {
341
+ HybridBridge.addEventPropertyToUserAction(actionId, key, value)
342
+ }
343
+
344
+ fun completeUserAction(actionId: String) {
345
+ HybridBridge.completeUserAction(actionId)
346
+ }
347
+
348
+ fun setCompleteUserActionAutomatically(actionId: String, enabled: Boolean) {
349
+ HybridBridge.setCompleteUserActionAutomatically(actionId, enabled)
350
+ }
351
+
311
352
  fun startView(name: String) {
312
353
  Dynatrace.startView(name)
313
354
  }
@@ -1,6 +1,7 @@
1
1
  package com.dynatrace.android.agent
2
2
 
3
3
  import android.util.Log
4
+ import com.facebook.react.bridge.Dynamic
4
5
  import com.facebook.react.bridge.ReadableArray
5
6
  import com.facebook.react.bridge.ReadableMap
6
7
  import com.facebook.react.bridge.ReadableType
@@ -0,0 +1,175 @@
1
+ package com.dynatrace.android.agent
2
+
3
+ import android.util.Log
4
+ import com.dynatrace.android.agent.conf.ConfigurationSubscriber
5
+ import com.facebook.react.bridge.LifecycleEventListener
6
+ import com.facebook.react.bridge.ReactApplicationContext
7
+ import com.facebook.react.bridge.UiThreadUtil
8
+ import java.util.concurrent.atomic.AtomicBoolean
9
+
10
+ private const val TAG = "dtxScreenshotSelfMonitor"
11
+
12
+ /**
13
+ * Remote feature-flag key in the cross-platform configuration map that turns screenshot
14
+ * self-monitoring on. Monitoring is off unless the server delivers this flag as `true`, which keeps
15
+ * the per-frame UI-change observation dormant for apps where it isn't enabled.
16
+ */
17
+ private const val CONFIG_KEY_REPLAY_METRICS = "replay_metrics_enabled"
18
+
19
+ // DEM-26735: replay metrics experiment sunsets on 2026-10-15T00:00:00Z; remove this class after that date.
20
+ private const val EXPERIMENT_EXPIRY_EPOCH_MS = 1_792_022_400_000L
21
+
22
+ /**
23
+ * Drives the screenshot self-monitoring feature.
24
+ *
25
+ * Responsibilities:
26
+ * - Subscribes to the agent configuration and toggles monitoring based on
27
+ * [CONFIG_KEY_REPLAY_METRICS].
28
+ * - When enabled, registers a [UIChangeScreenshotListener] on the current activity and keeps it
29
+ * bound to the foreground activity across the React host lifecycle.
30
+ *
31
+ * Threading: configuration is delivered on a background thread, but [ViewTreeObserver][android.view.ViewTreeObserver]
32
+ * registration must happen on the main thread. All monitoring state ([monitoringEnabled],
33
+ * [uiChangeListener], [lifecycleListenerRegistered]) is therefore confined to the UI thread —
34
+ * config toggles are posted there and the host lifecycle callbacks already run there — so no locking
35
+ * is required.
36
+ *
37
+ * A single instance is registered for the lifetime of the React instance via [initialize].
38
+ */
39
+ internal class ScreenshotSelfMonitor private constructor(
40
+ private val reactApplicationContext: ReactApplicationContext
41
+ ) : LifecycleEventListener, ConfigurationSubscriber {
42
+
43
+ private var monitoringEnabled = false
44
+ private var uiChangeListener: UIChangeScreenshotListener? = null
45
+ private var lifecycleListenerRegistered = false
46
+
47
+ private fun register() {
48
+ // The publisher immediately notifies us with the current configuration on registration.
49
+ HybridBridge.addConfigurationSubscriber(this)
50
+ }
51
+
52
+ override fun notify(configuration: Map<String, Any>) {
53
+ val flagEnabled = configuration[CONFIG_KEY_REPLAY_METRICS] as? Boolean ?: false
54
+ val enabled = flagEnabled && !isExperimentExpired()
55
+ if (flagEnabled && !enabled) {
56
+ Log.d(TAG, "notify: $CONFIG_KEY_REPLAY_METRICS=true but experiment expired — treating as disabled")
57
+ } else {
58
+ Log.d(TAG, "notify: $CONFIG_KEY_REPLAY_METRICS = $flagEnabled")
59
+ }
60
+ UiThreadUtil.runOnUiThread { setMonitoringEnabled(enabled) }
61
+ }
62
+
63
+ private fun isExperimentExpired(): Boolean =
64
+ System.currentTimeMillis() >= EXPERIMENT_EXPIRY_EPOCH_MS
65
+
66
+ private fun setMonitoringEnabled(enabled: Boolean) {
67
+ if (enabled == monitoringEnabled) {
68
+ Log.d(TAG, "setMonitoringEnabled: no change (still $enabled)")
69
+ return
70
+ }
71
+ monitoringEnabled = enabled
72
+
73
+ if (enabled) {
74
+ Log.d(TAG, "$CONFIG_KEY_REPLAY_METRICS enabled — starting screenshot self-monitoring")
75
+ registerUIChangeListener()
76
+ } else {
77
+ Log.d(TAG, "$CONFIG_KEY_REPLAY_METRICS disabled — stopping screenshot self-monitoring")
78
+ deregisterUIChangeListener()
79
+ }
80
+ }
81
+
82
+ private fun registerUIChangeListener() {
83
+ if (uiChangeListener != null) return
84
+
85
+ if (!lifecycleListenerRegistered) {
86
+ reactApplicationContext.addLifecycleEventListener(this)
87
+ lifecycleListenerRegistered = true
88
+ }
89
+
90
+ val activity = reactApplicationContext.currentActivity
91
+ if (activity == null) {
92
+ Log.w(TAG, "No current activity — UIChangeScreenshotListener registration deferred to onHostResume")
93
+ return
94
+ }
95
+
96
+ val listener = UIChangeScreenshotListener()
97
+ if (!listener.registerOn(activity)) {
98
+ Log.w(TAG, "Failed to register OnDrawListener")
99
+ return
100
+ }
101
+ uiChangeListener = listener
102
+ Log.d(TAG, "UIChangeScreenshotListener registered")
103
+ }
104
+
105
+ private fun deregisterUIChangeListener() {
106
+ if (lifecycleListenerRegistered) {
107
+ reactApplicationContext.removeLifecycleEventListener(this)
108
+ lifecycleListenerRegistered = false
109
+ }
110
+ uiChangeListener?.unregister()
111
+ uiChangeListener = null
112
+ Log.d(TAG, "UIChangeScreenshotListener deregistered")
113
+ }
114
+
115
+ // Host lifecycle callbacks are delivered on the UI thread.
116
+ override fun onHostResume() {
117
+ if (!monitoringEnabled) return
118
+
119
+ val activity = reactApplicationContext.currentActivity
120
+ if (activity == null) {
121
+ if (uiChangeListener != null) {
122
+ Log.d(TAG, "onHostResume: no activity, tearing down UIChangeScreenshotListener")
123
+ deregisterUIChangeListener()
124
+ }
125
+ return
126
+ }
127
+
128
+ // Deferred registration: monitoring was enabled before an activity was available
129
+ if (uiChangeListener == null) {
130
+ Log.d(TAG, "onHostResume: attempting deferred UIChangeScreenshotListener registration")
131
+ registerUIChangeListener()
132
+ return
133
+ }
134
+
135
+ val listener = uiChangeListener ?: return
136
+ Log.d(TAG, "onHostResume: re-registering UIChangeScreenshotListener on current activity")
137
+ listener.unregister()
138
+ if (!listener.registerOn(activity)) {
139
+ Log.w(TAG, "onHostResume: failed to re-register UIChangeScreenshotListener, tearing down")
140
+ deregisterUIChangeListener()
141
+ }
142
+ }
143
+
144
+ override fun onHostPause() {
145
+ Log.d(TAG, "onHostPause: unregistering UIChangeScreenshotListener")
146
+ uiChangeListener?.unregister()
147
+ }
148
+
149
+ override fun onHostDestroy() {
150
+ Log.d(TAG, "onHostDestroy: deregistering UIChangeScreenshotListener")
151
+ deregisterUIChangeListener()
152
+ }
153
+
154
+ companion object {
155
+ private val initialized = AtomicBoolean(false)
156
+
157
+ /**
158
+ * Creates and registers the single [ScreenshotSelfMonitor] for the current React instance.
159
+ * Safe to call multiple times (e.g. from different module init paths / architectures); only
160
+ * the first call takes effect.
161
+ */
162
+ @JvmStatic
163
+ fun initialize(reactApplicationContext: ReactApplicationContext) {
164
+ // Build-time master switch (see android/build.gradle isReplayMetricsBuildEnabled()).
165
+ // When disabled the monitor is never registered, so the remote flag can't turn it on.
166
+ if (!BuildConfig.REPLAY_METRICS_ENABLED) {
167
+ Log.d(TAG, "screenshot self-monitoring disabled at build time — not registering")
168
+ return
169
+ }
170
+ if (initialized.compareAndSet(false, true)) {
171
+ ScreenshotSelfMonitor(reactApplicationContext).register()
172
+ }
173
+ }
174
+ }
175
+ }
@@ -0,0 +1,124 @@
1
+ package com.dynatrace.android.agent
2
+
3
+ import android.app.Activity
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import android.os.SystemClock
7
+ import android.util.Log
8
+ import android.view.ViewTreeObserver
9
+ import java.lang.ref.WeakReference
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+
12
+ private const val TAG = "dtxScreenshotSelfMonitor"
13
+ private const val DEBOUNCE_MS = 500L
14
+ private const val MAX_DEBOUNCE_MS = 2000L
15
+
16
+ /**
17
+ * Self-monitoring listener that mirrors the Session Replay UI-change detection, but instead of
18
+ * capturing a screenshot it only increments the cross-platform screenshot counter via
19
+ * [HybridBridge.trackCrossPlatformScreenshot].
20
+ *
21
+ * The purpose is to estimate how many screenshots the Session Replay UI-change listener *would*
22
+ * take in production, without shipping the (not yet published) Session Replay package.
23
+ *
24
+ * It observes [ViewTreeObserver.OnDrawListener] on the current activity's decor view and debounces
25
+ * bursts of draws (a settled UI is reported once). Both [lastDrawTime] and [firstDrawTime] are only
26
+ * touched on the main thread (`onDraw` and the Handler-posted [captureRunnable]).
27
+ */
28
+ internal class UIChangeScreenshotListener : ViewTreeObserver.OnDrawListener {
29
+
30
+ private val pending = AtomicBoolean(false)
31
+ private val handler = Handler(Looper.getMainLooper())
32
+ private var registeredActivity: WeakReference<Activity>? = null
33
+
34
+ private var lastDrawTime = 0L
35
+ private var firstDrawTime = 0L
36
+
37
+ private val captureRunnable = Runnable { performCapture() }
38
+
39
+ override fun onDraw() {
40
+ val now = SystemClock.uptimeMillis()
41
+ lastDrawTime = now
42
+ if (pending.compareAndSet(false, true)) {
43
+ firstDrawTime = now
44
+ handler.postDelayed(captureRunnable, DEBOUNCE_MS)
45
+ }
46
+ }
47
+
48
+ fun registerOn(activity: Activity): Boolean {
49
+ if (registeredActivity?.get() === activity) return true
50
+
51
+ // Clean up previous registration if switching activities
52
+ unregister()
53
+
54
+ val decorView = activity.window?.decorView
55
+ if (decorView == null) {
56
+ Log.w(TAG, "Cannot register: decorView is null")
57
+ return false
58
+ }
59
+ val observer = decorView.viewTreeObserver
60
+ if (!observer.isAlive) {
61
+ Log.w(TAG, "Cannot register: ViewTreeObserver is not alive")
62
+ return false
63
+ }
64
+
65
+ observer.addOnDrawListener(this)
66
+ registeredActivity = WeakReference(activity)
67
+ Log.d(TAG, "Registered OnDrawListener on DecorView")
68
+ return true
69
+ }
70
+
71
+ fun unregister() {
72
+ val activity = registeredActivity?.get()
73
+ if (activity != null) {
74
+ try {
75
+ val decorView = activity.window?.decorView
76
+ val observer = decorView?.viewTreeObserver
77
+ if (observer != null && observer.isAlive) {
78
+ observer.removeOnDrawListener(this)
79
+ }
80
+ } catch (e: IllegalStateException) {
81
+ Log.w(TAG, "Expected cleanup issue removing OnDrawListener", e)
82
+ } catch (e: Exception) {
83
+ Log.e(TAG, "Unexpected error removing OnDrawListener", e)
84
+ }
85
+ }
86
+ registeredActivity = null
87
+ dispose()
88
+ }
89
+
90
+ private fun performCapture() {
91
+ if (registeredActivity?.get() == null) {
92
+ // Leave pending=true so onDraw won't schedule further captures
93
+ Log.w(TAG, "Activity reference lost (GC'd) — capture abandoned")
94
+ handler.removeCallbacks(captureRunnable)
95
+ return
96
+ }
97
+
98
+ val now = SystemClock.uptimeMillis()
99
+ val elapsed = now - lastDrawTime
100
+ val sinceFirst = now - firstDrawTime
101
+
102
+ // Reschedule if UI still active, unless max wait exceeded
103
+ if (elapsed < DEBOUNCE_MS && sinceFirst < MAX_DEBOUNCE_MS) {
104
+ val remaining = DEBOUNCE_MS - elapsed
105
+ Log.d(TAG, "UI still active, rescheduling in ${remaining}ms")
106
+ handler.postDelayed(captureRunnable, remaining)
107
+ return
108
+ }
109
+
110
+ pending.set(false)
111
+
112
+ try {
113
+ Log.d(TAG, "Tracking cross-platform screenshot (self-monitoring)")
114
+ HybridBridge.trackCrossPlatformScreenshot()
115
+ } catch (e: Exception) {
116
+ Log.e(TAG, "trackCrossPlatformScreenshot failed during UI change capture", e)
117
+ }
118
+ }
119
+
120
+ fun dispose() {
121
+ handler.removeCallbacks(captureRunnable)
122
+ pending.set(false)
123
+ }
124
+ }
@@ -179,6 +179,39 @@ class DynatraceRNBridge(
179
179
  impl.forwardAppStartEvent(attributes, appStartKeys)
180
180
  }
181
181
 
182
+ override fun setAutomaticUserActionDetection(enabled: Boolean) {
183
+ impl.setAutomaticUserActionDetection(enabled)
184
+ }
185
+
186
+ override fun createUserAction(
187
+ actionId: String,
188
+ name: String,
189
+ completeAutomatically: Boolean,
190
+ properties: ReadableMap?
191
+ ) {
192
+ impl.createUserAction(actionId, name, completeAutomatically, properties)
193
+ }
194
+
195
+ override fun addEventStringPropertyToUserAction(actionId: String, key: String, value: String) {
196
+ impl.addEventStringPropertyToUserAction(actionId, key, value)
197
+ }
198
+
199
+ override fun addEventDoublePropertyToUserAction(actionId: String, key: String, value: Double) {
200
+ impl.addEventDoublePropertyToUserAction(actionId, key, value)
201
+ }
202
+
203
+ override fun addEventBooleanPropertyToUserAction(actionId: String, key: String, value: Boolean) {
204
+ impl.addEventBooleanPropertyToUserAction(actionId, key, value)
205
+ }
206
+
207
+ override fun completeUserAction(actionId: String) {
208
+ impl.completeUserAction(actionId)
209
+ }
210
+
211
+ override fun setCompleteUserActionAutomatically(actionId: String, enabled: Boolean) {
212
+ impl.setCompleteUserActionAutomatically(actionId, enabled)
213
+ }
214
+
182
215
  override fun startView(name: String) {
183
216
  impl.startView(name)
184
217
  }
@@ -197,6 +197,46 @@ class DynatraceRNBridge(
197
197
  impl.forwardAppStartEvent(attributes, appStartKeys)
198
198
  }
199
199
 
200
+ @ReactMethod
201
+ fun setAutomaticUserActionDetection(enabled: Boolean) {
202
+ impl.setAutomaticUserActionDetection(enabled)
203
+ }
204
+
205
+ @ReactMethod
206
+ fun createUserAction(
207
+ actionId: String,
208
+ name: String,
209
+ completeAutomatically: Boolean,
210
+ properties: ReadableMap?
211
+ ) {
212
+ impl.createUserAction(actionId, name, completeAutomatically, properties)
213
+ }
214
+
215
+ @ReactMethod
216
+ fun addEventStringPropertyToUserAction(actionId: String, key: String, value: String) {
217
+ impl.addEventStringPropertyToUserAction(actionId, key, value)
218
+ }
219
+
220
+ @ReactMethod
221
+ fun addEventDoublePropertyToUserAction(actionId: String, key: String, value: Double) {
222
+ impl.addEventDoublePropertyToUserAction(actionId, key, value)
223
+ }
224
+
225
+ @ReactMethod
226
+ fun addEventBooleanPropertyToUserAction(actionId: String, key: String, value: Boolean) {
227
+ impl.addEventBooleanPropertyToUserAction(actionId, key, value)
228
+ }
229
+
230
+ @ReactMethod
231
+ fun completeUserAction(actionId: String) {
232
+ impl.completeUserAction(actionId)
233
+ }
234
+
235
+ @ReactMethod
236
+ fun setCompleteUserActionAutomatically(actionId: String, enabled: Boolean) {
237
+ impl.setCompleteUserActionAutomatically(actionId, enabled)
238
+ }
239
+
200
240
  @ReactMethod
201
241
  fun startView(name: String) {
202
242
  impl.startView(name)
@@ -1,4 +1,4 @@
1
1
  // TEMPLATE: plugin-gradle.template
2
2
  dependencies {
3
- classpath 'com.dynatrace.tools.android:gradle-plugin:8.341.1.1004'
3
+ classpath 'com.dynatrace.tools.android:gradle-plugin:8.343.1.1038'
4
4
  }