Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
18cef67
✨ add remote configuration for the session sampling
Fiona2016 Oct 8, 2026
e97d08b
✅ cover remote configuration end to end against the fake intake
Fiona2016 Oct 8, 2026
4677b6a
📝 document remote configuration
Fiona2016 Oct 8, 2026
597ade7
🐛 keep an immediate configuration enforced across restarts, exits and…
Fiona2016 Oct 9, 2026
bcb0012
✅ cover restarts, upgrades and ttl revalidation of remote configurati…
Fiona2016 Oct 9, 2026
bcfbf44
📝 describe the remote configuration cadence, failures and resumed ses…
Fiona2016 Oct 9, 2026
34b558f
♻️ refresh remote configuration on a return to the foreground, when t…
Fiona2016 Oct 9, 2026
5e2a8c4
✅ cover the foreground refresh of remote configuration end to end
Fiona2016 Oct 9, 2026
25957b4
📝 describe the foreground refresh of remote configuration
Fiona2016 Oct 9, 2026
4552760
🐛 keep an accepted remote configuration until it is on disk, and read…
Fiona2016 Oct 9, 2026
0845066
🐛 schedule nothing for a session that ended while it was being saved
Fiona2016 Oct 9, 2026
6315f84
✅ wait for the answer of each remote configuration scenario by a mark…
Fiona2016 Oct 9, 2026
329faeb
📝 describe the retry, foreground and startup behaviour of remote conf…
Fiona2016 Oct 9, 2026
f05f70d
🐛 never let a kept configuration override a newer session draw, and r…
Fiona2016 Oct 9, 2026
0213eb3
🐛 announce a session renewal in the same continuation as the check th…
Fiona2016 Oct 9, 2026
8a0d4de
✅ cover the remote configuration kill switch end to end
Fiona2016 Oct 9, 2026
905bc6b
📝 describe the per-session retry budget, the exit write, resumed sess…
Fiona2016 Oct 9, 2026
6cdbf79
🐛 never let an older immediate answer end a session drawn under a new…
Fiona2016 Oct 9, 2026
88392e0
💬 say what happens when remoteConfigurationEnabled is not true or false
Fiona2016 Oct 9, 2026
641d517
✅ give each remote configuration scenario more than one request timeo…
Fiona2016 Oct 9, 2026
6c1974d
📝 note that focus between windows counts and that older answers never…
Fiona2016 Oct 9, 2026
bae2467
🐛 refuse custom values nested past any configuration, retry an unexpe…
Fiona2016 Oct 10, 2026
8620017
📝 say when the retry budget is re-armed, and that only the automated …
Fiona2016 Oct 10, 2026
17e959b
🔀 merge feat/session-on-error into feat/remote-config
Fiona2016 Oct 10, 2026
dffd3f2
🔀 merge feat/session-on-error into feat/remote-config
Fiona2016 Oct 14, 2026
48438c7
💬 prefix the SDK's console messages with FlashCat
Fiona2016 Oct 14, 2026
950fd6f
📝 add acceptance steps for sampling, sessionOnError and remote config…
Fiona2016 Oct 14, 2026
99bc8f3
🔀 merge feat/session-on-error into feat/remote-config
Fiona2016 Oct 14, 2026
1a7cc4b
🔀 merge feat/session-on-error into feat/remote-config
Fiona2016 Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ device.
Events already reported keep the identity they were reported with, and events describing a moment
before the logout still resolve to the user who was logged in then.

### `getRemoteConfig(): Record<string, unknown> | undefined`

The application's own `custom` values, as delivered by the console's remote configuration — a copy,
or `undefined` when `remoteConfigurationEnabled` is off, nothing has been delivered yet, or the
console set none. Until the server answers, it is read from the configuration kept on disk, so it can
be read right after `init` resolves. Anyone holding the client token can read these values: put
nothing secret in them. See [Remote configuration](#remote-configuration).

### Configuration Options

| Option | Type | Required | Default | Description |
Expand All @@ -383,6 +391,7 @@ before the logout still resolve to the user who was logged in then.
| `version` | `string` | No | — | Application version |
| `sessionSampleRate` | `number` | No | `100` | Percentage of sessions collected (0–100). An out-of-range value fails `init`. See [Sampling](#sampling) |
| `sessionOnError` | `boolean` | No | `false` | Keeps the sessions `sessionSampleRate` did not draw in memory; a withheld session uploads no RUM events unless it reports an error, not even at quit. See [Sampling](#sampling) |
| `remoteConfigurationEnabled` | `boolean` | No | `false` | Let the console change the sampling and deliver `custom` values. See [Remote configuration](#remote-configuration) |
| `telemetrySampleRate` | `number` | No | `20` | Telemetry sample rate (0–100) |
| `batchSize` | `'SMALL' \| 'MEDIUM' \| 'LARGE'` | No | — | Batch size for event uploads |
| `uploadFrequency` | `'RARE' \| 'NORMAL' \| 'FREQUENT'` | No | — | Upload frequency for event batches |
Expand Down Expand Up @@ -445,6 +454,79 @@ await init({
> is uploaded whether or not the session ever reports an error. Do not combine the two until the
> Browser SDK can withhold replay for a bridged session.

### Remote configuration

With `remoteConfigurationEnabled: true`, the console can change `sessionSampleRate` and
`sessionOnError` without a new release of the application, and hand it `custom` values (read with
`getRemoteConfig()`). Off by default: nothing is requested and the init values apply.

```ts
await init({
// ...
sessionSampleRate: 100, // used until the console says otherwise, and for any knob it does not set
remoteConfigurationEnabled: true,
});
```

- **Precedence.** A value the console sets takes precedence over the init value; a value it does not
set leaves the init value in place. While the console's configuration is switched off, the init
values apply.
- **When it is asked for.** By the main process, at `init` and whenever a new session starts — there
is no timer between sessions. When the console allows it (`refresh_on_foreground`, off by
default), also when the user comes back to the application — a window of it gains focus; moving
focus between the application's own windows counts too, and the ttl absorbs it — and
the last completed request, successful or failed, finished at least the server's `ttl` ago (10
minutes by default, never less than one minute), revalidating with the ETag it holds. Such a
refresh leaves a retry already pending to ask in its place, and does not start a new round of
retries. Without that permission, a session that never goes
idle keeps the configuration it has until it turns over, after up to four hours. There is never
more than one request in flight, and nothing in `init` waits for one.
- **Failures.** Network failures, timeouts, malformed responses, HTTP 429 and 5xx responses change
nothing and are retried with successive delays of approximately 5 s and 60 s, each with ±20%
jitter. That two-attempt budget is re-armed by a new session and by any answer that lands; once a
run of failures has spent it, a foreground refresh the console allows still makes one attempt, with
no retries behind it.
Other HTTP errors are not retried. Either way the configuration in force stays as it was.
- **Kept on disk.** The last configuration accepted is kept in the application's `userData`
directory. A graceful exit retries pending or failed writes; a forced termination can leave an
older cache. New sessions use it before the network answers, or without the network at all. It
applies only to the same intake, application, `env` and `version`.
- **Resumed sessions.** A valid session resumed from the previous launch keeps its original draw,
subject to immediate activation: when what was kept asks to apply immediately, the session is
judged by it at startup, as below, unless it was drawn under a newer version than the one kept —
an older configuration never overrides a newer draw.
- **SDK upgrades.** An upgrade keeps the values but asks for the full configuration again rather
than revalidating the one the previous version read, and judges the running session again if this
version reads it differently.
- **Nothing published.** An application whose configuration was never published is answered with
version 0: whatever else the answer carries, the init values apply and no version is reported.
- **Trust.** Anyone holding the client token can read these values, so put nothing secret in them;
and whoever can answer the configured `proxy` or intake can also set them, the sampling included.
- **When a change applies.** A session's draw is locked for its whole life, so by default
(`next_session`) a change applies from the next session on. When the console asks for a change to
apply immediately, the running session is ended — and the next user activity draws a new one —
only where the new values decide it:
- a collected session ends when the rate becomes `0` (the emergency stop), unless it is a session
kept by `sessionOnError` and the switch stays on — rate `0` next to the switch is that switch's
ordinary setting;
- a session that was not collected, and was drawn at rate `0`, ends when the rate rises above `0`
or `sessionOnError` turns on, so that it is drawn again. One that lost a draw at a real rate keeps
its outcome.

Any other change waits for the next session, and none ends a session drawn under a newer version
than its own: answers can arrive out of order, and an older configuration never overrides a newer
draw.

- **What events report.** A session reports the rate it was drawn at (`0` for a session kept by
`sessionOnError`) and, when its draw read a delivered configuration, that configuration's version
as `_dd.configuration.rc_version` — on main process and renderer events alike, resumed sessions and
next-launch crash reports included.

**Renderers.** The main process owns the sessions and their sampling. A renderer's Browser SDK under
the bridge needs no configuration of its own (enable it in the main process only), and the main
process overwrites the sampling attributes and `rc_version` of every event
a renderer sends, so nothing is applied twice.

### Pre-warmed windows

Applications commonly pre-create a renderer — `new BrowserWindow({ show: false })` — and navigate
Expand Down
37 changes: 37 additions & 0 deletions docs/ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,43 @@ Chromium actually report — which differs between a packaged (asar) and an unpa
> Filtering on `source:electron` alone silently drops every renderer event — which is most of the
> volume (views, actions, resources, web vitals).

## 4b. Sampling and `sessionOnError`

Run against the real backend, on a dedicated test application. Verify each result in the event
store or the console, not only in the upload log: the intake answers `202` before it decodes.

- [ ] `sessionSampleRate: 0` + `sessionOnError: true`, a session that reports no error and is then
stopped: nothing is uploaded and **no session is stored**
- [ ] Same, with a main-process error and a renderer error: the session is stored with
`session.sampled_for_error: true` and `_dd.configuration.session_sample_rate: 0`, with both the
main-process and the renderer views and the activity that preceded the error
- [ ] Same, with `sessionOnError` off (negative control): no session is stored, errors included
- [ ] `Crash` in a withheld session: the crash is reported on the next launch, `is_crash: true`, under
a session stored with the marker and rate `0`
- [ ] Default configuration (no new options): sessions are stored as before, rate `100`, no marker

## 4c. Remote configuration

Use a dedicated test application, and publish through the same API the console uses — **never**
fabricate a configuration version toward a real backend: it shows up in the console's version
statistics. Record the application's configuration before you start.

- [ ] With `remoteConfigurationEnabled: true`, the request to `/api/v2/rum/config` carries
`sdk=electron`; a rule matching `sdk: electron` applies, and the console preview for another
`sdk` returns no values
- [ ] A session drawn under a published version is stored with `rc_version` equal to that version
- [ ] The next request carries `applied_version` and `If-None-Match`, and the backend answers `304`
- [ ] An `immediate` stop (rate `0`, switch off) ends the running session; nothing more is stored
- [ ] An `immediate` rate `0` with the switch on leaves an on-error session running, and its error
releases what it held
- [ ] Restart with the configuration endpoint unreachable: the first session draws with the cached
configuration (`rc_version` of the cached version); with the cache deleted (negative control),
it draws with the init values
- [ ] Publish a version restoring the recorded configuration, and check the endpoint serves it

> An application that never published answers version `0`; versions only go up, so restoring it
> means publishing `enabled: false` with no rules — clients then behave exactly as before.

## 5. Package

```sh
Expand Down
11 changes: 11 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ judged, and reported, by the draw they were made under.
not drawn, and of a withheld session that ended without an error (its late events), and stamps
`session.sampled_for_error` and `_dd.configuration.session_sample_rate`.

With `remoteConfigurationEnabled`, the sampling a draw reads comes from `RemoteConfiguration`: the
console's values over the init ones, fetched at init, at every new session and — when the console
allows it — on a return to the foreground once the `ttl` has passed, and kept on disk
(`_fc_remote_config`, replaced through `StateFile`, written synchronously on `APP_MAY_EXIT` if its
write has not landed) for later draws. At startup, a kept `immediate` configuration judges the
session the launch starts with before anything is collected. The session records the configuration
version it was drawn under next to its rate, and `SessionContext` reports it as
`_dd.configuration.rc_version`. A configuration published with `activation: immediate` asks
`SessionManager.applySamplingChange` to end the running session where the new sampling is decisive
for it; anything else applies from the next session.

`WithheldEventBuffer` sits between assembly and the RUM batch, so it sees main-process and renderer
events alike, already final. It holds a withheld session's events and releases them at the session's
first error; telemetry bypasses it. `CrashCollection` releases the session of a crash reported on the
Expand Down
9 changes: 9 additions & 0 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ fractional crash `date` (`fs.Stats.birthtimeMs`) and a fractional resource `date
start in nanoseconds) all the way through the `v0.1.0` release. When adding a scenario for a new
event type, assert `intake.getProtocolViolations()` is empty.

The fake intake also serves the remote configuration endpoint (`GET /api/v2/rum/config`): a test
sets what it answers with the `remoteConfig` fixture option or `intake.setRemoteConfig()` — a body
(with an `ETag`, and a 304 when it is sent back), a status, or a dropped connection; unset, it
answers like the real endpoint for an application with nothing published (200, version 0, switched
off) — and reads what the SDK asked with `intake.getConfigRequests()`. These automated suites use the fake
intake only: never send a fabricated configuration version to a real backend, where it shows up in
the console's version statistics. Manual acceptance runs against the dev backend, with versions
published through the console's own API and the application's configuration restored afterwards.

### Not covered

- **APM spans.** FlashCat exposes no `/api/v2/spans` ingest, so the SPANS track is not uploaded and
Expand Down
3 changes: 3 additions & 0 deletions e2e/app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
setUser,
getUser,
clearUser,
getRemoteConfig,
startOperation,
succeedOperation,
failOperation,
Expand Down Expand Up @@ -104,6 +105,8 @@ void app.whenReady().then(async () => {

ipcMain.handle('getUser', () => getUser());

ipcMain.handle('getRemoteConfig', () => getRemoteConfig());

ipcMain.handle('clearUser', () => {
clearUser();
});
Expand Down
1 change: 1 addition & 0 deletions e2e/app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
setUser: (user: Record<string, unknown>) => ipcRenderer.invoke('setUser', user),
getUser: () => ipcRenderer.invoke('getUser'),
clearUser: () => ipcRenderer.invoke('clearUser'),
getRemoteConfig: () => ipcRenderer.invoke('getRemoteConfig'),
generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'),
generateUncaughtExceptionAndExit: () => ipcRenderer.invoke('generateUncaughtExceptionAndExit'),
generateManualErrorAndExit: () => ipcRenderer.invoke('generateManualErrorAndExit'),
Expand Down
9 changes: 6 additions & 3 deletions e2e/lib/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test as base, _electron as electron, type ElectronApplication, type Pag
import { basename, join } from 'node:path';
import { mkdtemp, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { Intake } from './intake';
import { Intake, type RemoteConfigBehaviour } from './intake';
import { TestServer } from './testServer';
import { MainPage } from './mainPage';
import type { InitConfiguration } from '@flashcatcloud/electron-sdk';
Expand Down Expand Up @@ -51,6 +51,8 @@ export interface TestFixtures {
rumBrowserSdk: Record<string, unknown> | null;
/** Merged over the SDK configuration every scenario starts from. */
sdkConfig: Partial<InitConfiguration>;
/** What the fake config endpoint answers from the first request on. Unset: nothing published. */
remoteConfig: RemoteConfigBehaviour | undefined;
}

/**
Expand All @@ -59,9 +61,9 @@ export interface TestFixtures {
*/
export const test = base.extend<TestFixtures>({
intake: [
// eslint-disable-next-line no-empty-pattern
async ({}, use) => {
async ({ remoteConfig }, use) => {
const intake = new Intake();
intake.setRemoteConfig(remoteConfig);
await intake.start();
await use(intake);
await intake.stop();
Expand Down Expand Up @@ -102,6 +104,7 @@ export const test = base.extend<TestFixtures>({

rumBrowserSdk: [null, { option: true }],
sdkConfig: [{}, { option: true }],
remoteConfig: [undefined, { option: true }],
});

/** How the test app sequences `init()` against window creation. See `getInitMode` in its main.ts. */
Expand Down
Loading
Loading