diff --git a/packages/toolbar/package.json b/packages/toolbar/package.json index cd179cdf..9e7d1e6b 100644 --- a/packages/toolbar/package.json +++ b/packages/toolbar/package.json @@ -67,6 +67,7 @@ "@codemirror/view": "^6.38.8", "@launchpad-ui/components": "^0.17.7", "@launchpad-ui/tokens": "^0.15.1", + "@launchdarkly/session-replay": "^0.4.9", "@lezer/highlight": "^1.2.3", "@react-aria/focus": "^3.21.2", "@react-stately/flags": "^3.1.2", diff --git a/packages/toolbar/src/core/tests/InternalClientProvider.test.tsx b/packages/toolbar/src/core/tests/InternalClientProvider.test.tsx index 67cf649e..ab1f5b1e 100644 --- a/packages/toolbar/src/core/tests/InternalClientProvider.test.tsx +++ b/packages/toolbar/src/core/tests/InternalClientProvider.test.tsx @@ -14,11 +14,35 @@ const mockLDClient = { const mockInitialize = vi.fn(); +// Mock Session Replay +const mockLDRecord = { + start: vi.fn(), + stop: vi.fn(), +}; + +// Create a proper mock class for SessionReplay +class MockSessionReplay { + options: { manualStart: boolean; privacySetting: string }; + + constructor(options?: { manualStart?: boolean; privacySetting?: string }) { + this.options = { + manualStart: options?.manualStart ?? true, + privacySetting: options?.privacySetting ?? 'default', + }; + } +} + // Mock the SDK module vi.mock('launchdarkly-js-client-sdk', () => ({ initialize: mockInitialize, })); +// Mock the Session Replay module +vi.mock('@launchdarkly/session-replay', () => ({ + default: MockSessionReplay, + LDRecord: mockLDRecord, +})); + import { InternalClientProvider, useInternalClient, @@ -61,7 +85,8 @@ describe('InternalClientProvider', () => { // Reset all mock functions to default behavior mockLDClient.waitForInitialization.mockResolvedValue(undefined); mockLDClient.identify.mockResolvedValue(undefined); - mockLDClient.variation.mockReturnValue(true); + // Return false for session replay flag by default to prevent it from starting + mockLDClient.variation.mockReturnValue(false); mockLDClient.close.mockImplementation(() => {}); mockLDClient.on.mockImplementation(() => {}); mockLDClient.off.mockImplementation(() => {}); @@ -69,6 +94,10 @@ describe('InternalClientProvider', () => { mockInitialize.mockReturnValue(mockLDClient); + // Clear Session Replay mocks + mockLDRecord.start.mockClear(); + mockLDRecord.stop.mockClear(); + // Clear the internal client singleton setToolbarFlagClient(null); @@ -130,7 +159,9 @@ describe('InternalClientProvider', () => { key: 'toolbar-anonymous', anonymous: true, }, - undefined, + expect.objectContaining({ + observabilityPlugins: expect.any(Array), + }), ); }); }); @@ -149,7 +180,13 @@ describe('InternalClientProvider', () => { ); await waitFor(() => { - expect(mockInitialize).toHaveBeenCalledWith('test-client-id-123', customContext, undefined); + expect(mockInitialize).toHaveBeenCalledWith( + 'test-client-id-123', + customContext, + expect.objectContaining({ + observabilityPlugins: expect.any(Array), + }), + ); }); }); @@ -203,13 +240,18 @@ describe('InternalClientProvider', () => { ); await waitFor(() => { - expect(mockInitialize).toHaveBeenCalledWith('test-client-id-123', expect.any(Object), { - baseUrl: 'https://app.ld.catamorphic.com', - }); + expect(mockInitialize).toHaveBeenCalledWith( + 'test-client-id-123', + expect.any(Object), + expect.objectContaining({ + baseUrl: 'https://app.ld.catamorphic.com', + observabilityPlugins: expect.any(Array), + }), + ); }); }); - test('does not provide options when baseUrl is not specified', async () => { + test('includes observabilityPlugins even when baseUrl is not specified', async () => { render( @@ -217,7 +259,13 @@ describe('InternalClientProvider', () => { ); await waitFor(() => { - expect(mockInitialize).toHaveBeenCalledWith('test-client-id-123', expect.any(Object), undefined); + expect(mockInitialize).toHaveBeenCalledWith( + 'test-client-id-123', + expect.any(Object), + expect.objectContaining({ + observabilityPlugins: expect.any(Array), + }), + ); }); }); }); diff --git a/packages/toolbar/src/core/ui/Toolbar/context/InternalClientProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/InternalClientProvider.tsx index 8b61c5cc..f7b9dfd2 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/InternalClientProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/InternalClientProvider.tsx @@ -1,6 +1,7 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; import type { LDClient, LDContext } from 'launchdarkly-js-client-sdk'; import { setToolbarFlagClient } from '../../../../flags/createToolbarFlagFunction'; +import { enableSessionReplay, ENABLE_SESSION_REPLAY_FLAG_KEY } from '../../../../flags/toolbarFlags'; export interface AuthState { authenticated: boolean; @@ -97,7 +98,10 @@ export function InternalClientProvider({ setLoading(true); setError(null); - const { initialize } = await import('launchdarkly-js-client-sdk'); + const [{ initialize }, SessionReplay] = await Promise.all([ + import('launchdarkly-js-client-sdk'), + import('@launchdarkly/session-replay').then((m) => m.default), + ]); const context = initialContext || { kind: 'user', @@ -105,15 +109,17 @@ export function InternalClientProvider({ anonymous: true, }; - // Configure SDK options for custom URLs if provided - const hasCustomUrls = baseUrl || streamUrl || eventsUrl; - const options = hasCustomUrls - ? { - ...(baseUrl && { baseUrl }), - ...(streamUrl && { streamUrl }), - ...(eventsUrl && { eventsUrl }), - } - : undefined; + const sessionReplayPlugin = new SessionReplay({ + manualStart: true, + privacySetting: 'default', + }); + + const options = { + ...(baseUrl && { baseUrl }), + ...(streamUrl && { streamUrl }), + ...(eventsUrl && { eventsUrl }), + observabilityPlugins: [sessionReplayPlugin], + }; const ldClient = initialize(clientSideId, context, options); clientToCleanup = ldClient; @@ -149,6 +155,45 @@ export function InternalClientProvider({ }; }, [clientSideId, initialContext, baseUrl, streamUrl, eventsUrl]); // Re-initialize if any config changes + // Monitor Session Replay flag and start/stop recording accordingly + useEffect(() => { + if (!client) { + return; + } + + const checkAndUpdateSessionReplay = async () => { + try { + const shouldEnableReplay = enableSessionReplay(); + + if (shouldEnableReplay) { + const { LDRecord } = await import('@launchdarkly/session-replay'); + LDRecord.start({ forceNew: false, silent: false }); + console.log('[InternalClientProvider] Session Replay started'); + } else { + const { LDRecord } = await import('@launchdarkly/session-replay'); + LDRecord.stop(); + console.log('[InternalClientProvider] Session Replay stopped'); + } + } catch (err) { + console.error('[InternalClientProvider] Failed to control Session Replay:', err); + } + }; + + // Check initial state + checkAndUpdateSessionReplay(); + + // Listen for flag changes + const handleFlagChange = () => { + checkAndUpdateSessionReplay(); + }; + + client.on(`change:${ENABLE_SESSION_REPLAY_FLAG_KEY}`, handleFlagChange); + + return () => { + client.off(`change:${ENABLE_SESSION_REPLAY_FLAG_KEY}`, handleFlagChange); + }; + }, [client]); + const value: InternalClientContextValue = { client, loading, diff --git a/packages/toolbar/src/flags/toolbarFlags.ts b/packages/toolbar/src/flags/toolbarFlags.ts index 22c07dd7..705f2244 100644 --- a/packages/toolbar/src/flags/toolbarFlags.ts +++ b/packages/toolbar/src/flags/toolbarFlags.ts @@ -10,5 +10,13 @@ import { createToolbarFlagFunction } from './createToolbarFlagFunction'; * export const myFeature = createToolbarFlagFunction('my-feature-key', defaultValue); */ -// Placeholder - remove once real toolbar flags are added -export const __placeholder = createToolbarFlagFunction('__placeholder', false); +/** + * Flag key for Session Replay feature. + */ +export const ENABLE_SESSION_REPLAY_FLAG_KEY = 'toolbar-enable-session-replay'; + +/** + * Controls whether Session Replay is enabled for the toolbar. + * When enabled, Session Replay will record and send session data to LaunchDarkly. + */ +export const enableSessionReplay = createToolbarFlagFunction(ENABLE_SESSION_REPLAY_FLAG_KEY, false); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 173c801c..7259a0c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: '@codemirror/view': specifier: ^6.38.8 version: 6.38.8 + '@launchdarkly/session-replay': + specifier: ^0.4.9 + version: 0.4.9 '@launchpad-ui/components': specifier: ^0.17.7 version: 0.17.7(@react-aria/focus@3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-aria/interactions@3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-aria/utils@3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-stately/utils@3.10.8(react@19.2.0))(@react-types/shared@3.32.1(react@19.2.0))(react-aria-components@1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-aria@3.44.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-hook-form@7.65.0(react@19.2.0))(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-stately@3.42.0(react@19.2.0))(react@19.2.0) @@ -994,6 +997,18 @@ packages: peerDependencies: tslib: '2' + '@launchdarkly/js-client-sdk-common@1.13.0': + resolution: {integrity: sha512-3u3nWLKfu7ADbrBlbYq1rDROXG1WcRGRGhHFyAjtDPCCh783sPIb1frC1n4tj6b64bustVrZvLR9dGrQlp6tJQ==} + + '@launchdarkly/js-client-sdk@0.6.0': + resolution: {integrity: sha512-9aBgpGKXxaiPudNuQ9MoS8wFSIR1h0P2EFDP2veNJG7tBuxzDKfH3hzjW01C4/WyWN6bk7P7XKwisxw775klBQ==} + + '@launchdarkly/js-sdk-common@2.17.0': + resolution: {integrity: sha512-HYQNc4xbE58hBOO1yZsqE+BHOC36AYrHPZ5hwvlHoZRSsaoYCPM+Q/n+O+N3JhWXTqGsu6SwUzqUzTyxg8prGg==} + + '@launchdarkly/session-replay@0.4.9': + resolution: {integrity: sha512-Cur9amHWpblQb9weCyjX/Z0uU+AkxpuRsXUC3J3IzYS3VeJf2WPWw7d07X8pFoun5AGHzNEZtQQJP84B9RLyCA==} + '@launchpad-ui/components@0.17.7': resolution: {integrity: sha512-p5GWGeGtZzYdLqBosCijS6yRrwbHziQF9ZGoYLb0sHx9lbUyvf/5e8eDHkx+CiIGfL8ZhBtcmRAk5oJUJ4m63w==} peerDependencies: @@ -3228,6 +3243,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.run@9.23.0: + resolution: {integrity: sha512-Wc6gA27IvYPFTyXIZ3dy2J073fGiXpWKypMUNBbPFbJ8gnT1h4lYZQRRCE6O6rSzXiG2VNsDv6lUYxULjbWNUw==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -5416,6 +5434,20 @@ snapshots: '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) tslib: 2.8.1 + '@launchdarkly/js-client-sdk-common@1.13.0': + dependencies: + '@launchdarkly/js-sdk-common': 2.17.0 + + '@launchdarkly/js-client-sdk@0.6.0': + dependencies: + '@launchdarkly/js-client-sdk-common': 1.13.0 + + '@launchdarkly/js-sdk-common@2.17.0': {} + + '@launchdarkly/session-replay@0.4.9': + dependencies: + highlight.run: 9.23.0 + '@launchpad-ui/components@0.17.7(@react-aria/focus@3.21.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-aria/interactions@3.25.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-aria/utils@3.31.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@react-stately/utils@3.10.8(react@19.2.0))(@react-types/shared@3.32.1(react@19.2.0))(react-aria-components@1.13.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-aria@3.44.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react-hook-form@7.65.0(react@19.2.0))(react-router@7.9.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-stately@3.42.0(react@19.2.0))(react@19.2.0)': dependencies: '@internationalized/date': 3.10.0 @@ -8314,6 +8346,11 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.run@9.23.0: + dependencies: + '@launchdarkly/js-client-sdk': 0.6.0 + imurmurhash: 0.1.4 + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1