Skip to content

feat: TTID pre-launch capture for cross-platform SDKs support - #3635

Open
sbarrio wants to merge 13 commits into
developfrom
sbarrio/RUM-16664/pre-launch-ttid-module
Open

feat: TTID pre-launch capture for cross-platform SDKs support#3635
sbarrio wants to merge 13 commits into
developfrom
sbarrio/RUM-16664/pre-launch-ttid-module

Conversation

@sbarrio

@sbarrio sbarrio commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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-prelaunch module.

This PR adds three things:

  1. New AppLaunchPreInitCollector in dd-sdk-android-internal. It's a singleton that collects timing data before the SDK initializes — process start time, first Activity.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.

  2. New dd-sdk-android-rum-prelaunch module with a single ContentProvider (AppLaunchCollectorProvider) that installs the collector automatically at process start. No customer code required.

  3. 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 existing RumAppStartupDetector flow unchanged.

RumFirstDrawTimeReporter and WindowCallbacksRegistry are also moved to dd-sdk-android-internal, since both paths need them now. The originals in dd-sdk-android-rum are 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 pendingPreLaunchAction that is dispatched on the main thread after GlobalRumMonitor.registerIfAbsent() runs, ensuring the view is already open when the events arrive.

2. Memory leak in RumFirstDrawTimeReporterImpl

When subscribing to first-frame events for an activity that never calls setContentView() (e.g. an interstitial that just calls startActivity + finish()), WindowCallbacksRegistry wraps the Activity's Window.Callback and stores it in a WeakHashMap<Activity, WindowCallback>. Because Activity itself implements Window.Callback, WindowCallback ends up holding a strong reference back to the map key, preventing GC from ever collecting it. The WindowCallbackListener that would clean up this entry only fires on onContentChanged — which never happens if setContentView is never called. This caused NoLeakAssertionFailedError in all TTID auto-forwarding integration tests.

The fix registers an Application.ActivityLifecycleCallbacks alongside each WindowCallbackListener. If the Activity is destroyed before setContentView is called, the callback removes the listener and breaks the strong reference. Both the Activity and the listener are held as WeakReference inside 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 RumAppStartupDetector path runs exactly as before.

Additional Notes

  • dd-sdk-android-rum-prelaunch is opt-in. Cross-platform SDKs depend on it; native apps don't.
  • RumFirstDrawTimeReporterImpl takes an injectable warnLogger lambda. RumFeature passes one that routes through sdkCore.internalLogger with Target.TELEMETRY + Target.USER. The pre-init path defaults to Log.w since there's no SDK available at that point.
  • API surfaces updated for both modules. No public API change.
  • Tested on the example React Native SDK example app here: [RUM 16664] Report TTID and TTFD on RN apps dd-sdk-reactnative#1336

Review checklist (to be filled by reviewers)

  • Feature or bugfix MUST have appropriate tests (unit, integration, e2e)
  • Make sure you discussed the feature or bugfix with the maintaining team in an Issue
  • Make sure each commit and the PR mention the Issue number (cf the CONTRIBUTING doc)

@sbarrio sbarrio changed the title Sbarrio/rum 16664/pre launch ttid module feat: TTID pre-launch capture for cross-platform SDKs support Jul 15, 2026
@sbarrio
sbarrio force-pushed the sbarrio/RUM-16664/pre-launch-ttid-module branch from 29d98ba to 75a5402 Compare July 15, 2026 15:23
@codecov-commenter

codecov-commenter commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.03361% with 57 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.38%. Comparing base (7c1e46f) to head (01391aa).
⚠️ Report is 19 commits behind head on develop.

Files with missing lines Patch % Lines
...lin/com/datadog/android/rum/internal/RumFeature.kt 76.52% 24 Missing and 7 partials ⚠️
...m/datadog/android/rum/AppLaunchPreInitCollector.kt 87.23% 7 Missing and 5 partials ⚠️
...ndroid/rum/startup/RumFirstDrawTimeReporterImpl.kt 88.24% 2 Missing and 4 partials ⚠️
...rum/src/main/kotlin/com/datadog/android/rum/Rum.kt 0.00% 2 Missing and 1 partial ⚠️
...atadog/android/internal/utils/ProcessStartUtils.kt 0.00% 0 Missing and 1 partial ⚠️
...dog/android/rum/startup/WindowCallbacksRegistry.kt 96.15% 0 Missing and 1 partial ⚠️
...ndroid/rum/prelaunch/AppLaunchCollectorProvider.kt 91.67% 0 Missing and 1 partial ⚠️
...roid/rum/internal/startup/RumAppStartupDetector.kt 50.00% 1 Missing ⚠️
...oid/rum/tracking/NavigationViewTrackingStrategy.kt 0.00% 1 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3635      +/-   ##
===========================================
- Coverage    73.46%   73.38%   -0.08%     
===========================================
  Files          997      998       +1     
  Lines        36445    36628     +183     
  Branches      6166     6199      +33     
===========================================
+ Hits         26771    26876     +105     
- Misses        7968     8003      +35     
- Partials      1706     1749      +43     
Files with missing lines Coverage Δ
.../core/internal/time/DefaultAppStartTimeProvider.kt 100.00% <100.00%> (+5.00%) ⬆️
.../rum/internal/startup/RumAppStartupDetectorImpl.kt 96.23% <100.00%> (-0.36%) ⬇️
...android/rum/internal/startup/RumStartupScenario.kt 100.00% <100.00%> (ø)
...droid/rum/tracking/ActivityViewTrackingStrategy.kt 86.79% <100.00%> (+0.25%) ⬆️
...atadog/android/internal/utils/ProcessStartUtils.kt 0.00% <0.00%> (ø)
...dog/android/rum/startup/WindowCallbacksRegistry.kt 96.15% <96.15%> (ø)
...ndroid/rum/prelaunch/AppLaunchCollectorProvider.kt 91.67% <91.67%> (ø)
...roid/rum/internal/startup/RumAppStartupDetector.kt 87.50% <50.00%> (-12.50%) ⬇️
...oid/rum/tracking/NavigationViewTrackingStrategy.kt 85.07% <0.00%> (-1.29%) ⬇️
...rum/src/main/kotlin/com/datadog/android/rum/Rum.kt 83.33% <0.00%> (-3.09%) ⬇️
... and 3 more

... and 33 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@sbarrio
sbarrio force-pushed the sbarrio/RUM-16664/pre-launch-ttid-module branch from 75a5402 to f21cea2 Compare July 16, 2026 09:54
@sbarrio
sbarrio marked this pull request as ready for review July 17, 2026 14:50
@sbarrio
sbarrio requested review from a team as code owners July 17, 2026 14:50

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

@aleksandr-gringauz
aleksandr-gringauz self-requested a review July 17, 2026 15:44
@aleksandr-gringauz

Copy link
Copy Markdown
Contributor

TTID/TTFD events not assigned to a view

Was this happening only on React Native or on ordinary Android as well?

@aleksandr-gringauz

Copy link
Copy Markdown
Contributor

Memory leak in RumFirstDrawTimeReporterImpl

I believe this was fixed in #3349

}
}

private fun subscribeToFirstFrameDrawn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, we preserved the fix but in this PR the first frame subscription responsibility is moved out of RumAppStartupDetectorImpl entirely.

For the pre-launch path, AppLaunchPreInitCollector owns the subscription and uses a Handle (same as your #3349 fix) to unsubscribe on activity destroy. For the warm-launch path, RumFeature manages handles in a WeakHashMap<Activity, RumFirstDrawTimeReporter.Handle>. The subscription is just owned at a higher level since the pre-launch module needs direct access to the first-frame timing.

You can see the changes at 9bbeb22

@aleksandr-gringauz

aleksandr-gringauz commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

I had a look at this PR and have the following concerns:

  1. A lot of code duplication was introduced, especially in detecting the startup type, computing the TTID duration, process startup time (things from ``RumAppStartupDetectorImpl.ktandDefaultAppStartTimeProvider.kt`).
  2. The code in RumFeature.kt became extremely complicated and hard to understand.

IIUC the main problem for RN SDK is that it is initialized much later than Application.onCreate and thus our current implementation on Android doesn't work for it. Fair enough. Let me suggest a slightly different approach, let me know what you think.

  1. Make RumAppStartupDetector and its impl available for use in RN SDK. For example by introducing a new module called dd-sdk-android-rum-internal. dd-sdk-android-rum-prelaunch in your PR seems too specific, we might want a place that we want to put similar things later on.
  2. Create ContentProvider similar to AppLaunchCollectorProvider from your PR that creates an instance of RumAppStartupDetector in RN SDK code.
  3. Make AdvancedRumMonitor.sendAppStartEvent and AdvancedRumMonitor.sendTTIDEvent available on RN side. IIUC it is enough to put them in AdvancedNetworkRumMonitor interface.
  4. The logic that records the TTID and then when the SDK is initialized calls sendAppStartEvent and sendTTIDEvent should live in RN code.
  5. Create an internal configuration argument _RumInternalProxy.setApplicationLaunchTrackindEnabled. If this argument is false it should disable creation of the instance of RumAppStartupDetector in RumFeature.kt.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)?

@sbarrio sbarrio Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, thanks for raising.

I extracted it to guardedProcessStartNs(computed, fallback, thresholdNs) in ProcessStartUtils.kt. Both AppLaunchPreInitCollector and DefaultAppStartTimeProvider call this shared function. They use different clocks (elapsedRealtime vs uptime) but the guard logic is identical.

Changes at f288419

val weakActivity = collector.activity!!
val hasSavedInstanceStateBundle = collector.hasSavedInstanceState

return if (collector.isFirstActivityForProcess) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like duplication of logic from RumAppStartupDetectorImpl. Is it possible to avoid it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree.

Changes at 5a6267f

The cold/warm classification logic is now in a single RumStartupScenario.build(...) factory method on the companion object of RumStartupScenario. Both RumAppStartupDetectorImpl.onBeforeActivityCreated() and RumFeature.constructScenario() call this factory.

return null
}

if (!configuration.appStartupActivityPredicate.shouldTrackStartup(activity)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the second activity is correctly tracked. AppLaunchPreInitCollector records the very first activity unconditionally (it runs pre-SDK init, so it can't apply the predicate yet). When RumFeature initializes, constructScenario() checks the predicate against the captured activity, if its rejected, it returns null.

initRumAppStartupDetector() treats null as "pre-launch data not usable" and falls back to the normal RumAppStartupDetector, which handles predicate-excluded activities via onNextActivityCreated.

I also added a specific unit test for this.

You can see the changes at 5a6267f

Comment on lines +859 to +869
// 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need this because when the SDK initializes after the app has already launched, the viewTrackingStrategy has never received onActivityStarted/onActivityResumed for the startup Activity. If we send AppStart/TTID without first opening a RUM view, those events are dropped with no view to attach to.

However I agree that it was too complicated so I've simplified it. I've introduced an opt-in interface named ReplayableViewTrackingStrategy { fun onLateActivityReady(activity: Activity) }. Both ActivityViewTrackingStrategy and NavigationViewTrackingStrategy implement it. RumFeature now just calls (viewTrackingStrategy as? ReplayableViewTrackingStrategy)?.onLateActivityReady(activity). That way we can have a clean fallback and no type checking.

@sbarrio

sbarrio commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

I had a look at this PR and have the following concerns:

  1. A lot of code duplication was introduced, especially in detecting the startup type, computing the TTID duration, process startup time (things from ``RumAppStartupDetectorImpl.ktandDefaultAppStartTimeProvider.kt`).
  2. The code in RumFeature.kt became extremely complicated and hard to understand.

IIUC the main problem for RN SDK is that it is initialized much later than Application.onCreate and thus our current implementation on Android doesn't work for it. Fair enough. Let me suggest a slightly different approach, let me know what you think.

  1. Make RumAppStartupDetector and its impl available for use in RN SDK. For example by introducing a new module called dd-sdk-android-rum-internal. dd-sdk-android-rum-prelaunch in your PR seems too specific, we might want a place that we want to put similar things later on.
  2. Create ContentProvider similar to AppLaunchCollectorProvider from your PR that creates an instance of RumAppStartupDetector in RN SDK code.
  3. Make AdvancedRumMonitor.sendAppStartEvent and AdvancedRumMonitor.sendTTIDEvent available on RN side. IIUC it is enough to put them in AdvancedNetworkRumMonitor interface.
  4. The logic that records the TTID and then when the SDK is initialized calls sendAppStartEvent and sendTTIDEvent should live in RN code.
  5. Create an internal configuration argument _RumInternalProxy.setApplicationLaunchTrackindEnabled. If this argument is false it should disable creation of the instance of RumAppStartupDetector in RumFeature.kt.

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.

@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 🙇

@sbarrio
sbarrio force-pushed the sbarrio/RUM-16664/pre-launch-ttid-module branch from f21cea2 to 732b1f4 Compare July 31, 2026 10:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 732b1f4dc5

ℹ️ 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".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 859ab0dfc1

ℹ️ 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".

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01391aa86f

ℹ️ 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".

@sbarrio

sbarrio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

TTID/TTFD events not assigned to a view

Was this happening only on React Native or on ordinary Android as well?

@aleksandr-gringauz This was only happening on React Native (and most probably on Flutter, but I didn't test it there).

The Android SDK initializes in Application.onCreate, before the first Activity is created, so there's always an open RUM view before sendTTIDEvent is called, that's not true for RN (and other Cross-platform SDKs), since they initialize their bridge much later. In the case of RN this happens when initialize is called and the native Rum.enable() takes place.

@sbarrio

sbarrio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Memory leak in RumFirstDrawTimeReporterImpl

I believe this was fixed in #3349

@aleksandr-gringauz

Yes, and the fix from #3349 is preserved in this branch. We extended the same Handle pattern you introduced there to cover the new AppLaunchPreInitCollector subscription: subscribeToFirstFrameDrawn now returns a Handle, and we call handle.unsubscribe() if the activity is destroyed before its first frame fires. The RumFirstDrawTimeReporter.Handle interface follows the exact same design as your fix.

You can see the changes at 9bbeb22

@sbarrio

sbarrio commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

I had a look at this PR and have the following concerns:

  1. A lot of code duplication was introduced, especially in detecting the startup type, computing the TTID duration, process startup time (things from ``RumAppStartupDetectorImpl.ktandDefaultAppStartTimeProvider.kt`).
  2. The code in RumFeature.kt became extremely complicated and hard to understand.

IIUC the main problem for RN SDK is that it is initialized much later than Application.onCreate and thus our current implementation on Android doesn't work for it. Fair enough. Let me suggest a slightly different approach, let me know what you think.

  1. Make RumAppStartupDetector and its impl available for use in RN SDK. For example by introducing a new module called dd-sdk-android-rum-internal. dd-sdk-android-rum-prelaunch in your PR seems too specific, we might want a place that we want to put similar things later on.
  2. Create ContentProvider similar to AppLaunchCollectorProvider from your PR that creates an instance of RumAppStartupDetector in RN SDK code.
  3. Make AdvancedRumMonitor.sendAppStartEvent and AdvancedRumMonitor.sendTTIDEvent available on RN side. IIUC it is enough to put them in AdvancedNetworkRumMonitor interface.
  4. The logic that records the TTID and then when the SDK is initialized calls sendAppStartEvent and sendTTIDEvent should live in RN code.
  5. Create an internal configuration argument _RumInternalProxy.setApplicationLaunchTrackindEnabled. If this argument is false it should disable creation of the instance of RumAppStartupDetector in RumFeature.kt.

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.

@aleksandr-gringauz

Sorry for the delay, it took me a while to get the PR back in shape.

The main problem with your approach is that if we delegate the logic to each Cross Platform SDK we will end up having to write and maintain the same Content provider and logic on each independent SDK instead of having it written once by having it centralized on the Android SDK. Our approach also allows cross platform SDKs to immediately benefit from this by simply adding the prelaunch module dependency, without any further changes.

I do however agree that we could rename the dd-sdk-android-rum-prelaunch to anything other that you think fits better with the naming conventions of the Android SDK.

Regarding the other issues you raised, I've refactored, removed deduplication and solved several issues on the latest commits, so hopefully it all looks a bit better now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants