Skip to content

Commit 2833c04

Browse files
committed
RUM-17613 Flush the pending timeseries batch on backgrounding
Replace the collector's four-state enum with a generation-counted state and schedule a 200 ms deferred suspension when the app leaves the foreground, so the buffered batch is written instead of dropped. Sampling only runs on a foreground view, which makes the collectInBackground switch obsolete. Ref: RUM-17613
1 parent a90a51b commit 2833c04

15 files changed

Lines changed: 490 additions & 235 deletions

detekt_custom_safe_calls_third_party.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,6 @@ datadog:
259259
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.api.SdkCore?, com.datadog.android.api.SdkCore?)"
260260
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?, com.datadog.android.flags.internal.repository.DefaultFlagsRepository.FlagsState?)"
261261
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI?, com.datadog.trace.bootstrap.instrumentation.api.AgentTracer.TracerAPI?)"
262-
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.rum.internal.timeseries.DefaultTimeseriesCollector.State?, com.datadog.android.rum.internal.timeseries.DefaultTimeseriesCollector.State?)"
263262
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(com.datadog.android.trace.api.tracer.DatadogTracer?, com.datadog.android.trace.api.tracer.DatadogTracer?)"
264263
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.String?, kotlin.String?)"
265264
- "java.util.concurrent.atomic.AtomicReference.compareAndSet(kotlin.collections.Set?, kotlin.collections.Set?)"
@@ -272,14 +271,12 @@ datadog:
272271
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.FlagsClientState?)"
273272
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.flags.model.ProviderContext?)"
274273
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.rum.internal.domain.RumContext?)"
275-
- "java.util.concurrent.atomic.AtomicReference.constructor(com.datadog.android.rum.internal.timeseries.DefaultTimeseriesCollector.State?)"
276274
- "java.util.concurrent.atomic.AtomicReference.constructor(kotlin.collections.Map?)"
277275
- "java.util.concurrent.atomic.AtomicReference.constructor(kotlin.String?)"
278276
- "java.util.concurrent.atomic.AtomicReference.constructor(kotlin.collections.Set?)"
279277
- "java.util.concurrent.atomic.AtomicReference.get()"
280278
- "java.util.concurrent.atomic.AtomicReference.getAndSet(kotlin.String?)"
281279
- "java.util.concurrent.atomic.AtomicReference.getAndSet(java.util.concurrent.Future?)"
282-
- "java.util.concurrent.atomic.AtomicReference.getAndSet(com.datadog.android.rum.internal.timeseries.DefaultTimeseriesCollector.State?)"
283280
- "java.util.concurrent.atomic.AtomicReference.set(android.app.Application.ActivityLifecycleCallbacks?)"
284281
- "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.api.FeatureEventReceiver?)"
285282
- "java.util.concurrent.atomic.AtomicReference.set(com.datadog.android.api.SdkCore?)"

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/RumFeature.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -459,7 +459,6 @@ internal class RumFeature(
459459
insightsCollector: InsightsCollector
460460
): TimeseriesCollector.Factory = DefaultTimeseriesCollectorFactory(
461461
internalLogger = sdkCore.internalLogger,
462-
collectInBackground = configuration.collectInBackground,
463462
scheduledExecutorService = vitalExecutorService,
464463
pipelinesProvider = { applicationId, sessionId, sessionType ->
465464
listOfNotNull(

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/scope/RumSessionScope.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ internal class RumSessionScope(
237237
timeseriesCollector = timeseriesCollectorFactory.create(
238238
sessionId = sessionId,
239239
applicationId = parentScope.getRumContext().applicationId,
240-
sessionType = getRumContext().resolveSessionType(rumSessionTypeOverride)
240+
sessionType = getRumContext().resolveSessionType(rumSessionTypeOverride),
241+
viewType = getActiveRumContext().viewType
241242
)
242243
timeseriesCollector.onSessionStart()
243244
}

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/DefaultTimeseriesCollector.kt

Lines changed: 147 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -11,102 +11,104 @@ import com.datadog.android.api.InternalLogger
1111
import com.datadog.android.core.internal.utils.scheduleSafe
1212
import com.datadog.android.rum.internal.domain.scope.RumViewType
1313
import java.util.concurrent.ScheduledExecutorService
14+
import java.util.concurrent.ScheduledFuture
1415
import java.util.concurrent.TimeUnit
15-
import java.util.concurrent.atomic.AtomicInteger
16-
import java.util.concurrent.atomic.AtomicReference
1716

1817
/**
1918
* Per-session timeseries collector.
2019
*
21-
* Lifecycle invariants (enforced by [onSessionStart]/[onSessionStop] idempotency):
20+
* Lifecycle:
2221
* - Each instance is single-use: exactly one [onSessionStart] followed by exactly one [onSessionStop].
23-
* - Multiple [onSessionStart] / [onSessionStop] calls are safe — duplicate calls are no-ops.
24-
* - After [onSessionStop], the instance must not be restarted; create a new instance.
22+
* Duplicate calls are no-ops, and `RumSessionScope` builds a fresh instance per session.
23+
* - [onViewTypeUpdate] is only meaningful between those two calls.
2524
*
26-
* Background sampling:
27-
* - When [collectInBackground] is `false`, [onViewTypeUpdate] pauses sampling on leaving foreground
28-
* and resumes it on returning to foreground.
25+
* Foreground gating:
26+
* - Sampling only runs while the app is in the foreground, so [onSessionStart] starts a sampling
27+
* chain only when the session begins on a foreground view.
28+
* - Leaving the foreground schedules a deferred suspension which flushes whatever is buffered, so
29+
* the pending batch reaches the writer instead of being dropped when the app is backgrounded.
30+
* - Returning to the foreground cancels a suspension that has not fired yet, or starts a fresh
31+
* sampling generation if it already did.
2932
*
3033
* Threading:
3134
* - [onSessionStart] / [onSessionStop] / [onViewTypeUpdate] are called from the RUM event-handler thread.
32-
* - Sampling tasks run on a [scheduledExecutorService] shared with other RUM components
33-
* (owned by the SDK core); the instance neither owns nor shuts it down.
34-
* - [Pipeline] is responsible for its own thread safety via internal synchronization.
35-
* - Duplicate-chain prevention: each sampling chain carries a generation number.
36-
* When [startSampling] starts a new generation, any in-flight or queued ticks from the
37-
* previous generation self-terminate on their first check.
35+
* - Sampling and suspension tasks run on a [scheduledExecutorService] shared with other RUM
36+
* components (owned by the SDK core); the instance neither owns nor shuts it down.
37+
* - Access to a [Pipeline] is serialized on the pipeline instance, so a flush from the suspension
38+
* task cannot interleave with a sampling tick.
39+
* - Duplicate-chain prevention: each sampling chain carries a generation number. When a new
40+
* generation starts, in-flight or queued ticks from the previous one self-terminate on their
41+
* first check.
3842
*/
3943
internal class DefaultTimeseriesCollector(
4044
private val internalLogger: InternalLogger,
4145
internal val pipelines: List<Pipeline<*>>,
42-
private val collectInBackground: Boolean,
43-
internal val scheduledExecutorService: ScheduledExecutorService
46+
internal val scheduledExecutorService: ScheduledExecutorService,
47+
@Volatile private var currentViewType: RumViewType?
4448
) : TimeseriesCollector {
4549

46-
private enum class State { IDLE, RUNNING, SUSPENDED, STOPPED }
47-
private val state = AtomicReference(State.IDLE)
48-
49-
// Incremented on every start/resume. Ticks carrying a stale generation self-terminate.
50-
private val currentGeneration = AtomicInteger(0)
50+
private val state = State()
5151

5252
@Volatile
53-
private var currentViewType: RumViewType? = null
53+
private var recentSuspension: ScheduledFuture<*>? = null
54+
55+
// region PUBLIC API
5456

5557
@WorkerThread
5658
override fun onSessionStart() {
57-
if (state.compareAndSet(State.IDLE, State.RUNNING)) {
58-
startSampling()
59-
}
59+
state.set(isActive = currentViewType.isForeground)?.let { generation -> scheduleSampling(generation) }
6060
}
6161

6262
@WorkerThread
6363
override fun onSessionStop() {
64-
if (state.getAndSet(State.STOPPED) != State.STOPPED) {
65-
pipelines.forEach { pipeline ->
66-
try {
67-
synchronized(pipeline, pipeline::flush)
68-
} catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
69-
internalLogger.log(
70-
level = InternalLogger.Level.ERROR,
71-
targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
72-
messageBuilder = { ERROR_FLUSH_FAILED },
73-
throwable = t
74-
)
75-
}
76-
}
64+
cancelSuspension()
65+
if (state.set(false) != null) {
66+
flushPipelines()
7767
}
7868
}
7969

8070
@WorkerThread
8171
override fun onViewTypeUpdate(newViewType: RumViewType) {
82-
if (newViewType == currentViewType) return
83-
val isEnterForeground = !currentViewType.isForeground && newViewType.isForeground
72+
val oldViewType = currentViewType
73+
if (newViewType == oldViewType) return
74+
75+
val isEnterForeground = !oldViewType.isForeground && newViewType.isForeground
76+
val isLeaveForeground = oldViewType.isForeground && !newViewType.isForeground
77+
8478
currentViewType = newViewType
85-
if (!collectInBackground) {
86-
if (isEnterForeground && state.compareAndSet(State.SUSPENDED, State.RUNNING)) {
87-
startSampling()
88-
} else if (!newViewType.isForeground) {
89-
state.compareAndSet(State.RUNNING, State.SUSPENDED)
90-
}
79+
80+
if (isLeaveForeground) {
81+
scheduleStop(state.currentGeneration)
82+
} else if (isEnterForeground) {
83+
scheduleSampling(generation = state.startGeneration())
9184
}
9285
}
9386

94-
private fun startSampling() {
95-
val generation = currentGeneration.incrementAndGet()
96-
pipelines.forEach { schedulePipeline(it, generation) }
87+
// endregion
88+
89+
// region SAMPLING
90+
91+
private fun scheduleSampling(generation: Int) {
92+
cancelSuspension()
93+
pipelines.forEach { pipeline -> scheduledExecutorService.schedulePipeline(pipeline, generation) }
9794
}
9895

99-
private fun schedulePipeline(pipeline: Pipeline<*>, generation: Int) {
100-
scheduledExecutorService.scheduleSafe(
101-
OPERATION_NAME,
96+
private fun ScheduledExecutorService.schedulePipeline(
97+
pipeline: Pipeline<*>,
98+
generation: Int
99+
) {
100+
scheduleSafe(
101+
TIMESERIES_OPERATION_NAME,
102102
pipeline.intervalMs,
103103
TimeUnit.MILLISECONDS,
104104
internalLogger
105105
) {
106-
if (!isActive(generation)) return@scheduleSafe
106+
if (!state.isGenerationActive(generation)) return@scheduleSafe
107107
try {
108-
synchronized(pipeline) {
109-
if (isActive(generation)) pipeline.execute()
108+
// The chain stays alive during the suspension delay, so a tick landing while the
109+
// app is already out of the foreground must skip the sample but keep the chain.
110+
if (currentViewType.isForeground) {
111+
synchronized(pipeline) { pipeline.execute() }
110112
}
111113
} catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
112114
internalLogger.log(
@@ -116,24 +118,109 @@ internal class DefaultTimeseriesCollector(
116118
throwable = t
117119
)
118120
} finally {
119-
if (isActive(generation)) schedulePipeline(pipeline, generation)
121+
if (state.isGenerationActive(generation)) schedulePipeline(pipeline, generation)
122+
}
123+
}
124+
}
125+
126+
@WorkerThread
127+
private fun flushPipelines() {
128+
pipelines.forEach { pipeline ->
129+
try {
130+
synchronized(pipeline, pipeline::flush)
131+
} catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
132+
internalLogger.log(
133+
level = InternalLogger.Level.ERROR,
134+
targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
135+
messageBuilder = { ERROR_FLUSH_FAILED },
136+
throwable = t
137+
)
138+
}
139+
}
140+
}
141+
142+
// endregion
143+
144+
// region SUSPENSION
145+
146+
private fun scheduleStop(stopRequestGeneration: Int) {
147+
recentSuspension = scheduledExecutorService.scheduleSafe(
148+
SUSPEND_OPERATION_NAME,
149+
SUSPEND_DELAY_MS,
150+
TimeUnit.MILLISECONDS,
151+
internalLogger
152+
) {
153+
if (!currentViewType.isForeground && state.stopGeneration(stopRequestGeneration)) {
154+
flushPipelines()
120155
}
121156
}
122157
}
123158

124-
private fun isActive(generation: Int): Boolean =
125-
state.get() == State.RUNNING && currentGeneration.get() == generation
159+
private fun cancelSuspension() {
160+
recentSuspension?.cancel(false)
161+
recentSuspension = null
162+
}
163+
164+
// endregion
126165

127166
internal companion object {
128-
const val OPERATION_NAME = "Timeseries sampling"
167+
const val TIMESERIES_OPERATION_NAME = "Timeseries sampling"
168+
const val SUSPEND_OPERATION_NAME = "Timeseries suspend"
129169
const val ERROR_SAMPLING_FAILED = "Timeseries sampling iteration failed; rescheduling next sample."
130-
const val ERROR_FLUSH_FAILED = "Timeseries flush on session stop failed."
170+
const val ERROR_FLUSH_FAILED = "Timeseries flush failed."
171+
172+
// Matches ActivityViewTrackingStrategy.STOP_VIEW_DELAY_MS, which guards the same race:
173+
// an Activity-to-Activity transition leaves no active view for a moment when the tracking
174+
// strategy stops the view on pause rather than on stop.
175+
const val SUSPEND_DELAY_MS = 200L
176+
131177
val RumViewType?.isForeground: Boolean
132178
get() = when (this) {
133179
RumViewType.FOREGROUND, RumViewType.APPLICATION_LAUNCH -> true
134180
RumViewType.BACKGROUND -> false
135181
RumViewType.NONE -> false
136182
null -> false
137183
}
184+
185+
private class State {
186+
// Written under the monitor only, but read outside of it, hence @Volatile: a stale
187+
// generation would make the guards below reject a live sampling chain or flush.
188+
@Volatile
189+
var currentGeneration: Int = 0
190+
private set
191+
192+
private var active: Boolean = false
193+
194+
fun isGenerationActive(generation: Int): Boolean = synchronized(this) {
195+
currentGeneration == generation && active
196+
}
197+
198+
/**
199+
* Always starts a fresh generation, so that a suspension pending on the previous one
200+
* can no longer stop the sampling chain. Returns the new generation.
201+
*/
202+
fun startGeneration(): Int = synchronized(this) {
203+
active = true
204+
++currentGeneration
205+
}
206+
207+
/**
208+
* Deactivates [generation] if it is still the current one.
209+
* Returns true when this call is the one that deactivated it.
210+
*/
211+
fun stopGeneration(generation: Int): Boolean = synchronized(this) {
212+
currentGeneration == generation && set(false) != null
213+
}
214+
215+
/** Returns the new generation if this call changed the state, null otherwise. */
216+
fun set(isActive: Boolean): Int? = synchronized(this) {
217+
if (isActive == active) {
218+
null
219+
} else {
220+
active = isActive
221+
++currentGeneration
222+
}
223+
}
224+
}
138225
}
139226
}

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/DefaultTimeseriesCollectorFactory.kt

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ package com.datadog.android.rum.internal.timeseries
88

99
import com.datadog.android.api.InternalLogger
1010
import com.datadog.android.rum.RumSessionType
11+
import com.datadog.android.rum.internal.domain.scope.RumViewType
1112
import java.util.concurrent.ScheduledExecutorService
1213

1314
internal class DefaultTimeseriesCollectorFactory(
1415
private val internalLogger: InternalLogger,
15-
private val collectInBackground: Boolean,
1616
private val scheduledExecutorService: ScheduledExecutorService,
1717
private val pipelinesProvider: (
1818
applicationId: String,
@@ -21,11 +21,15 @@ internal class DefaultTimeseriesCollectorFactory(
2121
) -> List<Pipeline<*>>
2222
) : TimeseriesCollector.Factory {
2323

24-
override fun create(applicationId: String, sessionId: String, sessionType: RumSessionType) =
25-
DefaultTimeseriesCollector(
26-
internalLogger = internalLogger,
27-
collectInBackground = collectInBackground,
28-
scheduledExecutorService = scheduledExecutorService,
29-
pipelines = pipelinesProvider(applicationId, sessionId, sessionType)
30-
)
24+
override fun create(
25+
applicationId: String,
26+
sessionId: String,
27+
sessionType: RumSessionType,
28+
viewType: RumViewType?
29+
) = DefaultTimeseriesCollector(
30+
internalLogger = internalLogger,
31+
scheduledExecutorService = scheduledExecutorService,
32+
currentViewType = viewType,
33+
pipelines = pipelinesProvider(applicationId, sessionId, sessionType)
34+
)
3135
}

features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/timeseries/TimeseriesCollector.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ internal interface TimeseriesCollector {
1818

1919
@NoOpImplementation
2020
interface Factory {
21-
fun create(applicationId: String, sessionId: String, sessionType: RumSessionType): TimeseriesCollector
21+
fun create(
22+
applicationId: String,
23+
sessionId: String,
24+
sessionType: RumSessionType,
25+
viewType: RumViewType?
26+
): TimeseriesCollector
2227
}
2328
}

0 commit comments

Comments
 (0)