Skip to content

✨ add session sampling and error-session capture (sessionOnError) - #29

Merged
Fiona2016 merged 35 commits into
publishfrom
feat/session-on-error
Oct 15, 2026
Merged

Fiona2016 merged 35 commits into
publishfrom
feat/session-on-error

Conversation

@Fiona2016

@Fiona2016 Fiona2016 commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds session sampling and error-session capture to the Electron SDK.

  • sessionSampleRate (0–100, default 100): sessions are now drawn in the main process. The default keeps the existing behaviour, where every session is collected.
  • sessionOnError (default false): sessions the draw did not pick are still collected, but held in memory in the main process. They are uploaded only if the session reports an error: the last 60 seconds of main-process and renderer events are released once after a 0–3 s per-session delay, then collection continues normally. Sessions that never error are discarded.
    • Budget: 60 s window, 64 KiB / 200 non-view events, 50 views.
    • Eviction order: successful requests and long tasks first, errors last.
    • Release order: views oldest first, then errors, then the rest.
    • Only non-SDK errors of the current session trigger a release. Renderer errors dropped by the renderer's beforeSend never arrive.
    • Release happens immediately on before-quit and on a main-process uncaught exception.
    • A native crash in a held session is still reported on the next launch, together with its last view.
  • Events of held sessions report _dd.configuration.session_sample_rate: 0, and their views carry session.sampled_for_error: true.
  • For sessions that were not sampled, the bridge reports no session to renderers, so renderers do not record or upload replay for them.

Known limitation

Held sessions still upload renderer Session Replay when sessionReplayDirectUpload is enabled, because direct upload bypasses the main process. The README advises against combining it with sessionOnError.

Testing

  • Unit tests: 879 passed (vitest). Typecheck, e2e typecheck, rollup build and prettier pass; the only ESLint errors are the two pre-existing ones in untouched files.
  • Playwright end-to-end: 72/72 pass, including 20 sessionOnError scenarios with negative controls:
    • no error → 0 events; session end without an error → discarded, stragglers included;
    • main-process error → buffered main and renderer events released with markers; renderer beforeSend dropping the error → nothing;
    • uncaught exception → immediate release; host ending the process from its own uncaughtException listener → history and fatal error on disk, nothing at the intake;
    • an error reported in the same turn as process.exit(), from a will-quit listener, in the same turn as app.exit(), and from the host's own process exit listener → on disk;
    • a renderer envelope calling itself telemetry, and a renderer error with a fractional date → neither uploaded nor releasing the session; a renderer error with a fractional value in its free-form context → uploaded as it is, releasing the session; a renderer error with its status code as a string → neither uploaded nor releasing the session, while the same error with a number does;
    • rate 0 without the switch → nothing; a drawn session → unmarked, with its rate;
    • native crash in a held session, with its view, and with the view history gone (under a view of its own); native crash in an unsampled session → nothing.

Behaviour change for all users: view error counts and native crashes

The main-process view's error.count now counts only errors that belong to that view. Before, every main-process error incremented the counter of whichever view was current when the error was reported, which is not the same thing for a native crash: a crash is reported on the launch after it happened and belongs to the view that was running then, yet it bumped the new launch's view — along with its document_version and a scheduled view update — while the view it belonged to never got the count. With this change the crashed view carries error.count: 1 (rebuilt from the view history, or reported under its own view id when that history is gone) and the current view's count reflects its own errors only. This applies regardless of sessionSampleRate or sessionOnError: any application that reports native crashes will see the new launch's first view stop showing an error it did not have, and the previous launch's view show the one it did. Dashboards that summed crashes off the current view's counter will see them move to the view the crash occurred in. The crash error event itself is unchanged.

Behaviour change for all users: renderer event types and exit flush

  • Only the six RUM event types are accepted from a renderer through the bridge. Anything else is dropped and reported through SDK telemetry. Only telemetry produced by the SDK in the main process bypasses the withheld buffer.
  • Pending events are written synchronously on before-quit, will-quit and the process exit event. An error reported while quitting, or in the same turn as process.exit() / app.exit(), therefore reaches disk and is uploaded on the next launch.
  • Session and history state files are replaced atomically in request order. Malformed records read back from disk are treated as absent, which leads to a fresh draw instead of uploading.

Behaviour changes for all users: exit, recovery and renderer input

  • Once the process exit event has started, every event is written to disk as it arrives — an error reported from the application's own exit listener, registered after init(), reaches the batch file too. Before that point the flush runs on before-quit, will-quit and the start of exit, as described above.
  • A native crash recovered on the next launch is released at once rather than after the 0–3 s delay, and its minidump is deleted only once the report is in a batch file. A dump whose report could not be written is kept and reported on a later launch.
  • A renderer event carrying a fraction in a field the intake decodes as an integer (date, say) is dropped at the bridge and reported through SDK telemetry, since the intake would drop it silently; such an error can no longer release a withheld session. Telemetry messages about dropped renderer input no longer quote the input.
  • A saved session state is read as a legacy, pre-sampling state only when it carries none of the sampling fields; one with a damaged or partial draw (a null tracking type, a rate without a type, a rate outside 0–100) starts a fresh session instead of resuming as collected.
  • A main-process error whose context cannot be serialized (a BigInt, a circular reference) no longer prevents the session's release: the history is uploaded and the error itself is dropped by the batch, as it was for drawn sessions.
  • An oversized view update from a renderer (over 64 KiB) is dropped by the withheld buffer; the version held before it keeps the view's place.

Behaviour changes for all users: renderer input validation and crash dump retention

  • A bridge message carrying a property named __proto__, constructor or prototype anywhere — in the event or around it — is dropped at the bridge and reported through SDK telemetry: merged into an event, such a key reaches the prototype of every object in the main process.
  • A renderer event is checked only at the paths the intake decodes as integers (date, durations, counts, timestamps, the values of view.custom_timings, per event type — taken from the intake's own schema): every value there must be a whole number — a fraction, a string, a boolean or an object is refused, as the intake refuses it; absent and null are accepted, as the intake decodes either as zero. Free-form attributes — context, usr, feature_flags, custom attributes — are never inspected, so an error with context: { price: 19.99 } is accepted and uploaded as it is, and releases a withheld session like any other error.
  • A native crash dump is deleted only once its report is confirmed in a batch file: a batch write that fails (a full or read-only userData volume) keeps the dump for the next launch to report, and is reported through SDK telemetry.
  • Telemetry about a bridge message that could not be parsed carries only the message's length, never its text.

Add `sessionSampleRate` (0-100, default 100; an out-of-range value fails
init) and `sessionOnError` (default false). Each session is drawn once in
the main process and keeps its decision when resumed: drawn sessions report
as before, sessions not drawn report nothing, and with `sessionOnError` the
sessions the rate missed are held in memory and uploaded only once they
report an error.

- SessionManager draws the tracking type and persists it, with the error
  mark, in `_dd_s` and in the session history.
- SessionContext discards the events of sessions not drawn and the late
  events of withheld sessions that ended without an error, and stamps
  `session.sampled_for_error` on views and
  `_dd.configuration.session_sample_rate` (0 for on-error sessions) on
  main-process and renderer events alike.
- WithheldEventBuffer sits between assembly and the RUM batch: last 60s,
  64 KiB / 200 events plus 50 views, tiered eviction, release ordered
  views / errors / rest behind a per-session jitter of up to 3s, frozen
  window once scheduled, oversized releasing errors sent on their own,
  discard on session end, immediate release on session end, quit and
  uncaught main-process exceptions. Telemetry bypasses it.
- A native crash reported on the next launch releases its withheld
  session and brings its view, rebuilt from the view history.
- The bridge answers no session id for sessions not drawn, so renderers
  record nothing for them.
- Telemetry gains a debug log, used to report each release.
Scenarios against the fake intake: nothing uploaded for a withheld session
that never errors; release of main-process and renderer history with the
on-error markers; renderer errors dropped by beforeSend do not release;
discard on session end; release at once on an uncaught exception; native
crash of a withheld session reported with its view; and the negative
controls (drawn session unmarked at its rate, undrawn session uploads
nothing and renderers see no session).
- SessionManager writes its state through one promise chain, so a slow
  activity write can no longer land over the error mark; the current
  session's draw and mark live in a single copy.
- WithheldEventBuffer loses its unused stop(); an oversized error is
  measured once.
- RumCollection hands the ViewContext to ViewCollection and to the crash
  path directly, sharing its MainView type.
Accept extra released event types such as a renderer long task, and give
the scenarios, which sleep past the release jitter, a longer budget.
…release before returning

- Mark a withheld session's error on the history entry in force at the time
  of the error, plus the active entry when the session is the current one,
  rather than on every entry of the session. An error of the current launch
  no longer makes a native crash of the previous launch look already
  released, which left the crash without its view and dropped it at release.
- Chain the session file delete behind the queued state writes, so a save
  queued just before the session ended cannot recreate the file and resume
  an ended session on the next launch.
- Write the release triggered by an uncaught exception or a quit before
  returning (BatchProducer.postSync): the queued write never ran when the
  host exited from its own uncaughtException listener.
- Never evict the view that holds an error past the view limit: the error
  was dropped at release for want of its view.
- Persist the rate a session was drawn at and report that one, so a session
  resumed under a changed configuration, or saved before sessions were
  sampled, is not extrapolated by a rate that did not draw it.
- Attribute the telemetry of a session the draw did not keep to no session,
  as the browser SDK does.
- Reword the sessionOnError warning, and the README on what an exit does.
…on only once it is released

- Move exit-time durability from the withheld buffer to the transport: the
  batch producer keeps what is posted in a queue, and on APP_MAY_EXIT (an
  uncaught exception, or now a quit) Transport writes the queue before
  returning. The buffer's own synchronous sink is gone with it. An expiry
  release queued just before a quit, an oversize error forwarded on its own,
  and the fatal error of a drawn session are no longer lost to the exit.
- Name batch files strictly increasingly: several rotations within one
  millisecond shared a name, and the later .log replaced the earlier one.
- Mark the session as errored when its buffer is released, not when the
  error arrives: the mark is what tells a crash reported on the next launch
  that the crashed view reached the batch, and before the release it had
  not, so the crash was reported without its view.
- Never evict the view just updated past the view limit: the view a crash
  brings along arrives before the crash, and was evicted by the time the
  crash needed it.
- Write the session file only while the session is active, and not from an
  activity read that completed after it expired: either write brought an
  ended session back on the next launch.
- Close the previous launch's history entry on startup, resumed session or
  not: an open entry is never pruned, and the file grew with every launch
  that resumed its session.
…ts view

A detail whose view was no longer held — evicted past the view limit, or a
renderer event dated in the previous session while the renderer had not
yet noticed the renewal — was dropped at release. An error session must
not lose its error or its history; the views only order the release. An
ended view with no detail left is still pruned.
… is issued

- An event left the batch queue before its append was issued, while the
  directory check or a rotation was still awaited; an exit flush in that
  window found nothing to write and lost it. It now leaves the queue only
  as the append is issued, and the queue skips it if the exit flush wrote
  it first.
- Orphan recovery never renames a .tmp onto a .log that exists: an append
  still in flight could recreate a .tmp a synchronous rotation had just
  renamed, and the next launch replaced the earlier batch with it.
- For a crash of a session that was resumed and is current, leave the mark
  to the release of what this launch holds, like any other error: a mark
  written while the rebuilt view still waited in the buffer told a second
  crash that the view had reached the batch when it had not.
- Run upload cycles one after another instead of skipping a flush while a
  cycle runs, so that flush() resolves only once what it was called for is
  uploaded; the e2e scenario no longer pads it with a sleep.
… and never share a file between the two writers

- An event that cannot be serialized (a BigInt in an error context, say)
  rejected the batch queue's chain for good, and a failed upload cycle did
  the same to the upload chain: nothing was written or uploaded after it.
  The event is dropped like a failed write, and a failed cycle is reported
  to the flush that ran it while the next cycle runs regardless.
- A large event is appended in several chunks; a line the exit flush wrote
  to the same file between two of them corrupted both. The exit flush now
  starts a new batch while an append is in flight and leaves that file to
  the append, which rotates it once done.
- A size rotation that completed after the exit flush had rotated the same
  batch and started a new one reset the batch state, leaving the new batch
  an untracked .tmp until the next launch. The reset now applies only when
  the batch rotated is still the current one.
- Telemetry formatted an error before checking whether it would send it,
  so a sampled-out telemetry could throw on a value it cannot serialize
  from inside an error path and lose the error being reported.
- The main process's sampling marker is assigned on bridged events rather
  than merged, so one a renderer supplied is replaced, absent included.
…its uncaughtException listener

The existing uncaught-exception scenario keeps the process alive and
flushes, so it passed with the exit-time write removed. The new scenario
ends the process from the application's own listener, registered after
the SDK's, and reads the batch files off disk: the held history and the
fatal error are there, and nothing reached the intake.
…ith the exit flush

- The withheld buffer measured events with browser-core's byte count,
  which takes an ASCII fast path and otherwise reaches for
  window.TextEncoder — undefined in the main process. Holding an error
  whose text was not ASCII threw, the session was never released, and an
  exit then had nothing to write. Bytes are counted with Buffer.
- The exit flush wrote the released batch synchronously but left the
  session's error mark to an asynchronous write, so a host that ended the
  process from its uncaughtException listener left the next launch
  resuming the session as withheld again. The flush now writes the session
  state and history too, and the history is serialized when a write runs
  rather than when it is queued, so an earlier queued write never lands an
  older state.
- The view of an error forwarded on its own for exceeding the budget had
  no protection against eviction past the view limit; a renderer view
  arriving during the jitter could push it out before the release.
- The synchronous batch rotation now also avoids a .log that exists, as a
  clock set back can make a new launch reuse an earlier launch's name.
- The main view counts only its own events: a crash reported on the next
  launch is rebuilt with its own view and count, and was counted twice.
- Docs: an append already issued is not completed by the exit flush; the
  application's uncaughtException listener must be registered after init.
- Tests: the host-exit scenario asserts the child's exit code; mock
  implementations no longer leak across cases; capped telemetry case.
…s file, and age the buffer by a monotonic clock

- The exit flush wrote the session state and history in place, where a
  write of the same file already issued could overwrite or interleave
  with it. Both are now written to a file of their own and renamed into
  place.
- A session ended with stopSession() right before a fatal exit kept its
  file: its delete was queued behind the writes. The exit flush deletes it.
- The view counter guard read the event's view before its type; a
  telemetry event assembled after the main view closed has none, and
  reporting an SDK failure threw. The type decides first.
- The withheld buffer aged its events by the wall clock, so a clock
  correction before the error pruned the minute it promises. It ages by
  the monotonic clock, as the browser SDK does.
… of its own

A native crash is reported on the next launch and attributed to the view
in force when it happened; when that view was no longer in the view
history, the crash was discarded at assembly for want of a container, and
a withheld session was left unreleased on top. A session must not lose
its crash: the session is released regardless, and the crash goes out
under a view id of its own, with the session's markers and a rate of 0,
when none can be rebuilt. The RUM hooks take the view a raw event names
itself as a fallback to the one in force at its time.
…along with the exit flush

- The exit flush ran on before-quit only. An error reported after the
  SDK's listener — by a listener registered later, during will-quit, or in
  the same turn as process.exit() or app.exit() — stayed behind its
  release jitter and was lost. The flush now runs on before-quit,
  will-quit and the process exit event, each pass taking what arrived
  since the one before; the exit event is what process.exit() and
  app.exit() still run.
- Telemetry bypasses the withheld buffer by provenance now, not by its
  type field: the SDK's own telemetry is assembled without a source,
  while a renderer's event carries one whatever it calls itself.
- A flush rotating the current batch detaches it before awaiting the
  rename: a synchronous write meanwhile opens a new batch instead of
  recreating the file the rename is taking away, which left it an
  untracked .tmp until the next launch.
- The synchronous write creates the track directory once, not per item.
… read back

- The session state and history files were written in place by the
  asynchronous path, whose open() is itself asynchronous: a write issued
  before a synchronous exit-time replacement could open the replacement
  and truncate it. Every write now goes to a temp file renamed into place,
  and every write and delete carries the generation it was requested at:
  one lands only if nothing requested later has landed since, so an
  earlier write or delete can never undo a newer snapshot. Temp files an
  earlier launch left behind are swept at startup.
- A saved session state or history record is validated when read: a
  non-empty id, a known tracking type, a finite rate, a boolean error
  mark, finite timestamps. A malformed record is treated as absent — a
  fresh session is drawn, a history entry resolves no session — rather
  than resumed under a draw that was never made. Records written before
  sessions were sampled are still read as drawn.
…ields defensively

- A renderer envelope is forwarded only when its event is one of the RUM
  event types the intake accepts; one claiming to be telemetry, say, is
  neither a RUM event nor the SDK's own telemetry, and is dropped with a
  telemetry error naming the type. The withheld buffer no longer throws
  on an event that lacks its error, resource or view fields: an error
  without an error object still releases, a resource without one is a
  success, and an event without a view is held and released under none.
- The error that earned a release is never evicted, even when the buffer
  holds only errors that did not earn one.
…, and the sampling messages

The README says which exits take a late error along (before-quit,
will-quit, the process exit event that process.exit() and app.exit() run),
that the buffer keeps up to 60 seconds of what preceded the error subject
to its limits, and that sessionOnError keeps undrawn sessions in memory.
The initialization messages state the consequence of an invalid rate and
that a resumed session keeps its draw.
The exit flush ran on the process exit event and then returned; an
error a later exit listener reported — the application's own, registered
after init() — scheduled a release that no turn of the event loop would
ever run. The exit event now puts the transport in a terminal mode: the
withheld buffer releases as a release is earned, and every event is
written before returning.

A batch write that throws on exit no longer skips the session state
write, which is the one the next launch reads. The batch queue's drain
chain settles after an unexpected throw, reporting it, instead of leaving
every later write queued behind a rejection. BatchManager.flushToDisk()
drains and rotates without uploading, for a caller that needs its events
on disk and nothing more.
…red crash at once

A crash recovered on the next launch joined the withheld buffer behind the
release jitter while its dump was deleted as soon as it was parsed, so a
second crash inside that window lost the first for good. A recovered
crash now releases the session at once — its upload is spread by the
batch cycle already — and the dump is deleted only once the report is in
a batch file; a dump whose report could not be written is kept for the
next launch.

The buffer's byte count no longer throws on an event that cannot be
serialized: it counts as larger than any budget, so a releasing error of
that kind is forwarded on its own and the history still releases. A view
update larger than the budget is dropped, the version held before it
keeping the view's place.
A saved state with a null tracking type, a rate without a type, or an
error mark without a draw was read as a pre-sampling state and resumed as
collected. Only a state with none of the sampling fields is a legacy one;
anything else must be a complete record, with a rate from 0 to 100, or a
fresh session is drawn.

An activity that reads the session file while a renewal's delete and
write are still queued sees the session before: that is the renewal's own
race, skipped silently rather than reported as an invalid state.
The intake decodes every number but a known few as an integer and fails
the whole event on a fraction, silently. A renderer error carrying one —
a fractional date, say — released a withheld session and was then never
stored. Such an event is dropped at the bridge and reported through SDK
telemetry, so it can neither upload nor earn a release.

Telemetry about dropped renderer input no longer quotes the input: the
unsupported-type message names no type, and the parse-error message
carries at most 200 characters of the payload.
…error the intake would drop

The host reports an error from its own process exit listener, registered
after the SDK's, and the batch files on disk carry it along with the held
history. A renderer error with a fractional date neither uploads nor
releases the session, while a well-formed one does.
…lush can and cannot take along

The README says a session that never errors uploads nothing, not even at
quit; that the buffer stays in memory while the session state and view
identifiers are persisted for crash attribution; that once the exit event
has started every event is written as it arrives, that an asynchronous
append already in progress is not completed by the drain, and that a
listener registered before init() which ends the process synchronously
pre-empts the SDK's.
A renderer envelope with an own `__proto__`, `constructor` or `prototype` key
passed the bridge's type check and was merged into the main-process event by
`combine`, which copies own keys: `__proto__` reached `Object.prototype`, for
every object in the process. The bridge now drops such an event whole and
reports it through telemetry. Dropped rather than stripped — nothing honest
sends one, and an event with a hole where the renderer put something is an
event nobody asked for.
…gers

The fractional-number check on renderer RUM events treated every number
outside a list of field names as one the intake decodes as an integer, so an
error carrying `context: { price: 19.99 }` — valid, the intake stores
`context` as it is — was dropped, and with `sessionOnError` could not release
its session. The check now follows, per event type, the paths the intake's
event schema types as integers; free-form attributes (`context`, `usr`,
`feature_flags`, custom attributes) are never inspected. The e2e intake's
protocol check keeps the same table.
The error reported when a bridge message could not be parsed carried the first
200 characters of the message; telemetry leaves the device, and that text is
the renderer's. It now reports only the message's length.
…s kept

`BatchProducer` swallowed an `appendFile` failure to keep its queue going, so
`flushToDisk()` resolved with nothing written and crash processing, trusting
it, deleted the only copy of the dump. The queue still goes on and the batch
is still rotated, so what was written uploads; the next `flush()` then rejects
with the first failure, and crash processing keeps the dump for the next
launch.
The check on renderer RUM events rejected a fraction at an integer path but
let any other value through — a string `"500"` at
`error.resource.status_code`, say. The intake refuses anything but a whole
number into an integer and drops the event silently, so such an error was
accepted here, released its withheld session, and never landed: the session
uploaded without the error that earned it. Every value at an integer path
must now be a safe integer; absent and `null` stay accepted, as the intake
decodes either as zero. The values of `view.custom_timings`, a map the intake
types as integers, are checked too. The e2e intake mirrors the rule and the
contract scenario pins it.
The scan ran on the RUM event only; a key outside it, on the envelope, got
through. Nothing read those keys, but the rule is the whole message: it now
runs on the parsed message before anything looks at its type.
…buffer

The architecture document still named the fork's upstream as the destination,
and its diagram sent telemetry through the withheld buffer, which the text
beside it says telemetry bypasses.
Every event type declares its own `_dd`, which shadows the common one; the
view's has no `session`, so the intake never decodes `_dd.session.plan` on
a view event and accepts anything there. The path was listed as common to all
types, so a view carrying a fraction there was dropped here for nothing. It
now belongs to the five types whose own `_dd` declares it. The e2e intake
mirrors the move.
@Fiona2016
Fiona2016 merged commit 2ef8502 into publish Oct 15, 2026
3 checks passed
@Fiona2016 Fiona2016 mentioned this pull request Oct 15, 2026
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.

1 participant