Skip to content

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

Open
sbarrio wants to merge 3 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 3 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 82.45614% with 60 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.34%. Comparing base (2fcaa92) to head (f21cea2).
⚠️ Report is 21 commits behind head on develop.

Files with missing lines Patch % Lines
...lin/com/datadog/android/rum/internal/RumFeature.kt 79.45% 24 Missing and 6 partials ⚠️
...m/datadog/android/rum/AppLaunchPreInitCollector.kt 82.42% 11 Missing and 5 partials ⚠️
...ndroid/rum/startup/RumFirstDrawTimeReporterImpl.kt 86.44% 6 Missing and 2 partials ⚠️
...rum/src/main/kotlin/com/datadog/android/rum/Rum.kt 0.00% 2 Missing and 1 partial ⚠️
...dog/android/rum/startup/WindowCallbacksRegistry.kt 92.31% 0 Missing and 2 partials ⚠️
...ndroid/rum/prelaunch/AppLaunchCollectorProvider.kt 91.67% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop    #3635      +/-   ##
===========================================
+ Coverage    73.29%   73.34%   +0.05%     
===========================================
  Files          995      995              
  Lines        36285    36486     +201     
  Branches      6123     6157      +34     
===========================================
+ Hits         26594    26760     +166     
- Misses        7984     8002      +18     
- Partials      1707     1724      +17     
Files with missing lines Coverage Δ
...roid/rum/internal/startup/RumAppStartupDetector.kt 100.00% <100.00%> (ø)
.../rum/internal/startup/RumAppStartupDetectorImpl.kt 96.97% <100.00%> (+0.38%) ⬆️
...ndroid/rum/prelaunch/AppLaunchCollectorProvider.kt 91.67% <91.67%> (ø)
...dog/android/rum/startup/WindowCallbacksRegistry.kt 92.31% <92.31%> (ø)
...rum/src/main/kotlin/com/datadog/android/rum/Rum.kt 83.33% <0.00%> (-3.09%) ⬇️
...ndroid/rum/startup/RumFirstDrawTimeReporterImpl.kt 86.44% <86.44%> (ø)
...m/datadog/android/rum/AppLaunchPreInitCollector.kt 82.42% <82.42%> (ø)
...lin/com/datadog/android/rum/internal/RumFeature.kt 89.23% <79.45%> (-3.59%) ⬇️

... and 36 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".

Comment on lines +819 to +820
val rumMonitor =
GlobalRumMonitor.get(sdkCore) as? AdvancedRumMonitor ?: return@addFirstFrameCallback

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +213 to +214
// Unregister lifecycle callbacks — we've captured what we need from the first Activity
registeredApplication?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +327 to +330
// 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +775 to +776
AppLaunchPreInitCollector.State.CAPTURING -> handleCapturingState(collector)
AppLaunchPreInitCollector.State.COMPLETE -> handleCompleteState(collector)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +872 to +875
scenario.activity.get()?.let { activity ->
when (capturedStrategy) {
is NavigationViewTrackingStrategy -> capturedStrategy.onActivityStarted(activity)
is ActivityViewTrackingStrategy -> capturedStrategy.onActivityResumed(activity)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +107 to +109
rumFeature.pendingPreLaunchAction?.let { action ->
rumFeature.pendingPreLaunchAction = null
Handler(Looper.getMainLooper()).post(action)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +879 to +880
rumMonitor.sendAppStartEvent(scenario)
rumMonitor.sendTTIDEvent(RumTTIDInfo(scenario = scenario, durationNs = durationNs))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

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

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

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?

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.

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?

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

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