diff --git a/frontend/app/components/Client/ProfileSettings/ProfileSettings.tsx b/frontend/app/components/Client/ProfileSettings/ProfileSettings.tsx index ab7c31460..96d98d081 100644 --- a/frontend/app/components/Client/ProfileSettings/ProfileSettings.tsx +++ b/frontend/app/components/Client/ProfileSettings/ProfileSettings.tsx @@ -3,6 +3,7 @@ import withPageTitle from 'HOCs/withPageTitle'; import { PageTitle } from 'UI'; import { observer } from 'mobx-react-lite'; import { useStore } from 'App/mstore'; +import LanguageSwitcher from "App/components/LanguageSwitcher"; import Settings from './Settings'; import ChangePassword from './ChangePassword'; import styles from './profileSettings.module.css'; @@ -20,107 +21,90 @@ function ProfileSettings() { return (
{t('Account')}
} /> -
-
-

{t('Profile')}

-
- {t( - 'Your email address is your identity on OpenReplay and is used to login.', - )} -
-
-
- -
-
+
} + />
{account.hasPassword && ( <> -
-
-

{t('Change Password')}

-
- {t('Updating your password from time to time enhances your account’s security.')} -
-
-
- -
-
+
} + />
)} -
-
-

{t('Organization API Key')}

-
- {t('Your API key gives you access to an extra set of services.')} -
-
-
- -
-
+
} + /> + +
} + /> {isEnterprise && (account.admin || account.superAdmin) && ( <>
-
-
-

{t('Tenant Key')}

-
- {t('For SSO (SAML) authentication.')} -
-
-
- -
-
+
} + /> )} {!isEnterprise && ( <>
-
-
-

{t('Data Collection')}

-
- {t('Enables you to control how OpenReplay captures data on your organization’s usage to improve our product.')} -
-
-
- -
-
+
} + /> )} {account.license && ( <>
- -
-
-

{t('License')}

-
- {t('License key and expiration date.')} -
-
-
- -
-
+
} /> )}
); } +function Section({ title, description, children }: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+
+ {description} +
+
+
+ {children} +
+
+ ) +} + export default withPageTitle('Account - OpenReplay Preferences')( observer(ProfileSettings), ); diff --git a/frontend/app/components/LanguageSwitcher/LanguageSwitcher.tsx b/frontend/app/components/LanguageSwitcher/LanguageSwitcher.tsx index 225d2ba3b..e408d6980 100644 --- a/frontend/app/components/LanguageSwitcher/LanguageSwitcher.tsx +++ b/frontend/app/components/LanguageSwitcher/LanguageSwitcher.tsx @@ -1,9 +1,7 @@ -import { Button, Dropdown, MenuProps, Space, Typography } from 'antd'; -import React, { useCallback, useState } from 'react'; +import { Button, Dropdown, MenuProps, Typography } from 'antd'; +import React from 'react'; import { useTranslation } from 'react-i18next'; -import { CaretDownOutlined } from '@ant-design/icons'; -import { Languages } from 'lucide-react'; -import { Icon } from '../ui'; +import { ChevronDown } from 'lucide-react'; const langs = [ { code: 'en', label: 'English' }, @@ -12,14 +10,25 @@ const langs = [ { code: 'ru', label: 'Русский' }, { code: 'zh', label: '中國人' }, ]; +const langLabels = { + en: 'English', + fr: 'Français', + es: 'Español', + ru: 'Русский', + zh: '中國人', +} function LanguageSwitcher() { const { i18n } = useTranslation(); + const [selected, setSelected] = React.useState(i18n.language); - const handleChangeLanguage = useCallback((lang: string) => { - i18n.changeLanguage(lang); - localStorage.setItem('i18nextLng', lang); - }, []); + const onChange = (val: string) => { + setSelected(val) + } + const handleChangeLanguage = () => { + void i18n.changeLanguage(selected) + localStorage.setItem('i18nextLng', selected) + } const menuItems: MenuProps['items'] = langs.map((lang) => ({ key: lang.code, @@ -31,21 +40,31 @@ function LanguageSwitcher() { })); return ( - handleChangeLanguage(e.key), - }} - placement="bottomLeft" - > - + + +
); } diff --git a/frontend/app/components/Onboarding/components/InstallOpenReplayTab/InstallOpenReplayTab.tsx b/frontend/app/components/Onboarding/components/InstallOpenReplayTab/InstallOpenReplayTab.tsx index 4a66fe2c3..e2735d32d 100644 --- a/frontend/app/components/Onboarding/components/InstallOpenReplayTab/InstallOpenReplayTab.tsx +++ b/frontend/app/components/Onboarding/components/InstallOpenReplayTab/InstallOpenReplayTab.tsx @@ -8,6 +8,7 @@ import MobileOnboardingTabs from '../OnboardingTabs/OnboardingMobileTabs'; import ProjectFormButton from '../ProjectFormButton'; import withOnboarding, { WithOnboardingProps } from '../withOnboarding'; import { useTranslation } from 'react-i18next'; +import { CircleHelp } from 'lucide-react' interface Props extends WithOnboardingProps { platforms: Array<{ @@ -45,8 +46,8 @@ function InstallOpenReplayTab(props: Props) {
diff --git a/frontend/app/components/Onboarding/components/OnboardingTabs/Callouts.tsx b/frontend/app/components/Onboarding/components/OnboardingTabs/Callouts.tsx new file mode 100644 index 000000000..0f544cf67 --- /dev/null +++ b/frontend/app/components/Onboarding/components/OnboardingTabs/Callouts.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import DocCard from "App/components/shared/DocCard"; +import { useTranslation } from 'react-i18next'; +import { Mail } from 'lucide-react' +import { CopyButton } from "UI"; + +export function CollabCard({ showUserModal }: { showUserModal: () => void }) { + const { t } = useTranslation(); + + return ( + +
+ + + {t('Invite and Collaborate')} + +
+
+ ) +} + +export function ProjectKeyCard({ projectKey }: { projectKey: string }) { + const { t } = useTranslation(); + return ( + +
+
{projectKey}
+ +
+
+ ) +} \ No newline at end of file diff --git a/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingMobileTabs.tsx b/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingMobileTabs.tsx index d280945ac..e39d22c7e 100644 --- a/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingMobileTabs.tsx +++ b/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingMobileTabs.tsx @@ -4,6 +4,7 @@ import DocCard from 'Shared/DocCard/DocCard'; import { useModal } from 'App/components/Modal'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; import AndroidInstallDocs from 'Components/Onboarding/components/OnboardingTabs/InstallDocs/AndroidInstallDocs'; +import { CollabCard, ProjectKeyCard } from "./Callouts"; import MobileInstallDocs from './InstallDocs/MobileInstallDocs'; import { useTranslation } from 'react-i18next'; @@ -39,18 +40,9 @@ function MobileTrackingCodeModal(props: Props) {
- - - {t('Invite and Collaborate')} - - + - -
- {site.projectKey} - -
-
+
); @@ -62,18 +54,9 @@ function MobileTrackingCodeModal(props: Props) {
- - - {t('Invite and Collaborate')} - - + - -
- {site.projectKey} - -
-
+
); diff --git a/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingTabs.tsx b/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingTabs.tsx index 6c92e527b..77bfe17e9 100644 --- a/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingTabs.tsx +++ b/frontend/app/components/Onboarding/components/OnboardingTabs/OnboardingTabs.tsx @@ -3,6 +3,7 @@ import { Tabs, Icon, CopyButton } from 'UI'; import DocCard from 'Shared/DocCard/DocCard'; import { useModal } from 'App/components/Modal'; import UserForm from 'App/components/Client/Users/components/UserForm/UserForm'; +import { CollabCard, ProjectKeyCard } from "./Callouts"; import InstallDocs from './InstallDocs'; import ProjectCodeSnippet from './ProjectCodeSnippet'; import { useTranslation } from 'react-i18next'; @@ -37,20 +38,9 @@ function TrackingCodeModal(props: Props) {
- - - {t('Invite and Collaborate')} - - - -
- {site.projectKey} - -
-
+ + +
- - - {t('Invite and Collaborate')} - - + - -
- {site.projectKey} - -
-
+
); diff --git a/frontend/app/components/Onboarding/components/SideMenu.tsx b/frontend/app/components/Onboarding/components/SideMenu.tsx index ccd50701d..e05efc868 100644 --- a/frontend/app/components/Onboarding/components/SideMenu.tsx +++ b/frontend/app/components/Onboarding/components/SideMenu.tsx @@ -41,7 +41,7 @@ function SideMenu(props: Props) { { const filterRE = getRE(value, 'i'); - const { t } = useTranslation(); return value ? list.filter( diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx index 13e9c2655..32f8f2bcd 100644 --- a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx +++ b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx @@ -1,9 +1,17 @@ /* eslint-disable i18next/no-literal-string */ import { ResourceType, Timed } from 'Player'; +import { WsChannel } from 'Player/web/messages'; import MobilePlayer from 'Player/mobile/IOSPlayer'; import WebPlayer from 'Player/web/WebPlayer'; import { observer } from 'mobx-react-lite'; -import React, { useMemo, useState } from 'react'; +import React, { + useMemo, + useState, + useEffect, + useCallback, + useRef, +} from 'react'; +import i18n from 'App/i18n' import { useModal } from 'App/components/Modal'; import { @@ -12,25 +20,27 @@ import { } from 'App/components/Session/playerContext'; import { formatMs } from 'App/date'; import { useStore } from 'App/mstore'; -import { formatBytes } from 'App/utils'; +import { formatBytes, debounceCall } from 'App/utils'; import { Icon, NoContent, Tabs } from 'UI'; import { Tooltip, Input, Switch, Form } from 'antd'; -import { SearchOutlined, InfoCircleOutlined } from '@ant-design/icons'; +import { + SearchOutlined, + InfoCircleOutlined, +} from '@ant-design/icons'; import FetchDetailsModal from 'Shared/FetchDetailsModal'; -import { WsChannel } from 'App/player/web/messages'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; import TabSelector from '../TabSelector'; import TimeTable from '../TimeTable'; import useAutoscroll, { getLastItemTime } from '../useAutoscroll'; -import { useRegExListFilterMemo, useTabListFilterMemo } from '../useListFilter'; import WSPanel from './WSPanel'; import { useTranslation } from 'react-i18next'; +import { mergeListsWithZoom, processInChunks } from './utils' +// Constants remain the same const INDEX_KEY = 'network'; - const ALL = 'ALL'; const XHR = 'xhr'; const JS = 'js'; @@ -62,6 +72,9 @@ export const NETWORK_TABS = TAP_KEYS.map((tab) => ({ const DOM_LOADED_TIME_COLOR = 'teal'; const LOAD_TIME_COLOR = 'red'; +const BATCH_SIZE = 2500; +const INITIAL_LOAD_SIZE = 5000; + export function renderType(r: any) { return ( {r.type}}> @@ -79,13 +92,17 @@ export function renderName(r: any) { } function renderSize(r: any) { - const { t } = useTranslation(); - if (r.responseBodySize) return formatBytes(r.responseBodySize); + const t = i18n.t; + const notCaptured = t('Not captured'); + const resSizeStr = t('Resource size') let triggerText; let content; - if (r.decodedBodySize == null || r.decodedBodySize === 0) { + if (r.responseBodySize) { + triggerText = formatBytes(r.responseBodySize); + content = undefined; + } else if (r.decodedBodySize == null || r.decodedBodySize === 0) { triggerText = 'x'; - content = t('Not captured'); + content = notCaptured; } else { const headerSize = r.headerSize || 0; const showTransferred = r.headerSize != null; @@ -100,7 +117,7 @@ function renderSize(r: any) { )} transferred over network`} )} -
  • {`${t('Resource size')}: ${formatBytes(r.decodedBodySize)} `}
  • +
  • {`${resSizeStr}: ${formatBytes(r.decodedBodySize)} `}
  • ); } @@ -168,6 +185,8 @@ function renderStatus({ ); } + +// Main component for Network Panel function NetworkPanelCont({ panelHeight }: { panelHeight: number }) { const { player, store } = React.useContext(PlayerContext); const { sessionStore, uiPlayerStore } = useStore(); @@ -216,6 +235,7 @@ function NetworkPanelCont({ panelHeight }: { panelHeight: number }) { const getTabNum = (tab: string) => tabsArr.findIndex((t) => t === tab) + 1; const getTabName = (tabId: string) => tabNames[tabId]; + return ( void, hasMore: boolean) => { + const observerRef = useRef(null); + const loadingRef = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting && hasMore) { + loadMoreCallback(); + } + }, + { threshold: 0.1 }, + ); + + if (loadingRef.current) { + observer.observe(loadingRef.current); + } + + // @ts-ignore + observerRef.current = observer; + + return () => { + if (observerRef.current) { + observerRef.current.disconnect(); + } + }; + }, [loadMoreCallback, hasMore, loadingRef]); + + return loadingRef; }; interface Props { @@ -302,8 +343,8 @@ interface Props { resourceList: Timed[]; fetchListNow: Timed[]; resourceListNow: Timed[]; - websocketList: Array; - websocketListNow: Array; + websocketList: Array; + websocketListNow: Array; player: WebPlayer | MobilePlayer; startedAt: number; isMobile?: boolean; @@ -349,107 +390,189 @@ export const NetworkPanelComp = observer( >(null); const { showModal } = useModal(); const [showOnlyErrors, setShowOnlyErrors] = useState(false); - const [isDetailsModalActive, setIsDetailsModalActive] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [displayedItems, setDisplayedItems] = useState([]); + const [totalItems, setTotalItems] = useState(0); + const [summaryStats, setSummaryStats] = useState({ + resourcesSize: 0, + transferredSize: 0, + }); + + const originalListRef = useRef([]); + const socketListRef = useRef([]); + const { sessionStore: { devTools }, } = useStore(); const { filter } = devTools[INDEX_KEY]; const { activeTab } = devTools[INDEX_KEY]; const activeIndex = activeOutsideIndex ?? devTools[INDEX_KEY].index; + const [inputFilterValue, setInputFilterValue] = useState(filter); - const socketList = useMemo( - () => - websocketList.filter( - (ws, i, arr) => - arr.findIndex((it) => it.channelName === ws.channelName) === i, - ), - [websocketList], + const debouncedFilter = useCallback( + debounceCall((filterValue) => { + devTools.update(INDEX_KEY, { filter: filterValue }); + }, 300), + [], ); - const list = useMemo( - () => - // TODO: better merge (with body size info) - do it in player - resourceList - .filter( - (res) => - !fetchList.some((ft) => { - // res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player) - if (res.name === ft.name) { - if (res.time === ft.time) return true; - if (res.url.includes(ft.url)) { - return ( - Math.abs(res.time - ft.time) < 350 || - Math.abs(res.timestamp - ft.timestamp) < 350 - ); - } - } - - if (res.name !== ft.name) { - return false; - } - if (Math.abs(res.time - ft.time) > 250) { - return false; - } // TODO: find good epsilons - if (Math.abs(res.duration - ft.duration) > 200) { - return false; - } - - return true; - }), - ) - .concat(fetchList) - .concat( - socketList.map((ws) => ({ - ...ws, - type: 'websocket', - method: 'ws', - url: ws.channelName, - name: ws.channelName, - status: '101', - duration: 0, - transferredBodySize: 0, - })), - ) - .filter((req) => - zoomEnabled - ? req.time >= zoomStartTs! && req.time <= zoomEndTs! - : true, - ) - .sort((a, b) => a.time - b.time), - [resourceList.length, fetchList.length, socketList.length], - ); - - let filteredList = useMemo(() => { - if (!showOnlyErrors) { - return list; - } - return list.filter( - (it) => parseInt(it.status) >= 400 || !it.success || it.error, + // Process socket lists once + useEffect(() => { + const uniqueSocketList = websocketList.filter( + (ws, i, arr) => + arr.findIndex((it) => it.channelName === ws.channelName) === i, ); - }, [showOnlyErrors, list]); - filteredList = useRegExListFilterMemo( - filteredList, - (it) => [it.status, it.name, it.type, it.method], - filter, - ); - filteredList = useTabListFilterMemo( - filteredList, - (it) => TYPE_TO_TAB[it.type], - ALL, - activeTab, - ); + socketListRef.current = uniqueSocketList; + }, [websocketList.length]); - const onTabClick = (activeTab: (typeof TAP_KEYS)[number]) => + // Initial data processing - do this only once when data changes + useEffect(() => { + setIsLoading(true); + + // Heaviest operation here, will create a final merged network list + const processData = async () => { + const fetchUrls = new Set( + fetchList.map((ft) => { + return `${ft.name}-${Math.floor(ft.time / 100)}-${Math.floor(ft.duration / 100)}`; + }), + ); + + // We want to get resources that aren't in fetch list + const filteredResources = await processInChunks(resourceList, (chunk) => + chunk.filter((res: any) => { + const key = `${res.name}-${Math.floor(res.time / 100)}-${Math.floor(res.duration / 100)}`; + return !fetchUrls.has(key); + }), + BATCH_SIZE, + 25, + ); + + const processedSockets = socketListRef.current.map((ws: any) => ({ + ...ws, + type: 'websocket', + method: 'ws', + url: ws.channelName, + name: ws.channelName, + status: '101', + duration: 0, + transferredBodySize: 0, + })); + + const mergedList: Timed[] = mergeListsWithZoom( + filteredResources as Timed[], + fetchList, + processedSockets as Timed[], + { enabled: Boolean(zoomEnabled), start: zoomStartTs ?? 0, end: zoomEndTs ?? 0 } + ) + + originalListRef.current = mergedList; + setTotalItems(mergedList.length); + + calculateResourceStats(resourceList); + + // Only display initial chunk + setDisplayedItems(mergedList.slice(0, INITIAL_LOAD_SIZE)); + setIsLoading(false); + }; + + void processData(); + }, [ + resourceList.length, + fetchList.length, + socketListRef.current.length, + zoomEnabled, + zoomStartTs, + zoomEndTs, + ]); + + const calculateResourceStats = (resourceList: Record) => { + setTimeout(() => { + let resourcesSize = 0 + let transferredSize = 0 + resourceList.forEach(({ decodedBodySize, headerSize, encodedBodySize }: any) => { + resourcesSize += decodedBodySize || 0 + transferredSize += (headerSize || 0) + (encodedBodySize || 0) + }) + + setSummaryStats({ + resourcesSize, + transferredSize, + }); + }, 0); + } + + useEffect(() => { + if (originalListRef.current.length === 0) return; + setIsProcessing(true); + const applyFilters = async () => { + let filteredItems: any[] = originalListRef.current; + + filteredItems = await processInChunks(filteredItems, (chunk) => + chunk.filter( + (it) => { + let valid = true; + if (showOnlyErrors) { + valid = parseInt(it.status) >= 400 || !it.success || it.error + } + if (filter) { + try { + const regex = new RegExp(filter, 'i'); + valid = valid && regex.test(it.status) || regex.test(it.name) || regex.test(it.type) || regex.test(it.method); + } catch (e) { + valid = valid && String(it.status).includes(filter) || it.name.includes(filter) || it.type.includes(filter) || (it.method && it.method.includes(filter)); + } + } + if (activeTab !== ALL) { + valid = valid && TYPE_TO_TAB[it.type] === activeTab; + } + + return valid; + }, + ), + ); + + // Update displayed items + setDisplayedItems(filteredItems.slice(0, INITIAL_LOAD_SIZE)); + setTotalItems(filteredItems.length); + setIsProcessing(false); + }; + + void applyFilters(); + }, [filter, activeTab, showOnlyErrors]); + + const loadMoreItems = useCallback(() => { + if (isProcessing) return; + + setIsProcessing(true); + setTimeout(() => { + setDisplayedItems((prevItems) => { + const currentLength = prevItems.length; + const newItems = originalListRef.current.slice( + currentLength, + currentLength + BATCH_SIZE, + ); + return [...prevItems, ...newItems]; + }); + setIsProcessing(false); + }, 10); + }, [isProcessing]); + + const hasMoreItems = displayedItems.length < totalItems; + const loadingRef = useInfiniteScroll(loadMoreItems, hasMoreItems); + + const onTabClick = (activeTab) => { devTools.update(INDEX_KEY, { activeTab }); - const onFilterChange = ({ - target: { value }, - }: React.ChangeEvent) => - devTools.update(INDEX_KEY, { filter: value }); + }; + + const onFilterChange = ({ target: { value } }) => { + setInputFilterValue(value) + debouncedFilter(value); + }; - // AutoScroll const [timeoutStartAutoscroll, stopAutoscroll] = useAutoscroll( - filteredList, + displayedItems, getLastItemTime(fetchListNow, resourceListNow), activeIndex, (index) => devTools.update(INDEX_KEY, { index }), @@ -462,24 +585,6 @@ export const NetworkPanelComp = observer( timeoutStartAutoscroll(); }; - const resourcesSize = useMemo( - () => - resourceList.reduce( - (sum, { decodedBodySize }) => sum + (decodedBodySize || 0), - 0, - ), - [resourceList.length], - ); - const transferredSize = useMemo( - () => - resourceList.reduce( - (sum, { headerSize, encodedBodySize }) => - sum + (headerSize || 0) + (encodedBodySize || 0), - 0, - ), - [resourceList.length], - ); - const referenceLines = useMemo(() => { const arr = []; @@ -513,7 +618,7 @@ export const NetworkPanelComp = observer( isSpot={isSpot} time={item.time + startedAt} resource={item} - rows={filteredList} + rows={displayedItems} fetchPresented={fetchList.length > 0} />, { @@ -525,12 +630,12 @@ export const NetworkPanelComp = observer( }, }, ); - devTools.update(INDEX_KEY, { index: filteredList.indexOf(item) }); + devTools.update(INDEX_KEY, { index: displayedItems.indexOf(item) }); stopAutoscroll(); }; - const tableCols = React.useMemo(() => { - const cols: any[] = [ + const tableCols = useMemo(() => { + const cols = [ { label: t('Status'), dataKey: 'status', @@ -585,7 +690,7 @@ export const NetworkPanelComp = observer( }); } return cols; - }, [showSingleTab]); + }, [showSingleTab, activeTab, t, getTabName, getTabNum, isSpot]); return ( } /> @@ -625,7 +730,7 @@ export const NetworkPanelComp = observer(
    -
    +
    + + {isProcessing && ( + + Processing data... + + )}
    + 0} + display={summaryStats.transferredSize > 0} /> 0} + display={summaryStats.resourcesSize > 0} />
    - - - {t('No Data')} + + {isLoading ? ( +
    +
    +
    +

    Processing initial network data...

    - } - size="small" - show={filteredList.length === 0} - > - {/* @ts-ignore */} - { - devTools.update(INDEX_KEY, { - index: filteredList.indexOf(row), - }); - player.jump(row.time); - }} - activeIndex={activeIndex} +
    + ) : ( + + + {t('No Data')} +
    + } + size="small" + show={displayedItems.length === 0} > - {tableCols} - - {selectedWsChannel ? ( - setSelectedWsChannel(null)} - /> - ) : null} - +
    + { + devTools.update(INDEX_KEY, { + index: displayedItems.indexOf(row), + }); + player.jump(row.time); + }} + activeIndex={activeIndex} + > + {tableCols} + + + {hasMoreItems && ( +
    +
    +
    + Loading more data ({totalItems - displayedItems.length}{' '} + remaining) +
    +
    + )} +
    + + {selectedWsChannel ? ( + setSelectedWsChannel(null)} + /> + ) : null} + + )}
    ); @@ -722,7 +860,6 @@ export const NetworkPanelComp = observer( ); const WebNetworkPanel = observer(NetworkPanelCont); - const MobileNetworkPanel = observer(MobileNetworkPanelCont); export { WebNetworkPanel, MobileNetworkPanel }; diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/utils.ts b/frontend/app/components/shared/DevTools/NetworkPanel/utils.ts new file mode 100644 index 000000000..c54130975 --- /dev/null +++ b/frontend/app/components/shared/DevTools/NetworkPanel/utils.ts @@ -0,0 +1,178 @@ +export function mergeListsWithZoom< + T extends Record, + Y extends Record, + Z extends Record, +>( + arr1: T[], + arr2: Y[], + arr3: Z[], + zoom?: { enabled: boolean; start: number; end: number }, +): Array { + // Early return for empty arrays + if (arr1.length === 0 && arr2.length === 0 && arr3.length === 0) { + return []; + } + + // Optimized for common case - no zoom + if (!zoom?.enabled) { + return mergeThreeSortedArrays(arr1, arr2, arr3); + } + + // Binary search for start indexes (faster than linear search for large arrays) + const index1 = binarySearchStartIndex(arr1, zoom.start); + const index2 = binarySearchStartIndex(arr2, zoom.start); + const index3 = binarySearchStartIndex(arr3, zoom.start); + + // Merge arrays within zoom range + return mergeThreeSortedArraysWithinRange( + arr1, + arr2, + arr3, + index1, + index2, + index3, + zoom.start, + zoom.end, + ); +} + +function binarySearchStartIndex>( + arr: T[], + threshold: number, +): number { + if (arr.length === 0) return 0; + + let low = 0; + let high = arr.length - 1; + + // Handle edge cases first for better performance + if (arr[high].time < threshold) return arr.length; + if (arr[low].time >= threshold) return 0; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + + if (arr[mid].time < threshold) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + return low; +} + +function mergeThreeSortedArrays< + T extends Record, + Y extends Record, + Z extends Record, +>(arr1: T[], arr2: Y[], arr3: Z[]): Array { + const totalLength = arr1.length + arr2.length + arr3.length; + // prealloc array size + const result = new Array(totalLength); + + let i = 0, + j = 0, + k = 0, + index = 0; + + while (i < arr1.length || j < arr2.length || k < arr3.length) { + const val1 = i < arr1.length ? arr1[i].time : Infinity; + const val2 = j < arr2.length ? arr2[j].time : Infinity; + const val3 = k < arr3.length ? arr3[k].time : Infinity; + + if (val1 <= val2 && val1 <= val3) { + result[index++] = arr1[i++]; + } else if (val2 <= val1 && val2 <= val3) { + result[index++] = arr2[j++]; + } else { + result[index++] = arr3[k++]; + } + } + + return result; +} + +// same as above, just with zoom stuff +function mergeThreeSortedArraysWithinRange< + T extends Record, + Y extends Record, + Z extends Record, +>( + arr1: T[], + arr2: Y[], + arr3: Z[], + startIdx1: number, + startIdx2: number, + startIdx3: number, + start: number, + end: number, +): Array { + // we don't know beforehand how many items will be there + const result = []; + + let i = startIdx1; + let j = startIdx2; + let k = startIdx3; + + while (i < arr1.length || j < arr2.length || k < arr3.length) { + const val1 = i < arr1.length ? arr1[i].time : Infinity; + const val2 = j < arr2.length ? arr2[j].time : Infinity; + const val3 = k < arr3.length ? arr3[k].time : Infinity; + + // Early termination: if all remaining values exceed end time + if (Math.min(val1, val2, val3) > end) { + break; + } + + if (val1 <= val2 && val1 <= val3) { + if (val1 <= end) { + result.push(arr1[i]); + } + i++; + } else if (val2 <= val1 && val2 <= val3) { + if (val2 <= end) { + result.push(arr2[j]); + } + j++; + } else { + if (val3 <= end) { + result.push(arr3[k]); + } + k++; + } + } + + return result; +} + +export function processInChunks( + items: any[], + processFn: (item: any) => any, + chunkSize = 1000, + overscan = 0, +) { + return new Promise((resolve) => { + if (items.length === 0) { + resolve([]); + return; + } + + let result: any[] = []; + let index = 0; + + const processNextChunk = () => { + const chunk = items.slice(index, index + chunkSize + overscan); + result = result.concat(processFn(chunk)); + index += chunkSize; + + if (index < items.length) { + setTimeout(processNextChunk, 0); + } else { + resolve(result); + } + }; + + processNextChunk(); + }); +} diff --git a/frontend/app/components/shared/DocCard/DocCard.tsx b/frontend/app/components/shared/DocCard/DocCard.tsx index 51e1c33ef..c41764404 100644 --- a/frontend/app/components/shared/DocCard/DocCard.tsx +++ b/frontend/app/components/shared/DocCard/DocCard.tsx @@ -18,7 +18,7 @@ function DocCard(props: Props) { } = props; return ( -
    +
    {props.icon && (
    { - if (event.key === 'Enter') { - fetchResults(); - } - }; - const fetchResults = () => { - void aiFiltersStore.omniSearch(query, appliedFilter.toData()); - }; - - const testingKey = - localStorage.getItem('__mauricio_testing_access') === 'true'; return ( <> @@ -36,15 +18,6 @@ function SessionsTabOverview() {
    - {testingKey ? ( - setQuery(e.target.value)} - className="mb-2" - placeholder="ask session ai" - /> - ) : null}
    @@ -59,4 +32,4 @@ export default withPermissions( '', false, false, -)(observer(SessionsTabOverview)); +)(SessionsTabOverview); diff --git a/frontend/app/components/shared/SessionsTabOverview/components/SessionTags/SessionTags.tsx b/frontend/app/components/shared/SessionsTabOverview/components/SessionTags/SessionTags.tsx index d22000d64..9e6bfb6b8 100644 --- a/frontend/app/components/shared/SessionsTabOverview/components/SessionTags/SessionTags.tsx +++ b/frontend/app/components/shared/SessionsTabOverview/components/SessionTags/SessionTags.tsx @@ -20,73 +20,13 @@ const tagIcons = { function SessionTags() { const { t } = useTranslation(); const screens = useBreakpoint(); - const { projectsStore, sessionStore, searchStore } = useStore(); - const total = sessionStore.total; + const { projectsStore, searchStore } = useStore(); const platform = projectsStore.active?.platform || ''; const activeTab = searchStore.activeTags; - const [isMobile, setIsMobile] = useState(false); - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const dropdownRef = useRef(null); - const filteredOptions = issues_types - .filter( - (tag) => - tag.type !== 'mouse_thrashing' && - (platform === 'web' - ? tag.type !== types.TAP_RAGE - : tag.type !== types.CLICK_RAGE), - ) - .map((tag) => ({ - value: tag.type, - icon: tagIcons[tag.type], - label: t(tag.name), - })); - - // Find the currently active option - const activeOption = - filteredOptions.find((option) => option.value === activeTab[0]) || - filteredOptions[0]; - - // Check if on mobile - useEffect(() => { - const checkIfMobile = () => { - setIsMobile(window.innerWidth < 768); - }; - - checkIfMobile(); - window.addEventListener('resize', checkIfMobile); - - return () => { - window.removeEventListener('resize', checkIfMobile); - }; - }, []); - - // Close dropdown when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - dropdownRef.current && - !(dropdownRef.current as HTMLElement).contains(event.target as Node) - ) { - setIsDropdownOpen(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, []); - - // Handler for dropdown item selection - const handleSelectOption = (value: string) => { - searchStore.toggleTag(value as any); - setIsDropdownOpen(false); - }; - - if (total === 0 && (activeTab.length === 0 || activeTab[0] === 'all')) { - return null; - } + React.useEffect(() => { + searchStore.toggleTag(types.ALL); + }, [projectsStore.activeSiteId]) return (
    diff --git a/frontend/app/layout/LangBanner.tsx b/frontend/app/layout/LangBanner.tsx new file mode 100644 index 000000000..cff21589e --- /dev/null +++ b/frontend/app/layout/LangBanner.tsx @@ -0,0 +1,30 @@ +import React from 'react' +import { + Languages, X, Info +} from 'lucide-react' +import { Button } from 'antd'; +import { useHistory } from "react-router-dom"; +import { client } from 'App/routes' + +function LangBanner({ onClose }: { onClose: () => void }) { + const history = useHistory() + + const onClick = () => { + history.push(client('account')) + } + return ( +
    + +
    + OpenReplay now supports French, Russian, Chinese, and Spanish 🎉. Update your language in settings. +
    +
    + +
    + ) +} + +export default LangBanner \ No newline at end of file diff --git a/frontend/app/layout/TopHeader.tsx b/frontend/app/layout/TopHeader.tsx index 07a41bd28..045098637 100644 --- a/frontend/app/layout/TopHeader.tsx +++ b/frontend/app/layout/TopHeader.tsx @@ -1,6 +1,7 @@ import { Layout, Space, Tooltip } from 'antd'; import { observer } from 'mobx-react-lite'; import React, { useEffect } from 'react'; +import LangBanner from './LangBanner'; import { INDEXES } from 'App/constants/zindex'; import Logo from 'App/layout/Logo'; @@ -11,14 +12,27 @@ import { useTranslation } from 'react-i18next'; const { Header } = Layout; +const langBannerClosedKey = '__or__langBannerClosed'; +const getLangBannerClosed = () => localStorage.getItem(langBannerClosedKey) === '1' function TopHeader() { const { userStore, notificationStore, projectsStore, settingsStore } = useStore(); const { account } = userStore; const { siteId } = projectsStore; const { initialDataFetched } = userStore; + const [langBannerClosed, setLangBannerClosed] = React.useState(getLangBannerClosed); const { t } = useTranslation(); + React.useEffect(() => { + const langBannerVal = localStorage.getItem(langBannerClosedKey); + if (langBannerVal === null) { + localStorage.setItem(langBannerClosedKey, '0') + } + if (langBannerVal === '0') { + localStorage.setItem(langBannerClosedKey, '1') + } + }, []) + useEffect(() => { if (!account.id || initialDataFetched) return; Promise.all([ @@ -29,51 +43,58 @@ function TopHeader() { }); }, [account]); + const closeLangBanner = () => { + setLangBannerClosed(true); + localStorage.setItem(langBannerClosedKey, '1'); + } return ( -
    - -
    { - settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed); - }} - style={{ paddingTop: '4px' }} - className="cursor-pointer xl:block hidden" - > - + {langBannerClosed ? null : } +
    + +
    { + settingsStore.updateMenuCollapsed(!settingsStore.menuCollapsed); + }} + style={{ paddingTop: '4px' }} + className="cursor-pointer xl:block hidden" > - - -
    + mouseEnterDelay={1} + > + + +
    -
    - -
    -
    +
    + +
    + - -
    + + + ); } diff --git a/frontend/app/layout/TopRight.tsx b/frontend/app/layout/TopRight.tsx index 313a960db..8c0c3da29 100644 --- a/frontend/app/layout/TopRight.tsx +++ b/frontend/app/layout/TopRight.tsx @@ -11,18 +11,13 @@ import ProjectDropdown from 'Shared/ProjectDropdown'; import { useStore } from 'App/mstore'; import { observer } from 'mobx-react-lite'; -interface Props { - account: any; - spotOnly?: boolean; -} - -function TopRight(props: Props) { +function TopRight() { const { userStore } = useStore(); const spotOnly = userStore.scopeState === 1; const { account } = userStore; return ( - {props.spotOnly ? null : ( + {spotOnly ? null : ( <> @@ -30,7 +25,6 @@ function TopRight(props: Props) { {account.name ? : null} - )} diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json index 57035348f..80292d6be 100644 --- a/frontend/app/locales/en.json +++ b/frontend/app/locales/en.json @@ -1498,5 +1498,8 @@ "More attribute": "More attribute", "More attributes": "More attributes", "Account settings updated successfully": "Account settings updated successfully", - "Include rage clicks": "Include rage clicks" + "Include rage clicks": "Include rage clicks", + "Interface Language": "Interface Language", + "Select the language in which OpenReplay will appear.": "Select the language in which OpenReplay will appear.", + "Language": "Language" } diff --git a/frontend/app/locales/es.json b/frontend/app/locales/es.json index 65670ecd8..0d2c3c73b 100644 --- a/frontend/app/locales/es.json +++ b/frontend/app/locales/es.json @@ -1498,5 +1498,8 @@ "More attribute": "Más atributos", "More attributes": "Más atributos", "Account settings updated successfully": "Configuración de la cuenta actualizada correctamente", - "Include rage clicks": "Incluir clics de ira" + "Include rage clicks": "Incluir clics de ira", + "Interface Language": "Idioma de la interfaz", + "Select the language in which OpenReplay will appear.": "Selecciona el idioma en el que aparecerá OpenReplay.", + "Language": "Idioma" } diff --git a/frontend/app/locales/fr.json b/frontend/app/locales/fr.json index 3292c2423..91c535204 100644 --- a/frontend/app/locales/fr.json +++ b/frontend/app/locales/fr.json @@ -1498,5 +1498,8 @@ "More attribute": "Plus d'attributs", "More attributes": "Plus d'attributs", "Account settings updated successfully": "Paramètres du compte mis à jour avec succès", - "Include rage clicks": "Inclure les clics de rage" + "Include rage clicks": "Inclure les clics de rage", + "Interface Language": "Langue de l'interface", + "Select the language in which OpenReplay will appear.": "Sélectionnez la langue dans laquelle OpenReplay apparaîtra.", + "Language": "Langue" } diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json index 80c720f3b..d713686d1 100644 --- a/frontend/app/locales/ru.json +++ b/frontend/app/locales/ru.json @@ -1498,5 +1498,8 @@ "More attribute": "Еще атрибут", "More attributes": "Еще атрибуты", "Account settings updated successfully": "Настройки аккаунта успешно обновлены", - "Include rage clicks": "Включить невыносимые клики" + "Include rage clicks": "Включить невыносимые клики", + "Interface Language": "Язык интерфейса", + "Select the language in which OpenReplay will appear.": "Выберите язык, на котором будет отображаться OpenReplay.", + "Language": "Язык" } diff --git a/frontend/app/locales/zh.json b/frontend/app/locales/zh.json index f60b057f9..236164820 100644 --- a/frontend/app/locales/zh.json +++ b/frontend/app/locales/zh.json @@ -1498,5 +1498,8 @@ "More attributes": "更多属性", "More attribute": "更多属性", "Account settings updated successfully": "帐户设置已成功更新", - "Include rage clicks": "包括点击狂怒" + "Include rage clicks": "包括点击狂怒", + "Interface Language": "界面语言", + "Select the language in which OpenReplay will appear.": "选择 OpenReplay 将显示的语言。", + "Language": "语言" } diff --git a/frontend/app/mstore/userStore.ts b/frontend/app/mstore/userStore.ts index 1010b8361..b1ddc8b0f 100644 --- a/frontend/app/mstore/userStore.ts +++ b/frontend/app/mstore/userStore.ts @@ -240,18 +240,7 @@ class UserStore { resolve(response); }) .catch(async (e) => { - const err = await e.response?.json(); - runInAction(() => { - this.saving = false; - }); - const errStr = err.errors[0] - ? err.errors[0].includes('already exists') - ? this.t( - "This email is already linked to an account or team on OpenReplay and can't be used again.", - ) - : err.errors[0] - : this.t('Error saving user'); - toast.error(errStr); + toast.error(e.message || this.t("Failed to save user's data.")); reject(e); }) .finally(() => { diff --git a/frontend/app/utils/index.ts b/frontend/app/utils/index.ts index bf2eb3ff9..d0aa1c07b 100644 --- a/frontend/app/utils/index.ts +++ b/frontend/app/utils/index.ts @@ -29,6 +29,15 @@ export function debounce(callback, wait, context = this) { }; } +export function debounceCall(func, wait) { + let timeout; + return function (...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; +} + export function randomInt(a, b) { const min = (b ? a : 0) - 0.5; const max = b || a || Number.MAX_SAFE_INTEGER; diff --git a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml index 4ecc87dd7..a7eb11693 100644 --- a/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml +++ b/scripts/helmcharts/openreplay/charts/http/templates/deployment.yaml @@ -95,6 +95,8 @@ spec: value: {{ .Values.global.jwtSecret }} - name: JWT_SPOT_SECRET value: {{ .Values.global.jwtSpotSecret }} + - name: TOKEN_SECRET + value: {{ .Values.global.tokenSecret }} ports: {{- range $key, $val := .Values.service.ports }} - name: {{ $key }} diff --git a/scripts/helmcharts/vars.yaml b/scripts/helmcharts/vars.yaml index 43c1375dd..8872068d8 100644 --- a/scripts/helmcharts/vars.yaml +++ b/scripts/helmcharts/vars.yaml @@ -124,6 +124,7 @@ global: assistJWTSecret: "{{ randAlphaNum 20}}" jwtSecret: "{{ randAlphaNum 20}}" jwtSpotSecret: "{{ randAlphaNum 20}}" + tokenSecret: "{{randAlphaNum 20}}" # In case of multiple nodes in the kubernetes cluster, # we'll have to create an RWX PVC for shared components. # If it's a single node, we'll use hostVolume, which is the default for the community/oss edition. diff --git a/tracker/tracker/CHANGELOG.md b/tracker/tracker/CHANGELOG.md index 80f804cdc..c28cbb080 100644 --- a/tracker/tracker/CHANGELOG.md +++ b/tracker/tracker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 16.1.0 + +- new `privateMode` option to hide all possible data from tracking + ## 16.0.3 - better handling for local svg spritemaps diff --git a/tracker/tracker/jest.config.js b/tracker/tracker/jest.config.js index 0f5bd56b9..da1a2b887 100644 --- a/tracker/tracker/jest.config.js +++ b/tracker/tracker/jest.config.js @@ -8,6 +8,14 @@ const config = { moduleNameMapper: { '(.+)\\.js': '$1', }, + globals: { + 'ts-jest': { + tsConfig: { + target: 'es2020', + lib: ['DOM', 'ES2022'], + }, + }, + }, } export default config diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index f40314129..9b2ca79f4 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "16.0.3", + "version": "16.1.0", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/main/app/observer/observer.ts b/tracker/tracker/src/main/app/observer/observer.ts index 664c6edc7..054159631 100644 --- a/tracker/tracker/src/main/app/observer/observer.ts +++ b/tracker/tracker/src/main/app/observer/observer.ts @@ -357,6 +357,9 @@ export default abstract class Observer { if (name === 'href' || value.length > 1e5) { value = '' } + if (['alt', 'placeholder'].includes(name) && this.app.sanitizer.privateMode) { + value = value.replaceAll(/./g, '*') + } this.app.attributeSender.sendSetAttribute(id, name, value) } @@ -389,7 +392,7 @@ export default abstract class Observer { { acceptNode: (node) => { if (this.app.nodes.getID(node) !== undefined) { - this.app.debug.warn('! Node is already bound', node) + this.app.debug.info('! Node is already bound', node) } return isIgnored(node) || this.app.nodes.getID(node) !== undefined ? NodeFilter.FILTER_REJECT diff --git a/tracker/tracker/src/main/app/sanitizer.ts b/tracker/tracker/src/main/app/sanitizer.ts index 32ec1468c..1d037a51b 100644 --- a/tracker/tracker/src/main/app/sanitizer.ts +++ b/tracker/tracker/src/main/app/sanitizer.ts @@ -1,6 +1,6 @@ import type App from './index.js' import { stars, hasOpenreplayAttribute } from '../utils.js' -import { isElementNode } from './guards.js' +import { isElementNode, isTextNode } from './guards.js' export enum SanitizeLevel { Plain, @@ -32,31 +32,46 @@ export interface Options { * * */ domSanitizer?: (node: Element) => SanitizeLevel + /** + * private by default mode that will mask all elements not marked by data-openreplay-unmask + * */ + privateMode?: boolean } export const stringWiper = (input: string) => input .trim() - .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/g, '█') + .replace(/[^\f\n\r\t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff\s]/g, '*') export default class Sanitizer { private readonly obscured: Set = new Set() private readonly hidden: Set = new Set() private readonly options: Options + public readonly privateMode: boolean private readonly app: App constructor(params: { app: App; options?: Partial }) { this.app = params.app - this.options = Object.assign( - { - obscureTextEmails: true, - obscureTextNumbers: false, - }, - params.options, - ) + const defaultOptions: Options = { + obscureTextEmails: true, + obscureTextNumbers: false, + privateMode: false, + domSanitizer: undefined, + } + this.privateMode = params.options?.privateMode ?? false + this.options = Object.assign(defaultOptions, params.options) } handleNode(id: number, parentID: number, node: Node) { + if (this.options.privateMode) { + if (isElementNode(node) && !hasOpenreplayAttribute(node, 'unmask')) { + return this.obscured.add(id) + } + if (isTextNode(node) && !hasOpenreplayAttribute(node.parentNode as Element, 'unmask')) { + return this.obscured.add(id) + } + } + if ( this.obscured.has(parentID) || (isElementNode(node) && diff --git a/tracker/tracker/src/main/modules/console.ts b/tracker/tracker/src/main/modules/console.ts index efdd3be88..1a30ae5e8 100644 --- a/tracker/tracker/src/main/modules/console.ts +++ b/tracker/tracker/src/main/modules/console.ts @@ -108,9 +108,13 @@ export default function (app: App, opts: Partial): void { return } - const sendConsoleLog = app.safe((level: string, args: unknown[]): void => - app.send(ConsoleLog(level, printf(args))), - ) + const sendConsoleLog = app.safe((level: string, args: unknown[]): void => { + let logMsg = printf(args) + if (app.sanitizer.privateMode) { + logMsg = logMsg.replaceAll(/./g, '*') + } + app.send(ConsoleLog(level, logMsg)) + }) let n = 0 const reset = (): void => { diff --git a/tracker/tracker/src/main/modules/input.ts b/tracker/tracker/src/main/modules/input.ts index e8297f2b8..7fc3626ba 100644 --- a/tracker/tracker/src/main/modules/input.ts +++ b/tracker/tracker/src/main/modules/input.ts @@ -205,7 +205,10 @@ export default function (app: App, opts: Partial): void { inputTime: number, ) { const { value, mask } = getInputValue(id, node) - const label = getInputLabel(node) + let label = getInputLabel(node) + if (app.sanitizer.privateMode) { + label = label.replaceAll(/./g, '*') + } app.send(InputChange(id, value, mask !== 0, label, hesitationTime, inputTime)) } diff --git a/tracker/tracker/src/main/modules/mouse.ts b/tracker/tracker/src/main/modules/mouse.ts index 262005757..ab15a2303 100644 --- a/tracker/tracker/src/main/modules/mouse.ts +++ b/tracker/tracker/src/main/modules/mouse.ts @@ -230,11 +230,12 @@ export default function (app: App, options?: MouseHandlerOptions): void { const normalizedY = roundNumber(clickY / contentHeight) sendMouseMove() + const label = getTargetLabel(target) app.send( MouseClick( id, mouseTarget === target ? Math.round(performance.now() - mouseTargetTime) : 0, - getTargetLabel(target), + app.sanitizer.privateMode ? label.replaceAll(/./g, '*') : label, isClickable(target) && !disableClickmaps ? getSelector(id, target, options) : '', normalizedX, normalizedY, diff --git a/tracker/tracker/src/main/modules/network.ts b/tracker/tracker/src/main/modules/network.ts index 135a14a75..453406dcf 100644 --- a/tracker/tracker/src/main/modules/network.ts +++ b/tracker/tracker/src/main/modules/network.ts @@ -101,7 +101,7 @@ export default function (app: App, opts: Partial = {}) { } function sanitize(reqResInfo: RequestResponseData) { - if (!options.capturePayload) { + if (!options.capturePayload || app.sanitizer.privateMode) { // @ts-ignore delete reqResInfo.request.body delete reqResInfo.response.body @@ -136,18 +136,19 @@ export default function (app: App, opts: Partial = {}) { if (options.useProxy) { return createNetworkProxy( context, - options.ignoreHeaders, + app.sanitizer.privateMode ? true : options.ignoreHeaders, setSessionTokenHeader, sanitize, (message) => { if (options.failuresOnly && message.status < 400) { return } + const url = app.sanitizer.privateMode ? '************' : message.url app.send( NetworkRequest( message.requestType, message.method, - message.url, + url, message.request, message.response, message.status, diff --git a/tracker/tracker/src/main/modules/timing.ts b/tracker/tracker/src/main/modules/timing.ts index f8e7d63cf..ff3e4cfae 100644 --- a/tracker/tracker/src/main/modules/timing.ts +++ b/tracker/tracker/src/main/modules/timing.ts @@ -147,7 +147,7 @@ export default function (app: App, opts: Partial): void { entry.transferSize > entry.encodedBodySize ? entry.transferSize - entry.encodedBodySize : 0, entry.encodedBodySize || 0, entry.decodedBodySize || 0, - entry.name, + app.sanitizer.privateMode ? entry.name.replaceAll(/./g, '*') : entry.name, entry.initiatorType, entry.transferSize, // @ts-ignore diff --git a/tracker/tracker/src/main/modules/viewport.ts b/tracker/tracker/src/main/modules/viewport.ts index a17332855..b43306aa1 100644 --- a/tracker/tracker/src/main/modules/viewport.ts +++ b/tracker/tracker/src/main/modules/viewport.ts @@ -1,6 +1,7 @@ import type App from '../app/index.js' import { getTimeOrigin } from '../utils.js' import { SetPageLocation, SetViewportSize, SetPageVisibility } from '../app/messages.gen.js' +import { stringWiper } from '../app/sanitizer.js' export default function (app: App): void { let url: string | null, width: number, height: number @@ -11,7 +12,10 @@ export default function (app: App): void { const { URL } = document if (URL !== url) { url = URL - app.send(SetPageLocation(url, referrer, navigationStart, document.title)) + const safeTitle = app.sanitizer.privateMode ? stringWiper(document.title) : document.title + const safeUrl = app.sanitizer.privateMode ? stringWiper(url) : url + const safeReferrer = app.sanitizer.privateMode ? stringWiper(referrer) : referrer + app.send(SetPageLocation(safeUrl, safeReferrer, navigationStart, safeTitle)) navigationStart = 0 referrer = url } diff --git a/tracker/tracker/src/tests/console.test.ts b/tracker/tracker/src/tests/console.test.ts index 085b008be..749da72e6 100644 --- a/tracker/tracker/src/tests/console.test.ts +++ b/tracker/tracker/src/tests/console.test.ts @@ -23,6 +23,9 @@ describe('Console logging module', () => { safe: jest.fn((callback) => callback), send: jest.fn(), attachStartCallback: jest.fn(), + sanitizer: { + privateMode: false, + }, ticker: { attach: jest.fn(), }, diff --git a/tracker/tracker/src/tests/sanitizer.unit.test.ts b/tracker/tracker/src/tests/sanitizer.unit.test.ts index 295a570a8..be41b61cc 100644 --- a/tracker/tracker/src/tests/sanitizer.unit.test.ts +++ b/tracker/tracker/src/tests/sanitizer.unit.test.ts @@ -2,8 +2,8 @@ import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globa import Sanitizer, { SanitizeLevel, Options, stringWiper } from '../main/app/sanitizer.js' describe('stringWiper', () => { - test('should replace all characters with █', () => { - expect(stringWiper('Sensitive Data')).toBe('██████████████') + test('should replace all characters with *', () => { + expect(stringWiper('Sensitive Data')).toBe('**************') }) }) @@ -126,7 +126,7 @@ describe('Sanitizer', () => { element.mockId = 1 element.innerText = 'Sensitive Data' const sanitizedText = sanitizer.getInnerTextSecure(element) - expect(sanitizedText).toEqual('██████████████') + expect(sanitizedText).toEqual('**************') }) test('should return empty string if node element does not exist', () => {