diff --git a/.changeset/ripe-islands-relate.md b/.changeset/ripe-islands-relate.md new file mode 100644 index 0000000000..4a67b1e017 --- /dev/null +++ b/.changeset/ripe-islands-relate.md @@ -0,0 +1,5 @@ +--- +'posthog-js': minor +--- + +Add session replay trigger groups handling (V2) diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index 0eae7ce74c..bdba45a35a 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -4142,4 +4142,404 @@ describe('Lazy SessionRecording', () => { expect(posthog.capture).toHaveBeenCalled() }) }) + + describe('V2 Trigger Groups Integration', () => { + it('registers session properties when trigger group matches and is sampled', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'error-group', + name: 'Error Tracking', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['$exception'], + }, + }, + ], + }, + }) + ) + + expect(sessionRecording.status).toBe('buffering') + + // Trigger the event + simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) + + // Should transition to sampled + expect(sessionRecording.status).toBe('sampled') + + // Verify session properties were registered + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ + id: 'error-group', + name: 'Error Tracking', + matched: true, + sampled: true, + }), + ]), + }) + ) + }) + + it('respects URL blocklist even when trigger group matches', () => { + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'all-events', + name: 'All Events', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['$pageview'], + }, + }, + ], + urlBlocklist: [ + { + matching: 'regex', + url: '/admin', + }, + ], + }, + }) + ) + + expect(sessionRecording.status).toBe('buffering') + + // Navigate to blocked URL + fakeNavigateTo('https://test.com/admin') + _emit(createIncrementalSnapshot({ data: { source: 1 } })) + + // Trigger event on blocked URL + simpleEventEmitter.emit('eventCaptured', { event: '$pageview' }) + + // Should be PAUSED, not SAMPLED (blocklist takes priority) + expect(sessionRecording.status).toBe('paused') + }) + + it('tracks multiple trigger groups with union behavior', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'errors', + name: 'Error Tracking', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['$exception'], + }, + }, + { + id: 'pageviews', + name: 'Pageview Tracking', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['$pageview'], + }, + }, + ], + }, + }) + ) + + // Trigger both groups + simpleEventEmitter.emit('eventCaptured', { event: '$exception' }) + simpleEventEmitter.emit('eventCaptured', { event: '$pageview' }) + + expect(sessionRecording.status).toBe('sampled') + + // Should track both groups + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ id: 'errors', sampled: true }), + expect.objectContaining({ id: 'pageviews', sampled: true }), + ]), + }) + ) + + // Find the call with the trigger groups property + const callsWithProperty = registerSpy.mock.calls.filter( + (call) => call[0].$sdk_debug_replay_matched_recording_trigger_groups + ) + expect(callsWithProperty.length).toBeGreaterThan(0) + const groups = + callsWithProperty[callsWithProperty.length - 1][0].$sdk_debug_replay_matched_recording_trigger_groups + expect(groups).toHaveLength(2) + }) + + it('triggers immediately when trigger group has empty conditions', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'all-sessions', + name: 'All Sessions', + sampleRate: 1.0, + minDurationMs: 0, + conditions: { + matchType: 'any', + // Empty conditions - no events, urls, or flags + }, + }, + ], + }, + }) + ) + + // Should immediately trigger without needing any events + // Status should be sampled (not buffering) + expect(sessionRecording.status).toBe('sampled') + + // Verify session properties were registered + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ + id: 'all-sessions', + name: 'All Sessions', + matched: true, + sampled: true, + }), + ]), + }) + ) + }) + + it('respects sampleRate < 1.0 and samples out when triggered', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'low-sample-group', + name: 'Low Sample Rate', + sampleRate: 0.0, // Always samples out + conditions: { + matchType: 'any', + events: ['test_event'], + }, + }, + ], + }, + }) + ) + + expect(sessionRecording.status).toBe('buffering') + + // Trigger the event + simpleEventEmitter.emit('eventCaptured', { event: 'test_event' }) + + // Should be ACTIVE (matched but sampled out), not SAMPLED + expect(sessionRecording.status).toBe('active') + + // Verify session properties show matched: true, sampled: false + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ + id: 'low-sample-group', + name: 'Low Sample Rate', + matched: true, + sampled: false, + }), + ]), + }) + ) + }) + + it('matchType all requires ALL conditions to match before triggering', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'checkout-errors', + name: 'Checkout Errors', + sampleRate: 1.0, + conditions: { + matchType: 'all', + events: ['error'], + urls: [{ url: '/checkout', matching: 'regex' }], + }, + }, + ], + }, + }) + ) + + expect(sessionRecording.status).toBe('buffering') + + // Navigate to matching URL first + fakeNavigateTo('https://test.com/checkout') + _emit(createIncrementalSnapshot({ data: { source: 1 } })) + + // URL matches but no event yet - should still be buffering + expect(sessionRecording.status).toBe('buffering') + + // Fire event on matching URL (both conditions now met) + simpleEventEmitter.emit('eventCaptured', { event: 'error' }) + + // Now should be sampled (ALL conditions met) + expect(sessionRecording.status).toBe('sampled') + + // Verify session properties + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ + id: 'checkout-errors', + name: 'Checkout Errors', + matched: true, + sampled: true, + }), + ]), + }) + ) + }) + + it('respects minDurationMs and delays full recording until duration passes', () => { + const registerSpy = jest.spyOn(posthog, 'register_for_session') + + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'long-session-group', + name: 'Long Sessions', + sampleRate: 1.0, + minDurationMs: 1500, + conditions: { + matchType: 'any', + events: ['test_event'], + }, + }, + ], + }, + }) + ) + + expect(sessionRecording.status).toBe('buffering') + + // Trigger the event + simpleEventEmitter.emit('eventCaptured', { event: 'test_event' }) + + // Should be sampled (matched and sampled - status doesn't depend on minDuration) + expect(sessionRecording.status).toBe('sampled') + + // Verify session properties show matched and sampled + expect(registerSpy).toHaveBeenCalledWith( + expect.objectContaining({ + $sdk_debug_replay_matched_recording_trigger_groups: expect.arrayContaining([ + expect.objectContaining({ + id: 'long-session-group', + name: 'Long Sessions', + matched: true, + sampled: true, + }), + ]), + }) + ) + + // Send events with timestamp below minimum duration + const { sessionStartTimestamp } = sessionManager.checkAndGetSessionAndWindowId(true) + _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 100 })) + + // Still below minimum - buffer shouldn't flush yet + expect(sessionRecording['_lazyLoadedSessionRecording']['_sessionDuration']).toBe(100) + expect(sessionRecording['_lazyLoadedSessionRecording']['_isBelowMinimumDuration']()).toBe(true) + // Status remains 'sampled' - minDuration doesn't affect status, only buffer flushing + + // Emit event that passes minimum duration + _emit(createIncrementalSnapshot({ data: { source: 1 }, timestamp: sessionStartTimestamp + 1600 })) + + // Now duration is past minimum, should transition to sampled + expect(sessionRecording['_lazyLoadedSessionRecording']['_sessionDuration']).toBe(1600) + expect(sessionRecording['_lazyLoadedSessionRecording']['_isBelowMinimumDuration']()).toBe(false) + }) + + it('stops checking triggers after initial buffer flush (performance optimization)', () => { + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + version: 2, + triggerGroups: [ + { + id: 'group-1', + name: 'Group 1', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['trigger_event'], + }, + }, + ], + }, + }) + ) + + const lazyRecorder = sessionRecording['_lazyLoadedSessionRecording'] + + // Trigger the event + simpleEventEmitter.emit('eventCaptured', { event: 'trigger_event' }) + expect(sessionRecording.status).toBe('sampled') + + // Add some data to buffer + _emit(createIncrementalSnapshot({ data: { source: 1 } })) + + // Manually trigger flush complete (simulating post-flush state) + // The actual call happens inside _flushBuffer when V2 is active + lazyRecorder['_strategy']?.onFlushComplete() + + // Verify the optimization flag is set + expect(lazyRecorder['_strategy']?.['_hasCompletedInitialFlush']).toBe(true) + + // Emit another event - the strategy should short-circuit and stop checking + // We can't directly verify the hook was removed since it's internal to the strategy, + // but we can verify the status doesn't change (optimization working) + const statusBefore = sessionRecording.status + simpleEventEmitter.emit('eventCaptured', { event: 'another_event' }) + const statusAfter = sessionRecording.status + + // Status should remain the same (no new trigger processing) + expect(statusAfter).toBe(statusBefore) + }) + }) }) diff --git a/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts b/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts index 782d987fb3..38fbafe21a 100644 --- a/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/sessionRecording-onRemoteConfig.test.ts @@ -16,12 +16,7 @@ import { type fullSnapshotEvent, type metaEvent } from '../../../extensions/repl import Mock = jest.Mock import { ConsentManager } from '../../../consent' import { SimpleEventEmitter } from '../../../utils/simple-event-emitter' -import { - allMatchSessionRecordingStatus, - AndTriggerMatching, - anyMatchSessionRecordingStatus, - OrTriggerMatching, -} from '../../../extensions/replay/external/triggerMatching' +import { AndTriggerMatching, OrTriggerMatching } from '../../../extensions/replay/external/triggerMatching' import { LazyLoadedSessionRecording, RECORDING_REMOTE_CONFIG_TTL_MS, @@ -197,12 +192,9 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', triggerMatchType: 'any' }, }) ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( - anyMatchSessionRecordingStatus - ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( - OrTriggerMatching - ) + // Trigger matching is now internal to V1 strategy + const strategy = sessionRecording['_lazyLoadedSessionRecording']['_strategy'] + expect(strategy?.['_triggerMatching']).toBeInstanceOf(OrTriggerMatching) }) it('uses allMatchSessionRecordingStatus when triggerMatching is "all"', () => { @@ -211,12 +203,9 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/', triggerMatchType: 'all' }, }) ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( - allMatchSessionRecordingStatus - ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( - AndTriggerMatching - ) + // Trigger matching is now internal to V1 strategy + const strategy = sessionRecording['_lazyLoadedSessionRecording']['_strategy'] + expect(strategy?.['_triggerMatching']).toBeInstanceOf(AndTriggerMatching) }) it('uses most restrictive when triggerMatching is not specified', () => { @@ -225,12 +214,9 @@ describe('SessionRecording', () => { sessionRecording: { endpoint: '/s/' }, }) ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_statusMatcher']).toBe( - allMatchSessionRecordingStatus - ) - expect(sessionRecording['_lazyLoadedSessionRecording']['_triggerMatching']).toBeInstanceOf( - AndTriggerMatching - ) + // Trigger matching is now internal to V1 strategy + const strategy = sessionRecording['_lazyLoadedSessionRecording']['_strategy'] + expect(strategy?.['_triggerMatching']).toBeInstanceOf(AndTriggerMatching) }) it('when the first event is a meta it does not take a manual full snapshot', () => { diff --git a/packages/browser/src/__tests__/extensions/replay/triggerGroups-v1-compat.test.ts b/packages/browser/src/__tests__/extensions/replay/triggerGroups-v1-compat.test.ts new file mode 100644 index 0000000000..47a9af1885 --- /dev/null +++ b/packages/browser/src/__tests__/extensions/replay/triggerGroups-v1-compat.test.ts @@ -0,0 +1,284 @@ +/** + * V1 Backward Compatibility Tests + * + * These tests ensure that existing V1 trigger configurations continue to work + * exactly as before when V2 trigger groups are not configured. + */ + +import { + ACTIVE, + allMatchSessionRecordingStatus, + anyMatchSessionRecordingStatus, + BUFFERING, + DISABLED, + EventTriggerMatching, + LinkedFlagMatching, + RecordingTriggersStatus, + SAMPLED, + TRIGGER_ACTIVATED, + TRIGGER_DISABLED, + TRIGGER_PENDING, + URLTriggerMatching, +} from '../../../extensions/replay/external/triggerMatching' + +describe('V1 Backward Compatibility', () => { + const defaultTriggersStatus: RecordingTriggersStatus = { + receivedFlags: true, + isRecordingEnabled: true, + isSampled: null, + rrwebError: false, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_DISABLED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as EventTriggerMatching, + linkedFlagMatching: { + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as LinkedFlagMatching, + sessionId: 'test-session', + } + + describe('anyMatchSessionRecordingStatus (V1)', () => { + it('should return SAMPLED when isSampled is true', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: true, + }) + expect(status).toBe(SAMPLED) + }) + + it('should return ACTIVE when event trigger activated', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + }) + expect(status).toBe(ACTIVE) + }) + + it('should return ACTIVE when URL trigger activated', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + }) + expect(status).toBe(ACTIVE) + }) + + it('should return ACTIVE when linked flag activated', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + linkedFlagMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as LinkedFlagMatching, + }) + expect(status).toBe(ACTIVE) + }) + + it('should return BUFFERING when any trigger is pending', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_PENDING, + } as unknown as EventTriggerMatching, + }) + expect(status).toBe(BUFFERING) + }) + + it('should return DISABLED when isSampled is false and no triggers', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: false, + }) + expect(status).toBe(DISABLED) + }) + + it('should return ACTIVE when no sampling configured and no triggers', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: null, + }) + expect(status).toBe(ACTIVE) + }) + }) + + describe('allMatchSessionRecordingStatus (V1)', () => { + it('should return SAMPLED when isSampled is true', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: true, + }) + expect(status).toBe(SAMPLED) + }) + + it('should return BUFFERING when any trigger is pending', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_PENDING, + } as unknown as EventTriggerMatching, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + }) + expect(status).toBe(BUFFERING) + }) + + it('should return ACTIVE when triggers have mixed states with some disabled', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as EventTriggerMatching, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + linkedFlagMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as LinkedFlagMatching, + }) + // With ALL match, if no triggers configured (DISABLED removed from set), should be ACTIVE + expect(status).toBe(ACTIVE) + }) + + it('should return ACTIVE when all triggers activated', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + linkedFlagMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as LinkedFlagMatching, + }) + expect(status).toBe(ACTIVE) + }) + + it('should return DISABLED when isSampled is false', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: false, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + }) + expect(status).toBe(DISABLED) + }) + }) + + describe('V1 Trigger Matching Behavior', () => { + it('should handle event + sampling with ANY match', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: true, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + }) + // Sampling takes precedence + expect(status).toBe(SAMPLED) + }) + + it('should handle event + sampling with ALL match', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: true, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + }) + expect(status).toBe(SAMPLED) + }) + + it('should handle URL + event with ANY match', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as EventTriggerMatching, + }) + // ANY means if URL is activated, should be ACTIVE + expect(status).toBe(ACTIVE) + }) + + it('should handle URL + event with ALL match', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as EventTriggerMatching, + }) + // ALL means if no triggers are configured (all DISABLED), should be ACTIVE by default + expect(status).toBe(ACTIVE) + }) + }) + + describe('V1 Edge Cases', () => { + it('should handle null isSampled with no triggers', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: null, + }) + // Should default to ACTIVE + expect(status).toBe(ACTIVE) + }) + + it('should handle undefined isSampled with triggers pending', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: undefined, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_PENDING, + } as unknown as EventTriggerMatching, + }) + // Should be BUFFERING because trigger is pending + expect(status).toBe(BUFFERING) + }) + + it('should return ACTIVE when trigger activated even if sampling false (ANY)', () => { + const status = anyMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: false, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + }) + // With ANY match, activated trigger takes precedence - sampling false is only checked at the end + expect(status).toBe(ACTIVE) + }) + + it('should prioritize sampling false over activated triggers (ALL)', () => { + const status = allMatchSessionRecordingStatus({ + ...defaultTriggersStatus, + isSampled: false, + eventTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + } as unknown as EventTriggerMatching, + urlTriggerMatching: { + triggerStatus: () => TRIGGER_ACTIVATED, + urlBlocked: false, + } as unknown as URLTriggerMatching, + }) + expect(status).toBe(DISABLED) + }) + }) +}) diff --git a/packages/browser/src/__tests__/extensions/replay/triggerGroups.test.ts b/packages/browser/src/__tests__/extensions/replay/triggerGroups.test.ts new file mode 100644 index 0000000000..d5c65b8587 --- /dev/null +++ b/packages/browser/src/__tests__/extensions/replay/triggerGroups.test.ts @@ -0,0 +1,276 @@ +import { + ACTIVE, + BUFFERING, + DISABLED, + RecordingTriggersStatusV2, + RRWEB_ERROR, + SAMPLED, + TriggerGroupMatching, + triggerGroupsMatchSessionRecordingStatus, + TRIGGER_ACTIVATED, + TRIGGER_DISABLED, + TRIGGER_PENDING, + URLTriggerMatching, + EventTriggerMatching, + LinkedFlagMatching, + PAUSED, +} from '../../../extensions/replay/external/triggerMatching' +import { SessionRecordingTriggerGroup } from '../../../types' +import { createMockPostHog } from '../../helpers/posthog-instance' + +const fakePostHog = createMockPostHog({ + register_for_session: () => {}, + onFeatureFlags: () => () => {}, // Returns cleanup function +}) + +// Shared test helper: Creates a mock TriggerGroupMatching with optional overrides +const createMockMatcher = ( + id: string, + triggerStatus: 'trigger_activated' | 'trigger_pending' | 'trigger_disabled', + overrides?: Partial +): TriggerGroupMatching => { + return { + group: { + id, + name: `Group ${id}`, + sampleRate: 1.0, + conditions: { matchType: 'any' }, + ...overrides, + }, + triggerStatus: () => triggerStatus, + stop: () => {}, + } as unknown as TriggerGroupMatching +} + +// Shared test base status for all triggerGroupsMatchSessionRecordingStatus tests +const createBaseStatus = (): RecordingTriggersStatusV2 => ({ + receivedFlags: true, + isRecordingEnabled: true, + isSampled: null, + rrwebError: false, + urlTriggerMatching: { + urlBlocked: false, + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as URLTriggerMatching, + eventTriggerMatching: {} as EventTriggerMatching, + linkedFlagMatching: {} as LinkedFlagMatching, + sessionId: 'test-session', + triggerGroupMatchers: [], + triggerGroupSamplingResults: new Map(), + minimumDuration: null, +}) + +describe('V2 Trigger Groups', () => { + describe('TriggerGroupMatching', () => { + it('should create matcher with event triggers and ANY match type', () => { + const group: SessionRecordingTriggerGroup = { + id: 'group-1', + name: 'Error Tracking', + sampleRate: 1.0, + conditions: { + matchType: 'any', + events: ['$exception', 'error'], + }, + } + + const matcher = new TriggerGroupMatching(fakePostHog, group, () => {}) + expect(matcher.group).toEqual(group) + }) + + it('should create matcher with URL triggers and ALL match type', () => { + const group: SessionRecordingTriggerGroup = { + id: 'group-2', + name: 'Checkout Flow', + sampleRate: 0.5, + conditions: { + matchType: 'all', + urls: [{ url: '/checkout', matching: 'regex' }], + events: ['checkout_started'], + }, + } + + const matcher = new TriggerGroupMatching(fakePostHog, group, () => {}) + expect(matcher.group).toEqual(group) + }) + + it('should create matcher with feature flag', () => { + const group: SessionRecordingTriggerGroup = { + id: 'group-3', + name: 'Beta Users', + sampleRate: 1.0, + conditions: { + matchType: 'any', + flag: 'beta-users', + }, + } + + const matcher = new TriggerGroupMatching(fakePostHog, group, () => {}) + expect(matcher.group).toEqual(group) + }) + + it('should create matcher with minDurationMs', () => { + const group: SessionRecordingTriggerGroup = { + id: 'group-4', + name: 'Quick Sessions', + sampleRate: 1.0, + minDurationMs: 0, + conditions: { + matchType: 'any', + events: ['error'], + }, + } + + const matcher = new TriggerGroupMatching(fakePostHog, group, () => {}) + expect(matcher.group.minDurationMs).toBe(0) + }) + }) + + describe('triggerGroupsMatchSessionRecordingStatus - Basic States', () => { + // Parameterized tests for basic pre-condition checks + test.each([ + { + name: 'rrweb error', + statusOverrides: { rrwebError: true }, + expectedStatus: RRWEB_ERROR, + }, + { + name: 'flags not received', + statusOverrides: { receivedFlags: false }, + expectedStatus: BUFFERING, + }, + { + name: 'recording not enabled', + statusOverrides: { isRecordingEnabled: false }, + expectedStatus: DISABLED, + }, + { + name: 'no trigger groups configured', + statusOverrides: { triggerGroupMatchers: [] }, + expectedStatus: DISABLED, + }, + ])('should return $expectedStatus when $name', ({ statusOverrides, expectedStatus }) => { + const status = triggerGroupsMatchSessionRecordingStatus({ + ...createBaseStatus(), + ...statusOverrides, + }) + expect(status).toBe(expectedStatus) + }) + + it('should return PAUSED when URL is blocked', () => { + const status = triggerGroupsMatchSessionRecordingStatus({ + ...createBaseStatus(), + urlTriggerMatching: { + urlBlocked: true, + triggerStatus: () => TRIGGER_DISABLED, + } as unknown as URLTriggerMatching, + triggerGroupMatchers: [createMockMatcher('group-1', TRIGGER_ACTIVATED)], + }) + expect(status).toBe(PAUSED) + }) + }) + + describe('triggerGroupsMatchSessionRecordingStatus - Union Behavior', () => { + // Parameterized tests for union (OR) behavior across trigger groups + test.each([ + { + name: 'ANY group is activated and sampled', + matchers: [ + createMockMatcher('group-1', TRIGGER_ACTIVATED), + createMockMatcher('group-2', TRIGGER_DISABLED), + ], + samplingResults: new Map([ + ['group-1', true], + ['group-2', false], + ]), + expectedStatus: SAMPLED, + }, + { + name: 'group activated but sample missed', + matchers: [createMockMatcher('group-1', TRIGGER_ACTIVATED)], + samplingResults: new Map([['group-1', false]]), + expectedStatus: ACTIVE, + }, + { + name: 'multiple groups activated and ANY sampled', + matchers: [ + createMockMatcher('group-1', TRIGGER_ACTIVATED), + createMockMatcher('group-2', TRIGGER_ACTIVATED), + createMockMatcher('group-3', TRIGGER_ACTIVATED), + ], + samplingResults: new Map([ + ['group-1', false], + ['group-2', true], // Only group-2 sampled + ['group-3', false], + ]), + expectedStatus: SAMPLED, + }, + { + name: 'ANY group is pending', + matchers: [ + createMockMatcher('group-1', TRIGGER_PENDING), + createMockMatcher('group-2', TRIGGER_DISABLED), + ], + samplingResults: new Map(), + expectedStatus: BUFFERING, + }, + { + name: 'all groups disabled', + matchers: [ + createMockMatcher('group-1', TRIGGER_DISABLED), + createMockMatcher('group-2', TRIGGER_DISABLED), + ], + samplingResults: new Map(), + expectedStatus: DISABLED, + }, + { + name: 'mix of activated (sampled), pending, and disabled groups', + matchers: [ + createMockMatcher('group-1', TRIGGER_ACTIVATED), + createMockMatcher('group-2', TRIGGER_PENDING), + createMockMatcher('group-3', TRIGGER_DISABLED), + ], + samplingResults: new Map([ + ['group-1', true], + ['group-2', false], + ['group-3', false], + ]), + expectedStatus: SAMPLED, // group-1 activated and sampled wins + }, + ])('should return $expectedStatus when $name', ({ matchers, samplingResults, expectedStatus }) => { + const status = triggerGroupsMatchSessionRecordingStatus({ + ...createBaseStatus(), + triggerGroupMatchers: matchers, + triggerGroupSamplingResults: samplingResults, + }) + expect(status).toBe(expectedStatus) + }) + }) + + describe('triggerGroupsMatchSessionRecordingStatus - Edge Cases', () => { + // Parameterized tests for edge cases with sampling results + test.each([ + { + name: 'empty sampling results map', + matchers: [createMockMatcher('group-1', TRIGGER_ACTIVATED)], + samplingResults: new Map(), + expectedStatus: ACTIVE, // Activated but no sampling decision + }, + { + name: 'missing sampling result for activated group', + matchers: [ + createMockMatcher('group-1', TRIGGER_ACTIVATED), + createMockMatcher('group-2', TRIGGER_ACTIVATED), + ], + samplingResults: new Map([['group-1', false]]), // group-2 missing + expectedStatus: ACTIVE, // At least one activated + }, + ])('should return $expectedStatus when $name', ({ matchers, samplingResults, expectedStatus }) => { + const status = triggerGroupsMatchSessionRecordingStatus({ + ...createBaseStatus(), + triggerGroupMatchers: matchers, + triggerGroupSamplingResults: samplingResults, + }) + expect(status).toBe(expectedStatus) + }) + }) +}) diff --git a/packages/browser/src/constants.ts b/packages/browser/src/constants.ts index a39be14724..48dcdd6ac4 100644 --- a/packages/browser/src/constants.ts +++ b/packages/browser/src/constants.ts @@ -46,6 +46,10 @@ export const SESSION_RECORDING_IS_SAMPLED = '$session_is_sampled' export const SESSION_RECORDING_PAST_MINIMUM_DURATION = '$session_past_minimum_duration' export const SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION = '$session_recording_url_trigger_activated_session' export const SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION = '$session_recording_event_trigger_activated_session' +// V2 Trigger Groups: Per-group persistence key prefixes (suffix with group ID) +export const SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX = '$posthog_sr_group_event_trigger_' +export const SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX = '$posthog_sr_group_url_trigger_' +export const SESSION_RECORDING_TRIGGER_V2_GROUP_SAMPLING_PREFIX = '$posthog_sr_group_sampling_' export const SESSION_RECORDING_FIRST_FULL_SNAPSHOT_TIMESTAMP = '$debug_first_full_snapshot_timestamp' export const ENABLED_FEATURE_FLAGS = '$enabled_feature_flags' export const PERSISTENCE_EARLY_ACCESS_FEATURES = '$early_access_features' diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 5146cff257..ea78c8e198 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -10,22 +10,12 @@ import { import { buildNetworkRequestOptions } from './config' import { ACTIVE, - allMatchSessionRecordingStatus, - AndTriggerMatching, - anyMatchSessionRecordingStatus, BUFFERING, DISABLED, EventTriggerMatching, LinkedFlagMatching, - nullMatchSessionRecordingStatus, - OrTriggerMatching, PAUSED, - PendingTriggerMatching, - RecordingTriggersStatus, - SAMPLED, SessionRecordingStatus, - TRIGGER_PENDING, - TriggerStatusMatching, TriggerType, URLTriggerMatching, } from './triggerMatching' @@ -47,7 +37,6 @@ import { isUndefined, } from '@posthog/core' import { - SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, SESSION_RECORDING_FIRST_FULL_SNAPSHOT_TIMESTAMP, SESSION_RECORDING_IS_SAMPLED, SESSION_RECORDING_OVERRIDE_SAMPLING, @@ -56,11 +45,9 @@ import { SESSION_RECORDING_OVERRIDE_URL_TRIGGER, SESSION_RECORDING_PAST_MINIMUM_DURATION, SESSION_RECORDING_REMOTE_CONFIG, - SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, } from '../../../constants' import { PostHog } from '../../../posthog-core' import { - CaptureResult, NetworkRecordOptions, PerformanceCaptureConfig, Properties, @@ -71,8 +58,13 @@ import { } from '../../../types' import { isLocalhost } from '../../../utils/request-utils' import Config from '../../../config' -import { sampleOnProperty } from '../../sampling' import { FlushedSizeTracker } from './flushed-size-tracker' +import { + RecordingStrategy, + V1RecordingStrategy, + V2TriggerGroupStrategy, + RecordingStrategyContext, +} from './recording-strategies' const BASE_ENDPOINT = '/s/' const DEFAULT_CANVAS_QUALITY = 0.4 @@ -343,9 +335,8 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt private _linkedFlagMatching: LinkedFlagMatching private _urlTriggerMatching: URLTriggerMatching private _eventTriggerMatching: EventTriggerMatching - // we need to be able to check the state of the event and url triggers separately - // as we make some decisions based on them without referencing LinkedFlag etc - private _triggerMatching: TriggerStatusMatching = new PendingTriggerMatching() + // Strategy pattern: V1 vs V2 trigger logic + private _strategy: RecordingStrategy | undefined private _fullSnapshotTimer?: ReturnType private _fullSnapshotTimestamps: Array<[string, number]> = [] @@ -395,13 +386,9 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } private get _minimumDuration(): number | null { - const duration = this._remoteConfig?.minimumDurationMilliseconds - return isNumber(duration) ? duration : null + return this._strategy?.getMinimumDuration(this.sessionId) ?? null } - private _statusMatcher: (triggersStatus: RecordingTriggersStatus) => SessionRecordingStatus = - nullMatchSessionRecordingStatus - private _onSessionIdListener: (() => void) | undefined = undefined private _onSessionIdleResetForcedListener: (() => void) | undefined = undefined private _samplingSessionListener: (() => void) | undefined = undefined @@ -626,10 +613,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } private get _fullSnapshotIntervalMillis(): number { - if ( - this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING && - !['sampled', 'active'].includes(this.status) - ) { + if (this._strategy?.hasPendingTriggers(this.sessionId) && !['sampled', 'active'].includes(this.status)) { return ONE_MINUTE } @@ -690,29 +674,24 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } private _activateTrigger(triggerType: TriggerType, matchDetail?: string) { + // V1 only: V2 uses per-group activation and never calls this method // Prevent re-entry: if we're already activating a trigger, skip to avoid infinite recursion // This can happen when _reportStarted emits custom events that match the trigger condition if (this._isActivatingTrigger) { return } - if (this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING) { - this._isActivatingTrigger = true - try { - // status is stored separately for URL and event triggers - this._instance?.persistence?.register({ - [triggerType === 'url' - ? SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION - : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION]: this._sessionId, - }) + this._isActivatingTrigger = true + try { + // Trigger persistence is handled by TriggerStatusMatching.activateTrigger() + this._strategy?.updateActiveTriggers() - this._flushBuffer() - this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason, { - [triggerType === 'url' ? 'matchedUrl' : 'matchedEvent']: matchDetail, - }) - } finally { - this._isActivatingTrigger = false - } + this._flushBuffer() + this._reportStarted((triggerType + '_trigger_matched') as SessionStartReason, { + [triggerType === 'url' ? 'matchedUrl' : 'matchedEvent']: matchDetail, + }) + } finally { + this._isActivatingTrigger = false } } @@ -775,31 +754,37 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._endpoint = config?.endpoint } - if (config?.triggerMatchType === 'any') { - this._statusMatcher = anyMatchSessionRecordingStatus - this._triggerMatching = new OrTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) + // Initialize the appropriate strategy based on config version + const isV2 = config?.version === 2 && config?.triggerGroups && config.triggerGroups.length > 0 + + if (isV2) { + this._strategy = new V2TriggerGroupStrategy( + this._instance, + this._urlTriggerMatching, + this._reportStarted.bind(this), + this._tryAddCustomEvent.bind(this) + ) } else { - // either the setting is "ALL" - // or we default to the most restrictive - this._statusMatcher = allMatchSessionRecordingStatus - this._triggerMatching = new AndTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) + this._strategy = new V1RecordingStrategy( + this._instance, + this._urlTriggerMatching, + this._eventTriggerMatching, + this._linkedFlagMatching, + this._reportStarted.bind(this), + this._tryTakeFullSnapshot.bind(this) + ) } - this._instance.register_for_session({ - $sdk_debug_replay_remote_trigger_matching_config: config?.triggerMatchType, - }) - this._urlTriggerMatching.onConfig(config) + // Let the strategy configure itself + this._strategy.onRemoteConfig(config) - this._eventTriggerMatching.onConfig(config) + // Setup event trigger listeners via strategy this._removeEventTriggerCaptureHook?.() - this._addEventTriggerListener() - - this._linkedFlagMatching.onConfig(config, (flag, variant) => { - this._reportStarted('linked_flag_matched', { - flag, - variant, - }) - }) + this._removeEventTriggerCaptureHook = this._strategy.setupEventTriggerListeners( + this._instance.on.bind(this._instance, 'eventCaptured'), + this.sessionId, + (triggerType, matchDetail) => this._activateTrigger(triggerType, matchDetail) + ) this._checkOverride(SESSION_RECORDING_OVERRIDE_SAMPLING, () => { this.overrideSampling() @@ -814,7 +799,8 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this.overrideTrigger('url') }) - this._makeSamplingDecision(this.sessionId) + // Let strategy make sampling decisions + this._strategy.makeSamplingDecisions(this.sessionId) this._startRecorder() if (this._rrwebError) { @@ -943,7 +929,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } if (isNumber(this._sampleRate) && isNullish(this._samplingSessionListener)) { - this._makeSamplingDecision(sessionId) + this._strategy?.makeSamplingDecisions(sessionId) } } @@ -969,9 +955,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._forceIdleSessionIdListener?.() this._forceIdleSessionIdListener = undefined - this._eventTriggerMatching.stop() - this._urlTriggerMatching.stop() - this._linkedFlagMatching.stop() + this._strategy?.stop() this._mutationThrottler?.stop() @@ -1013,13 +997,14 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._pageViewFallBack() } - // Check if the URL matches any trigger patterns - this._urlTriggerMatching.checkUrlTriggerConditions( + // Check if the URL matches any trigger patterns - delegate to strategy + this._strategy?.checkUrlTriggers( + this.sessionId, () => this._pauseRecording(), () => this._resumeRecording(), - (triggerType, matchDetail) => this._activateTrigger(triggerType, matchDetail), - this.sessionId + (triggerType, matchDetail) => this._activateTrigger(triggerType, matchDetail) ) + // always have to check if the URL is blocked really early, // or you risk getting stuck in a loop if (this._urlTriggerMatching.urlBlocked && !isRecordingPausedEvent(rawEvent)) { @@ -1044,11 +1029,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot - // we always start trigger pending so need to wait for flags before we know if we're really pending - if ( - rawEvent.type === EventType.FullSnapshot && - this._triggerMatching.triggerStatus(this.sessionId) === TRIGGER_PENDING - ) { + if (rawEvent.type === EventType.FullSnapshot && this._strategy?.hasPendingTriggers(this.sessionId)) { this._clearBufferBeforeMostRecentMeta() } @@ -1139,18 +1120,22 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } get status(): SessionRecordingStatus { - return this._statusMatcher({ - // can't get here without recording being enabled... - receivedFlags: true, - isRecordingEnabled: true, - // things that do still vary + if (!this._strategy) { + return DISABLED + } + + const context: RecordingStrategyContext = { + instance: this._instance, + sessionId: this.sessionId, isSampled: this._isSampled, rrwebError: this._rrwebError, urlTriggerMatching: this._urlTriggerMatching, eventTriggerMatching: this._eventTriggerMatching, linkedFlagMatching: this._linkedFlagMatching, - sessionId: this.sessionId, - }) + remoteConfig: this._remoteConfig, + } + + return this._strategy.getStatus(context) } log(message: string, level: 'log' | 'warn' | 'error' = 'log') { @@ -1232,6 +1217,9 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt $lib_version: Config.LIB_VERSION, }) }) + + // Notify strategy that initial flush is complete (performance optimization) + this._strategy?.onFlushComplete() } // buffer is empty, we clear it in case the session id has changed @@ -1492,75 +1480,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } private _clearConditionalRecordingPersistence(): void { - this._instance?.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) - this._instance?.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) - this._instance?.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) - this._instance?.persistence?.unregister(SESSION_RECORDING_PAST_MINIMUM_DURATION) - } - - private _makeSamplingDecision(sessionId: string): void { - const sessionIdChanged = this._sessionId !== sessionId - - // capture the current sample rate - // because it is re-used multiple times - // and the bundler won't minimize any of the references - const currentSampleRate = this._sampleRate - - if (!isNumber(currentSampleRate)) { - this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) - return - } - - const storedIsSampled = this._isSampled - - /** - * if we get this far, then we should make a sampling decision. - * When the session id changes or there is no stored sampling decision for this session id - * then we should make a new decision. - * - * Otherwise, we should use the stored decision. - */ - const makeDecision = sessionIdChanged || !isBoolean(storedIsSampled) - const shouldSample = makeDecision ? sampleOnProperty(sessionId, currentSampleRate) : storedIsSampled - - if (makeDecision) { - if (shouldSample) { - this._reportStarted(SAMPLED) - } else { - logger.warn( - `Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.` - ) - } - - this._tryAddCustomEvent('samplingDecisionMade', { - sampleRate: currentSampleRate, - isSampled: shouldSample, - }) - } - - this._instance.persistence?.register({ - [SESSION_RECORDING_IS_SAMPLED]: shouldSample ? sessionId : false, - }) - } - - private _addEventTriggerListener() { - if (this._eventTriggerMatching._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) { - return - } - - this._removeEventTriggerCaptureHook = this._instance.on('eventCaptured', (event: CaptureResult) => { - // If anything could go wrong here, it has the potential to block the main loop, - // so we catch all errors. - try { - this._eventTriggerMatching.checkEventTriggerConditions( - event.event, - (triggerType, matchDetail) => this._activateTrigger(triggerType, matchDetail), - this.sessionId - ) - } catch (e) { - logger.error('Could not activate event trigger', e) - } - }) + this._strategy?.clearConditionalRecordingPersistence() } get sdkDebugProperties(): Properties { diff --git a/packages/browser/src/extensions/replay/external/recording-strategies.ts b/packages/browser/src/extensions/replay/external/recording-strategies.ts new file mode 100644 index 0000000000..9043be2f52 --- /dev/null +++ b/packages/browser/src/extensions/replay/external/recording-strategies.ts @@ -0,0 +1,560 @@ +import { PostHog } from '../../../posthog-core' +import { + CaptureResult, + SessionRecordingPersistedConfig, + SessionRecordingTriggerGroup, + SessionStartReason, +} from '../../../types' +import { + SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, + SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, + SESSION_RECORDING_IS_SAMPLED, + SESSION_RECORDING_PAST_MINIMUM_DURATION, + SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX, + SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX, + SESSION_RECORDING_TRIGGER_V2_GROUP_SAMPLING_PREFIX, +} from '../../../constants' +import { + EventTriggerMatching, + LinkedFlagMatching, + URLTriggerMatching, + TriggerGroupMatching, + SessionRecordingStatus, + allMatchSessionRecordingStatus, + anyMatchSessionRecordingStatus, + triggerGroupsMatchSessionRecordingStatus, + RecordingTriggersStatusV2, + TriggerType, + AndTriggerMatching, + OrTriggerMatching, + TriggerStatusMatching, + TRIGGER_PENDING, +} from './triggerMatching' +import { sampleOnProperty } from '../../sampling' +import { isBoolean, isNull, isNullish, isNumber } from '@posthog/core' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('[SessionRecording]') + +/** + * Shared context that strategies need to access from the recorder + */ +export interface RecordingStrategyContext { + instance: PostHog + sessionId: string + isSampled: boolean | null + rrwebError: boolean + urlTriggerMatching: URLTriggerMatching + eventTriggerMatching: EventTriggerMatching + linkedFlagMatching: LinkedFlagMatching + remoteConfig: SessionRecordingPersistedConfig | undefined +} + +/** + * Strategy interface for handling different recording trigger configurations + */ +export interface RecordingStrategy { + /** + * Initialize the strategy with remote config + */ + onRemoteConfig(config: SessionRecordingPersistedConfig): void + + /** + * Get the current recording status + */ + getStatus(context: RecordingStrategyContext): SessionRecordingStatus + + /** + * Get the minimum duration for this session (if any) + */ + getMinimumDuration(sessionId: string): number | null + + /** + * Check URL triggers on each navigation + * Note: URL is read from window.location.href internally + */ + checkUrlTriggers( + sessionId: string, + onPause: () => void, + onResume: () => void, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): void + + /** + * Setup event trigger listeners + */ + setupEventTriggerListeners( + onEvent: (callback: (event: CaptureResult) => void) => () => void, + sessionId: string, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): (() => void) | undefined + + /** + * Make sampling decisions for the session + */ + makeSamplingDecisions(sessionId: string): void + + /** + * Called after the initial buffer flush (performance optimization hook) + */ + onFlushComplete(): void + + /** + * Clean up persistence keys for conditional recording + */ + clearConditionalRecordingPersistence(): void + + /** + * Update session properties with active trigger information + */ + updateActiveTriggers(): void + + /** + * Check if triggers are in pending state (waiting for activation) + */ + hasPendingTriggers(sessionId: string): boolean + + /** + * Stop and cleanup the strategy + */ + stop(): void +} + +/** + * V1 Strategy: Legacy trigger matching with global URL/Event/Flag triggers + */ +export class V1RecordingStrategy implements RecordingStrategy { + private _triggerMatching: TriggerStatusMatching | undefined + private _removeEventTriggerCaptureHook: (() => void) | undefined + private _sampleRate: number | null = null + + constructor( + private readonly _instance: PostHog, + private readonly _urlTriggerMatching: URLTriggerMatching, + private readonly _eventTriggerMatching: EventTriggerMatching, + private readonly _linkedFlagMatching: LinkedFlagMatching, + private readonly _reportStarted: (reason: SessionStartReason, payload?: Record) => void, + private readonly _tryTakeFullSnapshot: () => void + ) {} + + onRemoteConfig(config: SessionRecordingPersistedConfig): void { + this._sampleRate = isNumber(config.sampleRate) ? config.sampleRate : null + + // Setup trigger matching strategy (AND vs OR) + if (config.triggerMatchType === 'any') { + this._triggerMatching = new OrTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) + } else { + // either the setting is "ALL" or we default to the most restrictive + this._triggerMatching = new AndTriggerMatching([this._eventTriggerMatching, this._urlTriggerMatching]) + } + + this._instance.register_for_session({ + $sdk_debug_replay_remote_trigger_matching_config: config.triggerMatchType, + }) + + this._urlTriggerMatching.onConfig(config) + this._eventTriggerMatching.onConfig(config) + + this._linkedFlagMatching.onConfig(config, (flag, variant) => { + this._reportStarted('linked_flag_matched', { flag, variant }) + }) + } + + getStatus(context: RecordingStrategyContext): SessionRecordingStatus { + const config = context.remoteConfig + const matchFunction = + config?.triggerMatchType === 'any' ? anyMatchSessionRecordingStatus : allMatchSessionRecordingStatus + + return matchFunction({ + receivedFlags: true, + isRecordingEnabled: true, + isSampled: context.isSampled, + rrwebError: context.rrwebError, + urlTriggerMatching: context.urlTriggerMatching, + eventTriggerMatching: context.eventTriggerMatching, + linkedFlagMatching: context.linkedFlagMatching, + sessionId: context.sessionId, + }) + } + + getMinimumDuration(sessionId: string): number | null { + // V1: Minimum duration is global from config, doesn't need sessionId + void sessionId + const config = this._instance.get_property('$session_recording_remote_config') as + | SessionRecordingPersistedConfig + | undefined + const duration = config?.minimumDurationMilliseconds + return isNumber(duration) ? duration : null + } + + checkUrlTriggers( + sessionId: string, + onPause: () => void, + onResume: () => void, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): void { + this._urlTriggerMatching.checkUrlTriggerConditions(onPause, onResume, onActivate, sessionId) + } + + setupEventTriggerListeners( + onEvent: (callback: (event: CaptureResult) => void) => () => void, + sessionId: string, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): (() => void) | undefined { + if (this._eventTriggerMatching._eventTriggers.length === 0 || !isNullish(this._removeEventTriggerCaptureHook)) { + return undefined + } + + this._removeEventTriggerCaptureHook = onEvent((event: CaptureResult) => { + try { + this._eventTriggerMatching.checkEventTriggerConditions(event.event, onActivate, sessionId) + } catch (e) { + logger.error('Could not activate event trigger', e) + } + }) + + return this._removeEventTriggerCaptureHook + } + + makeSamplingDecisions(sessionId: string): void { + const currentSampleRate = this._sampleRate + + if (!isNumber(currentSampleRate)) { + this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) + return + } + + const storedIsSampled = this._getIsSampled(sessionId) + const sessionIdChanged = this._getStoredSessionId() !== sessionId + const makeDecision = sessionIdChanged || !isBoolean(storedIsSampled) + const shouldSample = makeDecision ? sampleOnProperty(sessionId, currentSampleRate) : storedIsSampled + + if (makeDecision) { + if (shouldSample) { + this._reportStarted('sampled') + } else { + logger.warn( + `Sample rate (${currentSampleRate}) has determined that this sessionId (${sessionId}) will not be sent to the server.` + ) + } + } + + this._instance.persistence?.register({ + [SESSION_RECORDING_IS_SAMPLED]: shouldSample ? sessionId : false, + }) + } + + onFlushComplete(): void { + // V1 doesn't use this optimization + } + + clearConditionalRecordingPersistence(): void { + this._instance.persistence?.unregister(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) + this._instance.persistence?.unregister(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) + this._instance.persistence?.unregister(SESSION_RECORDING_PAST_MINIMUM_DURATION) + } + + updateActiveTriggers(): void { + // V1 doesn't track active triggers in session properties + } + + hasPendingTriggers(sessionId: string): boolean { + return this._triggerMatching?.triggerStatus(sessionId) === TRIGGER_PENDING + } + + stop(): void { + this._removeEventTriggerCaptureHook?.() + this._removeEventTriggerCaptureHook = undefined + this._eventTriggerMatching.stop() + this._urlTriggerMatching.stop() + this._linkedFlagMatching.stop() + } + + private _getIsSampled(sessionId: string): boolean | null { + const currentValue = this._instance.get_property(SESSION_RECORDING_IS_SAMPLED) + if (currentValue === true) { + return null + } + return currentValue === false ? false : typeof currentValue === 'string' ? currentValue === sessionId : null + } + + private _getStoredSessionId(): string | null { + const currentValue = this._instance.get_property(SESSION_RECORDING_IS_SAMPLED) + return typeof currentValue === 'string' ? currentValue : null + } +} + +/** + * V2 Strategy: Trigger groups with per-group sampling and union behavior + */ +export class V2TriggerGroupStrategy implements RecordingStrategy { + private _triggerGroupMatchers: TriggerGroupMatching[] = [] + private _triggerGroupSamplingResults: Map = new Map() + private _hasCompletedInitialFlush: boolean = false + private _removeEventTriggerCaptureHook: (() => void) | undefined + + constructor( + private readonly _instance: PostHog, + private readonly _urlTriggerMatching: URLTriggerMatching, + private readonly _reportStarted: (reason: SessionStartReason, payload?: Record) => void, + private readonly _tryAddCustomEvent: (tag: string, payload: any) => void + ) {} + + onRemoteConfig(config: SessionRecordingPersistedConfig): void { + if (!config.triggerGroups || config.triggerGroups.length === 0) { + logger.warn('[V2Strategy] No trigger groups configured') + return + } + + // Setup trigger group matchers + this._setupTriggerGroups(config.triggerGroups) + + this._instance.register_for_session({ + $sdk_debug_replay_remote_trigger_matching_config: 'v2_trigger_groups', + $sdk_debug_replay_trigger_groups_count: config.triggerGroups.length, + }) + + // V2 needs URL blocklist (but not URL triggers) + this._urlTriggerMatching.onConfig(config) + } + + getStatus(context: RecordingStrategyContext): SessionRecordingStatus { + return triggerGroupsMatchSessionRecordingStatus({ + receivedFlags: true, + isRecordingEnabled: true, + isSampled: context.isSampled, + rrwebError: context.rrwebError, + urlTriggerMatching: context.urlTriggerMatching, + eventTriggerMatching: context.eventTriggerMatching, + linkedFlagMatching: context.linkedFlagMatching, + sessionId: context.sessionId, + triggerGroupMatchers: this._triggerGroupMatchers, + triggerGroupSamplingResults: this._triggerGroupSamplingResults, + minimumDuration: this.getMinimumDuration(context.sessionId), + } as RecordingTriggersStatusV2) + } + + getMinimumDuration(sessionId: string): number | null { + let lowestDuration: number | null = null + + for (const matcher of this._triggerGroupMatchers) { + const groupStatus = matcher.triggerStatus(sessionId) + + // Only consider activated groups - pending groups haven't triggered yet + if (groupStatus === 'trigger_activated') { + const groupDuration = matcher.group.minDurationMs + if (isNumber(groupDuration)) { + if (isNull(lowestDuration) || groupDuration < lowestDuration) { + lowestDuration = groupDuration + } + } + } + } + + return lowestDuration + } + + checkUrlTriggers( + sessionId: string, + onPause: () => void, + onResume: () => void, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): void { + // V2 doesn't use the global onActivate callback - each group activates itself + void onActivate + + // Check URL blocklist (global, not per-group) + this._urlTriggerMatching.checkUrlBlocklist(onPause, onResume) + + // Check URL triggers for each group + for (const matcher of this._triggerGroupMatchers) { + matcher.checkUrlTriggerConditions( + onPause, + onResume, + (triggerType) => { + // Use per-group activation instead of global V1 _activateTrigger + matcher.activateTrigger(triggerType, sessionId) + // Update session properties after activation + this.updateActiveTriggers() + }, + sessionId + ) + } + } + + setupEventTriggerListeners( + onEvent: (callback: (event: CaptureResult) => void) => () => void, + sessionId: string, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void + ): (() => void) | undefined { + // V2 doesn't use the global onActivate callback - each group activates itself + void onActivate + + this._removeEventTriggerCaptureHook = onEvent((event: CaptureResult) => { + // Performance optimization: Stop checking triggers after initial buffer flush + if (this._hasCompletedInitialFlush) { + logger.info('[SessionRecorder] Stopping trigger checks - initial buffer flushed') + this._removeEventTriggerCaptureHook?.() + this._removeEventTriggerCaptureHook = undefined + return + } + + try { + // V2: Each group activates its own trigger with per-group persistence + for (const matcher of this._triggerGroupMatchers) { + matcher.checkEventTriggerConditions( + event.event, + (triggerType) => { + matcher.activateTrigger(triggerType, sessionId) + this.updateActiveTriggers() + }, + sessionId + ) + } + } catch (e) { + logger.error('Could not activate event trigger for trigger groups', e) + } + }) + + return this._removeEventTriggerCaptureHook + } + + makeSamplingDecisions(sessionId: string): void { + const sessionIdChanged = this._getStoredSessionId() !== sessionId + + for (const matcher of this._triggerGroupMatchers) { + const group = matcher.group + const groupId = group.id + const sampleRate = group.sampleRate + + // Validate group ID is safe for use as persistence key suffix + if (!/^[a-zA-Z0-9_-]+$/.test(groupId)) { + logger.warn('[SessionRecorder] Invalid group ID for persistence - skipping group', { groupId }) + continue + } + + // Check if we have a stored decision for this group + const storageKey = SESSION_RECORDING_TRIGGER_V2_GROUP_SAMPLING_PREFIX + groupId + const storedValue = this._instance.get_property(storageKey) + const storedDecision = storedValue === sessionId ? true : storedValue === false ? false : null + + // Make decision if session changed or no stored decision + const makeDecision = sessionIdChanged || !isBoolean(storedDecision) + const shouldSample = makeDecision ? sampleOnProperty(sessionId + groupId, sampleRate) : storedDecision! + + if (makeDecision) { + this._tryAddCustomEvent('triggerGroupSamplingDecisionMade', { + group_id: groupId, + group_name: group.name, + sampleRate: sampleRate, + isSampled: shouldSample, + }) + } + + // Store the decision + this._triggerGroupSamplingResults.set(groupId, shouldSample) + this._instance.persistence?.register({ + [storageKey]: shouldSample ? sessionId : false, + }) + } + + // After all sampling decisions, register which groups are actively recording + this.updateActiveTriggers() + } + + onFlushComplete(): void { + this._hasCompletedInitialFlush = true + } + + clearConditionalRecordingPersistence(): void { + this._instance.persistence?.unregister(SESSION_RECORDING_IS_SAMPLED) + this._instance.persistence?.unregister(SESSION_RECORDING_PAST_MINIMUM_DURATION) + + // V2: Clear per-group trigger keys + for (const matcher of this._triggerGroupMatchers) { + const groupId = matcher.group.id + this._instance.persistence?.unregister(SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX + groupId) + this._instance.persistence?.unregister(SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX + groupId) + this._instance.persistence?.unregister(SESSION_RECORDING_TRIGGER_V2_GROUP_SAMPLING_PREFIX + groupId) + } + } + + updateActiveTriggers(): void { + const recordingGroups: Array<{ id: string; name: string; matched: boolean; sampled: boolean }> = [] + + for (const matcher of this._triggerGroupMatchers) { + const group = matcher.group + const groupId = group.id + const triggerStatus = matcher.triggerStatus( + this._instance.sessionManager!.checkAndGetSessionAndWindowId().sessionId + ) + const isMatched = triggerStatus === 'trigger_activated' + const isSampled = this._triggerGroupSamplingResults.get(groupId) === true + + if (isMatched) { + recordingGroups.push({ + id: groupId, + name: group.name, + matched: true, + sampled: isSampled, + }) + } + } + + this._instance.register_for_session({ + $sdk_debug_replay_matched_recording_trigger_groups: recordingGroups, + }) + } + + hasPendingTriggers(sessionId: string): boolean { + // V2: Check if any group has pending triggers + for (const matcher of this._triggerGroupMatchers) { + if (matcher.triggerStatus(sessionId) === TRIGGER_PENDING) { + return true + } + } + return false + } + + stop(): void { + this._removeEventTriggerCaptureHook?.() + this._removeEventTriggerCaptureHook = undefined + this._triggerGroupMatchers.forEach((matcher) => matcher.stop()) + this._triggerGroupMatchers = [] + this._triggerGroupSamplingResults.clear() + this._urlTriggerMatching.stop() + } + + private _setupTriggerGroups(groups: SessionRecordingTriggerGroup[]) { + // Clean up existing matchers + this._triggerGroupMatchers.forEach((matcher) => matcher.stop()) + this._triggerGroupMatchers = [] + this._triggerGroupSamplingResults.clear() + + // Create a matcher for each group + for (const group of groups) { + const matcher = new TriggerGroupMatching(this._instance, group, (flag, variant) => { + this._reportStarted('linked_flag_matched', { + flag, + variant, + group_id: group.id, + group_name: group.name, + }) + }) + this._triggerGroupMatchers.push(matcher) + } + } + + private _getStoredSessionId(): string | null { + // Get the session ID from any of the sampling keys (they should all match) + for (const matcher of this._triggerGroupMatchers) { + const storageKey = SESSION_RECORDING_TRIGGER_V2_GROUP_SAMPLING_PREFIX + matcher.group.id + const storedValue = this._instance.get_property(storageKey) + if (typeof storedValue === 'string') { + return storedValue + } + } + return null + } +} diff --git a/packages/browser/src/extensions/replay/external/triggerMatching.ts b/packages/browser/src/extensions/replay/external/triggerMatching.ts index a4ea83a39c..20ab45775b 100644 --- a/packages/browser/src/extensions/replay/external/triggerMatching.ts +++ b/packages/browser/src/extensions/replay/external/triggerMatching.ts @@ -1,10 +1,12 @@ import { SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION, SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION, + SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX, + SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX, } from '../../../constants' import { PostHog } from '../../../posthog-core' import { FlagVariant, RemoteConfig, SessionRecordingPersistedConfig, SessionRecordingUrlTrigger } from '../../../types' -import { isNullish, isBoolean, isString, isObject } from '@posthog/core' +import { isNullish, isBoolean, isString, isObject, isUndefined } from '@posthog/core' import { window } from '../../../utils/globals' import { logger } from '../../../utils/logger' @@ -34,6 +36,15 @@ export interface RecordingTriggersStatus { get sessionId(): string } +/** + * Extended interface for V2 trigger groups + */ +export interface RecordingTriggersStatusV2 extends RecordingTriggersStatus { + get triggerGroupMatchers(): TriggerGroupMatching[] + get triggerGroupSamplingResults(): Map // group id -> sampled decision + get minimumDuration(): number | null +} + export type TriggerType = 'url' | 'event' /* triggers can have one of three statuses: @@ -68,6 +79,12 @@ export type SessionRecordingStatus = (typeof sessionRecordingStatuses)[number] // while we have both lazy and eager loaded replay we might get either type of config type ReplayConfigType = RemoteConfig | SessionRecordingPersistedConfig +// Type for trigger group matching config - subset of SessionRecordingPersistedConfig properties +type TriggerMatchingConfig = Pick< + SessionRecordingPersistedConfig, + 'urlTriggers' | 'urlBlocklist' | 'eventTriggers' | 'linkedFlag' +> + function sessionRecordingUrlTriggerMatches( url: string, triggers: SessionRecordingUrlTrigger[], @@ -144,7 +161,17 @@ export class PendingTriggerMatching implements TriggerStatusMatching { } } -const isEagerLoadedConfig = (x: ReplayConfigType): x is RemoteConfig => { +export class AlwaysActivatedTriggerMatching implements TriggerStatusMatching { + triggerStatus(): TriggerStatus { + return TRIGGER_ACTIVATED + } + + stop(): void { + // no-op + } +} + +const isEagerLoadedConfig = (x: ReplayConfigType | TriggerMatchingConfig): x is RemoteConfig => { return 'sessionRecording' in x } @@ -156,12 +183,18 @@ export class URLTriggerMatching implements TriggerStatusMatching { private _compiledBlocklistRegexes: Map = new Map() private _lastCheckedUrl: string = '' + private _groupId?: string // Optional group ID for V2 per-group persistence urlBlocked: boolean = false - constructor(private readonly _instance: PostHog) {} + constructor( + private readonly _instance: PostHog, + groupId?: string + ) { + this._groupId = groupId + } - onConfig(config: ReplayConfigType) { + onConfig(config: ReplayConfigType | TriggerMatchingConfig) { this._urlTriggers = (isEagerLoadedConfig(config) ? isObject(config.sessionRecording) @@ -219,7 +252,12 @@ export class URLTriggerMatching implements TriggerStatusMatching { return TRIGGER_DISABLED } - const currentTriggerSession = this._instance?.get_property(SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION) + // V2: Use per-group persistence key if groupId is provided + const persistenceKey = this._groupId + ? SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX + this._groupId + : SESSION_RECORDING_URL_TRIGGER_ACTIVATED_SESSION + + const currentTriggerSession = this._instance?.get_property(persistenceKey) return currentTriggerSession === sessionId ? TRIGGER_ACTIVATED : TRIGGER_PENDING } @@ -235,17 +273,20 @@ export class URLTriggerMatching implements TriggerStatusMatching { return result } - checkUrlTriggerConditions( - onPause: () => void, - onResume: () => void, - onActivate: (triggerType: TriggerType, matchDetail?: string) => void, - sessionId: string - ) { + /** + * Check URL blocklist and pause/resume recording accordingly + * This is separate from trigger checking and is used by both V1 and V2 + * + * Performance optimization: Only checks when URL changes to avoid redundant regex matching + */ + checkUrlBlocklist(onPause: () => void, onResume: () => void): void { if (typeof window === 'undefined' || !window.location.href) { return } const url = window.location.href + + // Performance optimization: Skip if URL hasn't changed since last check if (url === this._lastCheckedUrl) { return } @@ -254,6 +295,8 @@ export class URLTriggerMatching implements TriggerStatusMatching { const wasBlocked = this.urlBlocked const isNowBlocked = sessionRecordingUrlTriggerMatches(url, this._urlBlocklist, this._compiledBlocklistRegexes) + this.urlBlocked = isNowBlocked + if (wasBlocked && isNowBlocked) { return } @@ -263,7 +306,23 @@ export class URLTriggerMatching implements TriggerStatusMatching { } else if (!isNowBlocked && wasBlocked) { onResume() } + } + checkUrlTriggerConditions( + onPause: () => void, + onResume: () => void, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void, + sessionId: string + ) { + // Check blocklist (includes URL change detection) + this.checkUrlBlocklist(onPause, onResume) + + // Check URL triggers (V1 only - V2 handles per-group) + if (typeof window === 'undefined' || !window.location.href) { + return + } + + const url = window.location.href const isActivated = this._urlTriggerStatus(sessionId) === TRIGGER_ACTIVATED const urlMatches = sessionRecordingUrlTriggerMatches(url, this._urlTriggers, this._compiledTriggerRegexes) @@ -297,7 +356,10 @@ export class LinkedFlagMatching implements TriggerStatusMatching { return result } - onConfig(config: ReplayConfigType, onStarted: (flag: string, variant: string | null) => void) { + onConfig( + config: ReplayConfigType | TriggerMatchingConfig, + onStarted: (flag: string, variant: string | null) => void + ) { this.linkedFlag = (isEagerLoadedConfig(config) ? isObject(config.sessionRecording) @@ -344,10 +406,16 @@ export class LinkedFlagMatching implements TriggerStatusMatching { export class EventTriggerMatching implements TriggerStatusMatching { _eventTriggers: string[] = [] + private _groupId?: string // Optional group ID for V2 per-group persistence - constructor(private readonly _instance: PostHog) {} + constructor( + private readonly _instance: PostHog, + groupId?: string + ) { + this._groupId = groupId + } - onConfig(config: ReplayConfigType) { + onConfig(config: ReplayConfigType | TriggerMatchingConfig) { this._eventTriggers = (isEagerLoadedConfig(config) ? isObject(config.sessionRecording) @@ -368,7 +436,12 @@ export class EventTriggerMatching implements TriggerStatusMatching { return TRIGGER_DISABLED } - const currentTriggerSession = this._instance?.get_property(SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION) + // V2: Use per-group persistence key if groupId is provided + const persistenceKey = this._groupId + ? SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX + this._groupId + : SESSION_RECORDING_EVENT_TRIGGER_ACTIVATED_SESSION + + const currentTriggerSession = this._instance?.get_property(persistenceKey) return currentTriggerSession === sessionId ? TRIGGER_ACTIVATED : TRIGGER_PENDING } @@ -406,6 +479,100 @@ export class EventTriggerMatching implements TriggerStatusMatching { } } +/** + * V2 Trigger Group Matching - manages a single trigger group with its own conditions + */ +export class TriggerGroupMatching implements TriggerStatusMatching { + private _urlTriggerMatching: URLTriggerMatching + private _eventTriggerMatching: EventTriggerMatching + private _linkedFlagMatching: LinkedFlagMatching + private _combinedMatching: TriggerStatusMatching + public readonly group: import('../../../types').SessionRecordingTriggerGroup + + constructor( + private readonly _instance: PostHog, + group: import('../../../types').SessionRecordingTriggerGroup, + onFlagStarted: (flag: string, variant: string | null) => void + ) { + this.group = group + // V2: Pass groupId to child matchers for per-group persistence + this._urlTriggerMatching = new URLTriggerMatching(_instance, group.id) + this._eventTriggerMatching = new EventTriggerMatching(_instance, group.id) + this._linkedFlagMatching = new LinkedFlagMatching(_instance) + + // Check if all conditions are empty (no events, urls, or flags) + const hasEvents = group.conditions.events && group.conditions.events.length > 0 + const hasUrls = group.conditions.urls && group.conditions.urls.length > 0 + const hasFlag = !!group.conditions.flag + + if (!hasEvents && !hasUrls && !hasFlag) { + // Empty conditions = trigger immediately on session start + this._combinedMatching = new AlwaysActivatedTriggerMatching() + } else { + // Convert group config to the format expected by the individual matchers + const config: TriggerMatchingConfig = { + urlTriggers: group.conditions.urls || [], + eventTriggers: group.conditions.events || [], + linkedFlag: group.conditions.flag || null, + urlBlocklist: [], // groups don't have blocklist + } + + this._urlTriggerMatching.onConfig(config) + this._eventTriggerMatching.onConfig(config) + this._linkedFlagMatching.onConfig(config, onFlagStarted) + + // Combine matchers based on the group's matchType + const matchers = [this._eventTriggerMatching, this._urlTriggerMatching, this._linkedFlagMatching] + this._combinedMatching = + group.conditions.matchType === 'any' + ? new OrTriggerMatching(matchers) + : new AndTriggerMatching(matchers) + } + } + + triggerStatus(sessionId: string): TriggerStatus { + return this._combinedMatching.triggerStatus(sessionId) + } + + checkEventTriggerConditions( + eventName: string, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void, + sessionId: string + ) { + this._eventTriggerMatching.checkEventTriggerConditions(eventName, onActivate, sessionId) + } + + checkUrlTriggerConditions( + onPause: () => void, + onResume: () => void, + onActivate: (triggerType: TriggerType, matchDetail?: string) => void, + sessionId: string + ) { + this._urlTriggerMatching.checkUrlTriggerConditions(onPause, onResume, onActivate, sessionId) + } + + /** + * V2: Activate this group's trigger and persist to group-specific key + * This prevents cross-group contamination and survives page reloads + */ + activateTrigger(triggerType: TriggerType, sessionId: string): void { + const persistenceKey = + triggerType === 'url' + ? SESSION_RECORDING_TRIGGER_V2_GROUP_URL_PREFIX + this.group.id + : SESSION_RECORDING_TRIGGER_V2_GROUP_EVENT_PREFIX + this.group.id + + this._instance.persistence?.register({ + [persistenceKey]: sessionId, + }) + } + + stop(): void { + this._urlTriggerMatching.stop() + this._eventTriggerMatching.stop() + this._linkedFlagMatching.stop() + } +} + // we need a no-op matcher before we can lazy-load the other matches, since all matchers wait on remote config anyway export function nullMatchSessionRecordingStatus(triggersStatus: RecordingTriggersStatus): SessionRecordingStatus { if (triggersStatus.rrwebError) { @@ -513,3 +680,81 @@ export function allMatchSessionRecordingStatus(triggersStatus: RecordingTriggers return ACTIVE } + +/** + * V2 Trigger Groups Status Matcher - implements union behavior: + * 1. Evaluate ALL trigger groups + * 2. For each matching group, check if its sample rate hits + * 3. If ANY group's sample rate hits → record session + */ +export function triggerGroupsMatchSessionRecordingStatus( + triggersStatus: RecordingTriggersStatusV2 +): SessionRecordingStatus { + if (triggersStatus.rrwebError) { + return RRWEB_ERROR + } + + if (!triggersStatus.receivedFlags) { + return BUFFERING + } + + if (!triggersStatus.isRecordingEnabled) { + return DISABLED + } + + // Check if any URL is blocked (url blocklist is global, not per-group) + if (triggersStatus.urlTriggerMatching.urlBlocked) { + return PAUSED + } + + const groupMatchers = triggersStatus.triggerGroupMatchers + const samplingResults = triggersStatus.triggerGroupSamplingResults + + if (groupMatchers.length === 0) { + // No V2 groups configured - should not happen, but treat as disabled + return DISABLED + } + + // Evaluate all groups to determine overall status + let anyGroupActivated = false + let anyGroupPending = false + let anyGroupSampled = false + + for (const matcher of groupMatchers) { + const groupStatus = matcher.triggerStatus(triggersStatus.sessionId) + + if (groupStatus === TRIGGER_ACTIVATED) { + anyGroupActivated = true + // Check if this group's sample rate hit + const groupId = matcher.group.id + const samplingResult = samplingResults.get(groupId) + + if (isUndefined(samplingResult)) { + logger.warn('[V2 Triggers] Group activated but no sampling decision found', { groupId }) + } else if (samplingResult === true) { + anyGroupSampled = true + } + } else if (groupStatus === TRIGGER_PENDING) { + anyGroupPending = true + } + } + + // Union behavior: if ANY group hit its sample rate, record + if (anyGroupSampled) { + return SAMPLED + } + + // If any group is activated (conditions met) but sample didn't hit, still record + // This ensures trigger activation always records, just without the "sampled" flag + if (anyGroupActivated) { + return ACTIVE + } + + // If any group is pending, keep buffering + if (anyGroupPending) { + return BUFFERING + } + + // All groups are either disabled or conditions not met + return DISABLED +} diff --git a/packages/browser/src/extensions/replay/session-recording.ts b/packages/browser/src/extensions/replay/session-recording.ts index 213e1b66fe..b0fbb5cc60 100644 --- a/packages/browser/src/extensions/replay/session-recording.ts +++ b/packages/browser/src/extensions/replay/session-recording.ts @@ -217,6 +217,9 @@ export class SessionRecording implements Extension { triggerMatchType: sessionRecordingConfigResponse?.triggerMatchType, masking: sessionRecordingConfigResponse?.masking, urlTriggers: sessionRecordingConfigResponse?.urlTriggers, + // V2 fields - will be undefined for V1 configs + version: sessionRecordingConfigResponse?.version, + triggerGroups: sessionRecordingConfigResponse?.triggerGroups, } satisfies SessionRecordingPersistedConfig, }) } diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index cc50e24774..f3668ba91b 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -246,6 +246,16 @@ export type SessionRecordingRemoteConfig = SessionRecordingCanvasOptions & { * which nobody wanted, now the default is all */ triggerMatchType?: 'any' | 'all' + /** + * Config version - defaults to 1 (legacy) + * When version is 2, triggerGroups is used instead of individual trigger fields + */ + version?: 1 | 2 + /** + * V2 Trigger Groups - multiple named trigger groups with their own conditions and sample rates + * Only used when version === 2 + */ + triggerGroups?: SessionRecordingTriggerGroup[] } /** @@ -488,6 +498,22 @@ export interface SessionRecordingUrlTrigger { matching: 'regex' } +/** + * V2 Trigger Group - represents a single trigger group with its own conditions and sample rate + */ +export interface SessionRecordingTriggerGroup { + id: string + name: string + sampleRate: number + minDurationMs?: number + conditions: { + matchType: 'any' | 'all' + events?: string[] + urls?: SessionRecordingUrlTrigger[] + flag?: string | FlagVariant + } +} + export type PropertyMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains' export interface ErrorTrackingSuppressionRule { diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index 69e513d0ab..992ba41980 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -9,7 +9,6 @@ "_activeTour", "_addActionHook", "_addDomEventHandlers", - "_addEventTriggerListener", "_addGreetingMessage", "_addSurveyToFocus", "_addToBuffer", @@ -78,6 +77,7 @@ "_clearSurveyTimeout", "_clearTimer", "_clicks", + "_combinedMatching", "_compileRegexCache", "_compiledBlocklistRegexes", "_compiledTriggerRegexes", @@ -168,6 +168,7 @@ "_getDnt", "_getElementsList", "_getInitialUserTraits", + "_getIsSampled", "_getItems", "_getLogger", "_getMatchingItems", @@ -180,6 +181,7 @@ "_getSessionState", "_getShownEventName", "_getStored", + "_getStoredSessionId", "_getSurveyById", "_getSurveysInFlightPromise", "_getTitle", @@ -187,6 +189,7 @@ "_getTracer", "_getValidEvaluationEnvironments", "_getWindowId", + "_groupId", "_handleBackToTickets", "_handleBannerActionClick", "_handleButtonClick", @@ -215,6 +218,7 @@ "_handleVisibilityChange", "_handleWidget", "_hasActionOrEventTriggeredSurvey", + "_hasCompletedInitialFlush", "_hasLoadedFlags", "_hasLoggedDeprecationWarning", "_hasMultipleTickets", @@ -298,7 +302,6 @@ "_loggedTracker", "_logger", "_makeReadonly", - "_makeSamplingDecision", "_manageTriggerSelectorListener", "_manageWidgetSelectorListener", "_markMessagesAsRead", @@ -491,6 +494,7 @@ "_setupPopstateListener", "_setupSessionRotationHandler", "_setupSiteApps", + "_setupTriggerGroups", "_severityNumber", "_severityText", "_sharedState", @@ -516,7 +520,6 @@ "_startScrollObserver", "_startSelectionChangedObserver", "_start_queue_if_opted_in", - "_statusMatcher", "_stopBuffering", "_stopCapturing", "_stopPolling", @@ -524,6 +527,7 @@ "_storage", "_storageKey", "_storedConsent", + "_strategy", "_suppressionRules", "_surveyCallbacks", "_surveyInFocus", @@ -537,6 +541,8 @@ "_timer", "_timestamp", "_transport", + "_triggerGroupMatchers", + "_triggerGroupSamplingResults", "_triggerMatching", "_triggerSelectorListeners", "_triggered_notifs",