From fcabdf22aea77c7d1a78a5025dc376768a41f63e Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 23 Apr 2026 19:27:07 +0200 Subject: [PATCH 1/9] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20add=20OperationCollect?= =?UTF-8?q?ion=20domain=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the main-process Operation APIs that consumers will call via the public SDK: startFeatureOperation, succeedFeatureOperation, and failFeatureOperation. Signatures mirror @datadog/browser-rum exactly (FailureReason = 'error' | 'abandoned' | 'other'; FeatureOperationOptions { operationKey?, context?, description? }). Events conform to vital-operation-step-schema.json: type='vital', vital.type='operation_step', vital.step_type='start' | 'end', fresh UUID per event, failure_reason only on fail events, operation_key omitted when unkeyed. Session, view, application, _dd.format_version, device, os, service, version context is added by the existing main- process Assembly hooks. Input validation rejects blank name and blank operationKey at the API boundary with displayError. Names outside [\w.@$-]* warn but the event is still emitted (backend is source of truth on character-set policy). No local active-operation tracking: renderer-originated events cross the IPC bridge already-assembled and bypass any main-process subscription, so a Set would desync on legitimate cross-process flows. The no-tracking invariant is pinned by the 'no local tracking (cross-process safety)' test group in OperationCollection.spec.ts. Adds the RawRumVital shape used by the module. Wiring into RumCollection and the public API lands in a follow-up commit. --- .../rum/operation/OperationCollection.spec.ts | 434 ++++++++++++++++++ .../rum/operation/OperationCollection.ts | 152 ++++++ src/domain/rum/operation/index.ts | 1 + src/domain/rum/rawRumData.types.ts | 21 +- 4 files changed, 605 insertions(+), 3 deletions(-) create mode 100644 src/domain/rum/operation/OperationCollection.spec.ts create mode 100644 src/domain/rum/operation/OperationCollection.ts create mode 100644 src/domain/rum/operation/index.ts diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts new file mode 100644 index 0000000..e531c46 --- /dev/null +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { OperationCollection } from './OperationCollection'; +import { EventFormat, EventKind, EventManager, type RawRumEvent } from '../../../event'; +import type { RawRumVital } from '../rawRumData.types'; +import { displayError } from '../../../tools/display'; + +vi.mock('../../../tools/display', () => ({ + displayError: vi.fn(), + displayInfo: vi.fn(), +})); + +// Strict RFC 4122 v4 — matches the schema pattern and browser-core's generateUUID output. +const UUID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; + +describe('OperationCollection', () => { + let eventManager: EventManager; + let operationCollection: OperationCollection; + let rawRumEvents: RawRumEvent[]; + + beforeEach(() => { + vi.clearAllMocks(); + eventManager = new EventManager(); + rawRumEvents = []; + eventManager.registerHandler({ + canHandle: (event): event is RawRumEvent => event.kind === EventKind.RAW && event.format === EventFormat.RUM, + handle: (event) => rawRumEvents.push(event), + }); + operationCollection = new OperationCollection(eventManager); + }); + + // --- API-01..API-06 --- + describe('public API dispatch', () => { + it('API-01: startFeatureOperation emits a start vital event', () => { + operationCollection.getApi().startFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.type).toBe('vital'); + expect(data.vital.type).toBe('operation_step'); + expect(data.vital.step_type).toBe('start'); + expect(data.vital.name).toBe('login'); + expect(data.vital.failure_reason).toBeUndefined(); + expect(data.vital.operation_key).toBeUndefined(); + expect(data.vital.id).toMatch(UUID_REGEX); + }); + + it('API-02: succeedFeatureOperation emits an end vital event without failure_reason', () => { + operationCollection.getApi().succeedFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBeUndefined(); + }); + + it('API-03: failFeatureOperation emits an end vital event with failure_reason', () => { + operationCollection.getApi().failFeatureOperation('login', 'error'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe('error'); + }); + + it('API-04: operationKey is forwarded to the event payload', () => { + operationCollection.getApi().startFeatureOperation('login', { operationKey: 'abc' }); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.operation_key).toBe('abc'); + }); + + it('API-05: options.context is forwarded to the event context', () => { + operationCollection.getApi().startFeatureOperation('login', { context: { key: 'value' } }); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.context).toEqual({ key: 'value' }); + }); + + it('API-06: each call produces a unique vital.id', () => { + operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(2); + const firstId = (rawRumEvents[0].data as RawRumVital).vital.id; + const secondId = (rawRumEvents[1].data as RawRumVital).vital.id; + expect(firstId).not.toBe(secondId); + }); + + it('forwards description into the vital section when provided', () => { + operationCollection.getApi().startFeatureOperation('login', { description: 'user tapped login' }); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.description).toBe('user tapped login'); + }); + }); + + // --- VAL-01..VAL-07 --- + describe('input validation', () => { + it('VAL-01: empty name is rejected and no event is emitted', () => { + // The backend rejects blank/empty names with its own non-empty + // precondition before evaluating the character-set regex; drop + // client-side to match. + operationCollection.getApi().startFeatureOperation(''); + + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation name cannot be empty'); + }); + + it('VAL-02: whitespace-only name is rejected and no event is emitted', () => { + operationCollection.getApi().startFeatureOperation(' '); + + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation name cannot be empty'); + }); + + it('VAL-03: empty operationKey is rejected as blank', () => { + operationCollection.getApi().startFeatureOperation('login', { operationKey: '' }); + + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); + }); + + it('VAL-04: whitespace-only operationKey is rejected', () => { + operationCollection.getApi().startFeatureOperation('login', { operationKey: ' ' }); + + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + }); + + it('VAL-05: undefined operationKey is valid and results in an unkeyed operation', () => { + operationCollection.getApi().startFeatureOperation('login', { operationKey: undefined }); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.operation_key).toBeUndefined(); + expect(displayError).not.toHaveBeenCalled(); + }); + + it('VAL-07: blank name on succeedFeatureOperation is rejected', () => { + operationCollection.getApi().succeedFeatureOperation(''); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + }); + + it('VAL-07: blank name on failFeatureOperation is rejected', () => { + operationCollection.getApi().failFeatureOperation('', 'error'); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + }); + + it('VAL-07: blank operationKey is rejected on succeedFeatureOperation', () => { + operationCollection.getApi().succeedFeatureOperation('login', { operationKey: '' }); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); + }); + + it('VAL-07: blank operationKey is rejected on failFeatureOperation', () => { + operationCollection.getApi().failFeatureOperation('login', 'error', { operationKey: ' ' }); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); + }); + }); + + // --- Name character-set validation (schema facet-path rule) --- + // The authoritative _vital-common-schema.json says vital.name "must contain + // only letters, digits, or the characters - _ . @ $". Names that fail this + // rule are warned about but still emitted — the backend is the source of + // truth, so client-side drop would force a customer SDK bump if the policy + // is ever relaxed. + describe('operation name character set', () => { + // Names outside the schema facet-path set (letters / digits / - _ . @ $) + // are warned about but still emitted: the backend is the source of truth + // for what the schema actually allows, so client-side drop would force a + // customer SDK bump if the backend ever relaxed the rule. + it.each([ + ['space', 'user login'], + ['slash', 'api/v1'], + ['colon', 'checkout:step1'], + ['comma', 'login,logout'], + ['plus', 'a+b'], + ['tab', 'login\ttwo'], + ['Unicode', 'ログイン'], + ['emoji', 'login🔐'], + ])('warns but still emits events with names containing %s', (_label, name) => { + operationCollection.getApi().startFeatureOperation(name); + + expect(rawRumEvents).toHaveLength(1); + expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('does not match'); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('still be sent'); + }); + + it.each([ + ['letters', 'login'], + ['digits', 'step42'], + ['hyphen', 'login-v2'], + ['underscore', 'user_login'], + ['dot', 'login.v2'], + ['at', 'login@prod'], + ['dollar', 'login$1'], + ['mixed allowed', 'login-v2@1.0.0_step$1'], + ['all digits', '12345'], + ['uppercase', 'LOGIN'], + ['mixed case', 'LoginV2'], + ])('accepts names with %s without warning', (_label, name) => { + operationCollection.getApi().startFeatureOperation(name); + + expect(rawRumEvents).toHaveLength(1); + expect(displayError).not.toHaveBeenCalled(); + expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); + }); + + it('warns but still emits on succeedFeatureOperation with invalid characters', () => { + operationCollection.getApi().succeedFeatureOperation('user login'); + expect(rawRumEvents).toHaveLength(1); + expect((rawRumEvents[0].data as RawRumVital).vital.step_type).toBe('end'); + expect(displayError).toHaveBeenCalledOnce(); + }); + + it('warns but still emits on failFeatureOperation with invalid characters', () => { + operationCollection.getApi().failFeatureOperation('user login', 'error'); + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe('error'); + expect(displayError).toHaveBeenCalledOnce(); + }); + + it('does not restrict operationKey to the same character set', () => { + // operation_key has no character-set constraint in the schema. + operationCollection.getApi().startFeatureOperation('login', { operationKey: 'session-42 / user foo' }); + expect(rawRumEvents).toHaveLength(1); + expect(displayError).not.toHaveBeenCalled(); + expect((rawRumEvents[0].data as RawRumVital).vital.operation_key).toBe('session-42 / user foo'); + }); + }); + + // --- PAY-01..PAY-10 --- + describe('payload structure', () => { + it('PAY-01: start payload shape', () => { + operationCollection.getApi().startFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.type).toBe('vital'); + expect(data.vital.type).toBe('operation_step'); + expect(data.vital.step_type).toBe('start'); + expect(data.vital.failure_reason).toBeUndefined(); + }); + + it('PAY-02: succeed payload shape', () => { + operationCollection.getApi().succeedFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBeUndefined(); + }); + + it('PAY-03: fail payload shape', () => { + operationCollection.getApi().failFeatureOperation('login', 'error'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe('error'); + }); + + it.each(['error', 'abandoned', 'other'] as const)('PAY-04: failure reason %s serialises correctly', (reason) => { + operationCollection.getApi().failFeatureOperation('login', reason); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.failure_reason).toBe(reason); + }); + + it('PAY-07: vital.id matches UUID v4 pattern', () => { + operationCollection.getApi().startFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.id).toMatch(UUID_REGEX); + }); + + it('PAY-08: vital.name matches the input', () => { + operationCollection.getApi().startFeatureOperation('checkout'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.name).toBe('checkout'); + }); + + it('PAY-09: unkeyed operation omits operation_key', () => { + operationCollection.getApi().startFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect('operation_key' in data.vital).toBe(false); + }); + + it('PAY-10: keyed operation includes operation_key', () => { + operationCollection.getApi().startFeatureOperation('login', { operationKey: 'abc' }); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.operation_key).toBe('abc'); + }); + + it('captures a non-zero startTime from timeStampNow on the emitted raw event', () => { + const before = Date.now(); + operationCollection.getApi().startFeatureOperation('login'); + const after = Date.now(); + + const event = rawRumEvents[0]; + expect(event.startTime).toBeDefined(); + expect(event.startTime).toBeGreaterThanOrEqual(before); + expect(event.startTime).toBeLessThanOrEqual(after); + expect((event.data as RawRumVital).date).toBe(event.startTime); + }); + + it('omits vital.description when not provided', () => { + operationCollection.getApi().startFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect('description' in data.vital).toBe(false); + }); + + it('defaults context to an empty object when options.context is omitted', () => { + operationCollection.getApi().startFeatureOperation('login'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.context).toEqual({}); + }); + }); + + // --- No-tracking behavior --- + // Electron intentionally does NOT track active operations locally (matches + // browser-sdk / Android). Renderer events flow through the bridge without + // updating main-process state, so any local tracking would produce false + // positives on cross-process start/stop flows. Consequently the main process + // never emits "duplicate start" / "stop without start" warnings. + describe('no local tracking (cross-process safety)', () => { + it('does not warn on duplicate start from the main process', () => { + operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(2); + expect(displayError).not.toHaveBeenCalled(); + }); + + it('does not warn on succeed without a prior start', () => { + operationCollection.getApi().succeedFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(1); + expect(displayError).not.toHaveBeenCalled(); + }); + + it('does not warn on fail without a prior start', () => { + operationCollection.getApi().failFeatureOperation('login', 'error'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe('error'); + expect(displayError).not.toHaveBeenCalled(); + }); + + it('does not warn on double-stop', () => { + const api = operationCollection.getApi(); + api.startFeatureOperation('login'); + api.succeedFeatureOperation('login'); + api.succeedFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(3); + expect(displayError).not.toHaveBeenCalled(); + }); + }); + + // --- PAR-01: parallel operations (no warnings expected on either side) --- + describe('parallel operations', () => { + it('PAR-01: operations with same name and different keys emit independently', () => { + const api = operationCollection.getApi(); + api.startFeatureOperation('upload', { operationKey: 'a' }); + api.startFeatureOperation('upload', { operationKey: 'b' }); + api.succeedFeatureOperation('upload', { operationKey: 'a' }); + api.failFeatureOperation('upload', 'error', { operationKey: 'b' }); + + expect(rawRumEvents).toHaveLength(4); + expect(displayError).not.toHaveBeenCalled(); + const payloads = rawRumEvents.map((e) => (e.data as RawRumVital).vital); + expect(payloads[0]).toMatchObject({ step_type: 'start', operation_key: 'a' }); + expect(payloads[1]).toMatchObject({ step_type: 'start', operation_key: 'b' }); + expect(payloads[2]).toMatchObject({ step_type: 'end', operation_key: 'a' }); + expect(payloads[3]).toMatchObject({ step_type: 'end', operation_key: 'b', failure_reason: 'error' }); + }); + + it('PAR-03: keyed and unkeyed with same name emit independently', () => { + operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startFeatureOperation('login', { operationKey: 'k1' }); + + expect(rawRumEvents).toHaveLength(2); + expect(displayError).not.toHaveBeenCalled(); + }); + }); + + // --- EDGE cases --- + describe('edge cases', () => { + // EDGE-04 (Unicode) is intentionally omitted: the schema facet-path rule + // restricts names to ASCII letters/digits/- _ . @ $, which excludes + // non-ASCII characters. The character-set test group above covers this. + + it('EDGE-05: long operation name is preserved', () => { + const longName = 'a'.repeat(500); + operationCollection.getApi().startFeatureOperation(longName); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.name).toBe(longName); + }); + + it('EDGE-06: schema-allowed special characters in operation name are preserved', () => { + operationCollection.getApi().startFeatureOperation('login-v2@1.0.0'); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.name).toBe('login-v2@1.0.0'); + }); + }); + + describe('lifecycle', () => { + it('stop() is callable without side effects (no owned subscriptions)', () => { + operationCollection.stop(); + // Subsequent calls still work; collection has no torn-down state. + operationCollection.getApi().startFeatureOperation('login'); + expect(rawRumEvents).toHaveLength(1); + }); + }); +}); diff --git a/src/domain/rum/operation/OperationCollection.ts b/src/domain/rum/operation/OperationCollection.ts new file mode 100644 index 0000000..f92c7b2 --- /dev/null +++ b/src/domain/rum/operation/OperationCollection.ts @@ -0,0 +1,152 @@ +import { type Context, generateUUID, timeStampNow } from '@datadog/browser-core'; +import { EventFormat, EventKind, EventManager, EventSource } from '../../../event'; +import { displayError } from '../../../tools/display'; +import type { RawRumVital } from '../rawRumData.types'; + +type OperationMethod = 'startFeatureOperation' | 'succeedFeatureOperation' | 'failFeatureOperation'; + +/** + * Failure reason for a RUM Operation step. + * + * Matches the schema enum values in vital-operation-step-schema.json. + */ +export type FailureReason = 'error' | 'abandoned' | 'other'; + +/** + * Options accepted by the RUM Operation APIs. + * + * Mirrors the browser-sdk's `FeatureOperationOptions` shape so consumers can + * share one mental model across main process and renderer process. + */ +export interface FeatureOperationOptions { + /** + * Key distinguishing parallel operations with the same name (e.g. separate + * upload tasks sharing the name "upload"). When omitted, the operation is + * treated as unkeyed. + */ + operationKey?: string; + + /** + * Custom attributes merged into the event's `context` section. + */ + context?: Context; + + /** + * Free-form description attached to `vital.description`. + */ + description?: string; +} + +/** + * Collect RUM vital operation step events emitted from the main process. + * + * No local duplicate-start / stop-without-start tracking is performed: + * renderer-originated start/stop events (from the bundled browser-sdk) + * flow through the bridge without updating main-process state, so any + * cross-process tracking would produce false positives when a developer + * legitimately starts in one process and stops in the other. Matches the + * bundled browser-sdk's no-tracking behavior; aligns with Android and + * Browser in the spec's parity matrix. + */ +export class OperationCollection { + constructor(private readonly eventManager: EventManager) {} + + getApi() { + return { + startFeatureOperation: (name: string, options?: FeatureOperationOptions) => + this.handle('startFeatureOperation', name, options), + succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => + this.handle('succeedFeatureOperation', name, options), + failFeatureOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => + this.handle('failFeatureOperation', name, options, failureReason), + }; + } + + stop(): void { + // No owned resources to release; method kept for RumCollection symmetry. + } + + private handle( + method: OperationMethod, + name: string, + options: FeatureOperationOptions | undefined, + failureReason?: FailureReason + ): void { + if (!validateArgs(method, name, options)) { + return; + } + const stepType = method === 'startFeatureOperation' ? 'start' : 'end'; + this.emitOperationStep(stepType, name, options, failureReason); + } + + private emitOperationStep( + stepType: 'start' | 'end', + name: string, + options: FeatureOperationOptions | undefined, + failureReason?: FailureReason + ): void { + const startTime = timeStampNow(); + const vital: RawRumVital['vital'] = { + id: generateUUID(), + name, + type: 'operation_step', + step_type: stepType, + }; + if (options?.operationKey !== undefined) { + vital.operation_key = options.operationKey; + } + if (failureReason !== undefined) { + vital.failure_reason = failureReason; + } + if (options?.description !== undefined) { + vital.description = options.description; + } + + const data: RawRumVital = { + type: 'vital', + date: startTime, + context: options?.context ?? {}, + vital, + }; + + this.eventManager.notify({ + kind: EventKind.RAW, + source: EventSource.MAIN, + format: EventFormat.RUM, + data, + startTime, + }); + } +} + +function isBlank(value: string): boolean { + return value.trim().length === 0; +} + +// Mirrors the backend's server-side `vital.name` character-set regex, +// `[\w.@$-]*` (letters, digits, `_`, `.`, `@`, `$`, `-`). Names that fail +// this pattern generate a developer warning but the event is still emitted +// — the backend is the source of truth on character-set policy, so client- +// side drop would force a customer SDK bump if the rule is ever relaxed. +// Blank / empty names are a separate check: they are rejected here because +// the backend rejects them with its own non-empty precondition before +// reaching the regex. +const VALID_OPERATION_NAME_REGEX = /^[\w.@$-]*$/; + +function validateArgs(method: OperationMethod, name: string, options: FeatureOperationOptions | undefined): boolean { + if (typeof name !== 'string' || isBlank(name)) { + displayError(`${method}: operation name cannot be empty or blank. Event will not be sent.`); + return false; + } + if (!VALID_OPERATION_NAME_REGEX.test(name)) { + displayError( + `${method}: operation name '${name}' does not match the backend-accepted pattern [\\w.@$-]* (letters, digits, _ . @ $ -). The event will still be sent and may be rejected by the backend.` + ); + // Warn but do not drop — the backend decides on character-set policy. + } + if (options?.operationKey !== undefined && isBlank(options.operationKey)) { + displayError(`${method}: operation key cannot be empty or blank. Event will not be sent.`); + return false; + } + return true; +} diff --git a/src/domain/rum/operation/index.ts b/src/domain/rum/operation/index.ts new file mode 100644 index 0000000..9c0ac1f --- /dev/null +++ b/src/domain/rum/operation/index.ts @@ -0,0 +1 @@ +export * from './OperationCollection'; diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index e5a6cd2..800fbbd 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -1,7 +1,7 @@ -import { RecursivePartial, ServerDuration } from '@datadog/browser-core'; -import { RumErrorEvent, RumViewEvent } from './rumEvent.types'; +import { RecursivePartial, ServerDuration, TimeStamp } from '@datadog/browser-core'; +import { RumErrorEvent, RumViewEvent, RumVitalOperationStepEvent } from './rumEvent.types'; -export type RawRumData = RawRumView | RawRumError; +export type RawRumData = RawRumView | RawRumError | RawRumVital; export interface RawRumView extends RecursivePartial { type: 'view'; @@ -51,3 +51,18 @@ export interface RawRumError extends RecursivePartial { }[]; }; } + +export interface RawRumVital extends RecursivePartial { + type: 'vital'; + date: TimeStamp; + context?: Record; + vital: { + id: string; + name?: string; + description?: string; + type: 'operation_step'; + step_type: 'start' | 'end' | 'update' | 'retry'; + operation_key?: string; + failure_reason?: 'error' | 'abandoned' | 'other'; + }; +} From d0f99ea642e52c58b83bab2fd59ec32067433f8b Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 23 Apr 2026 19:27:17 +0200 Subject: [PATCH 2/9] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20wire=20OperationCollec?= =?UTF-8?q?tion=20into=20the=20main-process=20public=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instantiates OperationCollection in RumCollection and exposes startFeatureOperation / succeedFeatureOperation / failFeatureOperation from the top-level @datadog/electron-sdk main-process module. Marked @experimental via JSDoc (no runtime feature flag on the main-process side — preview status communicated via the marker only). Renderer callers continue to use the bundled browser-sdk directly (requires the feature_operation_vital experimental flag on its init). An operation started in one process may be completed in the other — the backend correlates steps by name + operationKey regardless of origin. Follow-up (not in this PR): emit AddOperationStepVital usage telemetry once an addUsage pipeline exists alongside the current addError path. --- CHANGELOG.md | 6 +++ src/domain/rum/RumCollection.ts | 9 +++- src/domain/rum/index.ts | 3 +- src/index.ts | 81 ++++++++++++++++++++++++++++++++- 4 files changed, 94 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c18887..74691f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to `@datadog/electron-sdk` are documented here. +## [Unreleased] + +### ✨ Features + +- ⚗️ add RUM Operations API (`startFeatureOperation`, `succeedFeatureOperation`, `failFeatureOperation`) to the main process — preview/experimental. Renderer consumers keep using `@datadog/browser-rum` directly (requires the `feature_operation_vital` experimental flag); API signatures match between the two. + ## [0.1.3] - 2026-04-08 ### Internal diff --git a/src/domain/rum/RumCollection.ts b/src/domain/rum/RumCollection.ts index 07c3ac8..9ec7297 100644 --- a/src/domain/rum/RumCollection.ts +++ b/src/domain/rum/RumCollection.ts @@ -1,29 +1,34 @@ import { EventManager } from '../../event'; import type { FormatHooks } from '../../assembly'; import { ErrorCollection, CrashCollection } from './error'; +import { OperationCollection } from './operation'; import { ViewCollection } from './view'; export class RumCollection { private constructor( private readonly viewCollection: ViewCollection, - private readonly errorCollection: ErrorCollection + private readonly errorCollection: ErrorCollection, + private readonly operationCollection: OperationCollection ) {} static async start(eventManager: EventManager, hooks: FormatHooks): Promise { const viewCollection = await ViewCollection.start(eventManager, hooks); const errorCollection = new ErrorCollection(eventManager); + const operationCollection = new OperationCollection(eventManager); CrashCollection.start(eventManager); - return new RumCollection(viewCollection, errorCollection); + return new RumCollection(viewCollection, errorCollection, operationCollection); } getApi() { return { ...this.errorCollection.getApi(), + ...this.operationCollection.getApi(), }; } stop(): void { this.viewCollection.stop(); this.errorCollection.stop(); + this.operationCollection.stop(); } } diff --git a/src/domain/rum/index.ts b/src/domain/rum/index.ts index 59854f2..bda942a 100644 --- a/src/domain/rum/index.ts +++ b/src/domain/rum/index.ts @@ -1,4 +1,5 @@ export * from './RumCollection'; -export { ErrorOptions } from './error'; +export type { ErrorOptions } from './error'; +export type { FailureReason, FeatureOperationOptions } from './operation'; export * from './rawRumData.types'; export * from './rumEvent.types'; diff --git a/src/index.ts b/src/index.ts index 4915208..6b52377 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ import { buildConfiguration } from './config'; import { RumCollection } from './domain/rum'; import { SessionManager } from './domain/session'; import { UserActivityTracker } from './domain/UserActivityTracker'; -import type { ErrorOptions } from './domain/rum'; +import type { ErrorOptions, FailureReason, FeatureOperationOptions } from './domain/rum'; import { callMonitored, startTelemetry } from './domain/telemetry'; import { EventManager } from './event'; import { BridgeHandler, registerPreload } from './bridge'; @@ -59,6 +59,76 @@ export function addError(error: unknown, options?: ErrorOptions): void { callMonitored(() => rumApi?.addError(error, options)); } +/** + * Start a RUM Operation step from the main process. + * + * Emits a vital `operation_step` event with `step_type: "start"`. + * Pair every `startFeatureOperation` with exactly one `succeedFeatureOperation` + * or `failFeatureOperation`. Use `options.operationKey` to distinguish parallel + * operations with the same name. + * + * Renderer consumers should continue to call `DD_RUM.startFeatureOperation` + * on the bundled `@datadog/browser-rum` (with `feature_operation_vital` + * experimental flag enabled) — the API signatures match. An operation started + * in one process may be completed in the other; the backend correlates start + * and end steps by `name` + `operationKey`. + * + * The main-process API does not maintain any local active-operation tracking + * (by design — renderer-originated start/stop events cross the IPC bridge + * without updating main-process state, so local tracking would produce false + * "duplicate start" / "stop without start" warnings on legitimate cross- + * process flows). This matches the bundled browser-sdk's behavior. + * + * @experimental This API is in preview and may change in future releases. + * @example + * startFeatureOperation('checkout'); + * // ... later + * succeedFeatureOperation('checkout'); + * + * // Parallel operations with distinct keys + * startFeatureOperation('upload', { operationKey: 'profile_pic' }); + * startFeatureOperation('upload', { operationKey: 'cover_photo' }); + */ +export function startFeatureOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.startFeatureOperation(name, options)); +} + +/** + * Record the successful completion of a RUM Operation started with + * `startFeatureOperation`. + * + * Emits a vital `operation_step` event with `step_type: "end"` and no + * `failure_reason`. Pass the same `name` (and `operationKey`, if any) that + * was used when starting the operation. + * + * @experimental This API is in preview and may change in future releases. + * @example + * succeedFeatureOperation('upload', { operationKey: 'profile_pic' }); + */ +export function succeedFeatureOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.succeedFeatureOperation(name, options)); +} + +/** + * Record the failure of a RUM Operation started with `startFeatureOperation`. + * + * Emits a vital `operation_step` event with `step_type: "end"` and the + * supplied `failureReason`. Pass the same `name` (and `operationKey`, if any) + * that was used when starting the operation. + * + * @experimental This API is in preview and may change in future releases. + * @example + * failFeatureOperation('checkout', 'error'); + * failFeatureOperation('upload', 'abandoned', { operationKey: 'cover_photo' }); + */ +export function failFeatureOperation( + name: string, + failureReason: FailureReason, + options?: FeatureOperationOptions +): void { + callMonitored(() => rumApi?.failFeatureOperation(name, failureReason, options)); +} + /** * Internal API to flush all pending batches to the intake */ @@ -77,7 +147,14 @@ export function _generateTelemetryError() { } export type { InitConfiguration } from './config'; -export type { RumErrorEvent, RumViewEvent } from './domain/rum'; +export type { + FailureReason, + FeatureOperationOptions, + RumErrorEvent, + RumViewEvent, + RumVitalEvent, + RumVitalOperationStepEvent, +} from './domain/rum'; export type { TelemetryErrorEvent } from './domain/telemetry'; export { SESSION_TIME_OUT_DELAY } from './domain/session'; From b72a68a0bf38383e4cbb8665ad782584e3f4845a Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 23 Apr 2026 19:27:25 +0200 Subject: [PATCH 3/9] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20add=20e2e=20scenario?= =?UTF-8?q?=20for=20main-process=20Operation=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright scenario exercising the full main-process → Assembly → local intake round-trip for startFeatureOperation, succeedFeatureOperation, and failFeatureOperation. Asserts the emitted vital events conform to vital-operation-step-schema.json (step_type pairing, fresh vital.id per event, failure_reason only on fail, operation_key behaviour). Adds the supporting IPC hooks in the test app (main.ts, preload.ts) and mainPage page-object helpers so scenarios can drive the new API from the renderer context. --- e2e/app/src/main.ts | 20 ++++++ e2e/app/src/preload.ts | 6 ++ e2e/lib/mainPage.ts | 32 +++++++++ e2e/scenarios/operation.scenario.ts | 106 ++++++++++++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 e2e/scenarios/operation.scenario.ts diff --git a/e2e/app/src/main.ts b/e2e/app/src/main.ts index 769f55d..4aa21db 100644 --- a/e2e/app/src/main.ts +++ b/e2e/app/src/main.ts @@ -8,6 +8,11 @@ import { _generateTelemetryError, _flushTransport, stopSession, + startFeatureOperation, + succeedFeatureOperation, + failFeatureOperation, + type FailureReason, + type FeatureOperationOptions, type InitConfiguration, } from '@datadog/electron-sdk'; @@ -81,6 +86,21 @@ void app.whenReady().then(async () => { addError(new Error('test manual error'), { context: { foo: 'bar' }, startTime }); }); + ipcMain.handle('startFeatureOperation', (_event, name: string, options?: FeatureOperationOptions) => { + startFeatureOperation(name, options); + }); + + ipcMain.handle('succeedFeatureOperation', (_event, name: string, options?: FeatureOperationOptions) => { + succeedFeatureOperation(name, options); + }); + + ipcMain.handle( + 'failFeatureOperation', + (_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => { + failFeatureOperation(name, failureReason, options); + } + ); + ipcMain.handle('flushTransport', async () => { await _flushTransport(); }); diff --git a/e2e/app/src/preload.ts b/e2e/app/src/preload.ts index 3629247..6bb2b48 100644 --- a/e2e/app/src/preload.ts +++ b/e2e/app/src/preload.ts @@ -11,6 +11,12 @@ contextBridge.exposeInMainWorld('electronAPI', { generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'), generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'), generateManualError: (startTime?: number) => ipcRenderer.invoke('generateManualError', startTime), + startFeatureOperation: (name: string, options?: Record) => + ipcRenderer.invoke('startFeatureOperation', name, options), + succeedFeatureOperation: (name: string, options?: Record) => + ipcRenderer.invoke('succeedFeatureOperation', name, options), + failFeatureOperation: (name: string, failureReason: string, options?: Record) => + ipcRenderer.invoke('failFeatureOperation', name, failureReason, options), flushTransport: () => ipcRenderer.invoke('flushTransport'), crash: () => ipcRenderer.invoke('crash'), openBridgeFileWindow: () => ipcRenderer.invoke('openBridgeFileWindow'), diff --git a/e2e/lib/mainPage.ts b/e2e/lib/mainPage.ts index 6aed57a..7f4bdfc 100644 --- a/e2e/lib/mainPage.ts +++ b/e2e/lib/mainPage.ts @@ -1,4 +1,5 @@ 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 @@ -6,6 +7,13 @@ interface ElectronAppWindow { electronAPI: { generateTelemetryErrors: (count: number) => Promise; generateManualError: (startTime?: number) => Promise; + startFeatureOperation: (name: string, options?: FeatureOperationOptions) => Promise; + succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => Promise; + failFeatureOperation: ( + name: string, + failureReason: FailureReason, + options?: FeatureOperationOptions + ) => Promise; flushTransport: () => Promise; openBridgeFileWindow: () => Promise; openBridgeFileWindowNoIsolation: () => Promise; @@ -64,6 +72,30 @@ export class MainPage { ); } + async startFeatureOperation(name: string, options?: FeatureOperationOptions) { + await this.page.evaluate( + ({ name, options }) => + (globalThis as unknown as ElectronAppWindow).electronAPI.startFeatureOperation(name, options), + { name, options } + ); + } + + async succeedFeatureOperation(name: string, options?: FeatureOperationOptions) { + await this.page.evaluate( + ({ name, options }) => + (globalThis as unknown as ElectronAppWindow).electronAPI.succeedFeatureOperation(name, options), + { name, options } + ); + } + + async failFeatureOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) { + await this.page.evaluate( + ({ name, failureReason, options }) => + (globalThis as unknown as ElectronAppWindow).electronAPI.failFeatureOperation(name, failureReason, options), + { name, failureReason, options } + ); + } + async flushTransport() { await this.page.evaluate(() => (globalThis as unknown as ElectronAppWindow).electronAPI.flushTransport()); } diff --git a/e2e/scenarios/operation.scenario.ts b/e2e/scenarios/operation.scenario.ts new file mode 100644 index 0000000..2b676e3 --- /dev/null +++ b/e2e/scenarios/operation.scenario.ts @@ -0,0 +1,106 @@ +import { test, expect } from '../lib/helpers'; +import type { RumViewEvent, RumVitalOperationStepEvent } from '@datadog/electron-sdk'; + +// Strict RFC 4122 v4 — matches the schema pattern. +const UUID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; + +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.startFeatureOperation('checkout'); + await mainPage.succeedFeatureOperation('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(start.vital?.id).toMatch(UUID_REGEX); + + expect(end.vital?.failure_reason).toBeUndefined(); + expect(end.vital?.name).toBe('checkout'); + expect(end.vital?.id).toMatch(UUID_REGEX); + 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.startFeatureOperation('checkout'); + await mainPage.failFeatureOperation('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.startFeatureOperation('upload', { operationKey: 'photo_1' }); + await mainPage.succeedFeatureOperation('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.startFeatureOperation('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.succeedFeatureOperation('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'); +}); From ba387922927f5200d23ac0179a5f06d290768bbb Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Mon, 27 Apr 2026 17:22:42 +0200 Subject: [PATCH 4/9] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20address=20PR=20#102=20?= =?UTF-8?q?review=20feedback=20for=20Operation=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert manual CHANGELOG entry (changelog is autogenerated at release). - README: add `Operation Monitoring` section with usage, API table, types, cross-process note, and validation rules. - src/index.ts: trim doc blocks (move impl details to README), respect 120- char line width. - rawRumData.types: derive step_type, failure_reason, type from RumVitalOperationStepEvent['vital'] instead of duplicating the literals. - OperationCollection: switch character-set warning to displayWarn (added alongside displayError); validateArgs now takes `unknown` and uses isValidString + isIndexableObject; reject non-object options. - e2e: drop strict UUID v4 regex check (deferred to future event-shape validation harness). - Playground: wire start/succeed/failOperation IPC handlers and demo buttons, including a "Run 5 parallel uploads" affordance. --- CHANGELOG.md | 6 -- README.md | 67 +++++++++++++++++ e2e/scenarios/operation.scenario.ts | 5 -- playground/src/index.html | 10 +++ playground/src/main.ts | 28 +++++++- playground/src/preload.ts | 6 ++ playground/src/renderer.ts | 72 +++++++++++++++++++ .../rum/operation/OperationCollection.spec.ts | 45 +++++++++--- .../rum/operation/OperationCollection.ts | 22 +++--- src/domain/rum/rawRumData.types.ts | 8 ++- src/index.ts | 47 +++--------- src/tools/display.ts | 7 ++ 12 files changed, 252 insertions(+), 71 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74691f4..0c18887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,6 @@ All notable changes to `@datadog/electron-sdk` are documented here. -## [Unreleased] - -### ✨ Features - -- ⚗️ add RUM Operations API (`startFeatureOperation`, `succeedFeatureOperation`, `failFeatureOperation`) to the main process — preview/experimental. Renderer consumers keep using `@datadog/browser-rum` directly (requires the `feature_operation_vital` experimental flag); API signatures match between the two. - ## [0.1.3] - 2026-04-08 ### Internal diff --git a/README.md b/README.md index d708ae5..239889d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ 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 ### Renderer Process Support @@ -81,6 +82,72 @@ try { } ``` +### 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 { startFeatureOperation, succeedFeatureOperation, failFeatureOperation } from '@datadog/electron-sdk'; + +// Simple operation +startFeatureOperation('checkout'); +try { + await runCheckout(); + succeedFeatureOperation('checkout'); +} catch (error) { + failFeatureOperation('checkout', 'error'); +} + +// Parallel operations sharing a name — distinguished by `operationKey` +startFeatureOperation('upload', { operationKey: 'profile_pic' }); +startFeatureOperation('upload', { operationKey: 'cover_photo' }); +succeedFeatureOperation('upload', { operationKey: 'profile_pic' }); +failFeatureOperation('upload', 'abandoned', { operationKey: 'cover_photo' }); +``` + +#### API + +| Function | Signature | +| ------------------------- | ----------------------------------------------------------------------------------------- | +| `startFeatureOperation` | `(name: string, options?: FeatureOperationOptions) => void` | +| `succeedFeatureOperation` | `(name: string, options?: FeatureOperationOptions) => void` | +| `failFeatureOperation` | `(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => void` | + +```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; + /** Free-form description attached to `vital.description`. */ + description?: string; +} +``` + +#### Cross-process usage + +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`. + +#### Validation + +- Blank `name` or blank `operationKey` are rejected and an error is logged; no event + is emitted. +- Non-string `name` or non-object `options` are rejected the same way (defensive + guard for JS callers that bypass the TypeScript signatures). +- Names containing characters outside `[\w.@$-]*` (letters, digits, `_`, `.`, `@`, + `$`, `-`) emit a warning but the event is still sent — the backend is the source + of truth on the character-set policy. + ### Configuration Options | Option | Type | Required | Default | Description | diff --git a/e2e/scenarios/operation.scenario.ts b/e2e/scenarios/operation.scenario.ts index 2b676e3..d5875ed 100644 --- a/e2e/scenarios/operation.scenario.ts +++ b/e2e/scenarios/operation.scenario.ts @@ -1,9 +1,6 @@ import { test, expect } from '../lib/helpers'; import type { RumViewEvent, RumVitalOperationStepEvent } from '@datadog/electron-sdk'; -// Strict RFC 4122 v4 — matches the schema pattern. -const UUID_REGEX = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/; - test('emits start and succeed vital operation_step events', async ({ mainPage, intake }) => { await mainPage.flushTransport(); const viewEvents = await intake.getEventsByType('view'); @@ -27,11 +24,9 @@ test('emits start and succeed vital operation_step events', async ({ mainPage, i expect(start.vital?.name).toBe('checkout'); expect(start.vital?.failure_reason).toBeUndefined(); expect(start.vital?.operation_key).toBeUndefined(); - expect(start.vital?.id).toMatch(UUID_REGEX); expect(end.vital?.failure_reason).toBeUndefined(); expect(end.vital?.name).toBe('checkout'); - expect(end.vital?.id).toMatch(UUID_REGEX); expect(end.vital?.id).not.toBe(start.vital?.id); // Common RUM context is populated by the main-process Assembly pipeline. diff --git a/playground/src/index.html b/playground/src/index.html index 3ed83c7..cd765f7 100644 --- a/playground/src/index.html +++ b/playground/src/index.html @@ -190,6 +190,16 @@

Session File Content:

+ +
+ + + + + + +
+

IPC Activity Log:

diff --git a/playground/src/main.ts b/playground/src/main.ts index fffe81a..d0bacfa 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -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, + startFeatureOperation, + succeedFeatureOperation, + failFeatureOperation, + type FailureReason, + type FeatureOperationOptions, +} from '@datadog/electron-sdk'; import { loadWindowState, saveWindowState } from './main/windowState'; import { setupHotReload } from './main/hotReload'; @@ -98,6 +107,23 @@ ipcMain.handle('crash', () => { process.crash(); }); +// --- Operation Monitoring demo handlers --- + +ipcMain.handle('main:start-operation', (_event, name: string, options?: FeatureOperationOptions) => { + startFeatureOperation(name, options); +}); + +ipcMain.handle('main:succeed-operation', (_event, name: string, options?: FeatureOperationOptions) => { + succeedFeatureOperation(name, options); +}); + +ipcMain.handle( + 'main:fail-operation', + (_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => { + failFeatureOperation(name, failureReason, options); + } +); + void app.whenReady().then(async () => { // Initialize SDK on app ready (before window creation) console.log('Initializing SDK from main process...'); diff --git a/playground/src/preload.ts b/playground/src/preload.ts index bc372f8..a133b32 100644 --- a/playground/src/preload.ts +++ b/playground/src/preload.ts @@ -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), }); diff --git a/playground/src/renderer.ts b/playground/src/renderer.ts index 0cf9195..8d1d450 100644 --- a/playground/src/renderer.ts +++ b/playground/src/renderer.ts @@ -25,6 +25,13 @@ interface ElectronAPI { generateUnhandledRejection: () => Promise; crash: () => Promise; mainFetchApi: () => Promise; + startOperation: (name: string, options?: { operationKey?: string }) => Promise; + succeedOperation: (name: string, options?: { operationKey?: string }) => Promise; + failOperation: ( + name: string, + failureReason: 'error' | 'abandoned' | 'other', + options?: { operationKey?: string } + ) => Promise; } declare global { @@ -193,3 +200,68 @@ if (rendererFetchBtn) { } setupDemoButton('main-fetch', 'main:fetch-api', () => window.electronAPI.mainFetchApi()); + +// --- Operation Monitoring demo buttons --- + +setupDemoButton('op-start', 'main:start-operation(checkout)', () => window.electronAPI.startOperation('checkout')); +setupDemoButton('op-succeed', 'main:succeed-operation(checkout)', () => + window.electronAPI.succeedOperation('checkout') +); +setupDemoButton('op-fail', 'main:fail-operation(checkout, error)', () => + window.electronAPI.failOperation('checkout', 'error') +); +setupDemoButton('op-keyed-start', 'main:start-operation(upload/photo_1)', () => + window.electronAPI.startOperation('upload', { operationKey: 'photo_1' }) +); +setupDemoButton('op-keyed-succeed', 'main:succeed-operation(upload/photo_1)', () => + window.electronAPI.succeedOperation('upload', { operationKey: 'photo_1' }) +); + +// Simulate 5 parallel file uploads with different operation_keys, then complete +// each in a random order — mirrors a real upload-batch flow where individual +// uploads finish at unpredictable times. +async function runParallelUploads(): Promise { + const FAILURE_REASONS = ['error', 'abandoned', 'other'] as const; + const keys = Array.from({ length: 5 }, (_, i) => `photo_${i + 1}`); + + // Fire all start IPC calls in parallel so the SDK observes them as concurrent. + await Promise.all( + keys.map(async (key) => { + await window.electronAPI.startOperation('upload', { operationKey: key }); + logIpcCall(`main:start-operation(upload/${key})`, 'done'); + }) + ); + + // Shuffle to complete in a random order. + const completionOrder = [...keys].sort(() => Math.random() - 0.5); + + // Each completion fires after its own random delay, in parallel. + await Promise.all( + completionOrder.map(async (key) => { + // Random delay 50–500ms to simulate variable upload duration. + await new Promise((resolve) => setTimeout(resolve, 50 + Math.floor(Math.random() * 450))); + + // ~70% succeed, ~30% fail with a random failure_reason. + if (Math.random() < 0.7) { + await window.electronAPI.succeedOperation('upload', { operationKey: key }); + logIpcCall(`main:succeed-operation(upload/${key})`, 'done'); + } else { + const reason = FAILURE_REASONS[Math.floor(Math.random() * FAILURE_REASONS.length)]; + await window.electronAPI.failOperation('upload', reason, { operationKey: key }); + logIpcCall(`main:fail-operation(upload/${key}, ${reason})`, 'done'); + } + }) + ); +} + +const parallelBtn = document.getElementById('op-parallel-uploads') as HTMLButtonElement | null; +if (parallelBtn) { + parallelBtn.addEventListener('click', () => { + parallelBtn.disabled = true; + void runParallelUploads() + .catch((err) => logIpcCall('parallel-uploads', 'error', 0, String(err))) + .finally(() => { + parallelBtn.disabled = false; + }); + }); +} diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts index e531c46..f526695 100644 --- a/src/domain/rum/operation/OperationCollection.spec.ts +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -1,11 +1,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { OperationCollection } from './OperationCollection'; +import { OperationCollection, type FeatureOperationOptions } from './OperationCollection'; import { EventFormat, EventKind, EventManager, type RawRumEvent } from '../../../event'; import type { RawRumVital } from '../rawRumData.types'; -import { displayError } from '../../../tools/display'; +import { displayError, displayWarn } from '../../../tools/display'; vi.mock('../../../tools/display', () => ({ displayError: vi.fn(), + displayWarn: vi.fn(), displayInfo: vi.fn(), })); @@ -164,6 +165,32 @@ describe('OperationCollection', () => { expect(displayError).toHaveBeenCalledOnce(); expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); }); + + // The public API is typed as `(name: string, options?: FeatureOperationOptions)`, + // but the validator accepts `unknown` to defend against JS callers passing + // garbage. These tests pin the runtime contract independent of the type system. + it.each([ + ['null', null], + ['number', 42], + ['string', 'oops'], + ['boolean', true], + ['array', ['operationKey']], + ])('rejects non-object %s as options and emits no event', (_label, badOptions) => { + operationCollection.getApi().startFeatureOperation('login', badOptions as unknown as FeatureOperationOptions); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('options must be an object'); + }); + + it.each([ + ['null', null], + ['number', 42], + ])('rejects non-string %s as name and emits no event', (_label, badName) => { + operationCollection.getApi().startFeatureOperation(badName as unknown as string); + expect(rawRumEvents).toHaveLength(0); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation name cannot be empty'); + }); }); // --- Name character-set validation (schema facet-path rule) --- @@ -191,9 +218,9 @@ describe('OperationCollection', () => { expect(rawRumEvents).toHaveLength(1); expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); - expect(displayError).toHaveBeenCalledOnce(); - expect(vi.mocked(displayError).mock.calls[0][0]).toContain('does not match'); - expect(vi.mocked(displayError).mock.calls[0][0]).toContain('still be sent'); + expect(displayWarn).toHaveBeenCalledOnce(); + expect(vi.mocked(displayWarn).mock.calls[0][0]).toContain('does not match'); + expect(vi.mocked(displayWarn).mock.calls[0][0]).toContain('still be sent'); }); it.each([ @@ -212,7 +239,7 @@ describe('OperationCollection', () => { operationCollection.getApi().startFeatureOperation(name); expect(rawRumEvents).toHaveLength(1); - expect(displayError).not.toHaveBeenCalled(); + expect(displayWarn).not.toHaveBeenCalled(); expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); }); @@ -220,7 +247,7 @@ describe('OperationCollection', () => { operationCollection.getApi().succeedFeatureOperation('user login'); expect(rawRumEvents).toHaveLength(1); expect((rawRumEvents[0].data as RawRumVital).vital.step_type).toBe('end'); - expect(displayError).toHaveBeenCalledOnce(); + expect(displayWarn).toHaveBeenCalledOnce(); }); it('warns but still emits on failFeatureOperation with invalid characters', () => { @@ -229,14 +256,14 @@ describe('OperationCollection', () => { const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.step_type).toBe('end'); expect(data.vital.failure_reason).toBe('error'); - expect(displayError).toHaveBeenCalledOnce(); + expect(displayWarn).toHaveBeenCalledOnce(); }); it('does not restrict operationKey to the same character set', () => { // operation_key has no character-set constraint in the schema. operationCollection.getApi().startFeatureOperation('login', { operationKey: 'session-42 / user foo' }); expect(rawRumEvents).toHaveLength(1); - expect(displayError).not.toHaveBeenCalled(); + expect(displayWarn).not.toHaveBeenCalled(); expect((rawRumEvents[0].data as RawRumVital).vital.operation_key).toBe('session-42 / user foo'); }); }); diff --git a/src/domain/rum/operation/OperationCollection.ts b/src/domain/rum/operation/OperationCollection.ts index f92c7b2..c5f5bf4 100644 --- a/src/domain/rum/operation/OperationCollection.ts +++ b/src/domain/rum/operation/OperationCollection.ts @@ -1,6 +1,6 @@ -import { type Context, generateUUID, timeStampNow } from '@datadog/browser-core'; +import { type Context, generateUUID, isIndexableObject, timeStampNow } from '@datadog/browser-core'; import { EventFormat, EventKind, EventManager, EventSource } from '../../../event'; -import { displayError } from '../../../tools/display'; +import { displayError, displayWarn } from '../../../tools/display'; import type { RawRumVital } from '../rawRumData.types'; type OperationMethod = 'startFeatureOperation' | 'succeedFeatureOperation' | 'failFeatureOperation'; @@ -119,8 +119,8 @@ export class OperationCollection { } } -function isBlank(value: string): boolean { - return value.trim().length === 0; +function isValidString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; } // Mirrors the backend's server-side `vital.name` character-set regex, @@ -133,18 +133,22 @@ function isBlank(value: string): boolean { // reaching the regex. const VALID_OPERATION_NAME_REGEX = /^[\w.@$-]*$/; -function validateArgs(method: OperationMethod, name: string, options: FeatureOperationOptions | undefined): boolean { - if (typeof name !== 'string' || isBlank(name)) { +function validateArgs(method: OperationMethod, name: unknown, options: unknown): boolean { + if (!isValidString(name)) { displayError(`${method}: operation name cannot be empty or blank. Event will not be sent.`); return false; } if (!VALID_OPERATION_NAME_REGEX.test(name)) { - displayError( + // Warn but do not drop — the backend decides on character-set policy. + displayWarn( `${method}: operation name '${name}' does not match the backend-accepted pattern [\\w.@$-]* (letters, digits, _ . @ $ -). The event will still be sent and may be rejected by the backend.` ); - // Warn but do not drop — the backend decides on character-set policy. } - if (options?.operationKey !== undefined && isBlank(options.operationKey)) { + if (options !== undefined && !isIndexableObject(options)) { + displayError(`${method}: options must be an object when provided. Event will not be sent.`); + return false; + } + if (isIndexableObject(options) && options.operationKey !== undefined && !isValidString(options.operationKey)) { displayError(`${method}: operation key cannot be empty or blank. Event will not be sent.`); return false; } diff --git a/src/domain/rum/rawRumData.types.ts b/src/domain/rum/rawRumData.types.ts index 800fbbd..814a888 100644 --- a/src/domain/rum/rawRumData.types.ts +++ b/src/domain/rum/rawRumData.types.ts @@ -52,6 +52,8 @@ export interface RawRumError extends RecursivePartial { }; } +type RumVitalOperationStepEventVital = NonNullable; + export interface RawRumVital extends RecursivePartial { type: 'vital'; date: TimeStamp; @@ -60,9 +62,9 @@ export interface RawRumVital extends RecursivePartial rumApi?.startFeatureOperation(name, options)); } /** - * Record the successful completion of a RUM Operation started with - * `startFeatureOperation`. + * Record the successful completion of a RUM Operation started with `startFeatureOperation`. * - * Emits a vital `operation_step` event with `step_type: "end"` and no - * `failure_reason`. Pass the same `name` (and `operationKey`, if any) that - * was used when starting the operation. + * Pass the same `name` (and `operationKey`, if any) that was used when starting the operation. * * @experimental This API is in preview and may change in future releases. - * @example - * succeedFeatureOperation('upload', { operationKey: 'profile_pic' }); + * @see README "Operation Monitoring" for usage details. */ export function succeedFeatureOperation(name: string, options?: FeatureOperationOptions): void { callMonitored(() => rumApi?.succeedFeatureOperation(name, options)); @@ -112,14 +87,10 @@ export function succeedFeatureOperation(name: string, options?: FeatureOperation /** * Record the failure of a RUM Operation started with `startFeatureOperation`. * - * Emits a vital `operation_step` event with `step_type: "end"` and the - * supplied `failureReason`. Pass the same `name` (and `operationKey`, if any) - * that was used when starting the operation. + * Pass the same `name` (and `operationKey`, if any) that was used when starting the operation. * * @experimental This API is in preview and may change in future releases. - * @example - * failFeatureOperation('checkout', 'error'); - * failFeatureOperation('upload', 'abandoned', { operationKey: 'cover_photo' }); + * @see README "Operation Monitoring" for usage details. */ export function failFeatureOperation( name: string, diff --git a/src/tools/display.ts b/src/tools/display.ts index e70840a..c4eacb3 100644 --- a/src/tools/display.ts +++ b/src/tools/display.ts @@ -7,6 +7,13 @@ export function displayError(...args: unknown[]) { console.error(PREFIX, ...args); } +/** + * Display a warning in the console with the SDK prefix + */ +export function displayWarn(...args: unknown[]) { + console.warn(PREFIX, ...args); +} + /** * Display an info in the console with the SDK prefix */ From a838956d0702d2f1a6e8ee2393d92ca5fac14d1f Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Mon, 27 Apr 2026 17:28:31 +0200 Subject: [PATCH 5/9] =?UTF-8?q?=E2=9C=A8=20add=20start/succeed/failOperati?= =?UTF-8?q?on=20public=20APIs=20and=20deprecate=20FeatureOperation=20alias?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canonical public surface for RUM Operation Monitoring is now `startOperation` / `succeedOperation` / `failOperation`. The earlier-preview names `startFeatureOperation` / `succeedFeatureOperation` / `failFeatureOperation` are kept as deprecated aliases for backwards compatibility, with `@deprecated` JSDoc + a one-time runtime warning per method, and will be removed in the next major. Why API parity matters even on a soft-deprecation path: the browser-sdk exposes the same surface as `start/succeed/failFeatureOperation` today, so the deprecation gives Electron customers the canonical name immediately without breaking anyone using the early-preview alias. - OperationCollection: getApi() now exposes 6 methods — 3 canonical and 3 deprecated wrappers that warn-once-per-method (per instance) and forward to the canonical implementation. Logic kept in one place for testability. - src/index.ts: 3 canonical exports + 3 deprecated wrappers carrying the `@deprecated` JSDoc tag (so IDEs surface the deprecation at the customer call site). - README: examples switched to canonical names; added a deprecation banner. - Playground / e2e harness / e2e scenarios: switched to canonical names. - Unit tests: primary tests now exercise the canonical API; added a `deprecated *FeatureOperation wrappers` describe block covering the forward-and-warn behavior, warn-once policy, validation passthrough, and no-warning on the canonical path. (75 / 75 pass on the operation suite, 381 / 381 across the full suite.) --- README.md | 33 +-- e2e/app/src/main.ts | 18 +- e2e/app/src/preload.ts | 12 +- e2e/lib/mainPage.ts | 24 +- e2e/scenarios/operation.scenario.ts | 16 +- playground/src/main.ts | 12 +- .../rum/operation/OperationCollection.spec.ts | 206 +++++++++++++----- .../rum/operation/OperationCollection.ts | 50 ++++- src/index.ts | 50 ++++- 9 files changed, 293 insertions(+), 128 deletions(-) diff --git a/README.md b/README.md index 239889d..a943cbe 100644 --- a/README.md +++ b/README.md @@ -92,31 +92,38 @@ 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 { startFeatureOperation, succeedFeatureOperation, failFeatureOperation } from '@datadog/electron-sdk'; +import { startOperation, succeedOperation, failOperation } from '@datadog/electron-sdk'; // Simple operation -startFeatureOperation('checkout'); +startOperation('checkout'); try { await runCheckout(); - succeedFeatureOperation('checkout'); + succeedOperation('checkout'); } catch (error) { - failFeatureOperation('checkout', 'error'); + failOperation('checkout', 'error'); } // Parallel operations sharing a name — distinguished by `operationKey` -startFeatureOperation('upload', { operationKey: 'profile_pic' }); -startFeatureOperation('upload', { operationKey: 'cover_photo' }); -succeedFeatureOperation('upload', { operationKey: 'profile_pic' }); -failFeatureOperation('upload', 'abandoned', { operationKey: 'cover_photo' }); +startOperation('upload', { operationKey: 'profile_pic' }); +startOperation('upload', { operationKey: 'cover_photo' }); +succeedOperation('upload', { operationKey: 'profile_pic' }); +failOperation('upload', 'abandoned', { operationKey: 'cover_photo' }); ``` #### API -| Function | Signature | -| ------------------------- | ----------------------------------------------------------------------------------------- | -| `startFeatureOperation` | `(name: string, options?: FeatureOperationOptions) => void` | -| `succeedFeatureOperation` | `(name: string, options?: FeatureOperationOptions) => void` | -| `failFeatureOperation` | `(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => void` | +| Function | Signature | +| ------------------ | ----------------------------------------------------------------------------------------- | +| `startOperation` | `(name: string, options?: FeatureOperationOptions) => void` | +| `succeedOperation` | `(name: string, options?: FeatureOperationOptions) => void` | +| `failOperation` | `(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => void` | + +> **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`. ```ts type FailureReason = 'error' | 'abandoned' | 'other'; diff --git a/e2e/app/src/main.ts b/e2e/app/src/main.ts index 4aa21db..18a63d4 100644 --- a/e2e/app/src/main.ts +++ b/e2e/app/src/main.ts @@ -8,9 +8,9 @@ import { _generateTelemetryError, _flushTransport, stopSession, - startFeatureOperation, - succeedFeatureOperation, - failFeatureOperation, + startOperation, + succeedOperation, + failOperation, type FailureReason, type FeatureOperationOptions, type InitConfiguration, @@ -86,18 +86,18 @@ void app.whenReady().then(async () => { addError(new Error('test manual error'), { context: { foo: 'bar' }, startTime }); }); - ipcMain.handle('startFeatureOperation', (_event, name: string, options?: FeatureOperationOptions) => { - startFeatureOperation(name, options); + ipcMain.handle('startOperation', (_event, name: string, options?: FeatureOperationOptions) => { + startOperation(name, options); }); - ipcMain.handle('succeedFeatureOperation', (_event, name: string, options?: FeatureOperationOptions) => { - succeedFeatureOperation(name, options); + ipcMain.handle('succeedOperation', (_event, name: string, options?: FeatureOperationOptions) => { + succeedOperation(name, options); }); ipcMain.handle( - 'failFeatureOperation', + 'failOperation', (_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => { - failFeatureOperation(name, failureReason, options); + failOperation(name, failureReason, options); } ); diff --git a/e2e/app/src/preload.ts b/e2e/app/src/preload.ts index 6bb2b48..873dc48 100644 --- a/e2e/app/src/preload.ts +++ b/e2e/app/src/preload.ts @@ -11,12 +11,12 @@ contextBridge.exposeInMainWorld('electronAPI', { generateUncaughtException: () => ipcRenderer.invoke('generateUncaughtException'), generateUnhandledRejection: () => ipcRenderer.invoke('generateUnhandledRejection'), generateManualError: (startTime?: number) => ipcRenderer.invoke('generateManualError', startTime), - startFeatureOperation: (name: string, options?: Record) => - ipcRenderer.invoke('startFeatureOperation', name, options), - succeedFeatureOperation: (name: string, options?: Record) => - ipcRenderer.invoke('succeedFeatureOperation', name, options), - failFeatureOperation: (name: string, failureReason: string, options?: Record) => - ipcRenderer.invoke('failFeatureOperation', name, failureReason, options), + startOperation: (name: string, options?: Record) => + ipcRenderer.invoke('startOperation', name, options), + succeedOperation: (name: string, options?: Record) => + ipcRenderer.invoke('succeedOperation', name, options), + failOperation: (name: string, failureReason: string, options?: Record) => + ipcRenderer.invoke('failOperation', name, failureReason, options), flushTransport: () => ipcRenderer.invoke('flushTransport'), crash: () => ipcRenderer.invoke('crash'), openBridgeFileWindow: () => ipcRenderer.invoke('openBridgeFileWindow'), diff --git a/e2e/lib/mainPage.ts b/e2e/lib/mainPage.ts index 7f4bdfc..0a8c1bd 100644 --- a/e2e/lib/mainPage.ts +++ b/e2e/lib/mainPage.ts @@ -7,13 +7,9 @@ interface ElectronAppWindow { electronAPI: { generateTelemetryErrors: (count: number) => Promise; generateManualError: (startTime?: number) => Promise; - startFeatureOperation: (name: string, options?: FeatureOperationOptions) => Promise; - succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => Promise; - failFeatureOperation: ( - name: string, - failureReason: FailureReason, - options?: FeatureOperationOptions - ) => Promise; + startOperation: (name: string, options?: FeatureOperationOptions) => Promise; + succeedOperation: (name: string, options?: FeatureOperationOptions) => Promise; + failOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => Promise; flushTransport: () => Promise; openBridgeFileWindow: () => Promise; openBridgeFileWindowNoIsolation: () => Promise; @@ -72,26 +68,24 @@ export class MainPage { ); } - async startFeatureOperation(name: string, options?: FeatureOperationOptions) { + async startOperation(name: string, options?: FeatureOperationOptions) { await this.page.evaluate( - ({ name, options }) => - (globalThis as unknown as ElectronAppWindow).electronAPI.startFeatureOperation(name, options), + ({ name, options }) => (globalThis as unknown as ElectronAppWindow).electronAPI.startOperation(name, options), { name, options } ); } - async succeedFeatureOperation(name: string, options?: FeatureOperationOptions) { + async succeedOperation(name: string, options?: FeatureOperationOptions) { await this.page.evaluate( - ({ name, options }) => - (globalThis as unknown as ElectronAppWindow).electronAPI.succeedFeatureOperation(name, options), + ({ name, options }) => (globalThis as unknown as ElectronAppWindow).electronAPI.succeedOperation(name, options), { name, options } ); } - async failFeatureOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) { + async failOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) { await this.page.evaluate( ({ name, failureReason, options }) => - (globalThis as unknown as ElectronAppWindow).electronAPI.failFeatureOperation(name, failureReason, options), + (globalThis as unknown as ElectronAppWindow).electronAPI.failOperation(name, failureReason, options), { name, failureReason, options } ); } diff --git a/e2e/scenarios/operation.scenario.ts b/e2e/scenarios/operation.scenario.ts index d5875ed..b8a29c2 100644 --- a/e2e/scenarios/operation.scenario.ts +++ b/e2e/scenarios/operation.scenario.ts @@ -6,8 +6,8 @@ test('emits start and succeed vital operation_step events', async ({ mainPage, i const viewEvents = await intake.getEventsByType('view'); const view = viewEvents[0].body as RumViewEvent; - await mainPage.startFeatureOperation('checkout'); - await mainPage.succeedFeatureOperation('checkout'); + await mainPage.startOperation('checkout'); + await mainPage.succeedOperation('checkout'); await mainPage.flushTransport(); const vitalEvents = await intake.waitForEventCount('vital', 2); @@ -44,8 +44,8 @@ test('emits start and succeed vital operation_step events', async ({ mainPage, i test('emits start and fail vital operation_step events with failure_reason', async ({ mainPage, intake }) => { await mainPage.flushTransport(); - await mainPage.startFeatureOperation('checkout'); - await mainPage.failFeatureOperation('checkout', 'error'); + 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); @@ -60,8 +60,8 @@ test('emits start and fail vital operation_step events with failure_reason', asy test('forwards operationKey to the event payload on both start and end', async ({ mainPage, intake }) => { await mainPage.flushTransport(); - await mainPage.startFeatureOperation('upload', { operationKey: 'photo_1' }); - await mainPage.succeedFeatureOperation('upload', { operationKey: 'photo_1' }); + 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); @@ -74,7 +74,7 @@ test('forwards operationKey to the event payload on both start and end', async ( test('omits operation_key when the operation is unkeyed', async ({ mainPage, intake }) => { await mainPage.flushTransport(); - await mainPage.startFeatureOperation('login'); + await mainPage.startOperation('login'); await mainPage.flushTransport(); const vitalEvents = await intake.waitForEventCount('vital', 1); @@ -89,7 +89,7 @@ test('stop without prior start still emits the event (no local tracking)', async // tracking. The main-process API therefore emits unconditionally. await mainPage.flushTransport(); - await mainPage.succeedFeatureOperation('dangling'); + await mainPage.succeedOperation('dangling'); await mainPage.flushTransport(); const vitalEvents = await intake.waitForEventCount('vital', 1); diff --git a/playground/src/main.ts b/playground/src/main.ts index d0bacfa..6a9ca56 100644 --- a/playground/src/main.ts +++ b/playground/src/main.ts @@ -6,9 +6,9 @@ import { init, stopSession, _generateTelemetryError, - startFeatureOperation, - succeedFeatureOperation, - failFeatureOperation, + startOperation, + succeedOperation, + failOperation, type FailureReason, type FeatureOperationOptions, } from '@datadog/electron-sdk'; @@ -110,17 +110,17 @@ ipcMain.handle('crash', () => { // --- Operation Monitoring demo handlers --- ipcMain.handle('main:start-operation', (_event, name: string, options?: FeatureOperationOptions) => { - startFeatureOperation(name, options); + startOperation(name, options); }); ipcMain.handle('main:succeed-operation', (_event, name: string, options?: FeatureOperationOptions) => { - succeedFeatureOperation(name, options); + succeedOperation(name, options); }); ipcMain.handle( 'main:fail-operation', (_event, name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => { - failFeatureOperation(name, failureReason, options); + failOperation(name, failureReason, options); } ); diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts index f526695..7616a22 100644 --- a/src/domain/rum/operation/OperationCollection.spec.ts +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -31,8 +31,8 @@ describe('OperationCollection', () => { // --- API-01..API-06 --- describe('public API dispatch', () => { - it('API-01: startFeatureOperation emits a start vital event', () => { - operationCollection.getApi().startFeatureOperation('login'); + it('API-01: startOperation emits a start vital event', () => { + operationCollection.getApi().startOperation('login'); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; @@ -45,8 +45,8 @@ describe('OperationCollection', () => { expect(data.vital.id).toMatch(UUID_REGEX); }); - it('API-02: succeedFeatureOperation emits an end vital event without failure_reason', () => { - operationCollection.getApi().succeedFeatureOperation('login'); + it('API-02: succeedOperation emits an end vital event without failure_reason', () => { + operationCollection.getApi().succeedOperation('login'); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; @@ -54,8 +54,8 @@ describe('OperationCollection', () => { expect(data.vital.failure_reason).toBeUndefined(); }); - it('API-03: failFeatureOperation emits an end vital event with failure_reason', () => { - operationCollection.getApi().failFeatureOperation('login', 'error'); + it('API-03: failOperation emits an end vital event with failure_reason', () => { + operationCollection.getApi().failOperation('login', 'error'); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; @@ -64,22 +64,22 @@ describe('OperationCollection', () => { }); it('API-04: operationKey is forwarded to the event payload', () => { - operationCollection.getApi().startFeatureOperation('login', { operationKey: 'abc' }); + operationCollection.getApi().startOperation('login', { operationKey: 'abc' }); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.operation_key).toBe('abc'); }); it('API-05: options.context is forwarded to the event context', () => { - operationCollection.getApi().startFeatureOperation('login', { context: { key: 'value' } }); + operationCollection.getApi().startOperation('login', { context: { key: 'value' } }); const data = rawRumEvents[0].data as RawRumVital; expect(data.context).toEqual({ key: 'value' }); }); it('API-06: each call produces a unique vital.id', () => { - operationCollection.getApi().startFeatureOperation('login'); - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); + operationCollection.getApi().startOperation('login'); expect(rawRumEvents).toHaveLength(2); const firstId = (rawRumEvents[0].data as RawRumVital).vital.id; @@ -88,7 +88,7 @@ describe('OperationCollection', () => { }); it('forwards description into the vital section when provided', () => { - operationCollection.getApi().startFeatureOperation('login', { description: 'user tapped login' }); + operationCollection.getApi().startOperation('login', { description: 'user tapped login' }); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.description).toBe('user tapped login'); @@ -101,7 +101,7 @@ describe('OperationCollection', () => { // The backend rejects blank/empty names with its own non-empty // precondition before evaluating the character-set regex; drop // client-side to match. - operationCollection.getApi().startFeatureOperation(''); + operationCollection.getApi().startOperation(''); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); @@ -109,7 +109,7 @@ describe('OperationCollection', () => { }); it('VAL-02: whitespace-only name is rejected and no event is emitted', () => { - operationCollection.getApi().startFeatureOperation(' '); + operationCollection.getApi().startOperation(' '); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); @@ -117,7 +117,7 @@ describe('OperationCollection', () => { }); it('VAL-03: empty operationKey is rejected as blank', () => { - operationCollection.getApi().startFeatureOperation('login', { operationKey: '' }); + operationCollection.getApi().startOperation('login', { operationKey: '' }); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); @@ -125,14 +125,14 @@ describe('OperationCollection', () => { }); it('VAL-04: whitespace-only operationKey is rejected', () => { - operationCollection.getApi().startFeatureOperation('login', { operationKey: ' ' }); + operationCollection.getApi().startOperation('login', { operationKey: ' ' }); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); }); it('VAL-05: undefined operationKey is valid and results in an unkeyed operation', () => { - operationCollection.getApi().startFeatureOperation('login', { operationKey: undefined }); + operationCollection.getApi().startOperation('login', { operationKey: undefined }); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; @@ -140,27 +140,27 @@ describe('OperationCollection', () => { expect(displayError).not.toHaveBeenCalled(); }); - it('VAL-07: blank name on succeedFeatureOperation is rejected', () => { - operationCollection.getApi().succeedFeatureOperation(''); + it('VAL-07: blank name on succeedOperation is rejected', () => { + operationCollection.getApi().succeedOperation(''); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); }); - it('VAL-07: blank name on failFeatureOperation is rejected', () => { - operationCollection.getApi().failFeatureOperation('', 'error'); + it('VAL-07: blank name on failOperation is rejected', () => { + operationCollection.getApi().failOperation('', 'error'); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); }); - it('VAL-07: blank operationKey is rejected on succeedFeatureOperation', () => { - operationCollection.getApi().succeedFeatureOperation('login', { operationKey: '' }); + it('VAL-07: blank operationKey is rejected on succeedOperation', () => { + operationCollection.getApi().succeedOperation('login', { operationKey: '' }); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); }); - it('VAL-07: blank operationKey is rejected on failFeatureOperation', () => { - operationCollection.getApi().failFeatureOperation('login', 'error', { operationKey: ' ' }); + it('VAL-07: blank operationKey is rejected on failOperation', () => { + operationCollection.getApi().failOperation('login', 'error', { operationKey: ' ' }); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); @@ -176,7 +176,7 @@ describe('OperationCollection', () => { ['boolean', true], ['array', ['operationKey']], ])('rejects non-object %s as options and emits no event', (_label, badOptions) => { - operationCollection.getApi().startFeatureOperation('login', badOptions as unknown as FeatureOperationOptions); + operationCollection.getApi().startOperation('login', badOptions as unknown as FeatureOperationOptions); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); expect(vi.mocked(displayError).mock.calls[0][0]).toContain('options must be an object'); @@ -186,7 +186,7 @@ describe('OperationCollection', () => { ['null', null], ['number', 42], ])('rejects non-string %s as name and emits no event', (_label, badName) => { - operationCollection.getApi().startFeatureOperation(badName as unknown as string); + operationCollection.getApi().startOperation(badName as unknown as string); expect(rawRumEvents).toHaveLength(0); expect(displayError).toHaveBeenCalledOnce(); expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation name cannot be empty'); @@ -214,7 +214,7 @@ describe('OperationCollection', () => { ['Unicode', 'ログイン'], ['emoji', 'login🔐'], ])('warns but still emits events with names containing %s', (_label, name) => { - operationCollection.getApi().startFeatureOperation(name); + operationCollection.getApi().startOperation(name); expect(rawRumEvents).toHaveLength(1); expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); @@ -236,22 +236,22 @@ describe('OperationCollection', () => { ['uppercase', 'LOGIN'], ['mixed case', 'LoginV2'], ])('accepts names with %s without warning', (_label, name) => { - operationCollection.getApi().startFeatureOperation(name); + operationCollection.getApi().startOperation(name); expect(rawRumEvents).toHaveLength(1); expect(displayWarn).not.toHaveBeenCalled(); expect((rawRumEvents[0].data as RawRumVital).vital.name).toBe(name); }); - it('warns but still emits on succeedFeatureOperation with invalid characters', () => { - operationCollection.getApi().succeedFeatureOperation('user login'); + it('warns but still emits on succeedOperation with invalid characters', () => { + operationCollection.getApi().succeedOperation('user login'); expect(rawRumEvents).toHaveLength(1); expect((rawRumEvents[0].data as RawRumVital).vital.step_type).toBe('end'); expect(displayWarn).toHaveBeenCalledOnce(); }); - it('warns but still emits on failFeatureOperation with invalid characters', () => { - operationCollection.getApi().failFeatureOperation('user login', 'error'); + it('warns but still emits on failOperation with invalid characters', () => { + operationCollection.getApi().failOperation('user login', 'error'); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.step_type).toBe('end'); @@ -261,7 +261,7 @@ describe('OperationCollection', () => { it('does not restrict operationKey to the same character set', () => { // operation_key has no character-set constraint in the schema. - operationCollection.getApi().startFeatureOperation('login', { operationKey: 'session-42 / user foo' }); + operationCollection.getApi().startOperation('login', { operationKey: 'session-42 / user foo' }); expect(rawRumEvents).toHaveLength(1); expect(displayWarn).not.toHaveBeenCalled(); expect((rawRumEvents[0].data as RawRumVital).vital.operation_key).toBe('session-42 / user foo'); @@ -271,7 +271,7 @@ describe('OperationCollection', () => { // --- PAY-01..PAY-10 --- describe('payload structure', () => { it('PAY-01: start payload shape', () => { - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect(data.type).toBe('vital'); @@ -281,7 +281,7 @@ describe('OperationCollection', () => { }); it('PAY-02: succeed payload shape', () => { - operationCollection.getApi().succeedFeatureOperation('login'); + operationCollection.getApi().succeedOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.step_type).toBe('end'); @@ -289,7 +289,7 @@ describe('OperationCollection', () => { }); it('PAY-03: fail payload shape', () => { - operationCollection.getApi().failFeatureOperation('login', 'error'); + operationCollection.getApi().failOperation('login', 'error'); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.step_type).toBe('end'); @@ -297,35 +297,35 @@ describe('OperationCollection', () => { }); it.each(['error', 'abandoned', 'other'] as const)('PAY-04: failure reason %s serialises correctly', (reason) => { - operationCollection.getApi().failFeatureOperation('login', reason); + operationCollection.getApi().failOperation('login', reason); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.failure_reason).toBe(reason); }); it('PAY-07: vital.id matches UUID v4 pattern', () => { - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.id).toMatch(UUID_REGEX); }); it('PAY-08: vital.name matches the input', () => { - operationCollection.getApi().startFeatureOperation('checkout'); + operationCollection.getApi().startOperation('checkout'); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.name).toBe('checkout'); }); it('PAY-09: unkeyed operation omits operation_key', () => { - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect('operation_key' in data.vital).toBe(false); }); it('PAY-10: keyed operation includes operation_key', () => { - operationCollection.getApi().startFeatureOperation('login', { operationKey: 'abc' }); + operationCollection.getApi().startOperation('login', { operationKey: 'abc' }); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.operation_key).toBe('abc'); @@ -333,7 +333,7 @@ describe('OperationCollection', () => { it('captures a non-zero startTime from timeStampNow on the emitted raw event', () => { const before = Date.now(); - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const after = Date.now(); const event = rawRumEvents[0]; @@ -344,14 +344,14 @@ describe('OperationCollection', () => { }); it('omits vital.description when not provided', () => { - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect('description' in data.vital).toBe(false); }); it('defaults context to an empty object when options.context is omitted', () => { - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); const data = rawRumEvents[0].data as RawRumVital; expect(data.context).toEqual({}); @@ -366,22 +366,22 @@ describe('OperationCollection', () => { // never emits "duplicate start" / "stop without start" warnings. describe('no local tracking (cross-process safety)', () => { it('does not warn on duplicate start from the main process', () => { - operationCollection.getApi().startFeatureOperation('login'); - operationCollection.getApi().startFeatureOperation('login'); + operationCollection.getApi().startOperation('login'); + operationCollection.getApi().startOperation('login'); expect(rawRumEvents).toHaveLength(2); expect(displayError).not.toHaveBeenCalled(); }); it('does not warn on succeed without a prior start', () => { - operationCollection.getApi().succeedFeatureOperation('login'); + operationCollection.getApi().succeedOperation('login'); expect(rawRumEvents).toHaveLength(1); expect(displayError).not.toHaveBeenCalled(); }); it('does not warn on fail without a prior start', () => { - operationCollection.getApi().failFeatureOperation('login', 'error'); + operationCollection.getApi().failOperation('login', 'error'); expect(rawRumEvents).toHaveLength(1); const data = rawRumEvents[0].data as RawRumVital; @@ -392,9 +392,9 @@ describe('OperationCollection', () => { it('does not warn on double-stop', () => { const api = operationCollection.getApi(); - api.startFeatureOperation('login'); - api.succeedFeatureOperation('login'); - api.succeedFeatureOperation('login'); + api.startOperation('login'); + api.succeedOperation('login'); + api.succeedOperation('login'); expect(rawRumEvents).toHaveLength(3); expect(displayError).not.toHaveBeenCalled(); @@ -405,10 +405,10 @@ describe('OperationCollection', () => { describe('parallel operations', () => { it('PAR-01: operations with same name and different keys emit independently', () => { const api = operationCollection.getApi(); - api.startFeatureOperation('upload', { operationKey: 'a' }); - api.startFeatureOperation('upload', { operationKey: 'b' }); - api.succeedFeatureOperation('upload', { operationKey: 'a' }); - api.failFeatureOperation('upload', 'error', { operationKey: 'b' }); + api.startOperation('upload', { operationKey: 'a' }); + api.startOperation('upload', { operationKey: 'b' }); + api.succeedOperation('upload', { operationKey: 'a' }); + api.failOperation('upload', 'error', { operationKey: 'b' }); expect(rawRumEvents).toHaveLength(4); expect(displayError).not.toHaveBeenCalled(); @@ -420,8 +420,8 @@ describe('OperationCollection', () => { }); it('PAR-03: keyed and unkeyed with same name emit independently', () => { - operationCollection.getApi().startFeatureOperation('login'); - operationCollection.getApi().startFeatureOperation('login', { operationKey: 'k1' }); + operationCollection.getApi().startOperation('login'); + operationCollection.getApi().startOperation('login', { operationKey: 'k1' }); expect(rawRumEvents).toHaveLength(2); expect(displayError).not.toHaveBeenCalled(); @@ -436,14 +436,14 @@ describe('OperationCollection', () => { it('EDGE-05: long operation name is preserved', () => { const longName = 'a'.repeat(500); - operationCollection.getApi().startFeatureOperation(longName); + operationCollection.getApi().startOperation(longName); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.name).toBe(longName); }); it('EDGE-06: schema-allowed special characters in operation name are preserved', () => { - operationCollection.getApi().startFeatureOperation('login-v2@1.0.0'); + operationCollection.getApi().startOperation('login-v2@1.0.0'); const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.name).toBe('login-v2@1.0.0'); @@ -454,8 +454,96 @@ describe('OperationCollection', () => { it('stop() is callable without side effects (no owned subscriptions)', () => { operationCollection.stop(); // Subsequent calls still work; collection has no torn-down state. + operationCollection.getApi().startOperation('login'); + expect(rawRumEvents).toHaveLength(1); + }); + }); + + // The deprecated `*FeatureOperation` wrappers exist only for backwards + // compatibility with the early-preview API name. They emit a one-time + // deprecation warning per method and forward to the canonical + // implementation. These tests pin both the wrapping behavior and the + // warn-once policy so noisy callers don't drown the console. + describe('deprecated *FeatureOperation wrappers', () => { + it('startFeatureOperation forwards to startOperation and emits the same event', () => { operationCollection.getApi().startFeatureOperation('login'); + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('start'); + expect(data.vital.name).toBe('login'); + }); + + it('succeedFeatureOperation forwards to succeedOperation and emits the same event', () => { + operationCollection.getApi().succeedFeatureOperation('login'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBeUndefined(); + }); + + it('failFeatureOperation forwards to failOperation and emits the same event', () => { + operationCollection.getApi().failFeatureOperation('login', 'error'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe('error'); + }); + + it('forwards every option the canonical method accepts', () => { + operationCollection + .getApi() + .startFeatureOperation('upload', { operationKey: 'k1', context: { foo: 'bar' }, description: 'desc' }); + + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.operation_key).toBe('k1'); + expect(data.vital.description).toBe('desc'); + expect(data.context).toEqual({ foo: 'bar' }); + }); + + it('emits a deprecation warning the first time each *FeatureOperation method is called', () => { + const api = operationCollection.getApi(); + api.startFeatureOperation('login'); + api.succeedFeatureOperation('login'); + api.failFeatureOperation('login', 'error'); + + expect(displayWarn).toHaveBeenCalledTimes(3); + const messages = vi.mocked(displayWarn).mock.calls.map((c) => c[0] as string); + expect(messages.some((m) => m.includes('startFeatureOperation') && m.includes('startOperation'))).toBe(true); + expect(messages.some((m) => m.includes('succeedFeatureOperation') && m.includes('succeedOperation'))).toBe(true); + expect(messages.some((m) => m.includes('failFeatureOperation') && m.includes('failOperation'))).toBe(true); + }); + + it('warns at most once per deprecated method name (warn-once policy)', () => { + const api = operationCollection.getApi(); + api.startFeatureOperation('login'); + api.startFeatureOperation('login'); + api.startFeatureOperation('login'); + + // The deprecation warning fires once; the events are still emitted. + expect(displayWarn).toHaveBeenCalledOnce(); + expect(rawRumEvents).toHaveLength(3); + }); + + it('canonical methods do not trigger the deprecation warning', () => { + const api = operationCollection.getApi(); + api.startOperation('login'); + api.succeedOperation('login'); + api.failOperation('login', 'error'); + + expect(displayWarn).not.toHaveBeenCalled(); + expect(rawRumEvents).toHaveLength(3); + }); + + it('still routes through validateArgs (blank name on a deprecated wrapper is rejected)', () => { + operationCollection.getApi().startFeatureOperation(''); + expect(rawRumEvents).toHaveLength(0); + // The deprecation warning still fires before validation. + expect(displayWarn).toHaveBeenCalledOnce(); + expect(displayError).toHaveBeenCalledOnce(); + expect(vi.mocked(displayError).mock.calls[0][0]).toContain('startOperation: operation name cannot be empty'); }); }); }); diff --git a/src/domain/rum/operation/OperationCollection.ts b/src/domain/rum/operation/OperationCollection.ts index c5f5bf4..03a0d29 100644 --- a/src/domain/rum/operation/OperationCollection.ts +++ b/src/domain/rum/operation/OperationCollection.ts @@ -3,7 +3,18 @@ import { EventFormat, EventKind, EventManager, EventSource } from '../../../even import { displayError, displayWarn } from '../../../tools/display'; import type { RawRumVital } from '../rawRumData.types'; -type OperationMethod = 'startFeatureOperation' | 'succeedFeatureOperation' | 'failFeatureOperation'; +type OperationMethod = 'startOperation' | 'succeedOperation' | 'failOperation'; + +// Map of deprecated -> canonical method names. The deprecated wrappers fire a +// runtime warning once per method name and forward to the canonical +// implementation. Kept in OperationCollection (rather than in src/index.ts) +// so the wiring is in one testable place; the customer-facing `@deprecated` +// JSDoc lives on the public exports in src/index.ts where IDEs surface it. +const DEPRECATED_TO_CANONICAL: Record = { + startFeatureOperation: 'startOperation', + succeedFeatureOperation: 'succeedOperation', + failFeatureOperation: 'failOperation', +}; /** * Failure reason for a RUM Operation step. @@ -49,16 +60,29 @@ export interface FeatureOperationOptions { * Browser in the spec's parity matrix. */ export class OperationCollection { + // Tracks which deprecated method names have already emitted their one-time + // deprecation warning, so noisy hot-path callers don't drown the console. + private readonly warnedDeprecations = new Set(); + constructor(private readonly eventManager: EventManager) {} getApi() { return { + startOperation: (name: string, options?: FeatureOperationOptions) => this.handle('startOperation', name, options), + succeedOperation: (name: string, options?: FeatureOperationOptions) => + this.handle('succeedOperation', name, options), + failOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => + this.handle('failOperation', name, options, failureReason), + + // Deprecated wrappers — kept for backwards compatibility until the next + // major. They warn-once-per-method and forward to the canonical methods + // above. Do not call these from new code; use the un-prefixed names. startFeatureOperation: (name: string, options?: FeatureOperationOptions) => - this.handle('startFeatureOperation', name, options), + this.handleDeprecated('startFeatureOperation', name, options), succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => - this.handle('succeedFeatureOperation', name, options), + this.handleDeprecated('succeedFeatureOperation', name, options), failFeatureOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => - this.handle('failFeatureOperation', name, options, failureReason), + this.handleDeprecated('failFeatureOperation', name, options, failureReason), }; } @@ -75,10 +99,26 @@ export class OperationCollection { if (!validateArgs(method, name, options)) { return; } - const stepType = method === 'startFeatureOperation' ? 'start' : 'end'; + const stepType = method === 'startOperation' ? 'start' : 'end'; this.emitOperationStep(stepType, name, options, failureReason); } + private handleDeprecated( + deprecatedMethod: keyof typeof DEPRECATED_TO_CANONICAL, + name: string, + options: FeatureOperationOptions | undefined, + failureReason?: FailureReason + ): void { + const canonical = DEPRECATED_TO_CANONICAL[deprecatedMethod]; + if (!this.warnedDeprecations.has(deprecatedMethod)) { + this.warnedDeprecations.add(deprecatedMethod); + displayWarn( + `${deprecatedMethod}() is deprecated and will be removed in a future major release. Use ${canonical}() instead.` + ); + } + this.handle(canonical, name, options, failureReason); + } + private emitOperationStep( stepType: 'start' | 'end', name: string, diff --git a/src/index.ts b/src/index.ts index 86aa4dc..126b5da 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,36 +62,72 @@ export function addError(error: unknown, options?: ErrorOptions): void { /** * Start a RUM Operation step. * - * Pair every `startFeatureOperation` with exactly one `succeedFeatureOperation` or `failFeatureOperation`. + * Pair every `startOperation` with exactly one `succeedOperation` or `failOperation`. * Use `options.operationKey` to distinguish parallel operations sharing the same name. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. */ -export function startFeatureOperation(name: string, options?: FeatureOperationOptions): void { - callMonitored(() => rumApi?.startFeatureOperation(name, options)); +export function startOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.startOperation(name, options)); } /** - * Record the successful completion of a RUM Operation started with `startFeatureOperation`. + * Record the successful completion of a RUM Operation started with `startOperation`. * * Pass the same `name` (and `operationKey`, if any) that was used when starting the operation. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. */ -export function succeedFeatureOperation(name: string, options?: FeatureOperationOptions): void { - callMonitored(() => rumApi?.succeedFeatureOperation(name, options)); +export function succeedOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.succeedOperation(name, options)); } /** - * Record the failure of a RUM Operation started with `startFeatureOperation`. + * Record the failure of a RUM Operation started with `startOperation`. * * Pass the same `name` (and `operationKey`, if any) that was used when starting the operation. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. */ +export function failOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.failOperation(name, failureReason, options)); +} + +/** + * @deprecated Use `startOperation` instead. This alias exists for backwards + * compatibility with the API name used in early previews and will be removed + * in a future major release. + * + * @experimental This API is in preview and may change in future releases. + * @see README "Operation Monitoring" for usage details. + */ +export function startFeatureOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.startFeatureOperation(name, options)); +} + +/** + * @deprecated Use `succeedOperation` instead. This alias exists for backwards + * compatibility with the API name used in early previews and will be removed + * in a future major release. + * + * @experimental This API is in preview and may change in future releases. + * @see README "Operation Monitoring" for usage details. + */ +export function succeedFeatureOperation(name: string, options?: FeatureOperationOptions): void { + callMonitored(() => rumApi?.succeedFeatureOperation(name, options)); +} + +/** + * @deprecated Use `failOperation` instead. This alias exists for backwards + * compatibility with the API name used in early previews and will be removed + * in a future major release. + * + * @experimental This API is in preview and may change in future releases. + * @see README "Operation Monitoring" for usage details. + */ export function failFeatureOperation( name: string, failureReason: FailureReason, From a66e0406d24fd7c1a9e370df788eb8ab6d637669 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Wed, 29 Apr 2026 13:28:49 +0200 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=93=9D=20restructure=20README=20Opera?= =?UTF-8?q?tion=20Monitoring=20docs=20(round-2=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Distribute the Operation Monitoring content into the existing README sections instead of bundling it into one block under `## API`: - Move the feature description, usage examples, and cross-process note into a sub-section of `## Available Features` — alongside the existing `Renderer Process Support` sub-section. - Replace the per-API table under `## API` with three signature sub- sections (one per public function), matching the style of the existing `init` / `addError` entries. - Replace the verbose `Validation` block with a single Note line under the `startOperation` signature: `name` is required and should only contain letters, digits, `_`, `.`, `@`, `$`, `-`. - Keep the `FailureReason` / `FeatureOperationOptions` types and the deprecated-aliases note in the `## API` section. Addresses round-2 review comments on README.md (#102). --- README.md | 87 ++++++++++++++++++++++--------------------------------- 1 file changed, 35 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index a943cbe..77bdaa8 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,33 @@ await init({ - **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 In order to monitor the renderer process, the [Browser SDK](https://docs.datadoghq.com/real_user_monitoring/application_monitoring/browser/setup/) must be setup in pages loaded by the renderer. @@ -82,48 +109,19 @@ try { } ``` -### 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. +### `startOperation(name: string, options?: FeatureOperationOptions): void` -> ⚗️ This API is in preview and the signatures may change before stable release. - -```ts -import { startOperation, succeedOperation, failOperation } from '@datadog/electron-sdk'; +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`. -// Simple operation -startOperation('checkout'); -try { - await runCheckout(); - succeedOperation('checkout'); -} catch (error) { - failOperation('checkout', 'error'); -} +> Note: `name` is required and should only contain letters, digits, `_`, `.`, `@`, `$`, `-`. -// 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' }); -``` +### `succeedOperation(name: string, options?: FeatureOperationOptions): void` -#### API +Record the successful completion of a RUM Operation. Pass the same `name` (and `operationKey`, if any) used to start it. -| Function | Signature | -| ------------------ | ----------------------------------------------------------------------------------------- | -| `startOperation` | `(name: string, options?: FeatureOperationOptions) => void` | -| `succeedOperation` | `(name: string, options?: FeatureOperationOptions) => void` | -| `failOperation` | `(name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => void` | +### `failOperation(name: string, failureReason: FailureReason, options?: FeatureOperationOptions): void` -> **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`. +Record the failure of a RUM Operation. `failureReason` must be one of `'error' | 'abandoned' | 'other'`. ```ts type FailureReason = 'error' | 'abandoned' | 'other'; @@ -138,22 +136,7 @@ interface FeatureOperationOptions { } ``` -#### Cross-process usage - -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`. - -#### Validation - -- Blank `name` or blank `operationKey` are rejected and an error is logged; no event - is emitted. -- Non-string `name` or non-object `options` are rejected the same way (defensive - guard for JS callers that bypass the TypeScript signatures). -- Names containing characters outside `[\w.@$-]*` (letters, digits, `_`, `.`, `@`, - `$`, `-`) emit a warning but the event is still sent — the backend is the source - of truth on the character-set policy. +> **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 From 6bc54561b7391ccbb68003c1e5975f039571b7e5 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Wed, 29 Apr 2026 13:31:37 +0200 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=8E=A8=20reflow=20Operation=20comment?= =?UTF-8?q?s=20to=20120-char=20width=20(round-2=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round of feedback noted that comments and JSDoc in this PR were wrapping at 80 chars while the project's prettier config uses 120. Round 2 confirmed that some comments still wrapped at 80 — re-flow every comment block touched by the Operation work to the project width. Touched files: - src/index.ts: deprecated-alias JSDocs. - src/domain/rum/operation/OperationCollection.ts: top-of-file map comment, type / interface / class JSDocs, inline comments inside getApi(), VALID_OPERATION_NAME_REGEX comment block. - src/domain/rum/operation/OperationCollection.spec.ts: VAL-01 inline comment, options-validation preamble, character-set group preamble, no-tracking group preamble, EDGE-04 omission note, deprecated-wrappers group preamble. No behavior change. Verified with prettier --check, eslint, tsc, and the full operation unit test suite (75/75 passing). --- .../rum/operation/OperationCollection.spec.ts | 48 ++++++++--------- .../rum/operation/OperationCollection.ts | 51 ++++++++----------- src/index.ts | 15 +++--- 3 files changed, 47 insertions(+), 67 deletions(-) diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts index 7616a22..60be99a 100644 --- a/src/domain/rum/operation/OperationCollection.spec.ts +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -98,9 +98,8 @@ describe('OperationCollection', () => { // --- VAL-01..VAL-07 --- describe('input validation', () => { it('VAL-01: empty name is rejected and no event is emitted', () => { - // The backend rejects blank/empty names with its own non-empty - // precondition before evaluating the character-set regex; drop - // client-side to match. + // The backend rejects blank/empty names with its own non-empty precondition before evaluating the character-set + // regex; drop client-side to match. operationCollection.getApi().startOperation(''); expect(rawRumEvents).toHaveLength(0); @@ -166,9 +165,9 @@ describe('OperationCollection', () => { expect(vi.mocked(displayError).mock.calls[0][0]).toContain('operation key cannot be empty'); }); - // The public API is typed as `(name: string, options?: FeatureOperationOptions)`, - // but the validator accepts `unknown` to defend against JS callers passing - // garbage. These tests pin the runtime contract independent of the type system. + // The public API is typed as `(name: string, options?: FeatureOperationOptions)`, but the validator accepts + // `unknown` to defend against JS callers passing garbage. These tests pin the runtime contract independent of the + // type system. it.each([ ['null', null], ['number', 42], @@ -194,16 +193,13 @@ describe('OperationCollection', () => { }); // --- Name character-set validation (schema facet-path rule) --- - // The authoritative _vital-common-schema.json says vital.name "must contain - // only letters, digits, or the characters - _ . @ $". Names that fail this - // rule are warned about but still emitted — the backend is the source of - // truth, so client-side drop would force a customer SDK bump if the policy - // is ever relaxed. + // The authoritative _vital-common-schema.json says vital.name "must contain only letters, digits, or the characters + // - _ . @ $". Names that fail this rule are warned about but still emitted — the backend is the source of truth, so + // client-side drop would force a customer SDK bump if the policy is ever relaxed. describe('operation name character set', () => { - // Names outside the schema facet-path set (letters / digits / - _ . @ $) - // are warned about but still emitted: the backend is the source of truth - // for what the schema actually allows, so client-side drop would force a - // customer SDK bump if the backend ever relaxed the rule. + // Names outside the schema facet-path set (letters / digits / - _ . @ $) are warned about but still emitted: the + // backend is the source of truth for what the schema actually allows, so client-side drop would force a customer + // SDK bump if the backend ever relaxed the rule. it.each([ ['space', 'user login'], ['slash', 'api/v1'], @@ -359,11 +355,10 @@ describe('OperationCollection', () => { }); // --- No-tracking behavior --- - // Electron intentionally does NOT track active operations locally (matches - // browser-sdk / Android). Renderer events flow through the bridge without - // updating main-process state, so any local tracking would produce false - // positives on cross-process start/stop flows. Consequently the main process - // never emits "duplicate start" / "stop without start" warnings. + // Electron intentionally does NOT track active operations locally (matches browser-sdk / Android). Renderer events + // flow through the bridge without updating main-process state, so any local tracking would produce false positives + // on cross-process start/stop flows. Consequently the main process never emits "duplicate start" / "stop without + // start" warnings. describe('no local tracking (cross-process safety)', () => { it('does not warn on duplicate start from the main process', () => { operationCollection.getApi().startOperation('login'); @@ -430,9 +425,8 @@ describe('OperationCollection', () => { // --- EDGE cases --- describe('edge cases', () => { - // EDGE-04 (Unicode) is intentionally omitted: the schema facet-path rule - // restricts names to ASCII letters/digits/- _ . @ $, which excludes - // non-ASCII characters. The character-set test group above covers this. + // EDGE-04 (Unicode) is intentionally omitted: the schema facet-path rule restricts names to ASCII letters / digits + // / - _ . @ $, which excludes non-ASCII characters. The character-set test group above covers this. it('EDGE-05: long operation name is preserved', () => { const longName = 'a'.repeat(500); @@ -459,11 +453,9 @@ describe('OperationCollection', () => { }); }); - // The deprecated `*FeatureOperation` wrappers exist only for backwards - // compatibility with the early-preview API name. They emit a one-time - // deprecation warning per method and forward to the canonical - // implementation. These tests pin both the wrapping behavior and the - // warn-once policy so noisy callers don't drown the console. + // The deprecated `*FeatureOperation` wrappers exist only for backwards compatibility with the early-preview API + // name. They emit a one-time deprecation warning per method and forward to the canonical implementation. These tests + // pin both the wrapping behavior and the warn-once policy so noisy callers don't drown the console. describe('deprecated *FeatureOperation wrappers', () => { it('startFeatureOperation forwards to startOperation and emits the same event', () => { operationCollection.getApi().startFeatureOperation('login'); diff --git a/src/domain/rum/operation/OperationCollection.ts b/src/domain/rum/operation/OperationCollection.ts index 03a0d29..5549c64 100644 --- a/src/domain/rum/operation/OperationCollection.ts +++ b/src/domain/rum/operation/OperationCollection.ts @@ -5,11 +5,10 @@ import type { RawRumVital } from '../rawRumData.types'; type OperationMethod = 'startOperation' | 'succeedOperation' | 'failOperation'; -// Map of deprecated -> canonical method names. The deprecated wrappers fire a -// runtime warning once per method name and forward to the canonical -// implementation. Kept in OperationCollection (rather than in src/index.ts) -// so the wiring is in one testable place; the customer-facing `@deprecated` -// JSDoc lives on the public exports in src/index.ts where IDEs surface it. +// Map of deprecated -> canonical method names. The deprecated wrappers fire a runtime warning once per method name and +// forward to the canonical implementation. Kept in OperationCollection (rather than in src/index.ts) so the wiring is +// in one testable place; the customer-facing `@deprecated` JSDoc lives on the public exports in src/index.ts where +// IDEs surface it. const DEPRECATED_TO_CANONICAL: Record = { startFeatureOperation: 'startOperation', succeedFeatureOperation: 'succeedOperation', @@ -26,14 +25,13 @@ export type FailureReason = 'error' | 'abandoned' | 'other'; /** * Options accepted by the RUM Operation APIs. * - * Mirrors the browser-sdk's `FeatureOperationOptions` shape so consumers can - * share one mental model across main process and renderer process. + * Mirrors the browser-sdk's `FeatureOperationOptions` shape so consumers can share one mental model across main + * process and renderer process. */ export interface FeatureOperationOptions { /** - * Key distinguishing parallel operations with the same name (e.g. separate - * upload tasks sharing the name "upload"). When omitted, the operation is - * treated as unkeyed. + * Key distinguishing parallel operations with the same name (e.g. separate upload tasks sharing the name "upload"). + * When omitted, the operation is treated as unkeyed. */ operationKey?: string; @@ -51,17 +49,14 @@ export interface FeatureOperationOptions { /** * Collect RUM vital operation step events emitted from the main process. * - * No local duplicate-start / stop-without-start tracking is performed: - * renderer-originated start/stop events (from the bundled browser-sdk) - * flow through the bridge without updating main-process state, so any - * cross-process tracking would produce false positives when a developer - * legitimately starts in one process and stops in the other. Matches the - * bundled browser-sdk's no-tracking behavior; aligns with Android and - * Browser in the spec's parity matrix. + * No local duplicate-start / stop-without-start tracking is performed: renderer-originated start/stop events (from the + * bundled browser-sdk) flow through the bridge without updating main-process state, so any cross-process tracking + * would produce false positives when a developer legitimately starts in one process and stops in the other. Matches + * the bundled browser-sdk's no-tracking behavior; aligns with Android and Browser in the spec's parity matrix. */ export class OperationCollection { - // Tracks which deprecated method names have already emitted their one-time - // deprecation warning, so noisy hot-path callers don't drown the console. + // Tracks which deprecated method names have already emitted their one-time deprecation warning, so noisy hot-path + // callers don't drown the console. private readonly warnedDeprecations = new Set(); constructor(private readonly eventManager: EventManager) {} @@ -74,9 +69,8 @@ export class OperationCollection { failOperation: (name: string, failureReason: FailureReason, options?: FeatureOperationOptions) => this.handle('failOperation', name, options, failureReason), - // Deprecated wrappers — kept for backwards compatibility until the next - // major. They warn-once-per-method and forward to the canonical methods - // above. Do not call these from new code; use the un-prefixed names. + // Deprecated wrappers — kept for backwards compatibility until the next major. They warn-once-per-method and + // forward to the canonical methods above. Do not call these from new code; use the un-prefixed names. startFeatureOperation: (name: string, options?: FeatureOperationOptions) => this.handleDeprecated('startFeatureOperation', name, options), succeedFeatureOperation: (name: string, options?: FeatureOperationOptions) => @@ -163,14 +157,11 @@ function isValidString(value: unknown): value is string { return typeof value === 'string' && value.trim().length > 0; } -// Mirrors the backend's server-side `vital.name` character-set regex, -// `[\w.@$-]*` (letters, digits, `_`, `.`, `@`, `$`, `-`). Names that fail -// this pattern generate a developer warning but the event is still emitted -// — the backend is the source of truth on character-set policy, so client- -// side drop would force a customer SDK bump if the rule is ever relaxed. -// Blank / empty names are a separate check: they are rejected here because -// the backend rejects them with its own non-empty precondition before -// reaching the regex. +// Mirrors the backend's server-side `vital.name` character-set regex, `[\w.@$-]*` (letters, digits, `_`, `.`, `@`, +// `$`, `-`). Names that fail this pattern generate a developer warning but the event is still emitted — the backend +// is the source of truth on character-set policy, so client-side drop would force a customer SDK bump if the rule is +// ever relaxed. Blank / empty names are a separate check: they are rejected here because the backend rejects them +// with its own non-empty precondition before reaching the regex. const VALID_OPERATION_NAME_REGEX = /^[\w.@$-]*$/; function validateArgs(method: OperationMethod, name: unknown, options: unknown): boolean { diff --git a/src/index.ts b/src/index.ts index 126b5da..1aa5e1a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,9 +97,8 @@ export function failOperation(name: string, failureReason: FailureReason, option } /** - * @deprecated Use `startOperation` instead. This alias exists for backwards - * compatibility with the API name used in early previews and will be removed - * in a future major release. + * @deprecated Use `startOperation` instead. This alias exists for backwards compatibility with the API name used in + * early previews and will be removed in a future major release. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. @@ -109,9 +108,8 @@ export function startFeatureOperation(name: string, options?: FeatureOperationOp } /** - * @deprecated Use `succeedOperation` instead. This alias exists for backwards - * compatibility with the API name used in early previews and will be removed - * in a future major release. + * @deprecated Use `succeedOperation` instead. This alias exists for backwards compatibility with the API name used in + * early previews and will be removed in a future major release. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. @@ -121,9 +119,8 @@ export function succeedFeatureOperation(name: string, options?: FeatureOperation } /** - * @deprecated Use `failOperation` instead. This alias exists for backwards - * compatibility with the API name used in early previews and will be removed - * in a future major release. + * @deprecated Use `failOperation` instead. This alias exists for backwards compatibility with the API name used in + * early previews and will be removed in a future major release. * * @experimental This API is in preview and may change in future releases. * @see README "Operation Monitoring" for usage details. From 924d5414a5bd7b9c229775d2b5f3317667611023 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Wed, 29 Apr 2026 13:33:07 +0200 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=85=20collapse=20redundant=20tagged?= =?UTF-8?q?=20tests=20in=20OperationCollection.spec=20(round-2=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-SDK contract spec uses three test-tag families: API-XX (public API dispatch), PAY-XX (event payload shape), VAL-XX (input validation). On Electron all three tag families flow through the same `handle()` → `emitOperationStep()` path, so a payload-shape assertion and a dispatch assertion for the same call end up checking the same thing twice. Round-2 review pointed at API-02 vs PAY-02 specifically, with the same pattern in a handful of others. Collapse the duplicates by carrying both tags on a single test and dropping the redundant PAY-XX entry: - API-01 absorbs PAY-01 / PAY-07 / PAY-08 / PAY-09 (start payload, UUID, name match, unkeyed-operation `'operation_key' in vital === false`). - API-02 / PAY-02: succeedOperation end payload without failure_reason. - API-03 / PAY-03: failOperation end payload with failure_reason. - API-04 / PAY-10: operationKey forwarded to event payload. The payload group keeps PAY-04 (failure-reason enum variants), the startTime contract test, and the description / context defaulting tests — none of those are subsumed by the API tests. The header comment documents which PAY-XX tags are now covered by the API-XX tests so the spec-cross-reference stays auditable. 68 tests pass (was 75; -7 duplicate PAY-XX cases). --- .../rum/operation/OperationCollection.spec.ts | 68 +++---------------- 1 file changed, 8 insertions(+), 60 deletions(-) diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts index 60be99a..c3560ac 100644 --- a/src/domain/rum/operation/OperationCollection.spec.ts +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -31,7 +31,7 @@ describe('OperationCollection', () => { // --- API-01..API-06 --- describe('public API dispatch', () => { - it('API-01: startOperation emits a start vital event', () => { + it('API-01 / PAY-01 / PAY-07 / PAY-08 / PAY-09: startOperation emits a start vital event with full payload shape', () => { operationCollection.getApi().startOperation('login'); expect(rawRumEvents).toHaveLength(1); @@ -41,11 +41,11 @@ describe('OperationCollection', () => { expect(data.vital.step_type).toBe('start'); expect(data.vital.name).toBe('login'); expect(data.vital.failure_reason).toBeUndefined(); - expect(data.vital.operation_key).toBeUndefined(); + expect('operation_key' in data.vital).toBe(false); expect(data.vital.id).toMatch(UUID_REGEX); }); - it('API-02: succeedOperation emits an end vital event without failure_reason', () => { + it('API-02 / PAY-02: succeedOperation emits an end vital event without failure_reason', () => { operationCollection.getApi().succeedOperation('login'); expect(rawRumEvents).toHaveLength(1); @@ -54,7 +54,7 @@ describe('OperationCollection', () => { expect(data.vital.failure_reason).toBeUndefined(); }); - it('API-03: failOperation emits an end vital event with failure_reason', () => { + it('API-03 / PAY-03: failOperation emits an end vital event with failure_reason', () => { operationCollection.getApi().failOperation('login', 'error'); expect(rawRumEvents).toHaveLength(1); @@ -63,7 +63,7 @@ describe('OperationCollection', () => { expect(data.vital.failure_reason).toBe('error'); }); - it('API-04: operationKey is forwarded to the event payload', () => { + it('API-04 / PAY-10: operationKey is forwarded to the event payload', () => { operationCollection.getApi().startOperation('login', { operationKey: 'abc' }); const data = rawRumEvents[0].data as RawRumVital; @@ -264,34 +264,10 @@ describe('OperationCollection', () => { }); }); - // --- PAY-01..PAY-10 --- + // --- PAY-04, plus payload shape concerns not already covered by the public-API-dispatch group --- + // PAY-01, PAY-02, PAY-03, PAY-07, PAY-08, PAY-09 and PAY-10 are covered by the API-01..API-04 tests above (every + // call routes through the same `handle()`, so payload shape and dispatch are checked in a single test). describe('payload structure', () => { - it('PAY-01: start payload shape', () => { - operationCollection.getApi().startOperation('login'); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.type).toBe('vital'); - expect(data.vital.type).toBe('operation_step'); - expect(data.vital.step_type).toBe('start'); - expect(data.vital.failure_reason).toBeUndefined(); - }); - - it('PAY-02: succeed payload shape', () => { - operationCollection.getApi().succeedOperation('login'); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.vital.step_type).toBe('end'); - expect(data.vital.failure_reason).toBeUndefined(); - }); - - it('PAY-03: fail payload shape', () => { - operationCollection.getApi().failOperation('login', 'error'); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.vital.step_type).toBe('end'); - expect(data.vital.failure_reason).toBe('error'); - }); - it.each(['error', 'abandoned', 'other'] as const)('PAY-04: failure reason %s serialises correctly', (reason) => { operationCollection.getApi().failOperation('login', reason); @@ -299,34 +275,6 @@ describe('OperationCollection', () => { expect(data.vital.failure_reason).toBe(reason); }); - it('PAY-07: vital.id matches UUID v4 pattern', () => { - operationCollection.getApi().startOperation('login'); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.vital.id).toMatch(UUID_REGEX); - }); - - it('PAY-08: vital.name matches the input', () => { - operationCollection.getApi().startOperation('checkout'); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.vital.name).toBe('checkout'); - }); - - it('PAY-09: unkeyed operation omits operation_key', () => { - operationCollection.getApi().startOperation('login'); - - const data = rawRumEvents[0].data as RawRumVital; - expect('operation_key' in data.vital).toBe(false); - }); - - it('PAY-10: keyed operation includes operation_key', () => { - operationCollection.getApi().startOperation('login', { operationKey: 'abc' }); - - const data = rawRumEvents[0].data as RawRumVital; - expect(data.vital.operation_key).toBe('abc'); - }); - it('captures a non-zero startTime from timeStampNow on the emitted raw event', () => { const before = Date.now(); operationCollection.getApi().startOperation('login'); From e1bd1e14cb0bdaa8d9b721d0931c9a69efe1a09c Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Wed, 29 Apr 2026 18:17:39 +0200 Subject: [PATCH 9/9] =?UTF-8?q?=E2=9C=A8=20warn-but-emit=20on=20off-enum?= =?UTF-8?q?=20failureReason=20values=20(round-2=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a runtime check for `failOperation`'s `failureReason` argument: JS callers bypassing the TS signature can pass any string (or other JS value); when the value is not in the schema enum ('error' | 'abandoned' | 'other') we now log a developer warning so the typo is visible in the console, but still emit the event. Mirrors the existing policy on the `vital.name` character-set rule (`displayWarn`, never drop): the backend is the source of truth on the enum policy and `failure_reason` carries the most diagnostic value on a fail event, so swallowing it on a typo would lose more signal than it protects. - Add `VALID_FAILURE_REASONS = ['error', 'abandoned', 'other']`. - Extend `validateArgs` with a `failureReason?: unknown` parameter and the new warn-but-emit branch. - `JSON.stringify` the offending value in the warning so we don't trip `@typescript-eslint/no-base-to-string` on non-string inputs and the message stays readable for numbers / null / objects. - Add 6 parametrised tests covering off-enum string, empty / blank string, number, null, and boolean. Existing PAY-04 valid-enum tests now also assert that no warning fires on the happy path. 74/74 tests passing. Addresses round-2 review question on OperationCollection.ts:101 (#102). --- .../rum/operation/OperationCollection.spec.ts | 24 +++++++++++++++++++ .../rum/operation/OperationCollection.ts | 16 +++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/domain/rum/operation/OperationCollection.spec.ts b/src/domain/rum/operation/OperationCollection.spec.ts index c3560ac..6a58115 100644 --- a/src/domain/rum/operation/OperationCollection.spec.ts +++ b/src/domain/rum/operation/OperationCollection.spec.ts @@ -273,6 +273,30 @@ describe('OperationCollection', () => { const data = rawRumEvents[0].data as RawRumVital; expect(data.vital.failure_reason).toBe(reason); + expect(displayWarn).not.toHaveBeenCalled(); + }); + + // Unknown / off-enum `failureReason` values can only reach this code path from JS callers that bypass the TS + // signature. Mirror the name character-set policy: warn so the typo is visible in the developer console, but + // still emit the event — `failure_reason` carries the most diagnostic value on a fail event, so dropping on a + // typo would lose more signal than it protects, and the backend remains the source of truth on the enum policy. + it.each([ + ['off-enum string', 'cancelled'], + ['empty string', ''], + ['whitespace', ' '], + ['number', 42 as unknown as 'error'], + ['null', null as unknown as 'error'], + ['boolean', true as unknown as 'error'], + ])('warns but still emits when failOperation receives a %s failure reason', (_label, reason) => { + operationCollection.getApi().failOperation('login', reason as 'error'); + + expect(rawRumEvents).toHaveLength(1); + const data = rawRumEvents[0].data as RawRumVital; + expect(data.vital.step_type).toBe('end'); + expect(data.vital.failure_reason).toBe(reason); + expect(displayWarn).toHaveBeenCalledOnce(); + expect(vi.mocked(displayWarn).mock.calls[0][0]).toContain('failure reason'); + expect(vi.mocked(displayWarn).mock.calls[0][0]).toContain('still be sent'); }); it('captures a non-zero startTime from timeStampNow on the emitted raw event', () => { diff --git a/src/domain/rum/operation/OperationCollection.ts b/src/domain/rum/operation/OperationCollection.ts index 5549c64..0fe7642 100644 --- a/src/domain/rum/operation/OperationCollection.ts +++ b/src/domain/rum/operation/OperationCollection.ts @@ -90,7 +90,7 @@ export class OperationCollection { options: FeatureOperationOptions | undefined, failureReason?: FailureReason ): void { - if (!validateArgs(method, name, options)) { + if (!validateArgs(method, name, options, failureReason)) { return; } const stepType = method === 'startOperation' ? 'start' : 'end'; @@ -164,7 +164,11 @@ function isValidString(value: unknown): value is string { // with its own non-empty precondition before reaching the regex. const VALID_OPERATION_NAME_REGEX = /^[\w.@$-]*$/; -function validateArgs(method: OperationMethod, name: unknown, options: unknown): boolean { +// Mirrors the schema enum for `vital.failure_reason`. JS callers bypassing the TS signature could pass any string; +// warn but still emit so the backend (source of truth on the enum policy) gets to decide. +const VALID_FAILURE_REASONS: readonly FailureReason[] = ['error', 'abandoned', 'other']; + +function validateArgs(method: OperationMethod, name: unknown, options: unknown, failureReason?: unknown): boolean { if (!isValidString(name)) { displayError(`${method}: operation name cannot be empty or blank. Event will not be sent.`); return false; @@ -183,5 +187,13 @@ function validateArgs(method: OperationMethod, name: unknown, options: unknown): displayError(`${method}: operation key cannot be empty or blank. Event will not be sent.`); return false; } + if (failureReason !== undefined && !VALID_FAILURE_REASONS.includes(failureReason as FailureReason)) { + // Warn but do not drop — the backend is the source of truth on the enum policy and `failureReason` carries the + // most diagnostic value of any field on a fail event, so swallowing it on a typo would lose more signal than it + // protects. JSON.stringify keeps the warning safe when JS callers pass non-string values. + displayWarn( + `${method}: failure reason ${JSON.stringify(failureReason)} is not one of the expected values '${VALID_FAILURE_REASONS.join("' | '")}'. The event will still be sent and may be rejected by the backend.` + ); + } return true; }