Skip to content

feat: keep sessions and replays missed by sampling until the session errors - #36

Open
Fiona2016 wants to merge 37 commits into
publishfrom
feat/session-on-error
Open

Fiona2016 wants to merge 37 commits into
publishfrom
feat/session-on-error

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds error-session capture to RUM and Session Replay.

RumConfiguration.Builder.setSessionOnError(Boolean)

Default false. Remote key: rum.sessionOnError.

  • What it does: sessions the session sample rate does not pick enter a new WITHHELD state. Their events are assembled and run through the event mappers, then held in memory by WithheldEventWriter instead of being written.
  • On error: the last 60 seconds of events are released once, after a 0–3 s delay derived from a hash of the session id. Views go first (oldest first), then errors, then the rest. Every held event is released; views only set the order. The session is then collected normally.
  • Without an error: the session is discarded when it expires, renews or stops. Late events of discarded sessions are dropped.
  • Limits: 60 s window, 64 KiB / 200 non-view events, 50 views. Successful requests and long tasks are evicted first, errors last.
  • Release triggers:
    • Only errors that survive the mappers trigger a release.
    • A JVM crash releases synchronously inside the crash write, including when the crash event is oversized.
    • setForcedSession() releases immediately.
    • A pending release is sent as soon as the app goes to the background.
  • Session listener: reports these sessions as discarded, and getCurrentSessionId returns null until they are released.
  • NDK crashes and fatal ANRs: reported on the next launch for these sessions, at rate 0 with the marker.

setSessionReplayOnError(Boolean) on the Session Replay configuration

Default false. Remote key: rum.sessionReplayOnError.

  • What it does: replays the replay sample rate does not pick are recorded, but records and images are held in memory per session. Replays of sessions whose events are withheld are held too.
  • Window: the held replay is cut only at full snapshots and keeps the last 60 s across views, capped at 4 MiB. The first released segment always starts with Meta, Focus and a full snapshot.
  • Release: the held replay is released together with the withheld events, after their delay. A replay never reaches the intake before the session's views.
  • Replay counts: records_count and has_replay count only records that were actually written. On release, session.has_replay is set on released views and events whose view has replay records.

Reporting

  • Events of withheld sessions report _dd.configuration.session_sample_rate: 0.
  • View events carry session.sampled_for_error and session.sampled_for_error_replay. session.sampled_for_replay is reported only when an on-error mode applies to that session.
  • Customers who use neither switch produce byte-for-byte unchanged events.

Remote configuration

  • A rate of 0 with the switch on no longer resets a withheld session.
  • Toggling the switch while the rate is 0 resets the session.
  • beforeSampling returning 0 also turns sessionOnError off for that session.

Behaviour change for all users

  • RumViewEventFilter still keeps only the latest version of each view in a batch, but now places it at the view's first position instead of the position of its last write. A release followed by view updates in the same batch therefore keeps views ahead of errors and details. The set of uploaded events is unchanged.

Known limitations

  • After a JVM crash, a held replay is lost with the process; the held events are released.
  • An app that is force-stopped within about a second of an error can lose the release, because the write is still in flight.

Testing

  • RUM: 2385 unit tests, 0 failures.
  • Session Replay: 2492 unit tests, 19 failures. The same 19 mapper test classes also fail on publish with JDK 21; they are a pre-existing Typeface.DEFAULT reflection error.
  • ktlint: clean on the touched files.
  • Emulator end-to-end, against a local capture intake, with each scenario paired with a negative control:
    • no error → 0 requests;
    • an error → views first, the markers set, and the rate reported as 0;
    • the mapper dropping the error → nothing is sent;
    • a JVM crash → handled;
    • setForcedSession() → released immediately;
    • remote configuration at rate 0 with the switch on → the session is not reset;
    • replay type 3 → released;
    • replay type 5 → released after the events, with has_replay set;
    • a cut at 75 s → the first segment starts with Meta, Focus and a full snapshot;
    • a normal Session Replay customer → the view session keys are unchanged.

…n error

Adds RumConfiguration.Builder.setSessionOnError and the remote
configuration keys rum.sessionOnError / rum.sessionReplayOnError
(absent keeps the init value; the console wins over init).

A session the session sample rate does not keep is drawn as WITHHELD
when the switch is on: it is assembled as usual but its events go to an
in-memory WithheldEventWriter instead of the batch. Events are held
after the event mappers have run and serialized, so an error dropped by
a mapper releases nothing. The buffer keeps the last 60s, at most 64KiB
and 200 non-view events plus 50 views (latest update per view, current
view never evicted), evicting successful requests and long tasks first
and errors last, newest first.

The first error of the session releases the buffer after a per-session
jitter of 0-3s (String.hashCode based), with the window frozen when the
release is scheduled; views go first oldest to newest, then errors, then
the rest. A JVM crash releases synchronously within the crash write, an
oversized error is written on its own, and setForcedSession releases
immediately. When the session expires, is renewed or stopped, an
unreleased buffer is dropped and its id remembered (last four) so late
events of that session are dropped too; a session that had errored is
released instead. Withdrawn consent drops what is held.

Withheld sessions report a session sample rate of 0, view events carry
session.sampled_for_error, the session listener sees them as discarded
and getCurrentSessionId answers null until release. The last view is
still written locally, so a native crash or fatal ANR is reported at the
next launch with the marker and rate 0.

A console reset no longer ends a session kept on error while the rate
is 0 and the switch is on; a switch change at rate 0 now resets the
running session, and beforeSampling returning 0 turns the switch off.
… session errors

Adds RumConfiguration.Builder.setSessionReplayOnError (remote key
rum.sessionReplayOnError, latched per session at the draw).

RUM now tells Session Replay, on the session bus message, whether the
session's events are kept on error, the replay switch it was drawn
under, and whether it has reported its error. Session Replay keeps its
own replay draw and holds the records of two kinds of session in memory
instead of writing them: a collected session whose replay the rate
missed while the switch is on, and a session whose events are withheld,
whichever way the replay draw went, since its replay has nothing to
attach to until the events are released. The error releasing a
replay-only session is judged after the event mappers, like the events'
own; forcing the session releases too.

Held records are cut only at full snapshots, keeping from the newest full
snapshot at least a minute old, so the release spans the last minute
across views and starts playable. View record counts and has_replay are
only updated for records actually written, so nothing held and thrown
away is ever claimed. A session that ends unreleased drops its records,
and records of that session still queued are dropped too.

View events carry session.sampled_for_error_replay for replays kept on
error and session.sampled_for_replay, which counts a held replay whose
events are held too.
Session Replay resources now go through the record writer, which holds
them with the records of a withheld session and writes or drops them
together; a resource hash is remembered as sent only once it is written.

The held replay is released when the session's events actually are -
after the jitter, by an explicit bus message from the event buffer - not
when the error is first seen. Released events and the releasing error
claim session.has_replay when their view has replay records (sent or
held), counted by Session Replay apart from records_count. A release
waiting for the jitter goes at once when the app leaves the foreground,
and an oversized crash releases the history right after it.

The release forwards every held detail, views only setting the order,
so an error whose view was never held or was evicted still goes out.
The held replay gains a 4MiB bound (images bounded apart), cut at full
snapshots, and a release cut at a periodic full snapshot starts with
the view's meta and focus records.

sampled_for_replay is only reported for sessions in an on-error mode,
and Session Replay leaves its feature context untouched when nothing is
withheld, so views of customers who did not opt in are unchanged.
…n in a batch

The upload filter keeps only the latest version of each view in a batch.
It used to keep that version where it was written, so when a released
error session's buffer (views first, oldest first, then errors and
details) shared a batch with the live updates written right after the
release - always the case for a crash, which updates the view's crash
count - the views ended up last and out of start order. The intake
builds the session out of the first view it sees.

The latest version now takes the place of the view's first occurrence
in the batch; which versions survive is unchanged.
…ur consent and never evict a crash

Session Replay now keeps aside the replay of a session whose events were
withheld until RUM says whether that session was released or thrown away,
instead of discarding it as soon as the next session announces itself:
RUM's word lands on the storage thread and can arrive after the next
session has started. RUM sends an explicit discard for such a session, and
a stopped session draining beside its successor no longer announces itself
as current.

Images captured while a replay is withheld are held across sessions and
sent only with released records that show them, since the recorder
captures an image once per process. Records and images captured while
tracking consent is withdrawn are neither held nor sent, and what was held
is dropped. The replay byte budget is enforced on every record.

A crash is never evicted from the withheld event buffer, even when it does
not fit alongside an earlier large error. A session kept on error is only
ended by the emergency stop, not by a console rate leaving zero.
…rop buffers on consent withdrawal

Session Replay keeps aside every replay whose session ended while held,
including a collected session's replay held on error, until RUM says what
became of it; RUM now reports the end of such a session too. Several ended
sessions can wait at once, since storage may lag more than one session
behind. A stopped session draining beside its successor no longer draws a
session of its own, which ended the running session's buffer, and a late
error of a stopped session no longer unmarks the running session's release.
Session Replay handles RUM's announcements and fates under one lock.

Both features now observe tracking consent directly: what was held in
memory under a consent since withdrawn is dropped even when no event comes
by to notice. A replay span over the byte budget is dropped rather than
kept, a sent record flushes the held images it shows, and a view's meta
and focus survive the consent drop so the resumed replay stays playable.
…withheld session's data

A withheld session that ends without an error now also forgets the view it
wrote locally for the native crash reporter, so a crash of a later
uncollected session is not reported against it. Both writers remember more
discarded sessions, since a stopped session keeps draining its requests
while the sessions after it come and go. A stopped session keeps its own
state for what it still drains instead of expiring. An SDK stop flushes a
release still waiting for its jitter.

Session Replay notes RUM's word on an ended session the moment it is
given, so a released session is never the one thrown away when too many
ended sessions wait, and a view's meta and focus survive the drop of a
span over the byte budget. A view of a session whose replay was released
claims the replay as soon as the records exist, so the final view of a
stopped session does not replace the released one without it.

The web view consumers leave the replay and the replay claim alone while
the native replay of the session is withheld.
…t rate 0 on app-launch vitals

The recorder captures each image once per process. An image held for a
replay kept on error and then dropped unsent - evicted over the image
budget or cleared when consent is withdrawn - was never captured again, so
a replay released later showed a placeholder for it. The writer now tells
the recorder what it dropped; the recorder forgets those ids and resolves
such an image again from its drawable the next time it is shown, instead
of answering from its caches.

The app-launch vitals of a session kept on error report a session sample
rate of 0, like every other event of such a session.
… view's replay markers once

The core takes a feature out of its registry before stopping it, so the
flush of a release still waiting for its jitter could not look the RUM
scope up at stop time: the scope is kept from initialization.

A view of a withheld session keeps its Session Replay entry when it
completes, so it can still claim the replay held for it when the session
is released; Session Replay drops the entry once nothing is sent or held
for the view. A view's replay markers stay as resolved once they are
true, so a late update written after another session took over the
replay context still describes its own session.

The replay switch alone no longer restarts a running session on an
immediate configuration change: a replay draw is made once per session.
…eport a withheld session's last view however old

A session already released holds nothing more once consent is withdrawn:
its events go to the batch, where consent decides, instead of through the
buffer, where a release granted later would have carried out what was
collected while consent was withdrawn.

An image first captured while consent is withdrawn is forgotten by the
recorder like one dropped from the held store, so it is captured again;
the forgotten ids are bounded, and only the view in progress keeps its
start across a consent drop. More discarded sessions are remembered,
since a request can outlive many sessions.

The native crash reporter writes the last view of a session kept on error
with the crash however old the view is: that session uploaded nothing
before it crashed, so this view is the only one the intake will get.
…hing for a stopped session thrown away

The SDK stop ends the session kept on error with it: released if it had
reported its error, thrown away if not, along with the view it wrote
locally - a crash after the stop must not be reported against a session
that never errored. The write scope only queues that work, and the core
shuts its executor down without draining it once the features are stopped,
so the stop waits for the queue to get there, the way a crash does.

A stopped session still draining its requests hands its views nothing to
write with while it is withheld: it was thrown away at the stop, and
remembering its id for a while could not cover a request that outlives
dozens of later sessions. A stopped session that had errored is collected
by then and keeps writing.
Settling the withheld session at the stop looked the write scope up
synchronously, which waits on the context thread without a bound; a stop
asked for from the RUM thread - a session listener, say - found that
thread waiting on the RUM thread in turn, and the stop never returned,
holding the SDK registry lock with it.

The stop now queues the work through the ordinary write scope and waits,
with a bound, for the persistence thread to have run it. A stop asked
for from inside the SDK's own pipeline waits the bound out and goes on
without the work, which is what it did before, minus the hang.
…sion Replay stops

The features stop in no particular order. Session Replay only heard that
a replay may go out when RUM actually released the events, after the
jitter, and a stop in between left it stopping with the replay still held
and nobody left to tell it. RUM now says so the moment the releasing error
is written, Session Replay notes it, and its own stop writes every replay
whose session reported an error, throws the rest away, and waits - with a
bound - for that to have run.
… an oversized error after its views, and flush held replay on stop

- A JVM crash releases the held events in the crash write, but the replay
  held in memory dies with the process. Released views and the crash claimed
  `session.has_replay` from the held records as well, so the session
  advertised a replay that never reached storage. On a crash, events now
  claim only the records already written. The same applies to the crash of
  a collected session whose replay only is withheld.
- An error larger than the whole budget was written on its own ahead of
  the session's views, and alone if the app died during the jitter. The
  history is now released at once, with the error after its views.
- The core unregisters a feature before stopping it, so Session Replay's
  stop could not find its own feature and never wrote the replay a
  released session had earned. The record writer now keeps the feature
  scope it found.
…ew contributes to a session kept on error

- The release telemetry counted what the buffer held, not what storage
  accepted. It now also reports `buffer.write_failures`.
- WebView events are not held while a session waits for its error, so an
  error raised in a WebView does not keep the session. Say so on
  `setSessionOnError`.
- Pin that a fatal ANR of a session kept on error is reported with its last
  view however old, as an NDK crash already is.
…ge to hold

An image over the held-replay budget was dropped without telling the
recorder, which captures each image once per process, so no later replay
that showed it would ever carry it.
…ts records are held

A session that reported its error turns TRACKED at once, but its release
waits for the jitter. A view completing in between removed its Session
Replay context entry, so the released view and its events could no longer
claim the replay written right after them. A completed view whose records
are still held is now only marked completed, and Session Replay drops the
entry once none of its records is held. This also stops the entries of
released views from staying behind for good.

Also say on `setSessionOnError` that app launch vitals are not part of a
session kept on error.
…top without waiting when nothing is held

- A crash release no longer claims the held replay, but still told Session
  Replay to write it. Written in the crash handler's last moments, it was
  stored with no event to open it from. A crash now releases the events
  only.
- Stopping the SDK queued a settle step and waited up to two seconds in
  both RUM and Session Replay, for every customer. It now returns at once
  when no session is withheld or watched and nothing is held.
- Session Replay's release telemetry reports `buffer.write_failures`, like
  RUM's.
- Say on `setSessionReplayOnError` that WebView content is not recorded
  while the replay is held, and that a replay held at a crash is lost.
… ever engaged, and drop a held replay a crash did not claim

- The stop skipped its settle step when no session was withheld, held or
  watched. That state is set by storage work which may still be queued
  when the stop is asked, and the core drops queued work once the
  features are stopped, so a session that had earned its release - or
  its replay - could be lost. The stop now decides from a flag set on the
  caller's thread before any such work is queued. A customer who never
  turned either switch on still pays nothing at the stop.
- A crash release claims no held replay, but left it held. If the process
  survived the crash, as when a wrapper reports a fatal error, the replay
  was written at the stop with nothing to open it from. Session Replay is
  now told to drop it.
- Say on `setSessionOnError` that an app killed while a release is being
  written can leave a kept session whose error never reached storage.
…eleased it

A crash release told Session Replay to drop the session's replay, which
also marks the session as thrown away there. When the process survives,
as when a wrapper reports a fatal error and carries on, every later
record of that still-collected session was dropped. The replay now waits
instead: the session's next event lets it out and the views written from
then on claim it. If the process dies, the held replay goes with it.
… claim it

A crash told Session Replay to expect the replay's release before the
crash release, which lets no replay out, ran. Should the process survive
with no later event of that session, Session Replay's stop wrote the held
replay with nothing to open it from. A crash no longer announces the
release, and a session that ends after outliving its crash drops the
replay. Also say that images of a replay released by the SDK's stop may
not be written before it shuts down.
@guguji5

guguji5 commented Oct 21, 2026

Copy link
Copy Markdown

codex发现了5个问题

[P1] 迟到崩溃会先写 error、后写唯一的 view,并且忽略 error 被 mapper 丢弃的结果
DatadogLateCrashReporter.kt:96-101 和 :154-162
对异常补采会话而言,磁盘里的 last view 是后端从未见过的唯一 view。目前 NDK crash 和 fatal ANR 都先写 error,再写 view,违反文档要求的“view → error”顺序。更严重的是,rumWriter.write(error) 的返回值被忽略:如果 error mapper 丢弃崩溃,后续 view 仍会上传,产生一个没有错误却被补采、可能被计费的会话。
应先执行 error mapper/序列化;只有 error 存活时,才按 view-first 顺序写入这对事件。

[P1] WebView 错误完全不能触发异常会话补采
WebViewRumEventConsumer.kt:32-60
withheld 会话的状态是 WITHHELD,这里仅接受 TRACKED,所以错误前的 WebView view/action/resource/error 全部丢弃;WebView error 本身也不会进入 WithheldEventWriter,因此无法释放会话。代码甚至在 RumConfiguration.kt:128-131 将其公开为限制,但需求没有批准这个例外,功能描述覆盖的是整个会话的错误与前情。

[P2] 撤回 consent 会丢掉已经获得释放资格的会话
RumFeature.kt:516-520、WithheldEventWriter.kt:392-395、SessionReplayFeature.kt:242-246
只要 consent 变为 NOT_GRANTED,事件与回放缓存都会无条件清空,包括已经出现有效错误、正在等待抖动释放的会话。需求明确规定:已经报错的仍应释放,因为数据采集时处于同意状态;只有尚未报错的缓存才应丢弃。当前实现还可能让回放保持 withheld,却再也收不到最终 fate。

[P2] 超过 64KiB 的错误绕过了 0–3 秒抖动
WithheldEventWriter.kt:206-216
实现把超大错误塞入 details 后立即释放整个历史。需求要求超大错误直接走正常写入,而历史仍按原抖动释放。当前行为会让相关的大错误在故障时集中冲击 intake,正好绕过抖动机制要解决的问题。

[P2] SDK stop 只等待回放 records,没有等待对应图片资源落盘
SessionReplayRecordWriter.kt:188-196、:301-326,以及 SessionReplayFeature.kt:189-212
release() 通过另一个 feature 异步提交资源写入,但紧接着就调用 onDone(),stop latch 因而提前完成。SDK 随后可能停止或移除 resources feature,导致已释放的 records 引用不存在的图片;公开 KDoc 中“images may not be written”正是这一问题。stop 应等待 records 和相关 resources 全部完成。

opus也认同了这5个问题


我逐条核对了同事列的 5 个问题,全部成立,引用的行号也和 origin/feat/session-on-error@8cf97e966 一致。同意暂缓合并。

上一轮我把第 2、3、4 条当作「作者在注释里写明的取舍」,把第 1、5 条漏掉了。按 PRD 来看,这 5 条都应该算缺陷。

1. [P1] 迟到崩溃先写 error,并且忽略 error 被丢弃——成立

  • mapper 丢弃: 迟到崩溃用的是 RumFeature.dataWriter,也就是 RumDataWriter,不经过扣留缓冲。error mapper 返回 null 时,write 返回 false,但两处调用都没看返回值,接着照样写 view。对异常补采会话来说,这会上传一个没有错误的会话,而且会被计费。
  • 写入顺序: 先写 error、后写 view。RumViewEventFilter 只会把 view 的最新版本放回它第一次出现的位置,不会把它挪到 error 前面,所以同一批里还是 error 在前。普通会话的 view 早就上传过,这个顺序没影响;但对异常补采会话,这个 view 是后端唯一能拿到的 view。
  • 修法: 先调用 RumDataWriter.serialize(error),返回 null 就整对放弃。error 存活时,先写 view,再用 writeSerialized 写 error。只对 sampledForError 的会话这样处理,普通会话保持现有行为,这样改动最小。

2. [P1] WebView 错误不能触发补采——成立

WebViewRumEventConsumer.kt:44 只接受 "TRACKED",扣留中的会话状态是 "WITHHELD"。所以 WebView 的 view、action、resource 和 error 全部被丢掉,error 也不会进入 WithheldEventWriter。RumConfiguration.kt:128-131 把这个当成对外限制写了出来,但 PRD 没有批准这个例外。

修起来不轻:

  • WebView 事件是 JsonObject,写进的是 web-rum 自己的存储,不是 RUM 的批次。现有缓冲不能直接复用,需要给 WebView 单独做一层扣留,或者把它接到 RUM 的缓冲里。
  • 还要把「WebView 出了 error、而且属于当前会话」这个信号传给 RUM,让它标记放行。
  • WebView 的回放也要跟着扣留和放行。

3. [P2] 撤回 consent 会丢掉已经有资格放行的会话——成立,而且比描述的更糟

除了缓冲被清空,我顺着状态往下走,确认了「回放再也收不到最终结果」这一点:

  • dropForConsent 把 withheldForSessionId 置空。等待抖动的那次放行检查这个 id,于是不再执行,releaseReplay 从来不会被调用。
  • 回放模块在错误出现时已经收到过 expectReplayRelease,状态是「放行待定」,所以一直处于扣留状态。RUM 之后发来的消息里,sessionReleased 也始终是 false。
  • 会话结束时 endSession 直接返回,不发 release 也不发 discard。
  • 结果是这个会话后面的回放全部留在内存里,最多 4 MiB,一直到 SDK stop 才处理。

修法要注意一个限制: TrackingConsentProvider.setConsent 是先改 consent、再通知回调。回调执行时 ConsentAwareFileOrchestrator 已经切到 NO_OP,这时在回调里放行,写入会被静默丢弃。想按 PRD「已出错的仍放行」来做,需要 core 提供一个在 consent 切换之前同步执行的钩子,或者接受偏差并在文档里写明。无论选哪种,回放这边都要补上:丢掉已放行的会话时,要给回放模块一个最终结果(discard 或重置状态),不能让它一直挂着。

4. [P2] 超过 64 KiB 的错误绕过了抖动——成立

WithheldEventWriter.kt:206-216 遇到超大错误就立刻放行整个历史。注释里的理由是:如果错误按 PRD 直接上报,会比它所在会话的 view 先到达后端,而 Web 也确实是这样做的。

建议的折中:把超大错误放进一个不占预算的单独槽位,照常走 scheduleReleaseOnce 的抖动。这样既保留抖动,又能保证 view 在错误前面。崩溃那条路径保持立即同步写入。如果要和 Web 严格一致,就按 PRD 原文直接上报。

5. [P2] stop 不等图片资源落盘——成立,而且通常是写不进去

  • release() 里的 sendResources 通过 SessionReplayResourcesWriter,异步把写入投递到 resources feature 的上下文线程和存储线程。stop() 在同一个任务里接着调用 onDone(),latch 提前放开。
  • DatadogCore.stop() 是先 features.remove(it)?.stop()。如果 resources feature 已经先被移除,getFeature(...) 返回 null,写入被直接跳过。
  • 即使还没被移除,之后 coreFeature.stop() 也会对执行器调用 shutdownNow(),队列里的写入被丢掉。

修法:

  • 像 featureScope 那样提前缓存 resources feature 的 scope。
  • stop 时用 getWriteContextSync 同步写入资源,或者给每个资源写入计数(成功和失败都算),全部完成后才调用 onDone()。
  • 修好以后,把 setSessionReplayOnError KDoc 里「images may not be written」那句删掉。

修复优先级

先修第 1 条和第 5 条,范围小、方案明确,第 4 条的折中方案也比较直接。第 2 条和第 3 条需要先拍板:WebView 要不要纳入首版,consent 撤回是改 core 还是接受偏差。

guguji5 and others added 4 commits October 21, 2026 20:17
- Late crash: write the last view ahead of the crash for a session kept
  on error, and write nothing when the crash cannot be serialized.
- WebView: hold the events of a withheld session with the native ones;
  a page error (not `source: agent`) releases the session, and releases
  a replay kept on error for a collected session.
- Consent: a release scheduled under granted consent runs at once
  through a write scope taken then when consent is withdrawn, replay
  and images included; one scheduled while consent was pending is
  dropped. A release that runs out its jitter writes under the consent
  in force then.
- Oversized errors: an error larger than the 64 KiB budget is written at
  once and not held; the history keeps its own jitter.
- SDK stop: wait for the images of a released replay to be written.
- In-process crash: the view update of the crash itself no longer lets
  the held replay out.
…urvives the mapper, view first

For a session kept on error, the last view written locally is the only one
the intake will ever get and the crash is the only reason it is kept. The
late NDK crash and fatal ANR paths wrote the crash first and ignored its
result, so a crash dropped by the error mapper still uploaded the view: a
kept session with no error. The crash is now serialized first; the pair is
dropped if the mapper drops it, and otherwise the view goes first, as in
every release. Collected sessions keep their current behaviour.
…e replay of a released session on consent withdrawal

- An error over the buffer budget released the whole history at once,
  skipping the jitter that spreads correlated releases. It is now kept
  apart, out of the budget and bounded to a few, and leaves with the
  history after the jitter, behind its views. A crash still leaves at once.
- Withdrawing consent while a released session waited for its jitter
  dropped what it held but never told Session Replay, which kept holding
  that session's replay until the SDK stopped. The session goes on
  collected, so Session Replay is now told its replay is released.
The stop called back as soon as the released records were written, while
their images were still queued through the resources feature; and the core
unregisters a feature before stopping it, so the images were often skipped
altogether. The resources writer now keeps the scope it found while
registered, the stop calls back only once the queued images are in
storage, and the resources feature lets Session Replay settle before it
stops, whichever of the two the core stops first.
@Fiona2016

Copy link
Copy Markdown
Collaborator Author

感谢逐条核对。5 条的处理如下,已推送到 d3414b523:

1. 迟到崩溃先写 error、忽略 mapper 丢弃 —— 已修 e8497a9e0

对 sampledForError 的会话,先 serialize 崩溃事件:mapper 丢弃就整对放弃,不写 view;保留下来就先写 view、再用 writeSerialized 写 error。NDK crash 和 fatal ANR 两条路径都改了。普通会话保持原有行为。

2. WebView 错误不能触发补采 —— 首版不纳入

iOS 0.7.0 已按同样方式发布:扣留期间丢弃 WebView 事件,WebView 错误不触发放行。要纳入,需要给 WebView 单独做一层扣留、把 WebView 错误作为放行信号传给 RUM、WebView 回放也一起扣留和放行,而且 iOS 要同步改。首版维持 setSessionOnError / setSessionReplayOnError KDoc 里写明的限制,后续三端一起补。

3. 撤回 consent —— 丢弃保持,回放挂起已修 8cfe93345

撤回时连已出错的一起丢弃,是原生端的既定取舍:core 在 NOT_GRANTED 下拒绝一切写入,正如你分析的,回调里放行也写不进去。这一点保持不变。
「回放永远收不到最终结果」是真 bug:dropForConsent 丢掉一个已放行的会话后,RUM 会话仍然继续被采集,所以现在给回放发「已放行」。回放之后正常写入,写入仍受 consent 约束,不再挂到 SDK stop。

4. 超大错误绕过抖动 —— 已修 8cfe93345

采用你建议的折中:超过 64 KiB 的错误单独存放,不占预算,最多保留 4 条,超出的计入 dropped_count。它们跟历史一起在抖动后放行,排在 view 之后。崩溃路径仍然立即同步写入。

5. stop 不等图片落盘 —— 已修 d3414b523

  • SessionReplayResourcesWriter 在构造时缓存 resources feature 的 scope。构造时 feature 已经在注册表里,core 在 stop 前把它移除后也还能写。
  • ResourcesWriter 新增 afterQueuedWrites,走和 write 相同的两级队列。两个 executor 都是单线程 FIFO,所以回调一定在已入队的图片写完之后执行。SessionReplayRecordWriter.stop 改为在这之后才调用 onDone。
  • ResourcesFeature.onStop 会先让 Session Replay 结算一次(settleHeldReplay,可重复调用)。所以不管 core 先停哪个 feature,图片都能在 resources storage 换成 NoOp 之前写入。
  • KDoc 里「images may not be written」那句已删除。

验证

  • 新增 9 个测试,逐条撤掉修复时对应测试都会失败。
  • RUM 2437、WebView 175 全部通过。Session Replay 2531 个中 19 个失败,与 publish 上的 *MapperTest 基线一致(JDK 21 下 Typeface.DEFAULT 反射问题),没有新增。
  • CodeQL 通过。

@guguji5

guguji5 commented Oct 22, 2026

Copy link
Copy Markdown

感谢逐条核对。5 条的处理如下,已推送到 d3414b523:

1. 迟到崩溃先写 error、忽略 mapper 丢弃 —— 已修 e8497a9e0

对 sampledForError 的会话,先 serialize 崩溃事件:mapper 丢弃就整对放弃,不写 view;保留下来就先写 view、再用 writeSerialized 写 error。NDK crash 和 fatal ANR 两条路径都改了。普通会话保持原有行为。

2. WebView 错误不能触发补采 —— 首版不纳入

iOS 0.7.0 已按同样方式发布:扣留期间丢弃 WebView 事件,WebView 错误不触发放行。要纳入,需要给 WebView 单独做一层扣留、把 WebView 错误作为放行信号传给 RUM、WebView 回放也一起扣留和放行,而且 iOS 要同步改。首版维持 setSessionOnError / setSessionReplayOnError KDoc 里写明的限制,后续三端一起补。

3. 撤回 consent —— 丢弃保持,回放挂起已修 8cfe93345

撤回时连已出错的一起丢弃,是原生端的既定取舍:core 在 NOT_GRANTED 下拒绝一切写入,正如你分析的,回调里放行也写不进去。这一点保持不变。 「回放永远收不到最终结果」是真 bug:dropForConsent 丢掉一个已放行的会话后,RUM 会话仍然继续被采集,所以现在给回放发「已放行」。回放之后正常写入,写入仍受 consent 约束,不再挂到 SDK stop。

4. 超大错误绕过抖动 —— 已修 8cfe93345

采用你建议的折中:超过 64 KiB 的错误单独存放,不占预算,最多保留 4 条,超出的计入 dropped_count。它们跟历史一起在抖动后放行,排在 view 之后。崩溃路径仍然立即同步写入。

5. stop 不等图片落盘 —— 已修 d3414b523

  • SessionReplayResourcesWriter 在构造时缓存 resources feature 的 scope。构造时 feature 已经在注册表里,core 在 stop 前把它移除后也还能写。
  • ResourcesWriter 新增 afterQueuedWrites,走和 write 相同的两级队列。两个 executor 都是单线程 FIFO,所以回调一定在已入队的图片写完之后执行。SessionReplayRecordWriter.stop 改为在这之后才调用 onDone。
  • ResourcesFeature.onStop 会先让 Session Replay 结算一次(settleHeldReplay,可重复调用)。所以不管 core 先停哪个 feature,图片都能在 resources storage 换成 NoOp 之前写入。
  • KDoc 里「images may not be written」那句已删除。

验证

  • 新增 9 个测试,逐条撤掉修复时对应测试都会失败。
  • RUM 2437、WebView 175 全部通过。Session Replay 2531 个中 19 个失败,与 publish 上的 *MapperTest 基线一致(JDK 21 下 Typeface.DEFAULT 反射问题),没有新增。
  • CodeQL 通过。

结论:同事的 3 个提交里,1 和 5 解决了;2、3、4 没有按 PRD 解决。其中 2 是明确推迟;3 和 4 是另选了方案,而且这两条都和我们在 fix/session-on-error-review 里已经对齐 PRD 的做法冲突。

# 同事的处理 是否解决
1 迟到崩溃 e8497a9 解决
2 WebView 不修,首版不纳入 未解决,需要产品拍板
3 撤回 consent 已报错的会话照样丢弃,只修了 replay 一直挂着的问题 部分解决,不符合 PRD §4.4
4 超大错误 单独存放、随历史一起抖动、最多 4 条 不符合 PRD
5 stop 不等图片 d3414b5 解决

1. 迟到崩溃:解决。 判断条件 isOnlyKnownHere 就是 session.sampledForError == true。先序列化崩溃事件;被 mapper 丢掉就整对不写,保留下来就先写 view、再写 error。NDK 崩溃和 fatal ANR 两条路径都覆盖了,和我的修法等价。

5. stop 不等图片:解决。 思路和我的不同,但成立:

  • afterQueuedWrites 走和写图片相同的两级队列(context executor 和 storage executor),两个都是单线程、先进先出,所以回调一定在已入队的图片写完之后才执行。
  • SessionReplayResourcesWriter 是在 resources feature 注册之后才构造的(onInitialize 里先 registerResourceFeature,再 createDataWriter),所以缓存的 scope 不会是空的。
  • ResourcesFeature.onStop 会先调用 settleHeldReplay,所以不管 core 先停哪个 feature,图片都能写进去。

2. WebView:未解决。 这是范围上的取舍,理由是 iOS 0.7.0 也是这么发布的。技术上能做,我的分支已经实现并在真机验证过。纳不纳入需要你或产品来定。如果维持现状,应该把 PRD 改掉,而不是只在 KDoc 里写一句限制。

3. 撤回 consent:不符合 PRD。 同事的理由是「core 在 NOT_GRANTED 下拒绝一切写入,回调里放行也写不进去」。这只对回调时才去拿的写入 scope 成立。

  • 我的修法是在排期放行的那一刻(当时还是 GRANTED)就提前拿好写入 scope,撤回 consent 时用这个 scope 写。
  • 昨天真机实测:consent 在 348.632 撤回,RUM 批次在 348.638、replay 在 348.705、图片在 348.731 依次落盘。所以「写不进去」这个前提不成立,PRD §4.4 是能做到的。
  • 他修的「replay 收不到最终结果、一直挂到 SDK stop」是真 bug,修法合理。但在我的方案里,已报错会话的 replay 本来就会用提前拿好的 scope 放出去,不会挂住。

4. 超大错误:不符合 PRD。 同事采用的「单独存放、不占预算、随历史一起抖动」是我在第一轮 review 里提的折中建议,这一点我得说清楚。后来你决定对齐 PRD 原文:超大错误立即写入,历史照常等抖动。他的版本还有两个问题:

  • 最常见的超大错误是 ANR(带全部线程的堆栈)。如果用户在抖动的 0 到 3 秒里杀掉 app,这条 ANR 会和历史一起丢掉。
  • 超过 4 条的超大错误会被直接丢弃,只计入 dropped_count;按 PRD 的做法一条都不会丢。

两个分支的关系

fix/session-on-error-review 是基于 8cf97e966 拉出来的。同事的 3 个提交改了同样的文件(WithheldEventWriter、DatadogLateCrashReporter、Session Replay 的 writer 和 feature),我的 PR 现在合进 feat/session-on-error 一定会冲突。

可以这样处理:

  • 1 和 5:用同事的实现,把我分支里对应的改动去掉。
  • 2、3、4:保留我的实现,把我分支 rebase 到 d3414b523 上,解决冲突后重跑测试和真机验证。
  • 前提:先和同事对齐 2、3、4 到底按 PRD 做,还是按他现在的取舍做。尤其是第 3 条,他的理由已经被真机结果推翻了。

Fiona2016 and others added 10 commits October 22, 2026 00:34
…ettle the replay of a released session on consent withdrawal"

This reverts commit 8cfe933.
… if it survives the mapper, view first"

This reverts commit e8497a9.
fix(rum): settle the review findings of session on error
…hort

A web page in a tracked WebView sets the content of the events held for a
withheld session, so nothing it sends may be able to take the app down:

- Numbers read from WebView events (date, resource status code) are parsed
  through a double. Gson's asLong threw NumberFormatException on a literal
  such as 1e99999999999 on the storage thread, which killed the process.
- Held view events get a byte bound of their own (256 KiB). They were only
  bounded in count, so 50 large views could exhaust the heap. A view over
  the bound alone is not held. Views left with nothing in the window are
  pruned before evicting by bytes.
- A WebView view dated in the future no longer stays the current view over
  the native views after it.
- Session Replay remembers the meta and focus records of at most 16 views.

Settling a withheld session or a held replay at SDK stop now waits at most
500 ms each instead of 2 s: the stop is usually called on the main thread,
and the core's own shutdown already waits up to 3 s.

Documents that remote configuration also controls the on-error switches, and
that with session on error enabled the event mappers run for every session.
…und the held images by count

A stopped session that expires while it still drains now becomes EXPIRED
again, as it does upstream, so the events it drains are no longer written.
Only a session involved in on-error capture keeps its own state while it
drains. Before this, a stopped session of an application using neither
switch kept writing after its inactivity timeout.

The images held for a withheld replay are bounded in count (512) as well as
in bytes: every entry also retains its filename and write callback.
…-error capture was used

The background callback that flushes a release waiting for its jitter
submitted a write-scope task on every Activity stop, also for applications
using neither switch. It now does so only once the withheld writer was
engaged, like the SDK stop already does.
…is used

The handling added for a stopped session that drains beside its successor
(no new draw once it expires or times out, no announcement to Session
Replay) also applied to applications using neither switch, which changed
their behaviour: a stopped session reaching its maximum duration was no
longer renewed, and Session Replay no longer heard from stopped sessions.
Both now apply only once the withheld writer was engaged, where they keep a
draw from taking the running session's buffer.

The RUM thread also no longer reads the writer's replay state - and takes
its lock, which the storage thread can hold across a file write - on every
event of an application that never used on-error capture.
…read the release state without the lock

The stopped-session handling (no new draw, no announcement to Session
Replay) was gated on the withheld writer having been engaged. An application
with session on error switched on whose sessions were all sampled in never
engages it, so its stopped sessions went back to redrawing on the next
interaction. It now applies wherever on-error capture is switched on, here or
by the console, or was already used.

The Activity lifecycle callback that flushes a waiting release is registered
only where on-error capture can be used: either switch on, or remote
configuration enabled.

The RUM thread reads whether a session or its replay was released through
volatile fields instead of the writer's lock, which the storage thread can
hold across a file write.
fix(rum): bound what a withheld session holds and keep the SDK stop short
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.

2 participants