Forked from Datadog's Electron SDK and rebranded to report to the FlashCat platform. Internal module/type names keep the
dd-/Datadogprefix per the fork convention; the published package is@flashcatcloud/electron-sdkand events are sent to FlashCat RUM ingest.
Real User Monitoring for Electron applications.
Alpha (v0.X.X) — This SDK is in early development. APIs may change between releases.
- Electron 30+ — on Electron 30 to 36, requests made through Electron's own
netmodule produce noresourceevents, because dd-trace only instruments it from Electron 37. Nodehttp/httpsand the globalfetchare traced on every supported version.
yarn add @flashcatcloud/electron-sdk
# or
npm install @flashcatcloud/electron-sdkThe Electron SDK uses dd-trace under the hood to monitor the main process and relies on the browser SDK to monitor renderer processes.
graph TB
subgraph Electron Application
subgraph Renderer Process
BP[Browser SDK]
end
subgraph Main Process
DDT[dd-trace]
SDK[Electron SDK]
end
end
DD[(FlashCat RUM)]
BP -->|DatadogEventBridge| SDK
DDT --> SDK
SDK -->|https://SITE/api/v2/rum| DD
%% Styling
classDef sdk fill:#fce8e6,stroke:#d93025
classDef trace fill:#e6f4ea,stroke:#137333
classDef browser fill:#fef7e0,stroke:#e37400
classDef ext fill:#f3e8fd,stroke:#7627bb
class BP browser
class DDT trace
class SDK sdk
class DD ext
Import the instrumentation entry point before electron in your main process:
// src/main.ts
import '@flashcatcloud/electron-sdk/instrument';
import { app, BrowserWindow } from 'electron';This initializes dd-trace and automatically instruments the needed APIs.
dd-trace's own instrumentation telemetry — which reports to a Datadog agent, and unrelated to FlashCat RUM — is disabled by default by this entry point. Set
DD_INSTRUMENTATION_TELEMETRY_ENABLED=truebefore startup if you specifically want it.
Then initialize the Electron SDK by calling init before creating any browser windows:
import { init } from '@flashcatcloud/electron-sdk';
await init({
clientToken: '<CLIENT_TOKEN>',
applicationId: '<APPLICATION_ID>',
service: 'my-electron-app',
site: 'browser.flashcat.cloud',
});Renderer processes are monitored by the FlashCat Browser SDK. Install it in the pages loaded by your renderer:
yarn add @flashcatcloud/browser-rum@^0.0.7
0.0.7is a minimum, not a suggestion. Earlier versions have nosessionReplayDirectUpload, and the Browser SDK ignores options it does not know — so Session Replay would silently record nothing, with no error to tell you why. See Session Replay below.
// src/renderer.ts
import { flashcatRum } from '@flashcatcloud/browser-rum';
flashcatRum.init({
applicationId: '<APPLICATION_ID>',
clientToken: '<CLIENT_TOKEN>',
site: 'browser.flashcat.cloud',
service: 'my-electron-app',
sessionSampleRate: 100,
trackResources: true,
trackLongTasks: true,
trackUserInteractions: true,
});No extra wiring is needed for RUM. The main-process SDK injects a preload script that exposes a
DatadogEventBridge global to every BrowserWindow; the Browser SDK auto-detects it and routes
its events through the main process instead of uploading them itself. Use the same
applicationId in both processes so the events land in one application.
This works for pages loaded over
file://as well ashttp(s)://— the injected bridge always allows the window's own host. SetallowedWebViewHostsonly when you also want to accept events from third-party pages loaded in a<webview>/BrowserView.
Session Replay is the one feature the bridge does not carry. Seeing a bridge, the Browser SDK
hands recording over to the host application by default — and the main process does not record, so
nothing is captured at all. Add both of these to the renderer's init to keep the recorder in the
page, uploading straight to the intake:
flashcatRum.init({
// ...
sessionReplaySampleRate: 100, // defaults to 0 — the option alone records nothing
sessionReplayDirectUpload: true, // requires @flashcatcloud/browser-rum >= 0.0.7
});Check the renderer's
@flashcatcloud/browser-rumversion before assuming this is wired up.sessionReplayDirectUploadlanded in0.0.7. On anything earlier the option is simply an unknown key: the Browser SDK drops it without complaint, hands recording to the host as usual, and captures nothing. There is no warning in the console and no error at the intake — the only symptom is a session with no replay.
Segments then bypass the main process entirely, so they need a Content Security Policy that allows
worker-src blob: and the intake origin, and they do not share the main process's disk-backed
retry.
window.DatadogEventBridge exposes the identifiers the main process owns, so a renderer can
attribute anything it uploads itself to the same session, device and user:
| Method | Returns |
|---|---|
getSessionId() |
Id of the session the main process considers active, or '' while none is (expired, not renewed yet) |
getAnonymousId() |
Device-scoped id, generated once and kept under app.getPath('userData') across restarts |
getUser() |
Identity set through setUser as JSON, or '{}' when nobody is logged in |
All three answer synchronously and without IPC: the anonymous id is delivered when the bridge is set up, and the main process pushes every session and identity change to open renderers.
Renderer events do not need getUser() — the main process stamps the identity on them as they pass
through. It is there for what a renderer uploads itself, such as Session Replay segments.
Main-process and renderer-process events carry different source values — this matters when
querying or filtering in the console:
| Origin | source |
container.source |
view.url |
|---|---|---|---|
| Main process | electron |
(absent) | electron://main-process |
| Renderer window | browser |
electron |
the page URL |
To select everything produced by an Electron app, match source:electron OR container.source:electron.
Filtering on source:electron alone returns main-process events only.
dd-trace instruments require('electron') at runtime, which requires correct module loading order. The SDK provides bundler plugins to ensure this works in all environments:
Vite (including Electron Forge with Vite and electron-vite):
// vite config
import { datadogVitePlugin } from '@flashcatcloud/electron-sdk/vite-plugin';
export default defineConfig({
plugins: [datadogVitePlugin()],
});Webpack (including Electron Forge with Webpack):
// webpack config
const { DatadogWebpackPlugin } = require('@flashcatcloud/electron-sdk/webpack-plugin');
module.exports = {
plugins: [new DatadogWebpackPlugin()],
};ESBuild
// esbuild config
import { datadogEsbuildPlugin } from '@flashcatcloud/electron-sdk/esbuild-plugin';
await esbuild.build({
plugins: [datadogEsbuildPlugin()],
});- Sessions — Session-based event grouping, with sampling and error-session capture
- RUM Views — One view per main process instance
- RUM Errors — Capture Node errors and crashes in main process, with sourcemap-ready stacks
- RUM Resources — Capture RUM resources from main process network calls
- Traces — Capture traces for network calls, command execution, IPC messages on main process
- Renderer Events — Capture RUM events from renderer processes via the browser SDK
Errors reported from the main process — uncaught exceptions, unhandled rejections, and
addError() — carry a stack in the same shape the renderer produces. In both processes, every
frame under your application root is rewritten to app:///<path relative to the app root>:
Error: something went wrong
at handleClick @ app:///dist/main.js:97:15
at <anonymous> @ process.processTimers (node:internal/timers:541:7)
The raw frame URL is the runtime installation path — which your build cannot know:
| Platform | Raw frame URL |
|---|---|
| macOS | /Applications/MyApp.app/Contents/Resources/app.asar/dist/main.js |
| Windows | C:/Users/<user>/AppData/Local/Programs/MyApp/…/dist/main.js |
| Linux (AppImage) | /tmp/.mount_XXXXXX/resources/app.asar/dist/main.js |
Sourcemaps uploaded against those paths would only ever match on the machine the paths happened to
describe. Anchoring each path on the application root strips the machine-specific part and leaves
one that is stable across installs and platforms. This is the same app:/// scheme the Sentry
Electron SDK uses, and it works the same for asar-packaged and unpackaged (development) builds.
Upload the sourcemaps for your bundle with a prefix matching the rewritten path — app:///dist/…
resolves to /dist/…, so the prefix is /dist:
flashcat-cli sourcemaps upload ./dist \
--service my-app \
--release-version 1.2.3 \
--minified-path-prefix /distPass the path,
/dist— notapp:///dist. The intake keys sourcemaps by URL path only, and the CLI rejects a prefix that is neither anhttp(s)URL nor an absolute path.
Anything that is not a path under your application root is left exactly as it is: node:internal/…
frames (kept because they are useful to read, skipped during un-minification), http(s) URLs,
native modules under app.asar.unpacked, and paths your own code has already normalized — so a
renderer beforeSend that already rewrites stacks keeps working unchanged. view.url is left
alone as well: it identifies the page, not the code.
Set normalizeStackPaths: false to report the raw absolute paths instead.
When one application root cannot express the mapping, normalizeStackPath rewrites a frame's path
before the built-in normalization runs. Return undefined to fall through to app:///, or a
string to use verbatim.
A build that emits to <app root>/public/dist but uploads its sourcemaps under /dist — so the
intermediate public/ segment has to be swallowed — while a linked package keeps its path relative
to the application root:
init({
// …
normalizeStackPath: (absolutePath) => {
const emitted = /\/public(\/dist\/.+)$/.exec(absolutePath);
if (emitted) {
return emitted[1]; // …/public/dist/renderer.js → /dist/renderer.js
}
const linked = /(\/node_modules\/@acme\/widgets\/dist\/.+)$/.exec(absolutePath);
return linked ? linked[1] : undefined; // everything else → built-in app:///
},
});It applies to main-process and renderer frames alike. A callback that throws is reported as an SDK error and that frame falls back to the built-in behaviour, so a faulty callback can never take error reporting down.
Native crash stacks (from
crashReporterminidumps) use a different, address-based format and are unaffected by this. They are reported as-is; symbolication of native frames is not supported yet.
Initialize the SDK. Returns true on success, false if configuration is invalid.
Report a manually handled error.
import { addError } from '@flashcatcloud/electron-sdk';
try {
riskyOperation();
} catch (error) {
addError(error, { context: { component: 'sync' } });
}Identify the logged-in user. The identity is attached to every subsequent main-process event, and to the renderer events that reach the main process over the bridge.
import { setUser, getUser, clearUser } from '@flashcatcloud/electron-sdk';
setUser({ id: 'user-123', name: 'Alice', email: 'alice@example.com' });
// Later, when the user logs out:
clearUser();interface User {
/** Required. */
id: string;
name?: string;
email?: string;
}id is required: a call without one is ignored with a warning, as is one whose name or email is
not a string — a half-applied identity is harder to notice than none at all. Only id, name and
email are read; any other property is dropped.
This does not touch usr.anonymous_id. The two identifiers coexist by design: the anonymous id
is device-scoped and stable across logins, and unique users are counted off it first. Before the
first setUser, events carry usr.anonymous_id and no usr.id at all — the SDK never backfills
one with the other.
The name matches flashcatRum.setUser() in @flashcatcloud/browser-rum, so both processes of the
same application use one vocabulary. When the main process has an identity, it takes precedence over
one set in a renderer, and it replaces it rather than merging field by field — see
docs/ARCHITECTURE.md. When it has none, renderer identities are left exactly as they arrive.
The identity currently set through setUser, or undefined when nobody is logged in. Returns a
copy — mutating it changes nothing.
Forget the identity, for instance on logout. Subsequent events carry no usr.id at all, rather
than an empty one: unique users are counted off NULLIF(usr_id, ''), where an absent field and an
empty string are different rows. usr.anonymous_id is unaffected — the device is still the same
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.
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.
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
clientToken |
string |
Yes | — | FlashCat client token |
applicationId |
string |
Yes | — | RUM application ID |
site |
string |
No | browser.flashcat.cloud |
Intake host, used verbatim — e.g. browser.flashcat.cloud (production), jira.flashcat.cloud (staging), or your own host |
service |
string |
Yes | — | Service name |
env |
string |
No | — | Application environment |
version |
string |
No | — | Application version |
sessionSampleRate |
number |
No | 100 |
Percentage of sessions collected (0–100). An out-of-range value fails init. See 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 |
remoteConfigurationEnabled |
boolean |
No | false |
Let the console change the sampling and deliver custom values. See 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 |
defaultPrivacyLevel |
'mask' | 'allow' | 'mask-user-input' |
No | 'mask' |
Default privacy level for renderer session replay |
allowedWebViewHosts |
string[] |
No | [] |
Extra hostnames allowed for the renderer bridge (the window's own host is always allowed) |
proxy |
string |
No | — | Proxy URL to upload through instead of site. See Self-hosted deployments |
normalizeStackPaths |
boolean |
No | true |
Rewrite stack frame paths to app:///<path relative to the app root>. See Error stacks and sourcemaps |
correctPrewarmedViewTimings |
boolean |
No | true |
Rebase FCP/LCP of pre-warmed windows onto the moment they became visible. See Pre-warmed windows |
sessionSampleRate is drawn once per session, in the main process, and decides for the renderers
too: their events reach the intake through the main process, which drops those of a session it did
not draw. A session resumed after a restart keeps the decision it was created with, and reports the
rate it was drawn at. A renderer's own sessionSampleRate does not apply to the events it sends over
the bridge: the main process decides, and its rate is what every event reports.
sessionOnError keeps the sessions the rate did not draw on standby instead of dropping them:
- The session's events — main process and renderers alike — are held in memory: up to 60 seconds of what preceded the error, subject to the limits (64 KiB of detail, 200 events, plus up to 50 views). The buffer itself never touches the disk; what is persisted locally is the session's state and the main-process view identifiers, which a native crash needs to be attributed on the next launch. A withheld session uploads no RUM events unless it reports an error — not even at quit.
- At the session's first error, what is held is handed to the upload batch together with the error,
0–3 s later (spread per session, so that one outage does not make every client upload at once),
and the session then reports as it happens, like any drawn session. When the application may be
about to exit, a release already earned is written to disk before returning instead: on an
uncaught exception in the main process (provided the application's own
uncaughtExceptionlistener is registered afterawait init(...)has completed, so that the SDK's runs first), onbefore-quitandwill-quit, and on the processexitevent, whichprocess.exit()andapp.exit()still run. Queued events are written synchronously at each of these points, and once the exit event has started every later event is written as it arrives; an asynchronous append already in progress is not completed by the drain. A listener registered beforeinit()that ends the process synchronously pre-empts the SDK's: nothing of that error is seen. - A session that ends without an error is thrown away whole, late events included.
Only errors the application reports count: an error a renderer's beforeSend dropped, or one the
SDK reports about itself, does not. A native crash counts too: it is reported on the next launch,
when the session's held events are long gone, so the crash comes with the view it happened in and
nothing before it.
Such a session reports session.sampled_for_error: true on its views and a
_dd.configuration.session_sample_rate of 0 on every RUM event, so that it is counted as itself
rather than extrapolated by the rate.
await init({
// ...
sessionSampleRate: 0, // no session is collected unconditionally…
sessionOnError: true, // …but every session that reports an error is
});Session Replay is not withheld. The renderer uploads its replay itself, past the main process. For a session the rate did not draw, renderers are told there is no session, so they record nothing. A session kept by
sessionOnErrorcannot be hidden from them that way — its renderer events must reach the main process to be held — so withsessionReplayDirectUploadon, its replay 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.
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.
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
initand 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'sttlago (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 ininitwaits 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
userDatadirectory. 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,envandversion. -
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
proxyor 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 bysessionOnErrorand the switch stays on — rate0next 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 above0orsessionOnErrorturns 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.
- a collected session ends when the rate becomes
-
What events report. A session reports the rate it was drawn at (
0for a session kept bysessionOnError) 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.
Applications commonly pre-create a renderer — new BrowserWindow({ show: false }) — and navigate
it long before the user ever sees it. Electron keeps painting such a window
(paintWhenInitiallyHidden defaults to true), so the page reports document.visibilityState === 'visible' and the browser SDK's usual "page was in the background" guard never trips. An
application that renders its first screen when the window is shown therefore reports an FCP and LCP
inflated by the entire pre-warm interval — measured at 8.2s for a window shown 8s after creation.
LCP is affected more widely than FCP: it keeps updating until the first user interaction, which
cannot happen while the window is hidden, so a large element appearing at show() becomes the LCP
even when FCP itself looks healthy.
The SDK corrects this the way the Paint Timing spec handles prerendered pages — by subtracting the activation instant, which the main process observes from the window lifecycle:
activationStart = max(0, firstVisibleAt − viewStart)
metric' = max(0, metric − activationStart)
Only the first view of a document (loading_type: initial_load) is eligible, and only windows the
SDK saw being created. Ordinary windows are anchored on their creation instant, so their metrics are
never rewritten. A window that has not been shown at all reports no FCP/LCP rather than a
meaningless one. WebContentsView and <webview> have no window lifecycle to observe and are left
untouched.
Set correctPrewarmedViewTimings: false to report the raw document-level timings instead.
site is the intake host, used verbatim — there is no list of accepted hosts. Point it at your own
FlashCat instance:
await init({
clientToken: '<CLIENT_TOKEN>',
applicationId: '<APPLICATION_ID>',
service: 'my-electron-app',
site: 'rum.example.internal',
});Events are then uploaded to https://rum.example.internal/api/v2/rum.
The scheme is always
https://. It is hardcoded in the URL template, so an intake served over plain HTTP, or one that is not at the root of its host, cannot be reached throughsite— useproxyfor those.
Set proxy to upload through an endpoint of your own instead. When proxy is set, site no longer
takes part in building the upload URL:
await init({
clientToken: '<CLIENT_TOKEN>',
applicationId: '<APPLICATION_ID>',
service: 'my-electron-app',
proxy: 'http://rum.example.internal:8080/forward',
});The SDK then POSTs to <proxy>?ddforward=%2Fapi%2Fv2%2Frum. Your endpoint must forward the request
body to /api/v2/rum on your FlashCat instance, preserving the DD-API-KEY and Content-Type
headers.
The SDK never reports its own uploads: it excludes exactly the origin it uploads to — scheme, host and port — from instrumentation. Application requests to the same host on a different port, which is the usual shape of a self-hosted deployment, are reported normally.