From 65acc8ec8365633eb5a5d730c3129c4e0bd77b46 Mon Sep 17 00:00:00 2001 From: Eduardo Fungairino Date: Fri, 6 Sep 2024 13:23:00 -0400 Subject: [PATCH] #9093 force session replay recording in sidebar welcome page (#9104) * force session replay recording in sidebar welcome page * reset listener mock history between tests --------- Co-authored-by: Graham Langford --- src/sidebar/ConnectedSidebar.test.tsx | 43 +++++++++- src/sidebar/ConnectedSidebar.tsx | 9 +- src/sidebar/sidebar.tsx | 26 +++--- src/sidebar/telemetryConstants.ts | 18 ++++ src/telemetry/performance.test.ts | 115 +++++++++++++++++--------- src/telemetry/performance.ts | 28 +++++-- 6 files changed, 178 insertions(+), 61 deletions(-) create mode 100644 src/sidebar/telemetryConstants.ts diff --git a/src/sidebar/ConnectedSidebar.test.tsx b/src/sidebar/ConnectedSidebar.test.tsx index 65a69fc2aa..b1fba9e901 100644 --- a/src/sidebar/ConnectedSidebar.test.tsx +++ b/src/sidebar/ConnectedSidebar.test.tsx @@ -33,8 +33,15 @@ import { import { appApiMock } from "@/testUtils/appApiMock"; import { valueToAsyncState } from "@/utils/asyncStateUtils"; import { API_PATHS } from "@/data/service/urlPaths"; +import { getConnectedTabIdForSidebarTopFrame } from "@/sidebar/connectedTarget"; +import sidebarSlice from "@/store/sidebar/sidebarSlice"; +import { datadogRum } from "@datadog/browser-rum"; jest.mock("@/auth/useLinkState"); +jest.mock("@datadog/browser-rum", () => ({ + datadogRum: { addAction: jest.fn(), setGlobalContextProperty: jest.fn() }, +})); +jest.mock("@/sidebar/connectedTarget"); // Needed until https://github.com/RickyMarou/jest-webextension-mock/issues/5 is implemented browser.webNavigation.onBeforeNavigate = { @@ -60,6 +67,7 @@ describe("SidebarApp", () => { appApiMock.onGet(API_PATHS.MOD_COMPONENTS_ALL).reply(200, []); useLinkStateMock.mockReturnValue(valueToAsyncState(true)); + jest.mocked(browser.webNavigation.onBeforeNavigate.addListener).mockReset(); }); test("renders not connected", async () => { @@ -111,21 +119,52 @@ describe("SidebarApp", () => { it("registers the navigation listener", async () => { await mockAuthenticatedMeApiResponse(); - const { unmount } = render( + const { unmount, getReduxStore } = render( , { - setupRedux(dispatch) { + setupRedux(dispatch, { store }) { dispatch(authActions.setAuth(authStateFactory())); + jest.spyOn(store, "dispatch"); }, }, ); + jest.mocked(getConnectedTabIdForSidebarTopFrame).mockReturnValue(4); expect( browser.webNavigation.onBeforeNavigate.addListener, ).toHaveBeenCalledWith(expect.any(Function)); + const listener = jest.mocked( + browser.webNavigation.onBeforeNavigate.addListener, + ).mock.calls[0]![0]; + + const mockDetails = { + frameId: 0, + tabId: 4, + documentLifecycle: "active", + url: "http://example.com", + parentFrameId: 0, + timeStamp: 0, + }; + + listener(mockDetails); + + const { dispatch } = getReduxStore(); + + expect(dispatch).toHaveBeenCalledWith( + sidebarSlice.actions.invalidatePanels(), + ); + expect(datadogRum.addAction).toHaveBeenCalledWith( + "connectedTabNavigation", + { url: "http://example.com" }, + ); + expect(datadogRum.setGlobalContextProperty).toHaveBeenCalledWith( + "connectedTabUrl", + "http://example.com", + ); + unmount(); // Removed on unmount diff --git a/src/sidebar/ConnectedSidebar.tsx b/src/sidebar/ConnectedSidebar.tsx index 19615e8ca7..b3ff9b83d6 100644 --- a/src/sidebar/ConnectedSidebar.tsx +++ b/src/sidebar/ConnectedSidebar.tsx @@ -58,6 +58,8 @@ import { validateModComponentRef, } from "@/types/modComponentTypes"; import { assertNotNullish } from "@/utils/nullishUtils"; +import { CONNECTED_TAB_URL_PERFORMANCE_KEY } from "@/sidebar/telemetryConstants"; +import { datadogRum } from "@datadog/browser-rum"; /** * Listeners to update the Sidebar's Redux state upon receiving messages from the contentScript. @@ -124,7 +126,7 @@ const ConnectedSidebar: React.VFC = () => { const navigationListener = ( details: chrome.webNavigation.WebNavigationFramedCallbackDetails, ) => { - const { frameId, tabId, documentLifecycle } = details; + const { frameId, tabId, documentLifecycle, url } = details; const connectedTabId = getConnectedTabIdForSidebarTopFrame(); if ( documentLifecycle === "active" && @@ -133,6 +135,11 @@ const ConnectedSidebar: React.VFC = () => { ) { console.log("navigationListener:connectedTabId", connectedTabId); dispatch(sidebarSlice.actions.invalidatePanels()); + datadogRum.addAction("connectedTabNavigation", { url }); + datadogRum.setGlobalContextProperty( + CONNECTED_TAB_URL_PERFORMANCE_KEY, + url, + ); } }; diff --git a/src/sidebar/sidebar.tsx b/src/sidebar/sidebar.tsx index e1f0108462..439887172d 100644 --- a/src/sidebar/sidebar.tsx +++ b/src/sidebar/sidebar.tsx @@ -49,24 +49,26 @@ import { FeatureFlags } from "@/auth/featureFlags"; // eslint-disable-next-line no-restricted-imports -- tsx file for the ReactDOM.render import { flagOn } from "@/auth/featureFlagStorage"; import { isPixieBrixDomain } from "@/utils/urlUtils"; +import { CONNECTED_TAB_URL_PERFORMANCE_KEY } from "@/sidebar/telemetryConstants"; /** - * Return session replay sample rate for the sidebar session. + * Return performance init arguments, including session replay sample rate for the sidebar session. */ -async function getSessionReplaySampleRateOverride(): Promise< - number | undefined -> { +async function getInitPerformanceValues() { const [forceRecord, url] = await Promise.all([ flagOn(FeatureFlags.ONBOARDING_SIDEBAR_FORCE_SESSION_REPLAY), getConnectedTargetUrl(), ]); - // For now, just check for pixiebrix domain. Activate anywhere with nextUrl might put the user back - // onto a non-welcome page landing page. - if (forceRecord && isPixieBrixDomain(url)) { - console.debug("Forcing session replay recording"); - return 100; - } + return { + // Force replay recording on pixiebrix domains. Activate anywhere with nextUrl might put the user back + // onto a non-welcome page landing page. + sessionReplaySampleRate: + forceRecord && isPixieBrixDomain(url) ? 100 : undefined, + additionalGlobalContext: { + [CONNECTED_TAB_URL_PERFORMANCE_KEY]: url, + }, + }; } async function init(): Promise { @@ -75,9 +77,7 @@ async function init(): Promise { void initRuntimeLogging(); try { - await initPerformanceMonitoring({ - sessionReplaySampleRate: await getSessionReplaySampleRateOverride(), - }); + await initPerformanceMonitoring(await getInitPerformanceValues()); } catch (error) { console.error("Failed to initialize performance monitoring", error); } diff --git a/src/sidebar/telemetryConstants.ts b/src/sidebar/telemetryConstants.ts new file mode 100644 index 0000000000..bb7f60a24f --- /dev/null +++ b/src/sidebar/telemetryConstants.ts @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export const CONNECTED_TAB_URL_PERFORMANCE_KEY = "connectedTabUrl"; diff --git a/src/telemetry/performance.test.ts b/src/telemetry/performance.test.ts index adbb2b2d55..d23ac8563e 100644 --- a/src/telemetry/performance.test.ts +++ b/src/telemetry/performance.test.ts @@ -45,48 +45,85 @@ describe("initPerformanceMonitoring", () => { expect(datadogRum.init).not.toHaveBeenCalled(); }); - it("should initialize performance monitoring", async () => { - jest.mocked(getDNT).mockResolvedValue(false); - jest.mocked(flagOn).mockResolvedValue(true); - process.env.DATADOG_APPLICATION_ID = "applicationId"; - process.env.DATADOG_CLIENT_TOKEN = "clientToken"; - process.env.ENVIRONMENT = "local"; - jest.mocked(getBaseURL).mockResolvedValue("https://example.com"); - browser.runtime.getManifest = jest - .fn() - .mockReturnValue({ version_name: "1.8.8-alpha+293128" }); - jest.mocked(readAuthData).mockResolvedValue({ - user: "634b1b49-4382-4292-87f4-d25c6a1db3d7", - organizationId: "24a7c934-a531-49d8-a15d-cdf0baa54146", - } as any); + describe("given the required initialization conditions", () => { + beforeEach(() => { + jest.mocked(getDNT).mockResolvedValue(false); + jest.mocked(flagOn).mockResolvedValue(true); + process.env.DATADOG_APPLICATION_ID = "applicationId"; + process.env.DATADOG_CLIENT_TOKEN = "clientToken"; + process.env.ENVIRONMENT = "local"; + jest.mocked(getBaseURL).mockResolvedValue("https://example.com"); + browser.runtime.getManifest = jest + .fn() + .mockReturnValue({ version_name: "1.8.8-alpha+293128" }); + jest.mocked(readAuthData).mockResolvedValue({ + user: "634b1b49-4382-4292-87f4-d25c6a1db3d7", + organizationId: "24a7c934-a531-49d8-a15d-cdf0baa54146", + } as any); + }); - await initPerformanceMonitoring(); + it("should initialize performance monitoring", async () => { + await initPerformanceMonitoring({ + additionalGlobalContext: { connectedTabUrl: "google.com" }, + }); - expect(datadogRum.init).toHaveBeenCalledWith({ - applicationId: "applicationId", - clientToken: "clientToken", - site: "datadoghq.com", - service: "pixiebrix-browser-extension", - env: "local", - version: expect.any(String), - sessionSampleRate: 100, - sessionReplaySampleRate: 20, - trackUserInteractions: true, - trackResources: true, - trackLongTasks: true, - defaultPrivacyLevel: "mask", - allowedTracingUrls: ["https://example.com"], - allowFallbackToLocalStorage: true, + expect(datadogRum.init).toHaveBeenCalledWith({ + applicationId: "applicationId", + clientToken: "clientToken", + site: "datadoghq.com", + service: "pixiebrix-browser-extension", + env: "local", + version: expect.any(String), + sessionSampleRate: 100, + sessionReplaySampleRate: 20, + trackUserInteractions: true, + trackResources: true, + trackLongTasks: true, + defaultPrivacyLevel: "mask", + allowedTracingUrls: ["https://example.com"], + allowFallbackToLocalStorage: true, + }); + expect(datadogRum.setGlobalContext).toHaveBeenCalledWith({ + code_version: process.env.SOURCE_VERSION, + connectedTabUrl: "google.com", + }); + expect(datadogRum.setUser).toHaveBeenCalledWith({ + email: undefined, + id: "634b1b49-4382-4292-87f4-d25c6a1db3d7", + organizationId: "24a7c934-a531-49d8-a15d-cdf0baa54146", + }); }); - expect(datadogRum.setGlobalContextProperty).toHaveBeenCalledWith( - "code_version", - process.env.SOURCE_VERSION, - ); - expect(datadogRum.setUser).toHaveBeenCalledWith({ - email: undefined, - id: "634b1b49-4382-4292-87f4-d25c6a1db3d7", - organizationId: "24a7c934-a531-49d8-a15d-cdf0baa54146", + + it("should force session replay recording when sessionSampleRate is set to 100", async () => { + await initPerformanceMonitoring({ sessionReplaySampleRate: 100 }); + + expect(datadogRum.init).toHaveBeenCalledWith({ + applicationId: "applicationId", + clientToken: "clientToken", + site: "datadoghq.com", + service: "pixiebrix-browser-extension", + env: "local", + version: expect.any(String), + sessionSampleRate: 100, + sessionReplaySampleRate: 100, + trackUserInteractions: true, + trackResources: true, + trackLongTasks: true, + defaultPrivacyLevel: "mask", + allowedTracingUrls: ["https://example.com"], + allowFallbackToLocalStorage: true, + }); + expect(datadogRum.setGlobalContext).toHaveBeenCalledWith({ + code_version: process.env.SOURCE_VERSION, + }); + expect(datadogRum.setUser).toHaveBeenCalledWith({ + email: undefined, + id: "634b1b49-4382-4292-87f4-d25c6a1db3d7", + organizationId: "24a7c934-a531-49d8-a15d-cdf0baa54146", + }); + expect(datadogRum.startSessionReplayRecording).toHaveBeenCalledWith({ + force: true, + }); }); - expect(datadogRum.startSessionReplayRecording).toHaveBeenCalled(); }); }); diff --git a/src/telemetry/performance.ts b/src/telemetry/performance.ts index dbf57fd26c..e01de31df4 100644 --- a/src/telemetry/performance.ts +++ b/src/telemetry/performance.ts @@ -33,10 +33,15 @@ import { FeatureFlags } from "@/auth/featureFlags"; * any user interactions or network requests are made. * * @param sessionReplaySampleRate The percentage of sessions to record for session replay. Default is 20%. + * @param additionalGlobalContext Additional global context to include with all RUM events. */ export async function initPerformanceMonitoring({ sessionReplaySampleRate = 20, -}: { sessionReplaySampleRate?: number } = {}): Promise { + additionalGlobalContext = {}, +}: { + sessionReplaySampleRate?: number; + additionalGlobalContext?: UnknownObject; +} = {}): Promise { const environment = process.env.ENVIRONMENT; const applicationId = process.env.DATADOG_APPLICATION_ID; const clientToken = process.env.DATADOG_CLIENT_TOKEN; @@ -99,15 +104,26 @@ export async function initPerformanceMonitoring({ allowFallbackToLocalStorage: true, }); - datadogRum.setGlobalContextProperty( - "code_version", - process.env.SOURCE_VERSION, - ); + datadogRum.setGlobalContext({ + code_version: process.env.SOURCE_VERSION, + ...additionalGlobalContext, + }); // https://docs.datadoghq.com/real_user_monitoring/browser/modifying_data_and_context/?tab=npm#user-session datadogRum.setUser(await mapAppUserToTelemetryUser(await readAuthData())); - datadogRum.startSessionReplayRecording(); + // NOTE: Datadog does not document this well, but when a rum session was already started for the + // user (say from the extension console page), initializing datadogRum again will not reuse the + // same session replay setting. Thus, if the initial session did not start a replay recording, any subsequent + // datadogRum.init calls will not start the session replay, even if the sessionReplaySampleRate is 100. + // Thus as a work-around, we call startSessionReplayRecording here with force: true to ensure that the session replay is started. + // See: https://github.com/DataDog/browser-sdk/issues/1967 + if (sessionReplaySampleRate === 100) { + console.debug("Forcing session replay recording"); + datadogRum.startSessionReplayRecording({ + force: true, + }); + } addAuthListener(updatePerson); }