From 4ca6f8a5ec1b8eeec146023d2b916bcab9792f52 Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Thu, 23 Apr 2026 14:50:41 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9A=97=EF=B8=8F=20=F0=9F=90=9B=20validat?= =?UTF-8?q?e=20feature-operation=20name=20against=20schema=20character=20s?= =?UTF-8?q?et?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend enforces [\w.@$-]* on vital.name via the vital-common-schema facet-path rule. The Browser SDK previously had no input validation on feature-operation vitals. Added validation in addOperationStepVital: - blank / whitespace-only name β†’ display.warn, drop event - name with characters outside [\w.@$-]* β†’ display.warn, emit event anyway (backend is source of truth on character-set policy) operation_key is not subject to this rule. Still gated behind the FEATURE_OPERATION_VITAL experimental flag. --- CHANGELOG.md | 8 +++ .../src/domain/vital/vitalCollection.spec.ts | 56 ++++++++++++++++++- .../src/domain/vital/vitalCollection.ts | 32 +++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0054ed0453..5d92fbc43d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ --- +## Unreleased + +**Public Changes:** + +- βš—οΈπŸ› fix(rum): validate `startFeatureOperation` / `succeedFeatureOperation` / `failFeatureOperation` `name`. Blank/empty names are dropped with a warning (matches the backend's own non-empty precondition). Names that fail the backend-accepted character-set pattern `[\w.@$-]*` also warn via `display.warn` but are still emitted, so a future backend policy relaxation does not force an SDK bump. Still gated behind the `feature_operation_vital` experimental flag. [RUM] + +--- + ## v6.32.0 **Public Changes:** diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 915209087e..57ecac26c6 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -1,6 +1,6 @@ import type { Duration } from '@datadog/browser-core' import { mockClock, type Clock } from '@datadog/browser-core/test' -import { addExperimentalFeatures, clocksNow, ExperimentalFeature, generateUUID } from '@datadog/browser-core' +import { addExperimentalFeatures, clocksNow, display, ExperimentalFeature, generateUUID } from '@datadog/browser-core' import { collectAndValidateRawRumEvents, mockPageStateHistory } from '../../../test' import type { RawRumEvent, RawRumVitalEvent } from '../../rawRumEvent.types' import { VitalType, RumEventType } from '../../rawRumEvent.types' @@ -271,6 +271,60 @@ describe('vitalCollection', () => { }) }) + // Mirrors the backend's `[\w.@$-]*` server-side validation regex. Names + // that fail the pattern generate a `display.warn` but the event is + // still emitted β€” the backend is the single source of truth, so a + // client-side drop would force a customer SDK bump if the policy is + // ever relaxed. Blank/empty names are dropped with a warning instead, + // matching the backend's own non-empty precondition. + describe('operation name character set', () => { + beforeEach(() => { + addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL]) + spyOn(display, 'warn') + }) + ;['user login', 'api/v1', 'checkout:step', 'a,b', 'login!', 'login\ttwo', 'ログむン', 'loginπŸ”'].forEach( + (invalidName) => { + it(`should warn but still emit on name outside the backend pattern: ${JSON.stringify(invalidName)}`, () => { + vitalCollection.addOperationStepVital(invalidName, 'start') + + expect(rawRumEvents.length).toBe(1) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.name).toBe(invalidName) + expect(display.warn).toHaveBeenCalledTimes(1) + expect((display.warn as jasmine.Spy).calls.mostRecent().args[0]).toContain('does not match') + expect((display.warn as jasmine.Spy).calls.mostRecent().args[0]).toContain('still be sent') + }) + } + ) + ;['', ' ', '\t\n'].forEach((blankName) => { + it(`should reject and warn on blank name: ${JSON.stringify(blankName)}`, () => { + vitalCollection.addOperationStepVital(blankName, 'start') + + expect(rawRumEvents.length).toBe(0) + expect(display.warn).toHaveBeenCalledTimes(1) + expect((display.warn as jasmine.Spy).calls.mostRecent().args[0]).toContain('cannot be empty or blank') + }) + }) + ;['login', 'step42', 'login-v2', 'user_login', 'login.v2', 'login@prod', 'login$1', 'LoginV2'].forEach( + (validName) => { + it(`should accept name that matches the backend pattern without warning: ${JSON.stringify(validName)}`, () => { + vitalCollection.addOperationStepVital(validName, 'start') + + expect(rawRumEvents.length).toBe(1) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.name).toBe(validName) + expect(display.warn).not.toHaveBeenCalled() + }) + } + ) + + it('should not restrict operationKey to the same character set', () => { + vitalCollection.addOperationStepVital('foo', 'start', { operationKey: 'session 42 / user foo' }) + + expect(rawRumEvents.length).toBe(1) + expect((rawRumEvents[0].rawRumEvent as RawRumVitalEvent).vital.operation_key).toBe('session 42 / user foo') + expect(display.warn).not.toHaveBeenCalled() + }) + }) + it('should create a duration vital from add API', () => { vitalCollection.addDurationVital({ id: generateUUID(), diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index d1bb2d4629..df8c7e10d5 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -2,6 +2,7 @@ import type { ClocksState, Duration } from '@datadog/browser-core' import { clocksNow, combine, + display, elapsed, ExperimentalFeature, generateUUID, @@ -124,6 +125,10 @@ export function startVitalCollection( return } + if (!validateOperationName(name)) { + return + } + const { operationKey, context, description, handlingStack } = options || {} const vital: OperationStepVital = { @@ -249,3 +254,30 @@ function processVital(vital: DurationVital | OperationStepVital): RawRumEventCol domainContext: handlingStack ? { handlingStack } : {}, } } + +/** + * Validates a feature-operation `vital.name`. + * + * Blank / empty names are rejected (the backend rejects them with its own + * non-empty precondition before evaluating the character-set regex). Names + * that fail the backend's `[\w.@$-]*` character-set regex trigger a 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. + * + * Returns `true` when the event should be emitted. + */ +const BACKEND_OPERATION_NAME_REGEX = /^[\w.@$-]*$/ + +function validateOperationName(name: string): boolean { + if (typeof name !== 'string' || name.trim().length === 0) { + display.warn('Feature operation name cannot be empty or blank. Event will not be sent.') + return false + } + if (!BACKEND_OPERATION_NAME_REGEX.test(name)) { + display.warn( + `Feature 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.` + ) + } + return true +} From cf2973455b5a159d1949ee5aa7420074d053c42e Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 24 Apr 2026 09:24:32 +0200 Subject: [PATCH 2/4] Update CHANGELOG.md Co-authored-by: Thomas Lebeau <1926949+thomas-lebeau@users.noreply.github.com> --- CHANGELOG.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d92fbc43d..0054ed0453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,6 @@ --- -## Unreleased - -**Public Changes:** - -- βš—οΈπŸ› fix(rum): validate `startFeatureOperation` / `succeedFeatureOperation` / `failFeatureOperation` `name`. Blank/empty names are dropped with a warning (matches the backend's own non-empty precondition). Names that fail the backend-accepted character-set pattern `[\w.@$-]*` also warn via `display.warn` but are still emitted, so a future backend policy relaxation does not force an SDK bump. Still gated behind the `feature_operation_vital` experimental flag. [RUM] - ---- - ## v6.32.0 **Public Changes:** From 5e6ec9df86a67c294fbe1442c8869da38c60b41f Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 24 Apr 2026 09:24:50 +0200 Subject: [PATCH 3/4] Update packages/rum-core/src/domain/vital/vitalCollection.ts Co-authored-by: Thomas Lebeau <1926949+thomas-lebeau@users.noreply.github.com> --- packages/rum-core/src/domain/vital/vitalCollection.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.ts b/packages/rum-core/src/domain/vital/vitalCollection.ts index df8c7e10d5..ff19333b05 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.ts @@ -256,8 +256,6 @@ function processVital(vital: DurationVital | OperationStepVital): RawRumEventCol } /** - * Validates a feature-operation `vital.name`. - * * Blank / empty names are rejected (the backend rejects them with its own * non-empty precondition before evaluating the character-set regex). Names * that fail the backend's `[\w.@$-]*` character-set regex trigger a warning From 05ae58d943fb655ff4d5a7e2c46803da25c6066c Mon Sep 17 00:00:00 2001 From: Valentin Pertuisot Date: Fri, 24 Apr 2026 09:34:30 +0200 Subject: [PATCH 4/4] Update packages/rum-core/src/domain/vital/vitalCollection.spec.ts Co-authored-by: Thomas Lebeau <1926949+thomas-lebeau@users.noreply.github.com> --- packages/rum-core/src/domain/vital/vitalCollection.spec.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 57ecac26c6..8861ef54e8 100644 --- a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts +++ b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts @@ -271,12 +271,6 @@ describe('vitalCollection', () => { }) }) - // Mirrors the backend's `[\w.@$-]*` server-side validation regex. Names - // that fail the pattern generate a `display.warn` but the event is - // still emitted β€” the backend is the single source of truth, so a - // client-side drop would force a customer SDK bump if the policy is - // ever relaxed. Blank/empty names are dropped with a warning instead, - // matching the backend's own non-empty precondition. describe('operation name character set', () => { beforeEach(() => { addExperimentalFeatures([ExperimentalFeature.FEATURE_OPERATION_VITAL])