Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,34 @@ await init({
- **RUM Views** — One view per main process instance
- **RUM Errors** — Capture Node errors and crashes in main process
- **Renderer Bridge** — Capture RUM events from renderer processes via the browser SDK
- **Operation Monitoring** _(experimental)_ — Track start / succeed / fail steps of critical user-facing workflows

### Operation Monitoring _(experimental)_

Operation Monitoring lets you track the lifecycle of critical user-facing workflows (login, checkout, file upload, video playback, …) by emitting paired `start` / `end` steps. The backend correlates the steps by `name` (and optional `operationKey`) and exposes them as a single Operation in the RUM UI.

> ⚗️ This API is in preview and the signatures may change before stable release.

```ts
import { startOperation, succeedOperation, failOperation } from '@datadog/electron-sdk';

// Simple operation
startOperation('checkout');
try {
await runCheckout();
succeedOperation('checkout');
} catch (error) {
failOperation('checkout', 'error');
}

// Parallel operations sharing a name — distinguished by `operationKey`
startOperation('upload', { operationKey: 'profile_pic' });
startOperation('upload', { operationKey: 'cover_photo' });
succeedOperation('upload', { operationKey: 'profile_pic' });
failOperation('upload', 'abandoned', { operationKey: 'cover_photo' });
```

The renderer process keeps using `@datadog/browser-rum` directly (with the `feature_operation_vital` experimental flag enabled on its init). API signatures match exactly, so you can start an operation in one process and complete it in the other — the backend correlates steps by `name` + `operationKey`.

### Renderer Process Support

Expand Down Expand Up @@ -81,6 +109,35 @@ try {
}
```

### `startOperation(name: string, options?: FeatureOperationOptions): void`

Start a RUM Operation step. Pair every `startOperation` with exactly one `succeedOperation` or `failOperation`. Use `options.operationKey` to distinguish parallel operations sharing the same `name`.

> Note: `name` is required and should only contain letters, digits, `_`, `.`, `@`, `$`, `-`.

### `succeedOperation(name: string, options?: FeatureOperationOptions): void`

Record the successful completion of a RUM Operation. Pass the same `name` (and `operationKey`, if any) used to start it.

### `failOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions): void`

Record the failure of a RUM Operation. `failureReason` must be one of `'error' | 'abandoned' | 'other'`.

```ts
type FailureReason = 'error' | 'abandoned' | 'other';

interface FeatureOperationOptions {
/** Distinguishes parallel operations sharing the same `name`. */
operationKey?: string;
/** Free-form attributes merged into the event's `context`. */
context?: Record<string, unknown>;
/** Free-form description attached to `vital.description`. */
description?: string;
}
```

> **Deprecated aliases.** The early-preview names `startFeatureOperation` / `succeedFeatureOperation` / `failFeatureOperation` are kept as deprecated aliases for backwards compatibility. They forward to the un-prefixed names above and emit a one-time runtime warning. They will be removed in the next major release — migrate to `startOperation` / `succeedOperation` / `failOperation`.

### Configuration Options

| Option | Type | Required | Default | Description |
Expand Down
20 changes: 20 additions & 0 deletions e2e/app/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
_generateTelemetryError,
_flushTransport,
stopSession,
startOperation,
succeedOperation,
failOperation,
type FailureReason,
type FeatureOperationOptions,
type InitConfiguration,
} from '@datadog/electron-sdk';

Expand Down Expand Up @@ -81,6 +86,21 @@ void app.whenReady().then(async () => {
addError(new Error('test manual error'), { context: { foo: 'bar' }, startTime });
});

ipcMain.handle('startOperation', (_event, name: string, options?: FeatureOperationOptions) => {
startOperation(name, options);
});

ipcMain.handle('succeedOperation', (_event, name: string, options?: FeatureOperationOptions) => {
succeedOperation(name, options);
});

ipcMain.handle(
'failOperation',
(_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => {
failOperation(name, failureReason, options);
}
);

ipcMain.handle('flushTransport', async () => {
await _flushTransport();
});
Expand Down
6 changes: 6 additions & 0 deletions e2e/app/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'),
generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'),
generateManualError: (startTime?: number) => ipcRenderer.invoke('generateManualError', startTime),
startOperation: (name: string, options?: Record<string, unknown>) =>
ipcRenderer.invoke('startOperation', name, options),
succeedOperation: (name: string, options?: Record<string, unknown>) =>
ipcRenderer.invoke('succeedOperation', name, options),
failOperation: (name: string, failureReason: string, options?: Record<string, unknown>) =>
ipcRenderer.invoke('failOperation', name, failureReason, options),
flushTransport: () => ipcRenderer.invoke('flushTransport'),
crash: () => ipcRenderer.invoke('crash'),
openBridgeFileWindow: () => ipcRenderer.invoke('openBridgeFileWindow'),
Expand Down
26 changes: 26 additions & 0 deletions e2e/lib/mainPage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import type { ElectronApplication, Page } from '@playwright/test';
import type { FailureReason, FeatureOperationOptions } from '@datadog/electron-sdk';
import { BridgeWindowPage } from './bridgeWindowPage';

// declare exposed IPC methods called directly in tests
interface ElectronAppWindow {
electronAPI: {
generateTelemetryErrors: (count: number) => Promise<void>;
generateManualError: (startTime?: number) => Promise<void>;
startOperation: (name: string, options?: FeatureOperationOptions) => Promise<void>;
succeedOperation: (name: string, options?: FeatureOperationOptions) => Promise<void>;
failOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => Promise<void>;
flushTransport: () => Promise<void>;
openBridgeFileWindow: () => Promise<void>;
openBridgeFileWindowNoIsolation: () => Promise<void>;
Expand Down Expand Up @@ -64,6 +68,28 @@ export class MainPage {
);
}

async startOperation(name: string, options?: FeatureOperationOptions) {
await this.page.evaluate(
({ name, options }) => (globalThis as unknown as ElectronAppWindow).electronAPI.startOperation(name, options),
{ name, options }
);
}

async succeedOperation(name: string, options?: FeatureOperationOptions) {
await this.page.evaluate(
({ name, options }) => (globalThis as unknown as ElectronAppWindow).electronAPI.succeedOperation(name, options),
{ name, options }
);
}

async failOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) {
await this.page.evaluate(
({ name, failureReason, options }) =>
(globalThis as unknown as ElectronAppWindow).electronAPI.failOperation(name, failureReason, options),
{ name, failureReason, options }
);
}

async flushTransport() {
await this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.flushTransport());
}
Expand Down
101 changes: 101 additions & 0 deletions e2e/scenarios/operation.scenario.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { test, expect } from '../lib/helpers';
import type { RumViewEvent, RumVitalOperationStepEvent } from '@datadog/electron-sdk';

test('emits start and succeed vital operation_step events', async ({ mainPage, intake }) => {
await mainPage.flushTransport();
const viewEvents = await intake.getEventsByType('view');
const view = viewEvents[0].body as RumViewEvent;

await mainPage.startOperation('checkout');
await mainPage.succeedOperation('checkout');
await mainPage.flushTransport();

const vitalEvents = await intake.waitForEventCount('vital', 2);
expect(vitalEvents).toHaveLength(2);

const vitals = vitalEvents.map((e) => e.body as RumVitalOperationStepEvent);
const start = vitals.find((v) => v.vital?.step_type === 'start')!;
const end = vitals.find((v) => v.vital?.step_type === 'end')!;
expect(start).toBeDefined();
expect(end).toBeDefined();

expect(start.type).toBe('vital');
expect(start.vital?.type).toBe('operation_step');
expect(start.vital?.name).toBe('checkout');
expect(start.vital?.failure_reason).toBeUndefined();
expect(start.vital?.operation_key).toBeUndefined();

expect(end.vital?.failure_reason).toBeUndefined();
expect(end.vital?.name).toBe('checkout');
expect(end.vital?.id).not.toBe(start.vital?.id);

// Common RUM context is populated by the main-process Assembly pipeline.
expect(start.session.id).toBe(view.session.id);
expect(end.session.id).toBe(view.session.id);
expect(start.application.id).toBe(view.application.id);
expect(start.view.id).toBe(view.view.id);
expect(end.view.id).toBe(view.view.id);
expect(start.source).toBe('electron');
expect(start._dd.format_version).toBe(2);
expect(typeof start.date).toBe('number');
expect(start.date).toBeGreaterThan(0);
});

test('emits start and fail vital operation_step events with failure_reason', async ({ mainPage, intake }) => {
await mainPage.flushTransport();

await mainPage.startOperation('checkout');
await mainPage.failOperation('checkout', 'error');
await mainPage.flushTransport();

const vitals = (await intake.waitForEventCount('vital', 2)).map((e) => e.body as RumVitalOperationStepEvent);
const start = vitals.find((v) => v.vital?.step_type === 'start')!;
const fail = vitals.find((v) => v.vital?.step_type === 'end')!;

expect(start.vital?.failure_reason).toBeUndefined();
expect(fail.vital?.failure_reason).toBe('error');
expect(fail.vital?.id).not.toBe(start.vital?.id);
});

test('forwards operationKey to the event payload on both start and end', async ({ mainPage, intake }) => {
await mainPage.flushTransport();

await mainPage.startOperation('upload', { operationKey: 'photo_1' });
await mainPage.succeedOperation('upload', { operationKey: 'photo_1' });
await mainPage.flushTransport();

const vitals = (await intake.waitForEventCount('vital', 2)).map((e) => e.body as RumVitalOperationStepEvent);
for (const v of vitals) {
expect(v.vital?.operation_key).toBe('photo_1');
expect(v.vital?.name).toBe('upload');
}
});

test('omits operation_key when the operation is unkeyed', async ({ mainPage, intake }) => {
await mainPage.flushTransport();

await mainPage.startOperation('login');
await mainPage.flushTransport();

const vitalEvents = await intake.waitForEventCount('vital', 1);
const start = vitalEvents[0].body as RumVitalOperationStepEvent;

expect(start.vital?.operation_key).toBeUndefined();
});

test('stop without prior start still emits the event (no local tracking)', async ({ mainPage, intake }) => {
// Electron intentionally does not track active operations locally; renderer
// start/stop events bridged via DatadogEventBridge would desync any main-side
// tracking. The main-process API therefore emits unconditionally.
await mainPage.flushTransport();

await mainPage.succeedOperation('dangling');
await mainPage.flushTransport();

const vitalEvents = await intake.waitForEventCount('vital', 1);
expect(vitalEvents).toHaveLength(1);

const end = vitalEvents[0].body as RumVitalOperationStepEvent;
expect(end.vital?.step_type).toBe('end');
expect(end.vital?.name).toBe('dangling');
});
10 changes: 10 additions & 0 deletions playground/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,16 @@ <h2>Session File Content:</h2>
<button id="main-fetch">Main Fetch</button>
</div>

<div class="section-label">Operation Monitoring (main process, experimental)</div>
<div class="button-group">
<button id="op-start">Start "checkout"</button>
<button id="op-succeed">Succeed "checkout"</button>
<button id="op-fail">Fail "checkout" (error)</button>
<button id="op-keyed-start">Start "upload" (key=photo_1)</button>
<button id="op-keyed-succeed">Succeed "upload" (key=photo_1)</button>
<button id="op-parallel-uploads">Run 5 parallel uploads</button>
</div>

<div class="session-section">
<h2>IPC Activity Log:</h2>
<div id="ipc-log"></div>
Expand Down
28 changes: 27 additions & 1 deletion playground/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'node:path';
import * as fs from 'node:fs';
import * as https from 'node:https';
import { init, stopSession, _generateTelemetryError } from '@datadog/electron-sdk';
import {
init,
stopSession,
_generateTelemetryError,
startOperation,
succeedOperation,
failOperation,
type FailureReason,
type FeatureOperationOptions,
} from '@datadog/electron-sdk';
import { loadWindowState, saveWindowState } from './main/windowState';
import { setupHotReload } from './main/hotReload';

Expand Down Expand Up @@ -98,6 +107,23 @@ ipcMain.handle('crash', () => {
process.crash();
});

// --- Operation Monitoring demo handlers ---

ipcMain.handle('main:start-operation', (_event, name: string, options?: FeatureOperationOptions) => {
startOperation(name, options);
});

ipcMain.handle('main:succeed-operation', (_event, name: string, options?: FeatureOperationOptions) => {
succeedOperation(name, options);
});

ipcMain.handle(
'main:fail-operation',
(_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => {
failOperation(name, failureReason, options);
}
);

void app.whenReady().then(async () => {
// Initialize SDK on app ready (before window creation)
console.log('Initializing SDK from main process...');
Expand Down
6 changes: 6 additions & 0 deletions playground/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'),
crash: () => ipcRenderer.invoke('crash'),
mainFetchApi: () => ipcRenderer.invoke('main:fetch-api'),
startOperation: (name: string, options?: { operationKey?: string }) =>
ipcRenderer.invoke('main:start-operation', name, options),
succeedOperation: (name: string, options?: { operationKey?: string }) =>
ipcRenderer.invoke('main:succeed-operation', name, options),
failOperation: (name: string, failureReason: 'error' | 'abandoned' | 'other', options?: { operationKey?: string }) =>
ipcRenderer.invoke('main:fail-operation', name, failureReason, options),
});
Loading
Loading