feat: TTID pre-launch capture for cross-platform SDKs support - #3635
feat: TTID pre-launch capture for cross-platform SDKs support#3635sbarrio wants to merge 3 commits into
Conversation
29d98ba to
75a5402
Compare
75a5402 to
f21cea2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f21cea22f5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| val rumMonitor = | ||
| GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@addFirstFrameCallback |
There was a problem hiding this comment.
Defer CAPTURING callbacks until the monitor is registered
In the CAPTURING path this callback can run synchronously while RumFeature.onInitialize is still executing: addFirstFrameCallback immediately invokes the callback if the collector flips to COMPLETE between the state check and registration, and Rum.enable registers GlobalRumMonitor only after sdkCore.registerFeature(...) returns. In that race (SDK init during first-frame capture), this return drops the only AppStart/TTID emission instead of deferring it like handleCompleteState, so startup metrics are lost.
Useful? React with 👍 / 👎.
| // Unregister lifecycle callbacks — we've captured what we need from the first Activity | ||
| registeredApplication?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) |
There was a problem hiding this comment.
Keep observing activities until a frame is captured
When the prelaunch provider captures a splash/interstitial Activity that is destroyed before it draws its first frame, unregistering here means the collector never sees the next Activity. The only draw subscription remains attached to the destroyed Activity, state stays CAPTURING, and RumFeature's CAPTURING path waits forever, so AppStart/TTID are not reported; the default detector explicitly forwards pending startup to later activities via onNextActivityCreated.
Useful? React with 👍 / 👎.
| // Double-check: state may have transitioned to COMPLETE between the first check and the add | ||
| if (_state.get() == State.COMPLETE) { | ||
| if (firstFrameCallbacks.remove(cb)) { | ||
| cb(firstFrameNs) |
There was a problem hiding this comment.
Invoke callbacks after concurrent completion clears them
If a caller reads CAPTURING, then the first-frame callback sets COMPLETE and copies the current list, a callback added in the small window before firstFrameCallbacks.clear() will be cleared without being in the copied list. The second check then reaches this remove branch, gets false, and never invokes the callback, so SDK initialization racing with first draw can silently lose AppStart/TTID.
Useful? React with 👍 / 👎.
|
|
||
| private fun onBeforeActivityCreated(activity: Activity, savedInstanceState: Bundle?) { | ||
| // CAS: only the first caller transitions IDLE -> CAPTURING; concurrent claim() loses | ||
| if (!_state.compareAndSet(State.IDLE, State.CAPTURING)) { |
There was a problem hiding this comment.
Publish CAPTURING only after the snapshot is populated
When SDK initialization races with the first Activity callback, this CAS exposes State.CAPTURING before activity, timestamps, and flags are written. RumFeature.handleCapturingState() can then call constructScenario(), see a null activity or zero timestamps, and fall back to the default detector after the first Activity has already been created, losing the prelaunch AppStart/TTID measurement for that launch.
Useful? React with 👍 / 👎.
| AppLaunchPreInitCollector.State.CAPTURING -> handleCapturingState(collector) | ||
| AppLaunchPreInitCollector.State.COMPLETE -> handleCompleteState(collector) |
There was a problem hiding this comment.
Keep the default detector alive after prelaunch handoff
For the CAPTURING and COMPLETE prelaunch states, this path consumes the one-shot collector but never installs RumAppStartupDetector. After the initial prelaunch TTID is sent there are no Activity lifecycle callbacks left to observe later Activity recreation in the same process, so warm app launches that the default detector previously reported stop producing AppStart/TTID events for apps that add the prelaunch module.
Useful? React with 👍 / 👎.
| scenario.activity.get()?.let { activity -> | ||
| when (capturedStrategy) { | ||
| is NavigationViewTrackingStrategy -> capturedStrategy.onActivityStarted(activity) | ||
| is ActivityViewTrackingStrategy -> capturedStrategy.onActivityResumed(activity) |
There was a problem hiding this comment.
Avoid replaying start callbacks for already-destroyed activities
If SDK initialization happens after the captured splash/interstitial Activity has drawn and then been stopped or destroyed, the weak reference can still be non-null here. Replaying only onActivityStarted/onActivityResumed starts a RUM view for an Activity whose missed stop/destroy callbacks will never be delivered, leaving an incorrect long-lived view and attaching the AppStart/TTID to it.
Useful? React with 👍 / 👎.
| rumFeature.pendingPreLaunchAction?.let { action -> | ||
| rumFeature.pendingPreLaunchAction = null | ||
| Handler(Looper.getMainLooper()).post(action) |
There was a problem hiding this comment.
Dispatch prelaunch events before enable returns
When the collector is already COMPLETE and Rum.enable() is called on the main thread, posting this action means AppStart/TTID are not enqueued until after enable() returns. If the app calls RumMonitor.reportAppFullyDisplayed() immediately after enabling RUM in the same call stack, the TTFD event reaches RumSessionScopeStartupManager before any launch scenario exists and is ignored as “called before the application launch was detected,” so TTFD is lost for this startup.
Useful? React with 👍 / 👎.
| rumMonitor.sendAppStartEvent(scenario) | ||
| rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs)) |
There was a problem hiding this comment.
Consume prelaunch data after the first handoff
After these events are sent, AppLaunchPreInitCollector remains in COMPLETE with the original timestamps. If RUM is stopped and enabled again in the same process, initRumAppStartupDetector() will enter handleCompleteState() again and resend the first launch's AppStart/TTID, duplicating stale startup metrics for a later SDK enable.
Useful? React with 👍 / 👎.
Was this happening only on React Native or on ordinary Android as well? |
I believe this was fixed in #3349 |
| } | ||
| } | ||
|
|
||
| private fun subscribeToFirstFrameDrawn( |
There was a problem hiding this comment.
Are you sure we need to do these changes? I did them when I fixed a memory leak (#3349) and it seems you reverted it to some prior state.
|
I had a look at this PR and have the following concerns:
IIUC the main problem for RN SDK is that it is initialized much later than
I didn't thoroughly check that there are no hard issues with this approach, but at the first glance it should work. It will improve code reuse and should be simpler than the current solution. |
| * On API 23, falls back directly to DdRumContentProvider.createTimeNs. | ||
| */ | ||
| @Suppress("NewApi") // Process.getStartElapsedRealtime is guarded by isAtLeastN check | ||
| internal fun computeProcessStartNs(): Long { |
There was a problem hiding this comment.
Looks like this function duplicates logic from DefaultAppStartTimeProvider. Is it possible to avoid that my reusing code in any way (extracting to a function/class/etc)?
| val weakActivity = collector.activity!! | ||
| val hasSavedInstanceStateBundle = collector.hasSavedInstanceState | ||
|
|
||
| return if (collector.isFirstActivityForProcess) { |
There was a problem hiding this comment.
This looks like duplication of logic from RumAppStartupDetectorImpl. Is it possible to avoid it?
| return null | ||
| } | ||
|
|
||
| if (!configuration.appStartupActivityPredicate.shouldTrackStartup(activity)) { |
There was a problem hiding this comment.
Question. Suppose AppStartupActivityPredicate skips the first Activity. Will we detect the app launch from the second Activity? Because IIUC the new AppLaunchPreInitCollector only subscribes to the first Activity's first frame.
| // GlobalRumMonitor is not yet registered during onInitialize. Rum.kt calls | ||
| // pendingPreLaunchAction on the main thread after GlobalRumMonitor.registerIfAbsent(), | ||
| // guaranteeing the real monitor is available regardless of which thread Rum.enable() | ||
| // is called on (main thread for native Android, background thread for RN/Flutter). | ||
| // | ||
| // Additionally, the Activity has already completed its full lifecycle before the | ||
| // SDK initialized (e.g. a cross-platform bridge delay). The view tracking strategy | ||
| // missed onActivityStarted/onActivityResumed, so no RUM view has been started yet. | ||
| // We replay the relevant lifecycle callback here so startView is queued before | ||
| // AppStart/TTID. | ||
| val capturedStrategy = viewTrackingStrategy |
There was a problem hiding this comment.
I'm having hard time understanding this comment and code afterwards. It looks weird that we need to replay viewTrackingStrategy here. Very complicated and error-prone. Do we really need it? Could you give an example when it is needed?
@aleksandr-gringauz Thanks a ton for the thorough review and all the comments 🙌 Let me process them all and I'll come back to you shortly 🙇 |
What does this PR do?
Builds on the work developed by @marco-saia-datadog here: #3371 and updates it so it properly works and reports TTID on Android on cross platform SDKs that include the new
com.datadoghq:dd-sdk-android-rum-prelaunchmodule.This PR adds three things:
New
AppLaunchPreInitCollectorindd-sdk-android-internal. It's a singleton that collects timing data before the SDK initializes — process start time, firstActivity.onCreate, and first frame drawn. State transitions use atomic compare-and-swap (NOT_INSTALLED → IDLE → CAPTURING / CLAIMED → COMPLETE) so the collector and the SDK can't race on who drives startup.New
dd-sdk-android-rum-prelaunchmodule with a singleContentProvider(AppLaunchCollectorProvider) that installs the collector automatically at process start. No customer code required.RumFeature.initRumAppStartupDetector()now checks collector state on init: if data is already captured, read it; if capture is in progress, subscribe; if not installed or the SDK got there first, fall back to the existingRumAppStartupDetectorflow unchanged.RumFirstDrawTimeReporterandWindowCallbacksRegistryare also moved todd-sdk-android-internal, since both paths need them now. The originals indd-sdk-android-rumare deleted.On top of Marco's work, this PR fixes two issues that prevented the feature from working correctly:
1. TTID/TTFD events not assigned to a view
The app start and TTID events were being sent before the first RUM view was started, so they weren't attached to any view in the session. The fix defers those events via a
pendingPreLaunchActionthat is dispatched on the main thread afterGlobalRumMonitor.registerIfAbsent()runs, ensuring the view is already open when the events arrive.2. Memory leak in
RumFirstDrawTimeReporterImplWhen subscribing to first-frame events for an activity that never calls
setContentView()(e.g. an interstitial that just callsstartActivity + finish()),WindowCallbacksRegistrywraps theActivity'sWindow.Callbackand stores it in aWeakHashMap<Activity, WindowCallback>. BecauseActivityitself implementsWindow.Callback,WindowCallbackends up holding a strong reference back to the map key, preventing GC from ever collecting it. TheWindowCallbackListenerthat would clean up this entry only fires ononContentChanged— which never happens ifsetContentViewis never called. This causedNoLeakAssertionFailedErrorin all TTID auto-forwarding integration tests.The fix registers an
Application.ActivityLifecycleCallbacksalongside eachWindowCallbackListener. If theActivityis destroyed beforesetContentViewis called, the callback removes the listener and breaks the strong reference. Both theActivityand the listener are held asWeakReferenceinside the cleanup callback so the registration itself creates no new retention path.Motivation
React Native and Flutter initialize the Datadog SDK from JS/Dart, well after the first activity has launched. TTID goes unreported for those SDKs unless you add native initialization (
DdSdkNativeInitialization.initFromNative()), which means native Android code in a cross-platform project.The collector sidesteps this. By the time the SDK starts, the timing data is already waiting.
Native Android apps are unaffected. If the SDK initializes before the first activity, it claims the collector and the existing
RumAppStartupDetectorpath runs exactly as before.Additional Notes
dd-sdk-android-rum-prelaunchis opt-in. Cross-platform SDKs depend on it; native apps don't.RumFirstDrawTimeReporterImpltakes an injectablewarnLoggerlambda.RumFeaturepasses one that routes throughsdkCore.internalLoggerwithTarget.TELEMETRY + Target.USER. The pre-init path defaults toLog.wsince there's no SDK available at that point.Review checklist (to be filled by reviewers)