diff --git a/packages/rum-core/src/domain/vital/vitalCollection.spec.ts b/packages/rum-core/src/domain/vital/vitalCollection.spec.ts index 915209087e..8861ef54e8 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,54 @@ describe('vitalCollection', () => { }) }) + 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..ff19333b05 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,28 @@ function processVital(vital: DurationVital | OperationStepVital): RawRumEventCol domainContext: handlingStack ? { handlingStack } : {}, } } + +/** + * 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 +}