Skip to content

Commit

Permalink
#9093 force session replay recording in sidebar welcome page (#9104)
Browse files Browse the repository at this point in the history
* force session replay recording in sidebar welcome page

* reset listener mock history between tests

---------

Co-authored-by: Graham Langford <[email protected]>
  • Loading branch information
fungairino and grahamlangford authored Sep 6, 2024
1 parent a0e8129 commit 65acc8e
Show file tree
Hide file tree
Showing 6 changed files with 178 additions and 61 deletions.
43 changes: 41 additions & 2 deletions src/sidebar/ConnectedSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -111,21 +119,52 @@ describe("SidebarApp", () => {

it("registers the navigation listener", async () => {
await mockAuthenticatedMeApiResponse();
const { unmount } = render(
const { unmount, getReduxStore } = render(
<MemoryRouter>
<ConnectedSidebar />
</MemoryRouter>,
{
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
Expand Down
9 changes: 8 additions & 1 deletion src/sidebar/ConnectedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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" &&
Expand All @@ -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,
);
}
};

Expand Down
26 changes: 13 additions & 13 deletions src/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
Expand All @@ -75,9 +77,7 @@ async function init(): Promise<void> {
void initRuntimeLogging();

try {
await initPerformanceMonitoring({
sessionReplaySampleRate: await getSessionReplaySampleRateOverride(),
});
await initPerformanceMonitoring(await getInitPerformanceValues());
} catch (error) {
console.error("Failed to initialize performance monitoring", error);
}
Expand Down
18 changes: 18 additions & 0 deletions src/sidebar/telemetryConstants.ts
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

export const CONNECTED_TAB_URL_PERFORMANCE_KEY = "connectedTabUrl";
115 changes: 76 additions & 39 deletions src/telemetry/performance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
28 changes: 22 additions & 6 deletions src/telemetry/performance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
additionalGlobalContext = {},
}: {
sessionReplaySampleRate?: number;
additionalGlobalContext?: UnknownObject;
} = {}): Promise<void> {
const environment = process.env.ENVIRONMENT;
const applicationId = process.env.DATADOG_APPLICATION_ID;
const clientToken = process.env.DATADOG_CLIENT_TOKEN;
Expand Down Expand Up @@ -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);
}
Expand Down

0 comments on commit 65acc8e

Please sign in to comment.