Skip to content

Commit 61448ac

Browse files
committed
RUM-17613: Stop collecting timeseries in background and flush the batch
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. The collector now tracks the whole RumContext and attributes a batch flushed outside the foreground to the last foreground context, which makes the collectInBackground switch obsolete. Ref: RUM-17613
1 parent 27abec6 commit 61448ac

14 files changed

Lines changed: 752 additions & 385 deletions

File tree

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/domain/scope/RumSessionScope.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,11 @@ internal class RumSessionScope(
201201
}
202202
}
203203

204-
timeseriesCollector.onViewTypeUpdate(getActiveRumContext().viewType)
204+
// getActiveRumContext() copies the whole context chain, so it is only built when there is a
205+
// collector to feed: timeseries collection is opt-in and this runs on every RUM event.
206+
if (timeseriesCollector !is NoOpTimeseriesCollector) {
207+
timeseriesCollector.onRumContextUpdate(getActiveRumContext())
208+
}
205209

206210
return if (isSessionComplete()) {
207211
null

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

Lines changed: 138 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,104 +9,85 @@ package com.datadog.android.rum.internal.timeseries
99
import androidx.annotation.WorkerThread
1010
import com.datadog.android.api.InternalLogger
1111
import com.datadog.android.core.internal.utils.scheduleSafe
12+
import com.datadog.android.rum.internal.domain.RumContext
1213
import com.datadog.android.rum.internal.domain.scope.RumViewType
1314
import java.util.concurrent.ScheduledExecutorService
15+
import java.util.concurrent.ScheduledFuture
1416
import java.util.concurrent.TimeUnit
15-
import java.util.concurrent.atomic.AtomicInteger
16-
import java.util.concurrent.atomic.AtomicReference
17-
18-
/**
19-
* Per-session timeseries collector.
20-
*
21-
* Lifecycle invariants (enforced by [onSessionStart]/[onSessionStop] idempotency):
22-
* - 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.
25-
*
26-
* Background sampling:
27-
* - When [collectInBackground] is `false`, [onViewTypeUpdate] pauses sampling on leaving foreground
28-
* and resumes it on returning to foreground.
29-
*
30-
* Threading:
31-
* - [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.
38-
*/
17+
3918
internal class DefaultTimeseriesCollector(
4019
private val internalLogger: InternalLogger,
4120
internal val pipelines: List<Pipeline<*>>,
42-
private val collectInBackground: Boolean,
43-
internal val scheduledExecutorService: ScheduledExecutorService
21+
internal val scheduledExecutorService: ScheduledExecutorService,
22+
@Volatile private var rumContext: RumContext
4423
) : TimeseriesCollector {
24+
private val state = State()
4525

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)
26+
// Batches are attributed to the view they are sent from, but a flush can happen once the app
27+
// already left the foreground, where there is no view to attribute to. Keep the last foreground
28+
// context so those batches stay attached to the view they were sent from.
29+
@Volatile
30+
private var lastForegroundRumContext: RumContext = rumContext
5131

5232
@Volatile
53-
private var currentViewType: RumViewType? = null
33+
private var recentSuspension: ScheduledFuture<*>? = null
5434

35+
// region PUBLIC API
5536
@WorkerThread
5637
override fun onSessionStart() {
57-
if (state.compareAndSet(State.IDLE, State.RUNNING)) {
58-
startSampling()
59-
}
38+
state.set(isActive = rumContext.viewType.isForeground)?.let { generation -> scheduleSampling(generation) }
6039
}
6140

6241
@WorkerThread
6342
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-
}
43+
cancelSuspension()
44+
if (state.set(false) != null) {
45+
flushPipelines(lastForegroundRumContext)
7746
}
7847
}
7948

8049
@WorkerThread
81-
override fun onViewTypeUpdate(newViewType: RumViewType) {
82-
if (newViewType == currentViewType) return
83-
val isEnterForeground = !currentViewType.isForeground && newViewType.isForeground
84-
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-
}
50+
override fun onRumContextUpdate(newRumContext: RumContext) {
51+
val oldViewType = rumContext.viewType
52+
val newViewType = newRumContext.viewType
53+
54+
val isEnterForeground = !oldViewType.isForeground && newViewType.isForeground
55+
val isLeaveForeground = oldViewType.isForeground && !newViewType.isForeground
56+
57+
rumContext = newRumContext
58+
if (newViewType.isForeground) lastForegroundRumContext = newRumContext
59+
60+
if (isLeaveForeground) {
61+
scheduleStop(state.currentGeneration)
62+
} else if (isEnterForeground) {
63+
scheduleSampling(generation = state.startGeneration())
9164
}
9265
}
9366

94-
private fun startSampling() {
95-
val generation = currentGeneration.incrementAndGet()
96-
pipelines.forEach { schedulePipeline(it, generation) }
67+
// endregion
68+
69+
//region SAMPLING
70+
71+
private fun scheduleSampling(generation: Int) {
72+
cancelSuspension()
73+
pipelines.forEach { pipeline -> scheduledExecutorService.schedulePipeline(pipeline, generation) }
9774
}
9875

99-
private fun schedulePipeline(pipeline: Pipeline<*>, generation: Int) {
100-
scheduledExecutorService.scheduleSafe(
101-
OPERATION_NAME,
76+
private fun ScheduledExecutorService.schedulePipeline(
77+
pipeline: Pipeline<*>,
78+
generation: Int
79+
) {
80+
scheduleSafe(
81+
TIMESERIES_OPERATION_NAME,
10282
pipeline.intervalMs,
10383
TimeUnit.MILLISECONDS,
10484
internalLogger
10585
) {
106-
if (!isActive(generation)) return@scheduleSafe
86+
if (!state.isGenerationActive(generation)) return@scheduleSafe
10787
try {
108-
synchronized(pipeline) {
109-
if (isActive(generation)) pipeline.execute()
88+
val currentRumContext = rumContext
89+
if (currentRumContext.viewType.isForeground) {
90+
pipeline.execute(currentRumContext)
11091
}
11192
} catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
11293
internalLogger.log(
@@ -116,24 +97,109 @@ internal class DefaultTimeseriesCollector(
11697
throwable = t
11798
)
11899
} finally {
119-
if (isActive(generation)) schedulePipeline(pipeline, generation)
100+
if (state.isGenerationActive(generation)) schedulePipeline(pipeline, generation)
101+
}
102+
}
103+
}
104+
105+
@WorkerThread
106+
private fun flushPipelines(rumContext: RumContext) {
107+
pipelines.forEach { pipeline ->
108+
try {
109+
pipeline.flush(rumContext)
110+
} catch (@Suppress("TooGenericExceptionCaught") t: Throwable) {
111+
internalLogger.log(
112+
level = InternalLogger.Level.ERROR,
113+
targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY),
114+
messageBuilder = { ERROR_FLUSH_FAILED },
115+
throwable = t
116+
)
120117
}
121118
}
122119
}
123120

124-
private fun isActive(generation: Int): Boolean =
125-
state.get() == State.RUNNING && currentGeneration.get() == generation
121+
// endregion
122+
123+
// region SUSPENSION
124+
125+
private fun scheduleStop(stopRequestGeneration: Int) {
126+
recentSuspension = scheduledExecutorService.scheduleSafe(
127+
SUSPEND_OPERATION_NAME,
128+
SUSPEND_DELAY_MS,
129+
TimeUnit.MILLISECONDS,
130+
internalLogger
131+
) {
132+
if (!rumContext.viewType.isForeground && state.stopGeneration(stopRequestGeneration)) {
133+
flushPipelines(lastForegroundRumContext)
134+
}
135+
}
136+
}
137+
138+
private fun cancelSuspension() {
139+
recentSuspension?.cancel(false)
140+
recentSuspension = null
141+
}
142+
143+
// endregion
126144

127145
internal companion object {
128-
const val OPERATION_NAME = "Timeseries sampling"
146+
const val TIMESERIES_OPERATION_NAME = "Timeseries sampling"
147+
const val SUSPEND_OPERATION_NAME = "Timeseries suspend"
129148
const val ERROR_SAMPLING_FAILED = "Timeseries sampling iteration failed; rescheduling next sample."
130-
const val ERROR_FLUSH_FAILED = "Timeseries flush on session stop failed."
149+
const val ERROR_FLUSH_FAILED = "Timeseries flush failed."
150+
151+
// Matches ActivityViewTrackingStrategy.STOP_VIEW_DELAY_MS, which guards the same race:
152+
// an Activity-to-Activity transition leaves no active view for a moment when the tracking
153+
// strategy stops the view on pause rather than on stop.
154+
const val SUSPEND_DELAY_MS = 200L
155+
131156
val RumViewType?.isForeground: Boolean
132157
get() = when (this) {
133158
RumViewType.FOREGROUND, RumViewType.APPLICATION_LAUNCH -> true
134159
RumViewType.BACKGROUND -> false
135160
RumViewType.NONE -> false
136161
null -> false
137162
}
163+
164+
private class State {
165+
// Written under the monitor only, but read outside of it, hence @Volatile: a stale
166+
// generation would make the guards below reject a live sampling chain or flush.
167+
@Volatile
168+
var currentGeneration: Int = 0
169+
private set
170+
171+
private var active: Boolean = false
172+
173+
fun isGenerationActive(generation: Int): Boolean = synchronized(this) {
174+
currentGeneration == generation && active
175+
}
176+
177+
/**
178+
* Always starts a fresh generation, so that a suspension pending on the previous one
179+
* can no longer stop the sampling chain. Returns the new generation.
180+
*/
181+
fun startGeneration(): Int = synchronized(this) {
182+
active = true
183+
++currentGeneration
184+
}
185+
186+
/**
187+
* Deactivates [generation] if it is still the current one.
188+
* Returns true when this call is the one that deactivated it.
189+
*/
190+
fun stopGeneration(generation: Int): Boolean = synchronized(this) {
191+
currentGeneration == generation && set(false) != null
192+
}
193+
194+
/** Returns the new generation if this call changed the state, null otherwise. */
195+
fun set(isActive: Boolean): Int? = synchronized(this) {
196+
if (isActive == active) {
197+
null
198+
} else {
199+
active = isActive
200+
++currentGeneration
201+
}
202+
}
203+
}
138204
}
139205
}

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

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ internal class DefaultTimeseriesCollectorFactory(
4545
val pipelines = mutableListOf<Pipeline<*>>()
4646

4747
if (TimeseriesType.CPU in configuration.enabledTypes) {
48-
pipelines += createCpuPipeline(sessionType, rumContext, customAttributes)
48+
pipelines += createCpuPipeline(sessionType, customAttributes)
4949
}
5050

5151
if (TimeseriesType.MEMORY in configuration.enabledTypes) {
5252
if (totalRamBytes > 0L) {
53-
pipelines += createMemoryPipeline(sessionType, rumContext, customAttributes)
53+
pipelines += createMemoryPipeline(sessionType, customAttributes)
5454
} else {
5555
sdkCore.internalLogger.log(
5656
InternalLogger.Level.WARN,
@@ -63,17 +63,13 @@ internal class DefaultTimeseriesCollectorFactory(
6363

6464
return DefaultTimeseriesCollector(
6565
internalLogger = sdkCore.internalLogger,
66-
collectInBackground = configuration.collectInBackground,
6766
scheduledExecutorService = scheduledExecutorService,
67+
rumContext = rumContext,
6868
pipelines = pipelines
6969
)
7070
}
7171

72-
private fun createMemoryPipeline(
73-
sessionType: RumSessionType,
74-
rumContext: RumContext,
75-
customAttributes: () -> Map<String, Any?>
76-
) = Pipeline(
72+
private fun createMemoryPipeline(sessionType: RumSessionType, customAttributes: () -> Map<String, Any?>) = Pipeline(
7773
sdkCore = sdkCore,
7874
reader = VitalReaderWrapper(
7975
vitalReader = MemoryVitalReader(internalLogger = sdkCore.internalLogger),
@@ -90,16 +86,11 @@ internal class DefaultTimeseriesCollectorFactory(
9086
internalLogger = sdkCore.internalLogger
9187
),
9288
dataWriter = dataWriter,
93-
rumContext = rumContext,
9489
customAttributes = customAttributes,
9590
insightsCollector = insightsCollector
9691
)
9792

98-
private fun createCpuPipeline(
99-
sessionType: RumSessionType,
100-
rumContext: RumContext,
101-
customAttributes: () -> Map<String, Any?>
102-
) = Pipeline(
93+
private fun createCpuPipeline(sessionType: RumSessionType, customAttributes: () -> Map<String, Any?>) = Pipeline(
10394
sdkCore = sdkCore,
10495
reader = CpuDatapointReader(
10596
cpuStatReader = CpuStatReader(internalLogger = sdkCore.internalLogger),
@@ -115,7 +106,6 @@ internal class DefaultTimeseriesCollectorFactory(
115106
internalLogger = sdkCore.internalLogger
116107
),
117108
dataWriter = dataWriter,
118-
rumContext = rumContext,
119109
customAttributes = customAttributes,
120110
insightsCollector = insightsCollector
121111
)

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,26 @@ internal class Pipeline<T : Any>(
2727
private val buffer: Buffer<T>,
2828
private val eventFactory: EventFactory<T, *>,
2929
private val dataWriter: DataWriter<Any>,
30-
private val rumContext: RumContext,
3130
private val customAttributes: () -> Map<String, Any?>,
3231
private val insightsCollector: InsightsCollector = NoOpInsightsCollector()
3332
) {
3433
val intervalMs: Long get() = reader.intervalMs
3534

3635
@WorkerThread
37-
fun execute() {
38-
reader.read()?.let(buffer::add)
39-
if (buffer.isFull()) drainAndWrite()
36+
fun execute(rumContext: RumContext) {
37+
// reader.read() may hit the filesystem (/proc); kept outside the lock so a concurrent
38+
// flush() waits only for the buffer and never for I/O.
39+
val dataPoint = reader.read()
40+
synchronized(this) {
41+
dataPoint?.let(buffer::add)
42+
if (buffer.isFull()) drainAndWrite(rumContext)
43+
}
4044
}
4145

4246
@WorkerThread
43-
fun flush() = drainAndWrite()
47+
fun flush(rumContext: RumContext) = synchronized(this) { drainAndWrite(rumContext) }
4448

45-
private fun drainAndWrite() {
49+
private fun drainAndWrite(rumContext: RumContext) {
4650
val dataPoints = buffer.drain().ifEmpty { return }
4751
// Snapshotted here and not at serialization time: serialization runs later on the core
4852
// context thread, by then the RUM monitor owning those attributes may already be

0 commit comments

Comments
 (0)