feat(tracker): Msg buffering and conditional recording (#1775)
* feat(tracker) start message buffering support * feat(tracker): buffered recordings * feat(tracker): buffered recordings timedelay adjust * fix(tracker): condition manager * fix(tracker): conditions handlers * fix(tracker): conditions * fix(tracker): pre-fetch feature flags and conditions, fix naming and dnt check repeating * fix(tracker): fix conditions fetch * feat(tracker): test coverage for conditionsManager * feat(tracker): some api connections * feat(tracker): fix projid in session info * feat(tracker): added fetch req status condition, partially added offline recording, type fixes * fix(tracker): fix tests * fix(tracker): fix network req c * fix(tracker): fix conditions test * feat(ui): conditional recording ui * fix(tracker): fix prestart callbacks * feat(ui): conditions ui and api stuff * feat(ui): fix ? * fix(tracker): map raw db response in tracker * fix(tracker): fix condition processing, add cond name to trigger event, change unit tests * fix(tracker): simplify mapping, rename functions * fix(tracker): change toggler design, change network request condition * fix(tracker): some formatting * fix(tracker): reformat logging * fix(ui): rm console log
This commit is contained in:
parent
261239bd30
commit
5e21d88e8c
40 changed files with 2112 additions and 723 deletions
|
|
@ -1,23 +1,9 @@
|
|||
import React, { useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { Drawer, Tag } from 'antd';
|
||||
import { Tag } from 'antd';
|
||||
import cn from 'classnames';
|
||||
import {
|
||||
Loader,
|
||||
Button,
|
||||
TextLink,
|
||||
NoContent,
|
||||
Pagination,
|
||||
PageTitle,
|
||||
Divider,
|
||||
Icon,
|
||||
} from 'UI';
|
||||
import {
|
||||
init,
|
||||
remove,
|
||||
fetchGDPR,
|
||||
setSiteId
|
||||
} from 'Duck/site';
|
||||
import { Loader, Button, TextLink, NoContent, Pagination, PageTitle, Divider, Icon } from 'UI';
|
||||
import { init, remove, fetchGDPR, setSiteId } from 'Duck/site';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import stl from './sites.module.css';
|
||||
import NewSiteForm from './NewSiteForm';
|
||||
|
|
@ -25,14 +11,16 @@ import SiteSearch from './SiteSearch';
|
|||
import AddProjectButton from './AddProjectButton';
|
||||
import InstallButton from './InstallButton';
|
||||
import ProjectKey from './ProjectKey';
|
||||
import { getInitials, sliceListPerPage } from 'App/utils';
|
||||
import { sliceListPerPage } from 'App/utils';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
import { useModal } from 'App/components/Modal';
|
||||
import CaptureRate from 'Shared/SessionSettings/components/CaptureRate';
|
||||
import { BranchesOutlined } from '@ant-design/icons';
|
||||
|
||||
type Project = {
|
||||
id: number;
|
||||
name: string;
|
||||
conditionsCount: number;
|
||||
platform: 'web' | 'mobile';
|
||||
host: string;
|
||||
projectKey: string;
|
||||
|
|
@ -41,12 +29,7 @@ type Project = {
|
|||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
const Sites = ({
|
||||
loading,
|
||||
sites,
|
||||
user,
|
||||
init
|
||||
}: PropsFromRedux) => {
|
||||
const Sites = ({ loading, sites, user, init }: PropsFromRedux) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCaptureRate, setShowCaptureRate] = useState(true);
|
||||
const [activeProject, setActiveProject] = useState<Project | null>(null);
|
||||
|
|
@ -60,26 +43,13 @@ const Sites = ({
|
|||
|
||||
const { showModal, hideModal } = useModal();
|
||||
|
||||
const EditButton = ({
|
||||
isAdmin,
|
||||
onClick
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const EditButton = ({ isAdmin, onClick }: { isAdmin: boolean; onClick: () => void }) => {
|
||||
const _onClick = () => {
|
||||
onClick();
|
||||
showModal(<NewSiteForm onClose={hideModal} />, { right: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
icon='edit'
|
||||
variant='text-primary'
|
||||
disabled={!isAdmin}
|
||||
onClick={_onClick}
|
||||
/>
|
||||
);
|
||||
return <Button icon="edit" variant="text-primary" disabled={!isAdmin} onClick={_onClick} />;
|
||||
};
|
||||
|
||||
const captureRateClickHandler = (project: Project) => {
|
||||
|
|
@ -99,7 +69,11 @@ const Sites = ({
|
|||
<div className="col-span-4">
|
||||
<div className="flex items-center">
|
||||
<div className="relative flex items-center justify-center w-10 h-10 rounded-full bg-tealx-light">
|
||||
<Icon color={'tealx'} size={18} name={project.platform === 'web' ? 'browser/browser' : 'mobile'} />
|
||||
<Icon
|
||||
color={'tealx'}
|
||||
size={18}
|
||||
name={project.platform === 'web' ? 'browser/browser' : 'mobile'}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2">{project.host}</span>
|
||||
<div className={'ml-4 flex items-center gap-2'}>
|
||||
|
|
@ -110,12 +84,21 @@ const Sites = ({
|
|||
<div className="col-span-3">
|
||||
<ProjectKey value={project.projectKey} tooltip="Project key copied to clipboard" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="col-span-3 flex items-center">
|
||||
<Button variant="text-primary" onClick={() => captureRateClickHandler(project)}>
|
||||
{project.sampleRate}%
|
||||
</Button>
|
||||
{project.conditionsCount > 0 ? (
|
||||
<Button
|
||||
variant="text-primary"
|
||||
onClick={() => captureRateClickHandler(project)}
|
||||
className="ml-2"
|
||||
>
|
||||
<BranchesOutlined rotate={90} /> {project.conditionsCount} Conditions
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="col-span-3 justify-self-end flex items-center">
|
||||
<div className="col-span-2 justify-self-end flex items-center">
|
||||
<div className="mr-4">
|
||||
<InstallButton site={project} />
|
||||
</div>
|
||||
|
|
@ -131,19 +114,19 @@ const Sites = ({
|
|||
<div className="bg-white rounded-lg">
|
||||
<div className={cn(stl.tabHeader, 'px-5 pt-5')}>
|
||||
<PageTitle
|
||||
title={<div className='mr-4'>Projects</div>}
|
||||
title={<div className="mr-4">Projects</div>}
|
||||
actionButton={
|
||||
<TextLink
|
||||
icon='book'
|
||||
href='https://docs.openreplay.com/installation'
|
||||
label='Installation Docs'
|
||||
icon="book"
|
||||
href="https://docs.openreplay.com/installation"
|
||||
label="Installation Docs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className='flex ml-auto items-center'>
|
||||
<div className="flex ml-auto items-center">
|
||||
<AddProjectButton isAdmin={isAdmin} />
|
||||
<div className='mx-2' />
|
||||
<div className="mx-2" />
|
||||
<SiteSearch onChange={(value) => setSearchQuery(value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -151,34 +134,30 @@ const Sites = ({
|
|||
<div className={stl.list}>
|
||||
<NoContent
|
||||
title={
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<AnimatedSVG name={ICONS.NO_PROJECTS} size={170} />
|
||||
<div className='text-center text-gray-600 my-4'>
|
||||
No matching results
|
||||
</div>
|
||||
<div className="text-center text-gray-600 my-4">No matching results</div>
|
||||
</div>
|
||||
}
|
||||
size='small'
|
||||
size="small"
|
||||
show={!loading && filteredSites.size === 0}
|
||||
>
|
||||
<div className='grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium'>
|
||||
<div className='col-span-4'>Project Name</div>
|
||||
<div className='col-span-3'>Key</div>
|
||||
<div className='col-span-2'>Capture Rate</div>
|
||||
<div className='col-span-3'></div>
|
||||
<div className="grid grid-cols-12 gap-2 w-full items-center px-5 py-3 font-medium">
|
||||
<div className="col-span-4">Project Name</div>
|
||||
<div className="col-span-3">Key</div>
|
||||
<div className="col-span-2">Capture Rate</div>
|
||||
<div className="col-span-3"></div>
|
||||
</div>
|
||||
<Divider className='m-0' />
|
||||
|
||||
{sliceListPerPage(filteredSites, page - 1, pageSize).map(
|
||||
(project: Project) => (
|
||||
<>
|
||||
<ProjectItem project={project} />
|
||||
<Divider className='m-0' />
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<Divider className="m-0" />
|
||||
|
||||
<div className='w-full flex items-center justify-center py-10'>
|
||||
{sliceListPerPage(filteredSites, page - 1, pageSize).map((project: Project) => (
|
||||
<>
|
||||
<ProjectItem project={project} />
|
||||
<Divider className="m-0" />
|
||||
</>
|
||||
))}
|
||||
|
||||
<div className="w-full flex items-center justify-center py-10">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={Math.ceil(filteredSites.size / pageSize)}
|
||||
|
|
@ -190,15 +169,12 @@ const Sites = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Drawer
|
||||
<CaptureRate
|
||||
setShowCaptureRate={setShowCaptureRate}
|
||||
showCaptureRate={showCaptureRate}
|
||||
projectId={activeProject?.id}
|
||||
open={showCaptureRate && !!activeProject}
|
||||
onClose={() => setShowCaptureRate(!showCaptureRate)}
|
||||
title='Capture Rate'
|
||||
closable={false}
|
||||
destroyOnClose
|
||||
>
|
||||
{activeProject && <CaptureRate projectId={activeProject.id} />}
|
||||
</Drawer>
|
||||
/>
|
||||
</Loader>
|
||||
);
|
||||
};
|
||||
|
|
@ -208,14 +184,14 @@ const mapStateToProps = (state: any) => ({
|
|||
sites: state.getIn(['site', 'list']),
|
||||
loading: state.getIn(['site', 'loading']),
|
||||
user: state.getIn(['user', 'account']),
|
||||
account: state.getIn(['user', 'account'])
|
||||
account: state.getIn(['user', 'account']),
|
||||
});
|
||||
|
||||
const connector = connect(mapStateToProps, {
|
||||
init,
|
||||
remove,
|
||||
fetchGDPR,
|
||||
setSiteId
|
||||
setSiteId,
|
||||
});
|
||||
|
||||
export default connector(withPageTitle('Projects - OpenReplay Preferences')(Sites));
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useHistory } from 'react-router';
|
|||
import { withSiteId, fflag, fflags } from 'App/routes';
|
||||
import Multivariant from "Components/FFlags/NewFFlag/Multivariant";
|
||||
import { toast } from 'react-toastify';
|
||||
import RolloutCondition from "Components/FFlags/NewFFlag/Conditions";
|
||||
import RolloutCondition from "Shared/ConditionSet";
|
||||
|
||||
function FlagView({ siteId, fflagId }: { siteId: string; fflagId: string }) {
|
||||
const { featureFlagsStore } = useStore();
|
||||
|
|
|
|||
|
|
@ -1,113 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Icon, Input, Button } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import { nonFlagFilters } from 'Types/filter/newFilter';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Conditions } from "App/mstore/types/FeatureFlag";
|
||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface Props {
|
||||
set: number;
|
||||
conditions: Conditions;
|
||||
removeCondition: (ind: number) => void;
|
||||
index: number
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
function RolloutCondition({ set, conditions, removeCondition, index, readonly }: Props) {
|
||||
const [forceRender, forceRerender] = React.useState(false);
|
||||
const onAddFilter = (filter: Record<string, any> = {}) => {
|
||||
if (conditions.filter.filters.findIndex(f => f.key === filter.key) !== -1) {
|
||||
return toast.error('Filter already exists')
|
||||
}
|
||||
conditions.filter.addFilter(filter);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
const onUpdateFilter = (filterIndex: number, filter: any) => {
|
||||
conditions.filter.updateFilter(filterIndex, filter);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onChangeEventsOrder = (_: any, { name, value }: any) => {
|
||||
conditions.filter.updateKey(name, value);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onRemoveFilter = (filterIndex: number) => {
|
||||
conditions.filter.removeFilter(filterIndex);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onPercentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value || '0';
|
||||
if (value.length > 3) return;
|
||||
if (parseInt(value, 10) > 100) return conditions.setRollout(100);
|
||||
conditions.setRollout(parseInt(value, 10));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'border bg-white rounded'}>
|
||||
<div className={'flex items-center border-b px-4 py-2 gap-2'}>
|
||||
<div>Condition</div>
|
||||
<div className={'p-2 rounded bg-gray-lightest'}>Set {set}</div>
|
||||
{readonly ? null : (
|
||||
<div
|
||||
className={cn('p-2 px-4 cursor-pointer rounded ml-auto', 'hover:bg-teal-light')}
|
||||
onClick={() => removeCondition(index)}
|
||||
>
|
||||
<Icon name={'trash'} color={'main'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'p-2'}>
|
||||
<div className={conditions.filter.filters.length > 0 ? 'p-2 mb-2' : ''}>
|
||||
<FilterList
|
||||
filter={conditions.filter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
hideEventsOrder
|
||||
excludeFilterKeys={nonFlagFilters}
|
||||
readonly={readonly}
|
||||
/>
|
||||
{readonly && !conditions.filter?.filters?.length ? (
|
||||
<div className={'p-2'}>No conditions</div>
|
||||
) : null}
|
||||
</div>
|
||||
{readonly ? null : (
|
||||
<div className={'px-2'}>
|
||||
<FilterSelection
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
excludeFilterKeys={nonFlagFilters}
|
||||
>
|
||||
<Button variant="text-primary" icon="plus">
|
||||
Add Condition
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
|
||||
<span>Rollout to</span>
|
||||
{readonly ? (
|
||||
<div className={'font-semibold'}>{conditions.rolloutPercentage}%</div>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
width={60}
|
||||
value={conditions.rolloutPercentage}
|
||||
onChange={onPercentChange}
|
||||
leadingButton={<div className={'p-2 text-disabled-text'}>%</div>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>of sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(RolloutCondition);
|
||||
|
|
@ -9,10 +9,11 @@ import {Prompt, useHistory} from 'react-router';
|
|||
import {withSiteId, fflags, fflagRead} from 'App/routes';
|
||||
import Description from './Description';
|
||||
import Header from './Header';
|
||||
import RolloutCondition from './Conditions';
|
||||
import RolloutCondition from 'Shared/ConditionSet';
|
||||
import Multivariant from './Multivariant';
|
||||
import { Payload } from './Helpers'
|
||||
import { toast } from 'react-toastify';
|
||||
import { nonFlagFilters } from 'Types/filter/newFilter';
|
||||
|
||||
function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
||||
const { featureFlagsStore } = useStore();
|
||||
|
|
@ -221,7 +222,10 @@ function NewFFlag({ siteId, fflagId }: { siteId: string; fflagId?: string }) {
|
|||
set={index + 1}
|
||||
index={index}
|
||||
conditions={condition}
|
||||
bottomLine1={'Rollout to'}
|
||||
bottomLine2={'of sessions'}
|
||||
removeCondition={current.removeCondition}
|
||||
excludeFilterKeys={nonFlagFilters}
|
||||
/>
|
||||
<div className={'my-2 w-full text-center'}>OR</div>
|
||||
</React.Fragment>
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ function TestsTable() {
|
|||
placeholder="E.g. Checkout user journey evaluation"
|
||||
style={{ marginBottom: '2em' }}
|
||||
value={newTestTitle}
|
||||
type={'text'}
|
||||
onChange={(e) => setNewTestTitle(e.target.value)}
|
||||
/>
|
||||
<Typography.Text strong>Test Objective (optional)</Typography.Text>
|
||||
|
|
|
|||
128
frontend/app/components/shared/ConditionSet/ConditionSet.tsx
Normal file
128
frontend/app/components/shared/ConditionSet/ConditionSet.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import React from 'react';
|
||||
import { Icon, Input, Button } from 'UI';
|
||||
import cn from 'classnames';
|
||||
import FilterList from 'Shared/Filters/FilterList';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import FilterSelection from 'Shared/Filters/FilterSelection';
|
||||
import { Typography } from 'antd';
|
||||
import { BranchesOutlined } from '@ant-design/icons';
|
||||
|
||||
interface Props {
|
||||
set: number;
|
||||
removeCondition: (ind: number) => void;
|
||||
index: number;
|
||||
readonly?: boolean;
|
||||
onAddFilter: (filter: Record<string, any>) => void;
|
||||
conditions: any;
|
||||
bottomLine1: string;
|
||||
bottomLine2: string;
|
||||
onPercentChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
excludeFilterKeys?: string[];
|
||||
onUpdateFilter: (filterIndex: number, filter: any) => void;
|
||||
onRemoveFilter: (filterIndex: number) => void;
|
||||
onChangeEventsOrder: (_: any, { name, value }: any) => void;
|
||||
isConditional?: boolean;
|
||||
changeName: (name: string) => void;
|
||||
}
|
||||
|
||||
function ConditionSetComponent({
|
||||
removeCondition,
|
||||
index,
|
||||
set,
|
||||
readonly,
|
||||
onAddFilter,
|
||||
bottomLine1,
|
||||
bottomLine2,
|
||||
onPercentChange,
|
||||
excludeFilterKeys,
|
||||
conditions,
|
||||
onUpdateFilter,
|
||||
onRemoveFilter,
|
||||
onChangeEventsOrder,
|
||||
isConditional,
|
||||
changeName,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={'border bg-white rounded'}>
|
||||
<div className={'flex items-center border-b px-4 py-2 gap-2'}>
|
||||
{conditions.name ? (
|
||||
<div className={'flex gap-2'}>
|
||||
<BranchesOutlined rotate={90} />
|
||||
<Typography.Text
|
||||
className={'underline decoration-dashed decoration-black cursor-pointer'}
|
||||
editable={{
|
||||
onChange: changeName,
|
||||
triggerType: ['icon', 'text'],
|
||||
maxLength: 20,
|
||||
}}
|
||||
>
|
||||
{conditions.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>Condition</div>
|
||||
<div className={'p-2 rounded bg-gray-lightest'}>Set {set}</div>
|
||||
</>
|
||||
)}
|
||||
{readonly ? null : (
|
||||
<div
|
||||
className={cn('p-2 px-4 cursor-pointer rounded ml-auto', 'hover:bg-teal-light')}
|
||||
onClick={() => removeCondition(index)}
|
||||
>
|
||||
<Icon name={'trash'} color={'main'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'p-2'}>
|
||||
<div className={conditions.filter.filters.length > 0 ? 'p-2 mb-2' : ''}>
|
||||
<FilterList
|
||||
filter={conditions.filter}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
hideEventsOrder
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
readonly={readonly}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
{readonly && !conditions.filter?.filters?.length ? (
|
||||
<div className={'p-2'}>No conditions</div>
|
||||
) : null}
|
||||
</div>
|
||||
{readonly ? null : (
|
||||
<div className={'px-2'}>
|
||||
<FilterSelection
|
||||
isConditional={isConditional}
|
||||
filter={undefined}
|
||||
onFilterClick={onAddFilter}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
>
|
||||
<Button variant="text-primary" icon="plus">
|
||||
Add Condition
|
||||
</Button>
|
||||
</FilterSelection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={'px-4 py-2 flex items-center gap-2 border-t'}>
|
||||
<span>{bottomLine1}</span>
|
||||
{readonly ? (
|
||||
<div className={'font-semibold'}>{conditions.rolloutPercentage}%</div>
|
||||
) : (
|
||||
<Input
|
||||
type="text"
|
||||
width={60}
|
||||
value={conditions.rolloutPercentage}
|
||||
onChange={onPercentChange}
|
||||
leadingButton={<div className={'p-2 text-disabled-text'}>%</div>}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span>{bottomLine2}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ConditionSetComponent);
|
||||
94
frontend/app/components/shared/ConditionSet/Conditions.tsx
Normal file
94
frontend/app/components/shared/ConditionSet/Conditions.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React from 'react';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { Conditions } from 'App/mstore/types/FeatureFlag';
|
||||
import { toast } from 'react-toastify';
|
||||
import ConditionSetComponent from './ConditionSet';
|
||||
|
||||
interface Props {
|
||||
set: number;
|
||||
conditions: Conditions;
|
||||
removeCondition: (ind: number) => void;
|
||||
index: number;
|
||||
readonly?: boolean;
|
||||
bottomLine1: string;
|
||||
bottomLine2: string;
|
||||
setChanged?: (changed: boolean) => void;
|
||||
excludeFilterKeys?: string[];
|
||||
isConditional?: boolean;
|
||||
}
|
||||
|
||||
function ConditionSet({
|
||||
set,
|
||||
conditions,
|
||||
removeCondition,
|
||||
index,
|
||||
readonly,
|
||||
bottomLine1,
|
||||
bottomLine2,
|
||||
setChanged,
|
||||
excludeFilterKeys,
|
||||
isConditional,
|
||||
}: Props) {
|
||||
const [forceRender, forceRerender] = React.useState(false);
|
||||
|
||||
const onAddFilter = (filter: Record<string, any> = {}) => {
|
||||
setChanged?.(true);
|
||||
if (conditions.filter.filters.findIndex((f) => f.key === filter.key) !== -1) {
|
||||
return toast.error('Filter already exists');
|
||||
}
|
||||
conditions.filter.addFilter(filter);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
const onUpdateFilter = (filterIndex: number, filter: any) => {
|
||||
setChanged?.(true);
|
||||
conditions.filter.updateFilter(filterIndex, filter);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onChangeEventsOrder = (_: any, { name, value }: any) => {
|
||||
setChanged?.(true)
|
||||
conditions.filter.updateKey(name, value);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onRemoveFilter = (filterIndex: number) => {
|
||||
setChanged?.(true)
|
||||
conditions.filter.removeFilter(filterIndex);
|
||||
forceRerender(!forceRender);
|
||||
};
|
||||
|
||||
const onPercentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setChanged?.(true)
|
||||
const value = e.target.value || '0';
|
||||
if (value.length > 3) return;
|
||||
if (parseInt(value, 10) > 100) return conditions.setRollout(100);
|
||||
conditions.setRollout(parseInt(value, 10));
|
||||
};
|
||||
|
||||
const changeName = (name: string) => {
|
||||
setChanged?.(true);
|
||||
conditions.name = name;
|
||||
};
|
||||
|
||||
return (
|
||||
<ConditionSetComponent
|
||||
set={set}
|
||||
changeName={changeName}
|
||||
removeCondition={removeCondition}
|
||||
index={index}
|
||||
readonly={readonly}
|
||||
onAddFilter={onAddFilter}
|
||||
bottomLine1={bottomLine1}
|
||||
bottomLine2={bottomLine2}
|
||||
onPercentChange={onPercentChange}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
conditions={conditions}
|
||||
onUpdateFilter={onUpdateFilter}
|
||||
onRemoveFilter={onRemoveFilter}
|
||||
onChangeEventsOrder={onChangeEventsOrder}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(ConditionSet);
|
||||
1
frontend/app/components/shared/ConditionSet/index.ts
Normal file
1
frontend/app/components/shared/ConditionSet/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Conditions'
|
||||
|
|
@ -27,40 +27,47 @@ export default class FilterDuration extends React.PureComponent {
|
|||
const {
|
||||
minDuration,
|
||||
maxDuration,
|
||||
isConditional,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={ styles.wrapper }>
|
||||
<div className={styles.wrapper}>
|
||||
<div className="flex items-center">
|
||||
<span basic className={ styles.label }>{ 'Min' }</span>
|
||||
<span basic className={styles.label}>
|
||||
{'Min'}
|
||||
</span>
|
||||
<Input
|
||||
min="1"
|
||||
type="number"
|
||||
placeholder="0 min"
|
||||
name="minDuration"
|
||||
value={ fromMs(minDuration) }
|
||||
onChange={ this.onChange }
|
||||
onKeyPress={ this.onKeyPress }
|
||||
value={fromMs(minDuration)}
|
||||
onChange={this.onChange}
|
||||
onKeyPress={this.onKeyPress}
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.props.onBlur}
|
||||
style={{ height: '26px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span basic className={ styles.label }>{ 'Max' }</span>
|
||||
<Input
|
||||
min="1"
|
||||
type="number"
|
||||
placeholder="∞ min"
|
||||
name="maxDuration"
|
||||
value={ fromMs(maxDuration) }
|
||||
onChange={ this.onChange }
|
||||
onKeyPress={ this.onKeyPress }
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.props.onBlur}
|
||||
style={{ height: '26px' }}
|
||||
style={{ height: '26px', width: '90px' }}
|
||||
/>
|
||||
</div>
|
||||
{isConditional ? null : (
|
||||
<div className="flex items-center">
|
||||
<span basic className={styles.label}>
|
||||
{'Max'}
|
||||
</span>
|
||||
<Input
|
||||
min="1"
|
||||
type="number"
|
||||
placeholder="∞ min"
|
||||
name="maxDuration"
|
||||
value={fromMs(maxDuration)}
|
||||
onChange={this.onChange}
|
||||
onKeyPress={this.onKeyPress}
|
||||
onFocus={() => this.setState({ focused: true })}
|
||||
onBlur={this.props.onBlur}
|
||||
style={{ height: '26px', width: '90px' }}
|
||||
/>
|
||||
</div>)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ interface Props {
|
|||
readonly?: boolean;
|
||||
hideIndex?: boolean;
|
||||
hideDelete?: boolean;
|
||||
isConditional?: boolean;
|
||||
}
|
||||
|
||||
function FilterItem(props: Props) {
|
||||
|
|
@ -32,8 +33,10 @@ function FilterItem(props: Props) {
|
|||
disableDelete = false,
|
||||
hideDelete = false,
|
||||
allowedFilterKeys = [],
|
||||
excludeFilterKeys = []
|
||||
, hideIndex = false } = props;
|
||||
excludeFilterKeys = [],
|
||||
isConditional,
|
||||
hideIndex = false,
|
||||
} = props;
|
||||
const canShowValues = !(filter.operator === 'isAny' || filter.operator === 'onAny' || filter.operator === 'isUndefined');
|
||||
const isSubFilter = filter.type === FilterType.SUB_FILTERS;
|
||||
const replaceFilter = (filter: any) => {
|
||||
|
|
@ -118,7 +121,7 @@ function FilterItem(props: Props) {
|
|||
}).join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<FilterValue filter={filter} onUpdate={props.onUpdate}/>
|
||||
<FilterValue isConditional={isConditional} filter={filter} onUpdate={props.onUpdate}/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface Props {
|
|||
supportsEmpty?: boolean
|
||||
readonly?: boolean;
|
||||
excludeFilterKeys?: Array<string>
|
||||
isConditional?: boolean;
|
||||
}
|
||||
function FilterList(props: Props) {
|
||||
const {
|
||||
|
|
@ -23,7 +24,8 @@ function FilterList(props: Props) {
|
|||
hideEventsOrder = false,
|
||||
saveRequestPayloads,
|
||||
supportsEmpty = true,
|
||||
excludeFilterKeys = []
|
||||
excludeFilterKeys = [],
|
||||
isConditional,
|
||||
} = props;
|
||||
|
||||
const filters = List(filter.filters);
|
||||
|
|
@ -86,6 +88,7 @@ function FilterList(props: Props) {
|
|||
disableDelete={cannotDeleteFilter}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
readonly={props.readonly}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
|
@ -108,6 +111,7 @@ function FilterList(props: Props) {
|
|||
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
|
||||
onRemoveFilter={() => onRemoveFilter(filterIndex)}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Icon, Loader } from 'UI';
|
|||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import stl from './FilterModal.module.css';
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
import { filtersMap, conditionalFiltersMap } from 'Types/filter/newFilter';
|
||||
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
|
||||
|
||||
function filterJson(
|
||||
|
|
@ -53,6 +53,7 @@ export const getMatchingEntries = (searchQuery: string, filters: Record<string,
|
|||
|
||||
interface Props {
|
||||
filters: any;
|
||||
conditionalFilters: any;
|
||||
onFilterClick?: (filter: any) => void;
|
||||
filterSearchList: any;
|
||||
// metaOptions: any,
|
||||
|
|
@ -61,18 +62,21 @@ interface Props {
|
|||
searchQuery?: string;
|
||||
excludeFilterKeys?: Array<string>;
|
||||
allowedFilterKeys?: Array<string>;
|
||||
isConditional?: boolean;
|
||||
}
|
||||
|
||||
function FilterModal(props: Props) {
|
||||
const {
|
||||
filters,
|
||||
conditionalFilters,
|
||||
onFilterClick = () => null,
|
||||
filterSearchList,
|
||||
isMainSearch = false,
|
||||
fetchingFilterSearchList,
|
||||
searchQuery = '',
|
||||
excludeFilterKeys = [],
|
||||
allowedFilterKeys = []
|
||||
allowedFilterKeys = [],
|
||||
isConditional,
|
||||
} = props;
|
||||
const showSearchList = isMainSearch && searchQuery.length > 0;
|
||||
|
||||
|
|
@ -84,7 +88,7 @@ function FilterModal(props: Props) {
|
|||
|
||||
const { matchingCategories, matchingFilters } = getMatchingEntries(
|
||||
searchQuery,
|
||||
filterJson(filters, excludeFilterKeys, allowedFilterKeys)
|
||||
filterJson(isConditional ? conditionalFilters : filters, excludeFilterKeys, allowedFilterKeys)
|
||||
);
|
||||
|
||||
const isResultEmpty =
|
||||
|
|
@ -176,6 +180,7 @@ export default connect((state: any, props: any) => {
|
|||
filters: props.isLive
|
||||
? state.getIn(['search', 'filterListLive'])
|
||||
: state.getIn(['search', 'filterList']),
|
||||
conditionalFilters: state.getIn(['search', 'filterListConditional']),
|
||||
filterSearchList: props.isLive
|
||||
? state.getIn(['liveSearch', 'filterSearchList'])
|
||||
: state.getIn(['search', 'filterSearchList']),
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ interface Props {
|
|||
excludeFilterKeys?: Array<string>;
|
||||
allowedFilterKeys?: Array<string>;
|
||||
disabled?: boolean;
|
||||
isConditional?: boolean;
|
||||
}
|
||||
|
||||
function FilterSelection(props: Props) {
|
||||
const { filter, onFilterClick, children, excludeFilterKeys = [], allowedFilterKeys = [], disabled = false } = props;
|
||||
const { filter, onFilterClick, children, excludeFilterKeys = [], allowedFilterKeys = [], disabled = false, isConditional } = props;
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -66,6 +67,7 @@ function FilterSelection(props: Props) {
|
|||
onFilterClick={onFilterClick}
|
||||
excludeFilterKeys={excludeFilterKeys}
|
||||
allowedFilterKeys={allowedFilterKeys}
|
||||
isConditional={isConditional}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const ASSIST_ROUTE = assistRoute();
|
|||
interface Props {
|
||||
filter: any;
|
||||
onUpdate: (filter: any) => void;
|
||||
isConditional?: boolean;
|
||||
}
|
||||
function FilterValue(props: Props) {
|
||||
const { filter } = props;
|
||||
|
|
@ -124,6 +125,7 @@ function FilterValue(props: Props) {
|
|||
onBlur={handleBlur}
|
||||
minDuration={durationValues.minDuration}
|
||||
maxDuration={durationValues.maxDuration}
|
||||
isConditional={props.isConditional}
|
||||
/>
|
||||
);
|
||||
case FilterType.NUMBER_MULTIPLE:
|
||||
|
|
|
|||
|
|
@ -1,101 +1,150 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Icon, Toggler, Button, Input, Loader, Tooltip } from 'UI';
|
||||
import { Conditions } from 'App/mstore/types/FeatureFlag';
|
||||
import { Icon, Input, Loader } from 'UI';
|
||||
import { useStore } from 'App/mstore';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import { Switch } from 'antd';
|
||||
import { Switch, Drawer, Button, Tooltip } from 'antd';
|
||||
import ConditionalRecordingSettings from 'Shared/SessionSettings/components/ConditionalRecordingSettings';
|
||||
|
||||
type Props = {
|
||||
isAdmin: boolean;
|
||||
projectId: number;
|
||||
}
|
||||
projectId?: number;
|
||||
setShowCaptureRate: (show: boolean) => void;
|
||||
open: boolean;
|
||||
showCaptureRate: boolean;
|
||||
};
|
||||
|
||||
function CaptureRate(props: Props) {
|
||||
const [conditions, setConditions] = React.useState<Conditions[]>([]);
|
||||
const { isAdmin, projectId } = props;
|
||||
const { settingsStore } = useStore();
|
||||
const [changed, setChanged] = useState(false);
|
||||
const [sessionSettings] = useState(settingsStore.sessionSettings);
|
||||
const loading = settingsStore.loadingCaptureRate;
|
||||
|
||||
const captureRate = sessionSettings.captureRate;
|
||||
const setCaptureRate = sessionSettings.changeCaptureRate;
|
||||
const captureAll = sessionSettings.captureAll;
|
||||
const setCaptureAll = sessionSettings.changeCaptureAll;
|
||||
const {
|
||||
sessionSettings: {
|
||||
captureRate,
|
||||
changeCaptureRate,
|
||||
captureAll,
|
||||
changeCaptureAll,
|
||||
captureConditions,
|
||||
},
|
||||
loadingCaptureRate,
|
||||
updateCaptureConditions,
|
||||
fetchCaptureConditions,
|
||||
} = settingsStore;
|
||||
|
||||
useEffect(() => {
|
||||
settingsStore.fetchCaptureRate(projectId);
|
||||
if (projectId) {
|
||||
void fetchCaptureConditions(projectId);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const changeCaptureRate = (input: string) => {
|
||||
React.useEffect(() => {
|
||||
setConditions(captureConditions.map((condition: any) => new Conditions(condition, true)));
|
||||
}, [captureConditions]);
|
||||
|
||||
const onCaptureRateChange = (input: string) => {
|
||||
setChanged(true);
|
||||
setCaptureRate(input);
|
||||
changeCaptureRate(input);
|
||||
};
|
||||
|
||||
const toggleRate = () => {
|
||||
const newValue = !captureAll;
|
||||
setChanged(true);
|
||||
const newValue = !captureAll;
|
||||
changeCaptureAll(newValue);
|
||||
if (newValue) {
|
||||
const updateObj = {
|
||||
rate: '100',
|
||||
captureAll: true
|
||||
};
|
||||
settingsStore.saveCaptureRate(projectId, updateObj);
|
||||
} else {
|
||||
setCaptureAll(newValue);
|
||||
changeCaptureRate('100');
|
||||
}
|
||||
};
|
||||
|
||||
const onUpdate = () => {
|
||||
updateCaptureConditions(projectId!, {
|
||||
rate: parseInt(captureRate, 10),
|
||||
captureAll,
|
||||
conditions: conditions.map((c) => c.toCaptureCondition()),
|
||||
}).finally(() => setChanged(false));
|
||||
};
|
||||
|
||||
const updateDisabled = !changed || !isAdmin || (captureAll && conditions.length === 0);
|
||||
|
||||
return (
|
||||
<Loader loading={loading}>
|
||||
{/*<h3 className='text-lg'>Capture Rate</h3>*/}
|
||||
<div className='my-1'>The percentage of session you want to capture</div>
|
||||
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
|
||||
<div className={cn('mt-2 mb-4 mr-1 flex items-center', { disabled: !isAdmin })}>
|
||||
<Switch checked={captureAll} onChange={toggleRate} />
|
||||
<span className='ml-2' style={{ color: captureAll ? '#000000' : '#999' }}>
|
||||
100%
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!captureAll && (
|
||||
<div className='flex items-center'>
|
||||
<Tooltip title="You don't have permission to change." disabled={isAdmin} delay={0}>
|
||||
<div className={cn('relative', { 'disabled': !isAdmin })}>
|
||||
<Input
|
||||
type='number'
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => changeCaptureRate(e.target.value)}
|
||||
value={captureRate.toString()}
|
||||
style={{ height: '38px', width: '100px' }}
|
||||
disabled={captureAll}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<Icon className='absolute right-0 mr-6 top-0 bottom-0 m-auto' name='percent' color='gray-medium'
|
||||
size='18' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<span className='mx-3'>of the sessions</span>
|
||||
<Button
|
||||
disabled={!changed}
|
||||
variant='outline'
|
||||
onClick={() =>
|
||||
settingsStore
|
||||
.saveCaptureRate(projectId, {
|
||||
rate: captureRate,
|
||||
captureAll
|
||||
})
|
||||
.finally(() => setChanged(false))
|
||||
}
|
||||
>
|
||||
<Drawer
|
||||
size={'large'}
|
||||
open={props.open}
|
||||
styles={{ content: { background: '#F6F6F6' } }}
|
||||
onClose={() => props.setShowCaptureRate(false)}
|
||||
title={
|
||||
<div className={'flex items-center w-full gap-2'}>
|
||||
<span className={'font-semibold'}>Capture Rate</span>
|
||||
<div className={'ml-auto'}></div>
|
||||
<Button type={'primary'} ghost onClick={() => props.setShowCaptureRate(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={updateDisabled} type={'primary'} onClick={onUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Loader>
|
||||
}
|
||||
closable={false}
|
||||
destroyOnClose
|
||||
>
|
||||
<Loader loading={loadingCaptureRate || !projectId}>
|
||||
<Tooltip title={isAdmin ? '' : "You don't have permission to change."}>
|
||||
<div className="my-2 flex items-center gap-2 h-8">
|
||||
<div className="font-semibold">The percentage of session you want to capture</div>
|
||||
<Tooltip
|
||||
title={
|
||||
'Define the percentage of user sessions to be recorded for detailed replay and analysis.' +
|
||||
'\nSessions exceeding this specified limit will not be captured or stored.'
|
||||
}
|
||||
>
|
||||
<Icon size={16} color={'black'} name={'info-circle'} />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
checked={captureAll}
|
||||
onChange={toggleRate}
|
||||
checkedChildren={'Conditional'}
|
||||
disabled={!isAdmin}
|
||||
unCheckedChildren={'Capture Rate'}
|
||||
/>
|
||||
{!captureAll ? (
|
||||
<div className={cn('relative', { disabled: !isAdmin })}>
|
||||
<Input
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (/^\d+$/.test(e.target.value) || e.target.value === '') {
|
||||
onCaptureRateChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
value={captureRate.toString()}
|
||||
style={{ height: '38px', width: '70px' }}
|
||||
disabled={captureAll}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
<Icon
|
||||
className="absolute right-0 mr-2 top-0 bottom-0 m-auto"
|
||||
name="percent"
|
||||
color="gray-medium"
|
||||
size="18"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{captureAll ? (
|
||||
<ConditionalRecordingSettings
|
||||
setChanged={setChanged}
|
||||
conditions={conditions}
|
||||
setConditions={setConditions}
|
||||
/>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
</Loader>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect((state: any) => ({
|
||||
isAdmin: state.getIn(['user', 'account', 'admin']) || state.getIn(['user', 'account', 'superAdmin'])
|
||||
isAdmin:
|
||||
state.getIn(['user', 'account', 'admin']) || state.getIn(['user', 'account', 'superAdmin']),
|
||||
}))(observer(CaptureRate));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { Conditions } from 'App/mstore/types/FeatureFlag';
|
||||
import React from 'react';
|
||||
import ConditionSet from 'Shared/ConditionSet';
|
||||
import { Button } from 'UI';
|
||||
import { nonConditionalFlagFilters } from 'Types/filter/newFilter';
|
||||
|
||||
function ConditionalRecordingSettings({
|
||||
conditions,
|
||||
setConditions,
|
||||
setChanged,
|
||||
}: {
|
||||
setChanged: (changed: boolean) => void;
|
||||
conditions: Conditions[];
|
||||
setConditions: (conditions: Conditions[]) => void;
|
||||
}) {
|
||||
const addConditionSet = () => {
|
||||
setChanged(true);
|
||||
setConditions([
|
||||
...conditions,
|
||||
new Conditions({ name: `Condition Set ${conditions.length + 1}` }, false),
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'relative py-1 px-5'}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
borderLeft: '1px dashed black',
|
||||
borderBottom: '1px dashed black',
|
||||
borderBottomLeftRadius: '6px',
|
||||
height: '22px',
|
||||
width: '14px',
|
||||
}}
|
||||
/>
|
||||
<div className={'flex gap-1 items-center'}>
|
||||
<span className={'font-semibold'}>matching</span>
|
||||
<Button variant={'text-primary'} icon={'plus'} onClick={addConditionSet}>
|
||||
Condition Set
|
||||
</Button>
|
||||
</div>
|
||||
<div className={'mt-2 flex flex-col gap-4'}>
|
||||
{conditions.map((condition, index) => (
|
||||
<>
|
||||
<ConditionSet
|
||||
key={index}
|
||||
set={index + 1}
|
||||
index={index}
|
||||
conditions={condition}
|
||||
removeCondition={() => setConditions(conditions.filter((_, i) => i !== index))}
|
||||
readonly={false}
|
||||
bottomLine1={'Capture'}
|
||||
bottomLine2={'of total session rate matching this condition.'}
|
||||
setChanged={setChanged}
|
||||
excludeFilterKeys={nonConditionalFlagFilters}
|
||||
isConditional
|
||||
/>
|
||||
{index !== conditions.length - 1 ? (
|
||||
<div className={'text-disabled-text flex justify-center w-full'}>
|
||||
<span>OR</span>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConditionalRecordingSettings;
|
||||
|
|
@ -44,7 +44,7 @@ function Tooltip(props: Props) {
|
|||
<TooltipAnchor className={anchorClassName} state={state}>{props.children}</TooltipAnchor>
|
||||
<FloatingTooltip
|
||||
state={state}
|
||||
className={cn('bg-gray-darkest color-white rounded py-1 px-2 animate-fade', className)}
|
||||
className={cn('bg-gray-darkest color-white rounded py-1 px-2 animate-fade whitespace-pre-wrap', className)}
|
||||
>
|
||||
{title}
|
||||
{/* <FloatingArrow state={state} className="" /> */}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ export const stringOperatorsLimited = options.filter(({ key }) => stringFilterKe
|
|||
export const stringOperators = options.filter(({ key }) => stringFilterKeys.includes(key));
|
||||
export const stringOperatorsPerformance = options.filter(({ key }) => stringFilterKeysPerformance.includes(key));
|
||||
export const targetOperators = options.filter(({ key }) => targetFilterKeys.includes(key));
|
||||
export const targetConditional = options.filter(({ key }) => ['on', 'notOn', 'startsWith', 'endsWith', 'contains'].includes(key));
|
||||
export const stringConditional = options.filter(({ key }) => ['isAny', 'is', 'isNot', 'startsWith', 'endsWith', 'contains'].includes(key));
|
||||
|
||||
export const booleanOperators = [
|
||||
{ key: 'true', label: 'true', value: 'true' },
|
||||
{ key: 'false', label: 'false', value: 'false' }
|
||||
|
|
@ -155,5 +158,7 @@ export default {
|
|||
issueOptions,
|
||||
issueCategories,
|
||||
methodOptions,
|
||||
pageUrlOperators
|
||||
pageUrlOperators,
|
||||
targetConditional,
|
||||
stringConditional
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ import { fetchListType, saveType, editType, initType, removeType } from './funcT
|
|||
import { createItemInListUpdater, mergeReducers, success, array } from './funcTools/tools';
|
||||
import { createEdit, createInit } from './funcTools/crud';
|
||||
import { createRequestReducer } from './funcTools/request';
|
||||
import { addElementToFiltersMap, addElementToFlagConditionsMap, addElementToLiveFiltersMap, clearMetaFilters } from 'Types/filter/newFilter';
|
||||
import {
|
||||
addElementToConditionalFiltersMap,
|
||||
addElementToFiltersMap,
|
||||
addElementToFlagConditionsMap,
|
||||
addElementToLiveFiltersMap,
|
||||
clearMetaFilters
|
||||
} from "Types/filter/newFilter";
|
||||
import { FilterCategory } from '../types/filter/filterType';
|
||||
import { refreshFilterOptions } from './search';
|
||||
|
||||
|
|
@ -46,6 +52,7 @@ const reducer = (state = initialState, action = {}) => {
|
|||
addElementToFiltersMap(FilterCategory.METADATA, '_' + item.key);
|
||||
addElementToLiveFiltersMap(FilterCategory.METADATA, '_' + item.key);
|
||||
addElementToFlagConditionsMap(FilterCategory.METADATA, '_' + item.key)
|
||||
addElementToConditionalFiltersMap(FilterCategory.METADATA, '_' + item.key)
|
||||
});
|
||||
return state.set('list', List(action.data).map(CustomField))
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { errors as errorsRoute, isRoute } from 'App/routes';
|
|||
import { fetchList as fetchSessionList, fetchAutoplayList } from './sessions';
|
||||
import { fetchList as fetchErrorsList } from './errors';
|
||||
import { FilterCategory, FilterKey } from 'Types/filter/filterType';
|
||||
import { filtersMap, liveFiltersMap, generateFilterOptions } from 'Types/filter/newFilter';
|
||||
import { filtersMap, liveFiltersMap, conditionalFiltersMap, generateFilterOptions } from 'Types/filter/newFilter';
|
||||
import { DURATION_FILTER } from 'App/constants/storageKeys';
|
||||
import Period, { CUSTOM_RANGE } from 'Types/app/period';
|
||||
|
||||
|
|
@ -50,6 +50,7 @@ const UPDATE_LATEST_REQUEST_TIME = 'filters/UPDATE_LATEST_REQUEST_TIME'
|
|||
const initialState = Map({
|
||||
filterList: generateFilterOptions(filtersMap),
|
||||
filterListLive: generateFilterOptions(liveFiltersMap),
|
||||
filterListConditional: generateFilterOptions(conditionalFiltersMap),
|
||||
list: List(),
|
||||
latestRequestTime: null,
|
||||
latestList: List(),
|
||||
|
|
@ -67,7 +68,10 @@ const initialState = Map({
|
|||
function reducer(state = initialState, action = {}) {
|
||||
switch (action.type) {
|
||||
case REFRESH_FILTER_OPTIONS:
|
||||
return state.set('filterList', generateFilterOptions(filtersMap)).set('filterListLive', generateFilterOptions(liveFiltersMap));
|
||||
return state
|
||||
.set('filterList', generateFilterOptions(filtersMap))
|
||||
.set('filterListLive', generateFilterOptions(liveFiltersMap))
|
||||
.set('filterListConditional', generateFilterOptions(conditionalFiltersMap));
|
||||
case EDIT:
|
||||
return state.mergeIn(['instance'], action.instance).set('currentPage', 1);
|
||||
case APPLY:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import { webhookService } from 'App/services';
|
|||
import { GettingStarted } from './types/gettingStarted';
|
||||
import { MENU_COLLAPSED } from 'App/constants/storageKeys';
|
||||
|
||||
interface CaptureConditions {
|
||||
rate: number;
|
||||
captureAll: boolean;
|
||||
conditions: { name: string; captureRate: number; filters: any[] }[];
|
||||
}
|
||||
|
||||
export default class SettingsStore {
|
||||
loadingCaptureRate: boolean = false;
|
||||
sessionSettings: SessionSettings = new SessionSettings();
|
||||
|
|
@ -20,7 +26,7 @@ export default class SettingsStore {
|
|||
|
||||
constructor() {
|
||||
makeAutoObservable(this, {
|
||||
sessionSettings: observable
|
||||
sessionSettings: observable,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -29,37 +35,74 @@ export default class SettingsStore {
|
|||
localStorage.setItem(MENU_COLLAPSED, collapsed.toString());
|
||||
};
|
||||
|
||||
saveCaptureRate(projectId: number, data: any) {
|
||||
saveCaptureRate = (projectId: number, data: any) => {
|
||||
return sessionService
|
||||
.saveCaptureRate(projectId, data)
|
||||
.then((data) => data.json())
|
||||
.then(({ data }) => {
|
||||
this.sessionSettings.merge({
|
||||
captureRate: data.rate,
|
||||
captureAll: data.captureAll
|
||||
captureAll: data.captureAll,
|
||||
});
|
||||
toast.success('Settings updated successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error saving capture rate');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchCaptureRate(projectId: number): Promise<any> {
|
||||
fetchCaptureRate = (projectId: number): Promise<any> => {
|
||||
this.loadingCaptureRate = true;
|
||||
return sessionService
|
||||
.fetchCaptureRate(projectId)
|
||||
.then((data) => {
|
||||
this.sessionSettings.merge({
|
||||
captureRate: data.rate,
|
||||
captureAll: data.captureAll
|
||||
captureAll: data.captureAll,
|
||||
});
|
||||
this.captureRateFetched = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingCaptureRate = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchCaptureConditions = (projectId: number): Promise<any> => {
|
||||
this.loadingCaptureRate = true;
|
||||
return sessionService
|
||||
.fetchCaptureConditions(projectId)
|
||||
.then((data) => {
|
||||
this.sessionSettings.merge({
|
||||
captureRate: data.rate,
|
||||
captureAll: data.captureAll,
|
||||
captureConditions: data.conditions,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingCaptureRate = false;
|
||||
});
|
||||
};
|
||||
|
||||
updateCaptureConditions = (projectId: number, data: CaptureConditions) => {
|
||||
this.loadingCaptureRate = true;
|
||||
return sessionService
|
||||
.saveCaptureConditions(projectId, data)
|
||||
.then((data) => data.json())
|
||||
.then(({ data }) => {
|
||||
this.sessionSettings.merge({
|
||||
captureRate: data.rate,
|
||||
captureAll: data.captureAll,
|
||||
captureConditions: data.conditions,
|
||||
});
|
||||
toast.success('Settings updated successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Error saving capture rate');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loadingCaptureRate = false;
|
||||
});
|
||||
};
|
||||
|
||||
fetchWebhooks = () => {
|
||||
this.hooksLoading = true;
|
||||
|
|
@ -83,7 +126,7 @@ export default class SettingsStore {
|
|||
else
|
||||
this.setWebhooks([
|
||||
...this.webhooks.filter((hook) => hook.webhookId !== data.webhookId),
|
||||
this.webhookInst
|
||||
this.webhookInst,
|
||||
]);
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
|
|||
|
|
@ -4,26 +4,40 @@ import Filter from "App/mstore/types/filter";
|
|||
|
||||
export class Conditions {
|
||||
rolloutPercentage = 100;
|
||||
filter = new Filter().fromJson({ name: 'Rollout conditions', filters: [] })
|
||||
filter = new Filter().fromJson({ name: 'Rollout conditions', filters: [] });
|
||||
name = 'Condition Set';
|
||||
|
||||
constructor(data?: Record<string, any>) {
|
||||
makeAutoObservable(this)
|
||||
if (data) {
|
||||
this.rolloutPercentage = data.rolloutPercentage
|
||||
this.filter = new Filter().fromJson(data)
|
||||
constructor(data?: Record<string, any>, isConditional?: boolean) {
|
||||
makeAutoObservable(this);
|
||||
this.name = data?.name;
|
||||
if (data && (data.rolloutPercentage || data.captureRate)) {
|
||||
this.rolloutPercentage = data.rolloutPercentage ?? data.captureRate;
|
||||
this.filter = new Filter(isConditional).fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
setRollout = (value: number) => {
|
||||
this.rolloutPercentage = value
|
||||
}
|
||||
this.rolloutPercentage = value;
|
||||
};
|
||||
|
||||
setName = (name: string) => {
|
||||
this.name = name;
|
||||
};
|
||||
|
||||
toJS() {
|
||||
return {
|
||||
name: this.filter.name,
|
||||
rolloutPercentage: this.rolloutPercentage,
|
||||
filters: this.filter.filters.map(f => f.toJson()),
|
||||
}
|
||||
filters: this.filter.filters.map((f) => f.toJson())
|
||||
};
|
||||
}
|
||||
|
||||
toCaptureCondition() {
|
||||
return {
|
||||
name: this.name,
|
||||
captureRate: this.rolloutPercentage,
|
||||
filters: this.filter.filters.map((f) => f.toJson())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { makeAutoObservable, runInAction, observable, action } from "mobx"
|
||||
import FilterItem from "./filterItem"
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
import { filtersMap, conditionalFiltersMap } from 'Types/filter/newFilter';
|
||||
|
||||
export default class Filter {
|
||||
public static get ID_KEY():string { return "filterId" }
|
||||
|
|
@ -16,7 +16,7 @@ export default class Filter {
|
|||
page: number = 1
|
||||
limit: number = 10
|
||||
|
||||
constructor() {
|
||||
constructor(private readonly isConditional = false) {
|
||||
makeAutoObservable(this, {
|
||||
filters: observable,
|
||||
eventsOrder: observable,
|
||||
|
|
@ -63,7 +63,9 @@ export default class Filter {
|
|||
|
||||
fromJson(json: any) {
|
||||
this.name = json.name
|
||||
this.filters = json.filters.map((i: Record<string, any>) => new FilterItem().fromJson(i))
|
||||
this.filters = json.filters.map((i: Record<string, any>) =>
|
||||
new FilterItem(undefined, this.isConditional).fromJson(i)
|
||||
);
|
||||
this.eventsOrder = json.eventsOrder
|
||||
return this
|
||||
}
|
||||
|
|
@ -80,7 +82,8 @@ export default class Filter {
|
|||
}
|
||||
|
||||
createFilterBykey(key: string) {
|
||||
return filtersMap[key] ? new FilterItem(filtersMap[key]) : new FilterItem()
|
||||
const usedMap = this.isConditional ? conditionalFiltersMap : filtersMap
|
||||
return usedMap[key] ? new FilterItem(usedMap[key]) : new FilterItem()
|
||||
}
|
||||
|
||||
toJson() {
|
||||
|
|
|
|||
|
|
@ -1,114 +1,119 @@
|
|||
import { makeAutoObservable, observable, action } from 'mobx';
|
||||
import { FilterKey, FilterType, FilterCategory } from 'Types/filter/filterType';
|
||||
import { filtersMap } from 'Types/filter/newFilter';
|
||||
import { filtersMap, conditionalFiltersMap } from 'Types/filter/newFilter';
|
||||
|
||||
export default class FilterItem {
|
||||
type: string = '';
|
||||
category: FilterCategory = FilterCategory.METADATA;
|
||||
key: string = '';
|
||||
label: string = '';
|
||||
value: any = [''];
|
||||
isEvent: boolean = false;
|
||||
operator: string = '';
|
||||
hasSource: boolean = false;
|
||||
source: string = '';
|
||||
sourceOperator: string = '';
|
||||
sourceOperatorOptions: any = [];
|
||||
filters: FilterItem[] = [];
|
||||
operatorOptions: any[] = [];
|
||||
options: any[] = [];
|
||||
isActive: boolean = true;
|
||||
completed: number = 0;
|
||||
dropped: number = 0;
|
||||
type: string = '';
|
||||
category: FilterCategory = FilterCategory.METADATA;
|
||||
key: string = '';
|
||||
label: string = '';
|
||||
value: any = [''];
|
||||
isEvent: boolean = false;
|
||||
operator: string = '';
|
||||
hasSource: boolean = false;
|
||||
source: string = '';
|
||||
sourceOperator: string = '';
|
||||
sourceOperatorOptions: any = [];
|
||||
filters: FilterItem[] = [];
|
||||
operatorOptions: any[] = [];
|
||||
options: any[] = [];
|
||||
isActive: boolean = true;
|
||||
completed: number = 0;
|
||||
dropped: number = 0;
|
||||
|
||||
constructor(data: any = {}) {
|
||||
makeAutoObservable(this, {
|
||||
type: observable,
|
||||
key: observable,
|
||||
value: observable,
|
||||
operator: observable,
|
||||
source: observable,
|
||||
filters: observable,
|
||||
isActive: observable,
|
||||
sourceOperator: observable,
|
||||
category: observable,
|
||||
constructor(data: any = {}, private readonly isConditional?: boolean) {
|
||||
makeAutoObservable(this, {
|
||||
type: observable,
|
||||
key: observable,
|
||||
value: observable,
|
||||
operator: observable,
|
||||
source: observable,
|
||||
filters: observable,
|
||||
isActive: observable,
|
||||
sourceOperator: observable,
|
||||
category: observable,
|
||||
|
||||
merge: action,
|
||||
});
|
||||
merge: action,
|
||||
});
|
||||
|
||||
if (Array.isArray(data.filters)) {
|
||||
data.filters = data.filters.map(function (i: Record<string, any>) {
|
||||
return new FilterItem(i);
|
||||
});
|
||||
}
|
||||
|
||||
this.merge(data);
|
||||
if (Array.isArray(data.filters)) {
|
||||
data.filters = data.filters.map(function (i: Record<string, any>) {
|
||||
return new FilterItem(i);
|
||||
});
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
this.merge(data);
|
||||
}
|
||||
|
||||
updateKey(key: string, value: any) {
|
||||
// @ts-ignore
|
||||
this[key] = value;
|
||||
}
|
||||
|
||||
merge(data: any) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
// @ts-ignore
|
||||
this[key] = data[key];
|
||||
});
|
||||
}
|
||||
|
||||
fromJson(json: any, mainFilterKey = '') {
|
||||
const isMetadata = json.type === FilterKey.METADATA;
|
||||
let _filter: any = (isMetadata ? filtersMap['_' + json.source] : filtersMap[json.type]) || {};
|
||||
console.log(_filter, json);
|
||||
if (this.isConditional) {
|
||||
_filter = conditionalFiltersMap[json.type] || conditionalFiltersMap[json.source];
|
||||
}
|
||||
if (mainFilterKey) {
|
||||
const mainFilter = filtersMap[mainFilterKey];
|
||||
const subFilterMap = {};
|
||||
mainFilter.filters.forEach((option: any) => {
|
||||
// @ts-ignore
|
||||
this[key] = value;
|
||||
subFilterMap[option.key] = option;
|
||||
});
|
||||
// @ts-ignore
|
||||
_filter = subFilterMap[json.type];
|
||||
}
|
||||
this.type = _filter.type;
|
||||
this.key = _filter.key;
|
||||
this.label = _filter.label;
|
||||
this.operatorOptions = _filter.operatorOptions;
|
||||
this.hasSource = _filter.hasSource;
|
||||
this.category = _filter.category;
|
||||
this.sourceOperatorOptions = _filter.sourceOperatorOptions;
|
||||
this.options = _filter.options;
|
||||
this.isEvent = Boolean(_filter.isEvent);
|
||||
|
||||
merge(data: any) {
|
||||
Object.keys(data).forEach((key) => {
|
||||
// @ts-ignore
|
||||
this[key] = data[key];
|
||||
});
|
||||
}
|
||||
|
||||
fromJson(json: any, mainFilterKey = '') {
|
||||
const isMetadata = json.type === FilterKey.METADATA;
|
||||
let _filter: any = (isMetadata ? filtersMap['_' + json.source] : filtersMap[json.type]) || {};
|
||||
|
||||
if (mainFilterKey) {
|
||||
const mainFilter = filtersMap[mainFilterKey];
|
||||
const subFilterMap = {};
|
||||
mainFilter.filters.forEach((option: any) => {
|
||||
// @ts-ignore
|
||||
subFilterMap[option.key] = option;
|
||||
});
|
||||
// @ts-ignore
|
||||
_filter = subFilterMap[json.type];
|
||||
}
|
||||
this.type = _filter.type;
|
||||
this.key = _filter.key;
|
||||
this.label = _filter.label;
|
||||
this.operatorOptions = _filter.operatorOptions;
|
||||
this.hasSource = _filter.hasSource;
|
||||
this.category = _filter.category;
|
||||
this.sourceOperatorOptions = _filter.sourceOperatorOptions;
|
||||
this.options = _filter.options;
|
||||
this.isEvent = _filter.isEvent;
|
||||
|
||||
(this.value = !json.value || json.value.length === 0 ? [''] : json.value);
|
||||
(this.operator = json.operator);
|
||||
this.source = isMetadata ? '_' + json.source : json.source;
|
||||
this.sourceOperator = json.sourceOperator;
|
||||
|
||||
this.filters =
|
||||
_filter.type === FilterType.SUB_FILTERS && json.filters ? json.filters.map((i: any) => new FilterItem().fromJson(i, json.type)) : [];
|
||||
|
||||
this.completed = json.completed;
|
||||
this.dropped = json.dropped;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): any {
|
||||
const isMetadata = this.category === FilterCategory.METADATA;
|
||||
const json = {
|
||||
type: isMetadata ? FilterKey.METADATA : this.key,
|
||||
isEvent: this.isEvent,
|
||||
value: this.value,
|
||||
operator: this.operator,
|
||||
source: isMetadata ? this.key.replace(/^_/, '') : this.source,
|
||||
sourceOperator: this.sourceOperator,
|
||||
filters: Array.isArray(this.filters) ? this.filters.map((i) => i.toJson()) : [],
|
||||
};
|
||||
if (this.type === FilterKey.DURATION) {
|
||||
json.value = this.value.map((i: any) => !i ? 0 : i)
|
||||
}
|
||||
return json;
|
||||
this.value = !json.value || json.value.length === 0 ? [''] : json.value;
|
||||
this.operator = json.operator;
|
||||
this.source = isMetadata ? '_' + json.source : json.source;
|
||||
this.sourceOperator = json.sourceOperator;
|
||||
|
||||
this.filters =
|
||||
_filter.type === FilterType.SUB_FILTERS && json.filters
|
||||
? json.filters.map((i: any) => new FilterItem().fromJson(i, json.type))
|
||||
: [];
|
||||
|
||||
this.completed = json.completed;
|
||||
this.dropped = json.dropped;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
toJson(): any {
|
||||
const isMetadata = this.category === FilterCategory.METADATA;
|
||||
const json = {
|
||||
type: isMetadata ? FilterKey.METADATA : this.key,
|
||||
isEvent: Boolean(this.isEvent),
|
||||
value: this.value,
|
||||
operator: this.operator,
|
||||
source: isMetadata ? this.key.replace(/^_/, '') : this.source,
|
||||
sourceOperator: this.sourceOperator,
|
||||
filters: Array.isArray(this.filters) ? this.filters.map((i) => i.toJson()) : [],
|
||||
};
|
||||
if (this.type === FilterKey.DURATION) {
|
||||
json.value = this.value.map((i: any) => (!i ? 0 : i));
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,71 +63,75 @@ export const generateGMTZones = (): Timezone[] => {
|
|||
};
|
||||
|
||||
export default class SessionSettings {
|
||||
defaultTimezones = [...generateGMTZones()]
|
||||
skipToIssue: boolean = localStorage.getItem(SKIP_TO_ISSUE) === 'true';
|
||||
timezone: Timezone;
|
||||
durationFilter: any = JSON.parse(localStorage.getItem(DURATION_FILTER) || JSON.stringify(defaultDurationFilter));
|
||||
captureRate: string = '0';
|
||||
captureAll: boolean = false;
|
||||
mouseTrail: boolean = localStorage.getItem(MOUSE_TRAIL) !== 'false';
|
||||
shownTimezone: 'user' | 'local';
|
||||
defaultTimezones = [...generateGMTZones()];
|
||||
skipToIssue: boolean = localStorage.getItem(SKIP_TO_ISSUE) === 'true';
|
||||
timezone: Timezone;
|
||||
durationFilter: any = JSON.parse(
|
||||
localStorage.getItem(DURATION_FILTER) || JSON.stringify(defaultDurationFilter)
|
||||
);
|
||||
captureRate: string = '0';
|
||||
captureAll: boolean = false;
|
||||
captureConditions: { name: string; captureRate: number; filters: any[] }[] = [];
|
||||
mouseTrail: boolean = localStorage.getItem(MOUSE_TRAIL) !== 'false';
|
||||
shownTimezone: 'user' | 'local';
|
||||
|
||||
constructor() {
|
||||
// compatibility fix for old timezone storage
|
||||
// TODO: remove after a while (1.7.1?)
|
||||
const userTimezoneOffset = moment().format('Z');
|
||||
const defaultTimezone = this.defaultTimezones.find((tz) =>
|
||||
tz.value.includes('UTC' + userTimezoneOffset.slice(0, 3))
|
||||
) || { label: 'Local', value: `UTC${userTimezoneOffset}` };
|
||||
|
||||
constructor() {
|
||||
// compatibility fix for old timezone storage
|
||||
// TODO: remove after a while (1.7.1?)
|
||||
const userTimezoneOffset = moment().format('Z');
|
||||
const defaultTimezone = this.defaultTimezones.find(tz => tz.value.includes('UTC' + userTimezoneOffset.slice(0,3))) || { label: 'Local', value: `UTC${userTimezoneOffset}` };
|
||||
|
||||
this.timezoneFix(defaultTimezone);
|
||||
// @ts-ignore
|
||||
this.timezone = JSON.parse(localStorage.getItem(TIMEZONE)) || defaultTimezone;
|
||||
if (localStorage.getItem(MOUSE_TRAIL) === null) {
|
||||
localStorage.setItem(MOUSE_TRAIL, 'true');
|
||||
}
|
||||
|
||||
this.shownTimezone = localStorage.getItem(SHOWN_TIMEZONE) === 'user' ? 'user' : 'local'
|
||||
makeAutoObservable(this);
|
||||
this.timezoneFix(defaultTimezone);
|
||||
// @ts-ignore
|
||||
this.timezone = JSON.parse(localStorage.getItem(TIMEZONE)) || defaultTimezone;
|
||||
if (localStorage.getItem(MOUSE_TRAIL) === null) {
|
||||
localStorage.setItem(MOUSE_TRAIL, 'true');
|
||||
}
|
||||
|
||||
merge = (settings: any) => {
|
||||
for (const key in settings) {
|
||||
if (settings.hasOwnProperty(key)) {
|
||||
this.updateKey(key, settings[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
this.shownTimezone = localStorage.getItem(SHOWN_TIMEZONE) === 'user' ? 'user' : 'local';
|
||||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
changeCaptureRate = (rate: string) => {
|
||||
if (!rate) return (this.captureRate = '0');
|
||||
// react do no see the difference between 01 and 1 decimals, this is why we have to use string casting
|
||||
if (parseInt(rate, 10) <= 100) this.captureRate = `${parseInt(rate, 10)}`;
|
||||
};
|
||||
|
||||
changeCaptureAll = (all: boolean) => {
|
||||
this.captureAll = all;
|
||||
};
|
||||
|
||||
timezoneFix(defaultTimezone: Record<string, string>) {
|
||||
if (localStorage.getItem(TIMEZONE) === '[object Object]' || !localStorage.getItem(TIMEZONE)) {
|
||||
localStorage.setItem(TIMEZONE, JSON.stringify(defaultTimezone));
|
||||
}
|
||||
merge = (settings: any) => {
|
||||
for (const key in settings) {
|
||||
if (settings.hasOwnProperty(key)) {
|
||||
this.updateKey(key, settings[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateKey = (key: string, value: any) => {
|
||||
runInAction(() => {
|
||||
// @ts-ignore
|
||||
this[key] = value;
|
||||
});
|
||||
changeCaptureRate = (rate: string) => {
|
||||
if (!rate) return (this.captureRate = '0');
|
||||
// react do no see the difference between 01 and 1 decimals, this is why we have to use string casting
|
||||
if (parseInt(rate, 10) <= 100) this.captureRate = `${parseInt(rate, 10)}`;
|
||||
};
|
||||
|
||||
if (key === 'captureRate' || key === 'captureAll') return;
|
||||
if (key === 'shownTimezone') {
|
||||
return localStorage.setItem(SHOWN_TIMEZONE, value as string);
|
||||
}
|
||||
if (key === 'durationFilter' || key === 'timezone') {
|
||||
localStorage.setItem(`__$session-${key}$__`, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(`__$session-${key}$__`, value);
|
||||
}
|
||||
};
|
||||
changeCaptureAll = (all: boolean) => {
|
||||
this.captureAll = all;
|
||||
};
|
||||
|
||||
timezoneFix(defaultTimezone: Record<string, string>) {
|
||||
if (localStorage.getItem(TIMEZONE) === '[object Object]' || !localStorage.getItem(TIMEZONE)) {
|
||||
localStorage.setItem(TIMEZONE, JSON.stringify(defaultTimezone));
|
||||
}
|
||||
}
|
||||
|
||||
updateKey = (key: string, value: any) => {
|
||||
runInAction(() => {
|
||||
// @ts-ignore
|
||||
this[key] = value;
|
||||
});
|
||||
|
||||
if (key === 'captureRate' || key === 'captureAll') return;
|
||||
if (key === 'shownTimezone') {
|
||||
return localStorage.setItem(SHOWN_TIMEZONE, value as string);
|
||||
}
|
||||
if (key === 'durationFilter' || key === 'timezone') {
|
||||
localStorage.setItem(`__$session-${key}$__`, JSON.stringify(value));
|
||||
} else {
|
||||
localStorage.setItem(`__$session-${key}$__`, value);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,9 +39,8 @@ export default class MessageLoader {
|
|||
: (b: Uint8Array) => Promise.resolve(b)
|
||||
// Each time called - new fileReader created
|
||||
const unarchived = (b: Uint8Array) => {
|
||||
const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd
|
||||
// zstd magical numbers 40 181 47 253
|
||||
console.log(isZstd, b[0], b[1], b[2], b[3])
|
||||
const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd
|
||||
if (isZstd) {
|
||||
return fzstd.decompress(b)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -25,12 +25,25 @@ export default class SettingsService {
|
|||
.then((response) => response.data || 0);
|
||||
}
|
||||
|
||||
getSessions(filter: any): Promise<{ sessions: ISession[], total: number }> {
|
||||
fetchCaptureConditions(
|
||||
projectId: number
|
||||
): Promise<{ rate: number; captureAll: boolean; conditions: any[] }> {
|
||||
return this.client
|
||||
.get(`/${projectId}/conditions`)
|
||||
.then((response) => response.json())
|
||||
.then((response) => response.data || []);
|
||||
}
|
||||
|
||||
saveCaptureConditions(projectId: number, data: any) {
|
||||
return this.client.post(`/${projectId}/conditions`, data);
|
||||
}
|
||||
|
||||
getSessions(filter: any): Promise<{ sessions: ISession[]; total: number }> {
|
||||
return this.client
|
||||
.post('/sessions/search', filter)
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then((response) => response.data || [])
|
||||
.catch(e => Promise.reject(e))
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
getSessionInfo(sessionId: string, isLive?: boolean): Promise<ISession> {
|
||||
|
|
@ -44,46 +57,44 @@ export default class SettingsService {
|
|||
getLiveSessions(filter: any): Promise<{ sessions: ISession[] }> {
|
||||
return this.client
|
||||
.post('/assist/sessions', cleanParams(filter))
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then((response) => response.data || [])
|
||||
.catch(e => Promise.reject(e))
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
getErrorStack(sessionId: string, errorId: string): Promise<{ trace: IErrorStack[] }> {
|
||||
return this.client
|
||||
.get(`/sessions/${sessionId}/errors/${errorId}/sourcemaps`)
|
||||
.then(r => r.json())
|
||||
.then(j => j.data || {})
|
||||
.catch(e => Promise.reject(e))
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || {})
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
getAutoplayList(params = {}): Promise<{ sessionId: string }[]> {
|
||||
return this.client
|
||||
.post('/sessions/search/ids', cleanParams(params))
|
||||
.then(r => r.json())
|
||||
.then(j => j.data || [])
|
||||
.catch(e => Promise.reject(e))
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || [])
|
||||
.catch((e) => Promise.reject(e));
|
||||
}
|
||||
|
||||
toggleFavorite(sessionId: string): Promise<any> {
|
||||
return this.client
|
||||
.get(`/sessions/${sessionId}/favorite`)
|
||||
.catch(Promise.reject)
|
||||
return this.client.get(`/sessions/${sessionId}/favorite`).catch(Promise.reject);
|
||||
}
|
||||
|
||||
getClickMap(params = {}): Promise<any[]> {
|
||||
return this.client
|
||||
.post('/heatmaps/url', params)
|
||||
.then(r => r.json())
|
||||
.then(j => j.data || [])
|
||||
.catch(Promise.reject)
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || [])
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
|
||||
getRecordingStatus(): Promise<any> {
|
||||
return this.client
|
||||
.get('/check-recording-status')
|
||||
.then(r => r.json())
|
||||
.then(j => j.data || {})
|
||||
.catch(Promise.reject)
|
||||
.then((r) => r.json())
|
||||
.then((j) => j.data || {})
|
||||
.catch(Promise.reject);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ export const setQueryParamKeyFromFilterkey = (filterKey: string) => {
|
|||
return 'ff';
|
||||
case FilterKey.DURATION:
|
||||
return 'duration';
|
||||
case FilterKey.FEATURE_FLAG:
|
||||
return 'feature_flag';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -145,6 +147,8 @@ export const getFilterKeyTypeByKey = (key: string) => {
|
|||
return FilterKey.FETCH_FAILED;
|
||||
case 'duration':
|
||||
return FilterKey.DURATION;
|
||||
case FilterKey.FEATURE_FLAG:
|
||||
return 'feature_flag';
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -301,4 +305,5 @@ export enum FilterKey {
|
|||
SLOWEST_RESOURCES = 'slowestResources',
|
||||
|
||||
CLICKMAP_URL = 'clickMapUrl',
|
||||
FEATURE_FLAG = 'featureFlag',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { stringConditional, targetConditional } from "App/constants/filterOptions";
|
||||
import { KEYS } from 'Types/filter/customFilter';
|
||||
import Record from 'Types/Record';
|
||||
import { FilterType, FilterKey, FilterCategory } from './filterType';
|
||||
|
|
@ -509,7 +510,17 @@ export const flagConditionFilters = [
|
|||
}
|
||||
];
|
||||
|
||||
const pathAnalysisStartPoint = [
|
||||
export const conditionalFilters = [
|
||||
{
|
||||
key: FilterKey.CLICK,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.INTERACTIONS,
|
||||
label: 'Click',
|
||||
operator: 'on',
|
||||
operatorOptions: filterOptions.targetConditional,
|
||||
icon: 'filters/click',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.LOCATION,
|
||||
type: FilterType.MULTIPLE,
|
||||
|
|
@ -517,16 +528,112 @@ const pathAnalysisStartPoint = [
|
|||
label: 'Visited URL',
|
||||
placeholder: 'Enter path',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringOperators,
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: 'filters/location',
|
||||
isEvent: true
|
||||
}
|
||||
},
|
||||
{
|
||||
key: FilterKey.CUSTOM,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.JAVASCRIPT,
|
||||
label: 'Custom Events',
|
||||
placeholder: 'Enter event key',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: 'filters/custom',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH,
|
||||
type: FilterType.SUB_FILTERS,
|
||||
category: FilterCategory.JAVASCRIPT,
|
||||
operator: 'is',
|
||||
label: 'Network Request',
|
||||
filters: [
|
||||
{
|
||||
key: FilterKey.FETCH_URL,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.PERFORMANCE,
|
||||
label: 'with URL',
|
||||
placeholder: 'Enter path or URL',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_STATUS_CODE,
|
||||
type: FilterType.NUMBER_MULTIPLE,
|
||||
category: FilterCategory.PERFORMANCE,
|
||||
label: 'with status code',
|
||||
placeholder: 'Enter status code',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_METHOD,
|
||||
type: FilterType.MULTIPLE_DROPDOWN,
|
||||
category: FilterCategory.PERFORMANCE,
|
||||
label: 'with method',
|
||||
operator: 'is',
|
||||
placeholder: 'Select method type',
|
||||
operatorOptions: filterOptions.stringOperatorsLimited,
|
||||
icon: 'filters/fetch',
|
||||
options: filterOptions.methodOptions
|
||||
},
|
||||
{
|
||||
key: FilterKey.FETCH_DURATION,
|
||||
type: FilterType.NUMBER,
|
||||
category: FilterCategory.PERFORMANCE,
|
||||
label: 'with duration (ms)',
|
||||
placeholder: 'E.g. 12',
|
||||
operator: '=',
|
||||
operatorOptions: filterOptions.customOperators,
|
||||
icon: "filters/fetch"
|
||||
},
|
||||
],
|
||||
icon: 'filters/fetch',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.ERROR,
|
||||
type: FilterType.MULTIPLE,
|
||||
category: FilterCategory.JAVASCRIPT,
|
||||
label: 'Error Message',
|
||||
placeholder: 'E.g. Uncaught SyntaxError',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
icon: 'filters/error',
|
||||
isEvent: true
|
||||
},
|
||||
{
|
||||
key: FilterKey.DURATION,
|
||||
type: FilterType.DURATION,
|
||||
category: FilterCategory.RECORDING_ATTRIBUTES,
|
||||
label: 'Duration',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.getOperatorsByKeys(['is']),
|
||||
icon: "filters/duration",
|
||||
isEvent: false
|
||||
},
|
||||
{
|
||||
key: FilterKey.FEATURE_FLAG,
|
||||
type: FilterType.STRING,
|
||||
category: FilterCategory.METADATA,
|
||||
label: 'Feature Flag',
|
||||
operator: 'is',
|
||||
operatorOptions: filterOptions.stringConditional,
|
||||
isEvent: false
|
||||
},
|
||||
];
|
||||
|
||||
export const eventKeys = filters.filter((i) => i.isEvent).map(i => i.key);
|
||||
export const nonFlagFilters = filters.filter(i => {
|
||||
return flagConditionFilters.findIndex(f => f.key === i.key) === -1;
|
||||
}).map(i => i.key);
|
||||
export const nonConditionalFlagFilters = filters.filter(i => {
|
||||
return conditionalFilters.findIndex(f => f.key === i.key) === -1;
|
||||
}).map(i => i.key);
|
||||
|
||||
export const clickmapFilter = {
|
||||
key: FilterKey.LOCATION,
|
||||
|
|
@ -577,6 +684,7 @@ export const filterLabelMap = filters.reduce((acc, filter) => {
|
|||
export let filtersMap = mapFilters(filters);
|
||||
export let liveFiltersMap = mapLiveFilters(filters);
|
||||
export let fflagsConditionsMap = mapFilters(flagConditionFilters);
|
||||
export let conditionalFiltersMap = mapFilters(conditionalFilters);
|
||||
|
||||
export const clearMetaFilters = () => {
|
||||
filtersMap = mapFilters(filters);
|
||||
|
|
@ -633,6 +741,26 @@ export const addElementToFlagConditionsMap = (
|
|||
};
|
||||
};
|
||||
|
||||
export const addElementToConditionalFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
key,
|
||||
type = FilterType.MULTIPLE,
|
||||
operator = 'is',
|
||||
operatorOptions = filterOptions.stringOperators,
|
||||
icon = 'filters/metadata'
|
||||
) => {
|
||||
conditionalFiltersMap[key] = {
|
||||
key,
|
||||
type,
|
||||
category,
|
||||
label: capitalize(key),
|
||||
operator: operator,
|
||||
operatorOptions,
|
||||
icon,
|
||||
isLive: true
|
||||
};
|
||||
};
|
||||
|
||||
export const addElementToLiveFiltersMap = (
|
||||
category = FilterCategory.METADATA,
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -6,45 +6,48 @@ export const YELLOW = 'yellow';
|
|||
export const GREEN = 'green';
|
||||
|
||||
export const STATUS_COLOR_MAP = {
|
||||
[ RED ]: '#CC0000',
|
||||
[ YELLOW ]: 'orange',
|
||||
[ GREEN ]: 'green',
|
||||
}
|
||||
[RED]: '#CC0000',
|
||||
[YELLOW]: 'orange',
|
||||
[GREEN]: 'green',
|
||||
};
|
||||
|
||||
export default Record({
|
||||
id: undefined,
|
||||
name: '',
|
||||
host: '',
|
||||
platform: 'web',
|
||||
status: RED,
|
||||
lastRecordedSessionAt: undefined,
|
||||
gdpr: GDPR(),
|
||||
recorded: undefined,
|
||||
stackIntegrations: false,
|
||||
projectKey: undefined,
|
||||
trackerVersion: undefined,
|
||||
saveRequestPayloads: false,
|
||||
sampleRate: 0,
|
||||
}, {
|
||||
idKey: 'id',
|
||||
methods: {
|
||||
validate() {
|
||||
return this.name.length > 0;
|
||||
},
|
||||
|
||||
toData() {
|
||||
const js = this.toJS();
|
||||
console.log(js, this)
|
||||
delete js.key;
|
||||
delete js.gdpr;
|
||||
return js;
|
||||
},
|
||||
export default Record(
|
||||
{
|
||||
id: undefined,
|
||||
name: '',
|
||||
host: '',
|
||||
platform: 'web',
|
||||
status: RED,
|
||||
lastRecordedSessionAt: undefined,
|
||||
gdpr: GDPR(),
|
||||
recorded: undefined,
|
||||
stackIntegrations: false,
|
||||
projectKey: undefined,
|
||||
trackerVersion: undefined,
|
||||
saveRequestPayloads: false,
|
||||
sampleRate: 0,
|
||||
conditionsCount: 0,
|
||||
},
|
||||
fromJS: ({ gdpr, projectId, name, ...rest }) => ({
|
||||
...rest,
|
||||
host: name,
|
||||
name: name,
|
||||
id: projectId === undefined ? undefined : `${ projectId }`, //?!?!?!?!?
|
||||
gdpr: GDPR(gdpr),
|
||||
})
|
||||
});
|
||||
{
|
||||
idKey: 'id',
|
||||
methods: {
|
||||
validate() {
|
||||
return this.name.length > 0;
|
||||
},
|
||||
|
||||
toData() {
|
||||
const js = this.toJS();
|
||||
delete js.key;
|
||||
delete js.gdpr;
|
||||
return js;
|
||||
},
|
||||
},
|
||||
fromJS: ({ gdpr, projectId, name, ...rest }) => ({
|
||||
...rest,
|
||||
host: name,
|
||||
name: name,
|
||||
id: projectId === undefined ? undefined : `${projectId}`, //?!?!?!?!?
|
||||
gdpr: GDPR(gdpr),
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -318,6 +318,7 @@ export default class Assist {
|
|||
if (this.app.active()) {
|
||||
this.assistDemandedRestart = true
|
||||
this.app.stop()
|
||||
this.app.clearBuffers()
|
||||
setTimeout(() => {
|
||||
this.app.start().then(() => { this.assistDemandedRestart = false })
|
||||
.then(() => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "11.0.0-beta.7",
|
||||
"version": "11.0.2-39",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import ConditionsManager from '../modules/conditionsManager.js'
|
||||
import FeatureFlags from '../modules/featureFlags.js'
|
||||
import type Message from './messages.gen.js'
|
||||
import {
|
||||
Timestamp,
|
||||
|
|
@ -21,14 +23,13 @@ import Nodes from './nodes.js'
|
|||
import Observer from './observer/top_observer.js'
|
||||
import Sanitizer from './sanitizer.js'
|
||||
import Ticker from './ticker.js'
|
||||
import Logger, { LogLevel } from './logger.js'
|
||||
import Logger, { LogLevel, ILogLevel } from './logger.js'
|
||||
import Session from './session.js'
|
||||
import { gzip } from 'fflate'
|
||||
import { deviceMemory, jsHeapSizeLimit } from '../modules/performance.js'
|
||||
import AttributeSender from '../modules/attributeSender.js'
|
||||
import type { Options as ObserverOptions } from './observer/top_observer.js'
|
||||
import type { Options as SanitizerOptions } from './sanitizer.js'
|
||||
import type { Options as LoggerOptions } from './logger.js'
|
||||
import type { Options as SessOptions } from './session.js'
|
||||
import type { Options as NetworkOptions } from '../modules/network.js'
|
||||
import CanvasRecorder from './canvas.js'
|
||||
|
|
@ -60,6 +61,7 @@ interface OnStartInfo {
|
|||
|
||||
const CANCELED = 'canceled' as const
|
||||
const uxtStorageKey = 'or_uxt_active'
|
||||
const bufferStorageKey = 'or_buffer_1'
|
||||
const START_ERROR = ':(' as const
|
||||
type SuccessfulStart = OnStartInfo & {
|
||||
success: true
|
||||
|
|
@ -97,6 +99,7 @@ enum ActivityState {
|
|||
NotActive,
|
||||
Starting,
|
||||
Active,
|
||||
ColdStart,
|
||||
}
|
||||
|
||||
type AppOptions = {
|
||||
|
|
@ -109,18 +112,16 @@ type AppOptions = {
|
|||
local_uuid_key: string
|
||||
ingestPoint: string
|
||||
resourceBaseHref: string | null // resourceHref?
|
||||
//resourceURLRewriter: (url: string) => string | boolean,
|
||||
verbose: boolean
|
||||
__is_snippet: boolean
|
||||
__debug_report_edp: string | null
|
||||
__debug__?: LoggerOptions
|
||||
__debug__?: ILogLevel
|
||||
localStorage: Storage | null
|
||||
sessionStorage: Storage | null
|
||||
forceSingleTab?: boolean
|
||||
disableStringDict?: boolean
|
||||
assistSocketHost?: string
|
||||
|
||||
// @deprecated
|
||||
/** @deprecated */
|
||||
onStart?: StartCallback
|
||||
network?: NetworkOptions
|
||||
} & WebworkerOptions &
|
||||
|
|
@ -150,8 +151,14 @@ export default class App {
|
|||
readonly localStorage: Storage
|
||||
readonly sessionStorage: Storage
|
||||
private readonly messages: Array<Message> = []
|
||||
/**
|
||||
* we need 2 buffers so we don't lose anything
|
||||
* @read coldStart implementation
|
||||
* */
|
||||
private bufferedMessages1: Array<Message> = []
|
||||
private readonly bufferedMessages2: Array<Message> = []
|
||||
/* private */
|
||||
readonly observer: Observer // non-privat for attachContextCallback
|
||||
readonly observer: Observer // non-private for attachContextCallback
|
||||
private readonly startCallbacks: Array<StartCallback> = []
|
||||
private readonly stopCallbacks: Array<() => any> = []
|
||||
private readonly commitCallbacks: Array<CommitCallback> = []
|
||||
|
|
@ -169,11 +176,15 @@ export default class App {
|
|||
public attributeSender: AttributeSender
|
||||
private canvasRecorder: CanvasRecorder | null = null
|
||||
private uxtManager: UserTestManager
|
||||
private conditionsManager: ConditionsManager | null = null
|
||||
public featureFlags: FeatureFlags
|
||||
|
||||
constructor(projectKey: string, sessionToken: string | undefined, options: Partial<Options>) {
|
||||
// if (options.onStart !== undefined) {
|
||||
// deprecationWarn("'onStart' option", "tracker.start().then(/* handle session info */)")
|
||||
// } ?? maybe onStart is good
|
||||
constructor(
|
||||
projectKey: string,
|
||||
sessionToken: string | undefined,
|
||||
options: Partial<Options>,
|
||||
private readonly signalError: (error: string, apis: string[]) => void,
|
||||
) {
|
||||
this.contextId = Math.random().toString(36).slice(2)
|
||||
this.projectKey = projectKey
|
||||
this.networkOptions = options.network
|
||||
|
|
@ -188,9 +199,9 @@ export default class App {
|
|||
local_uuid_key: '__openreplay_uuid',
|
||||
ingestPoint: DEFAULT_INGEST_POINT,
|
||||
resourceBaseHref: null,
|
||||
verbose: false,
|
||||
__is_snippet: false,
|
||||
__debug_report_edp: null,
|
||||
__debug__: LogLevel.Silent,
|
||||
localStorage: null,
|
||||
sessionStorage: null,
|
||||
disableStringDict: false,
|
||||
|
|
@ -214,9 +225,9 @@ export default class App {
|
|||
this.ticker = new Ticker(this)
|
||||
this.ticker.attach(() => this.commit())
|
||||
this.debug = new Logger(this.options.__debug__)
|
||||
this.notify = new Logger(this.options.verbose ? LogLevel.Warnings : LogLevel.Silent)
|
||||
this.session = new Session(this, this.options)
|
||||
this.attributeSender = new AttributeSender(this, Boolean(this.options.disableStringDict))
|
||||
this.featureFlags = new FeatureFlags(this)
|
||||
this.session.attachUpdateCallback(({ userID, metadata }) => {
|
||||
if (userID != null) {
|
||||
// TODO: nullable userID
|
||||
|
|
@ -340,7 +351,7 @@ export default class App {
|
|||
body: JSON.stringify({
|
||||
context,
|
||||
// @ts-ignore
|
||||
error: `${e}`,
|
||||
error: `${e as unknown as string}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
@ -362,9 +373,17 @@ export default class App {
|
|||
if (this._usingOldFetchPlugin && message[0] === MType.NetworkRequest) {
|
||||
return
|
||||
}
|
||||
// ====================================================
|
||||
|
||||
this.messages.push(message)
|
||||
// ====================================================
|
||||
if (this.activityState === ActivityState.ColdStart) {
|
||||
this.bufferedMessages1.push(message)
|
||||
if (!this.singleBuffer) {
|
||||
this.bufferedMessages2.push(message)
|
||||
}
|
||||
this.conditionsManager?.processMessage(message)
|
||||
} else {
|
||||
this.messages.push(message)
|
||||
}
|
||||
// TODO: commit on start if there were `urgent` sends;
|
||||
// Clarify where urgent can be used for;
|
||||
// Clarify workflow for each type of message in case it was sent before start
|
||||
|
|
@ -375,7 +394,11 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
/**
|
||||
* Normal workflow: add timestamp and tab data to batch, then commit it
|
||||
* every ~30ms
|
||||
* */
|
||||
private _nCommit(): void {
|
||||
if (this.worker !== undefined && this.messages.length) {
|
||||
requestIdleCb(() => {
|
||||
this.messages.unshift(TabData(this.session.getTabId()))
|
||||
|
|
@ -388,6 +411,39 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
coldStartCommitN = 0
|
||||
|
||||
/**
|
||||
* Cold start: add timestamp and tab data to both batches
|
||||
* every 2nd tick, ~60ms
|
||||
* this will make batches a bit larger and replay will work with bigger jumps every frame
|
||||
* but in turn we don't overload batch writer on session start with 1000 batches
|
||||
* */
|
||||
private _cStartCommit(): void {
|
||||
this.coldStartCommitN += 1
|
||||
if (this.coldStartCommitN === 2) {
|
||||
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
||||
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
||||
this.bufferedMessages2.push(Timestamp(this.timestamp()))
|
||||
this.bufferedMessages2.push(TabData(this.session.getTabId()))
|
||||
this.coldStartCommitN = 0
|
||||
}
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
if (this.activityState === ActivityState.ColdStart) {
|
||||
this._cStartCommit()
|
||||
} else {
|
||||
this._nCommit()
|
||||
}
|
||||
}
|
||||
|
||||
private postToWorker(messages: Array<Message>) {
|
||||
this.worker?.postMessage(messages)
|
||||
this.commitCallbacks.forEach((cb) => cb(messages))
|
||||
messages.length = 0
|
||||
}
|
||||
|
||||
private delay = 0
|
||||
|
||||
timestamp(): number {
|
||||
|
|
@ -559,19 +615,197 @@ export default class App {
|
|||
}
|
||||
}
|
||||
|
||||
private _start(startOpts: StartOptions = {}, resetByWorker = false): Promise<StartPromiseReturn> {
|
||||
if (!this.worker) {
|
||||
return Promise.resolve(UnsuccessfulStart('No worker found: perhaps, CSP is not set.'))
|
||||
coldInterval: ReturnType<typeof setInterval> | null = null
|
||||
orderNumber = 0
|
||||
coldStartTs = 0
|
||||
singleBuffer = false
|
||||
|
||||
private checkSessionToken(forceNew?: boolean) {
|
||||
const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null
|
||||
const needNewSessionID = forceNew || lsReset
|
||||
const sessionToken = this.session.getSessionToken()
|
||||
|
||||
return needNewSessionID || !sessionToken
|
||||
}
|
||||
/**
|
||||
* start buffering messages without starting the actual session, which gives
|
||||
* user 30 seconds to "activate" and record session by calling `start()` on conditional trigger
|
||||
* and we will then send buffered batch, so it won't get lost
|
||||
* */
|
||||
public async coldStart(startOpts: StartOptions = {}, conditional?: boolean) {
|
||||
this.singleBuffer = false
|
||||
const second = 1000
|
||||
if (conditional) {
|
||||
this.conditionsManager = new ConditionsManager(this, startOpts)
|
||||
}
|
||||
if (this.activityState !== ActivityState.NotActive) {
|
||||
return Promise.resolve(
|
||||
UnsuccessfulStart(
|
||||
'OpenReplay: trying to call `start()` on the instance that has been started already.',
|
||||
),
|
||||
)
|
||||
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
||||
if (conditional) {
|
||||
const r = await fetch(this.options.ingestPoint + '/v1/web/start', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...this.getTrackerInfo(),
|
||||
timestamp: now(),
|
||||
doNotRecord: true,
|
||||
bufferDiff: 0,
|
||||
userID: this.session.getInfo().userID,
|
||||
token: undefined,
|
||||
deviceMemory,
|
||||
jsHeapSizeLimit,
|
||||
timezone: getTimezone(),
|
||||
}),
|
||||
})
|
||||
// this token is needed to fetch conditions and flags,
|
||||
// but it can't be used to record a session
|
||||
const {
|
||||
token,
|
||||
userBrowser,
|
||||
userCity,
|
||||
userCountry,
|
||||
userDevice,
|
||||
userOS,
|
||||
userState,
|
||||
projectID,
|
||||
} = await r.json()
|
||||
this.session.assign({ projectID })
|
||||
this.session.setUserInfo({
|
||||
userBrowser,
|
||||
userCity,
|
||||
userCountry,
|
||||
userDevice,
|
||||
userOS,
|
||||
userState,
|
||||
})
|
||||
const onStartInfo = { sessionToken: token, userUUID: '', sessionID: '' }
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo))
|
||||
await this.conditionsManager?.fetchConditions(projectID as string, token as string)
|
||||
await this.featureFlags.reloadFlags(token as string)
|
||||
this.conditionsManager?.processFlags(this.featureFlags.flags)
|
||||
}
|
||||
const cycle = () => {
|
||||
this.orderNumber += 1
|
||||
adjustTimeOrigin()
|
||||
this.coldStartTs = now()
|
||||
if (this.orderNumber % 2 === 0) {
|
||||
this.bufferedMessages1.length = 0
|
||||
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
||||
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
||||
} else {
|
||||
this.bufferedMessages2.length = 0
|
||||
this.bufferedMessages2.push(Timestamp(this.timestamp()))
|
||||
this.bufferedMessages2.push(TabData(this.session.getTabId()))
|
||||
}
|
||||
this.stop(false)
|
||||
this.activityState = ActivityState.ColdStart
|
||||
if (startOpts.sessionHash) {
|
||||
this.session.applySessionHash(startOpts.sessionHash)
|
||||
}
|
||||
if (startOpts.forceNew) {
|
||||
// Reset session metadata only if requested directly
|
||||
this.session.reset()
|
||||
}
|
||||
this.session.assign({
|
||||
// MBTODO: maybe it would make sense to `forceNew` if the `userID` was changed
|
||||
userID: startOpts.userID,
|
||||
metadata: startOpts.metadata,
|
||||
})
|
||||
if (!isNewSession) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
this.send(TabChange(this.session.getTabId()))
|
||||
}
|
||||
this.observer.observe()
|
||||
this.ticker.start()
|
||||
}
|
||||
this.coldInterval = setInterval(() => {
|
||||
cycle()
|
||||
}, 30 * second)
|
||||
cycle()
|
||||
}
|
||||
|
||||
public offlineRecording(startOpts: StartOptions = {}) {
|
||||
this.singleBuffer = true
|
||||
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
||||
adjustTimeOrigin()
|
||||
this.coldStartTs = now()
|
||||
this.bufferedMessages1.length = 0
|
||||
const saverBuffer = this.localStorage.getItem(bufferStorageKey)
|
||||
if (saverBuffer) {
|
||||
const data = JSON.parse(saverBuffer)
|
||||
this.bufferedMessages1 = Array.isArray(data) ? data : []
|
||||
this.localStorage.removeItem(bufferStorageKey)
|
||||
}
|
||||
this.bufferedMessages1.push(Timestamp(this.timestamp()))
|
||||
this.bufferedMessages1.push(TabData(this.session.getTabId()))
|
||||
this.activityState = ActivityState.ColdStart
|
||||
if (startOpts.sessionHash) {
|
||||
this.session.applySessionHash(startOpts.sessionHash)
|
||||
}
|
||||
if (startOpts.forceNew) {
|
||||
// Reset session metadata only if requested directly
|
||||
this.session.reset()
|
||||
}
|
||||
this.session.assign({
|
||||
// MBTODO: maybe it would make sense to `forceNew` if the `userID` was changed
|
||||
userID: startOpts.userID,
|
||||
metadata: startOpts.metadata,
|
||||
})
|
||||
if (!isNewSession) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
this.send(TabChange(this.session.getTabId()))
|
||||
}
|
||||
this.observer.observe()
|
||||
this.ticker.start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the captured messages in localStorage (or whatever is used in its place)
|
||||
*
|
||||
* Then when this.offlineRecording is called, it will preload this messages and clear the storage item
|
||||
*
|
||||
* Keeping the size of local storage reasonable is up to the end users of this library
|
||||
* */
|
||||
public saveBuffer() {
|
||||
this.localStorage.setItem(bufferStorageKey, JSON.stringify(this.bufferedMessages1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the stored buffer to create session
|
||||
* */
|
||||
public uploadOfflineRecording() {
|
||||
this.stop(false)
|
||||
// then fetch it
|
||||
this.clearBuffers()
|
||||
}
|
||||
|
||||
private _start(
|
||||
startOpts: StartOptions = {},
|
||||
resetByWorker = false,
|
||||
conditionName?: string,
|
||||
): Promise<StartPromiseReturn> {
|
||||
const isColdStart = this.activityState === ActivityState.ColdStart
|
||||
if (isColdStart && this.coldInterval) {
|
||||
clearInterval(this.coldInterval)
|
||||
}
|
||||
if (!this.worker) {
|
||||
const reason = 'No worker found: perhaps, CSP is not set.'
|
||||
this.signalError(reason, [])
|
||||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
}
|
||||
if (
|
||||
this.activityState === ActivityState.Active ||
|
||||
this.activityState === ActivityState.Starting
|
||||
) {
|
||||
const reason =
|
||||
'OpenReplay: trying to call `start()` on the instance that has been started already.'
|
||||
this.signalError(reason, [])
|
||||
return Promise.resolve(UnsuccessfulStart(reason))
|
||||
}
|
||||
this.activityState = ActivityState.Starting
|
||||
adjustTimeOrigin()
|
||||
if (!isColdStart) {
|
||||
adjustTimeOrigin()
|
||||
}
|
||||
|
||||
if (startOpts.sessionHash) {
|
||||
this.session.applySessionHash(startOpts.sessionHash)
|
||||
|
|
@ -598,15 +832,13 @@ export default class App {
|
|||
tabId: this.session.getTabId(),
|
||||
})
|
||||
|
||||
const lsReset = this.sessionStorage.getItem(this.options.session_reset_key) !== null
|
||||
this.sessionStorage.removeItem(this.options.session_reset_key)
|
||||
const needNewSessionID = startOpts.forceNew || lsReset || resetByWorker
|
||||
const sessionToken = this.session.getSessionToken()
|
||||
const isNewSession = needNewSessionID || !sessionToken
|
||||
const isNewSession = this.checkSessionToken(startOpts.forceNew)
|
||||
this.sessionStorage.removeItem(this.options.session_reset_key)
|
||||
|
||||
this.debug.log(
|
||||
'OpenReplay: starting session; need new session id?',
|
||||
needNewSessionID,
|
||||
isNewSession,
|
||||
'session token: ',
|
||||
sessionToken,
|
||||
)
|
||||
|
|
@ -619,11 +851,14 @@ export default class App {
|
|||
body: JSON.stringify({
|
||||
...this.getTrackerInfo(),
|
||||
timestamp,
|
||||
doNotRecord: false,
|
||||
bufferDiff: timestamp - this.coldStartTs,
|
||||
userID: this.session.getInfo().userID,
|
||||
token: isNewSession ? undefined : sessionToken,
|
||||
deviceMemory,
|
||||
jsHeapSizeLimit,
|
||||
timezone: getTimezone(),
|
||||
condition: conditionName,
|
||||
}),
|
||||
})
|
||||
.then((r) => {
|
||||
|
|
@ -641,10 +876,14 @@ export default class App {
|
|||
})
|
||||
.then((r) => {
|
||||
if (!this.worker) {
|
||||
return Promise.reject('no worker found after start request (this might not happen)')
|
||||
const reason = 'no worker found after start request (this might not happen)'
|
||||
this.signalError(reason, [])
|
||||
return Promise.reject(reason)
|
||||
}
|
||||
if (this.activityState === ActivityState.NotActive) {
|
||||
return Promise.reject('Tracker stopped during authorization')
|
||||
const reason = 'Tracker stopped during authorization'
|
||||
this.signalError(reason, [])
|
||||
return Promise.reject(reason)
|
||||
}
|
||||
const {
|
||||
token,
|
||||
|
|
@ -673,7 +912,9 @@ export default class App {
|
|||
typeof delay !== 'number' ||
|
||||
(typeof beaconSizeLimit !== 'number' && typeof beaconSizeLimit !== 'undefined')
|
||||
) {
|
||||
return Promise.reject(`Incorrect server response: ${JSON.stringify(r)}`)
|
||||
const reason = `Incorrect server response: ${JSON.stringify(r)}`
|
||||
this.signalError(reason, [])
|
||||
return Promise.reject(reason)
|
||||
}
|
||||
this.delay = delay
|
||||
this.session.setSessionToken(token)
|
||||
|
|
@ -711,10 +952,39 @@ export default class App {
|
|||
this.compressionThreshold = compressionThreshold
|
||||
const onStartInfo = { sessionToken: token, userUUID, sessionID }
|
||||
|
||||
const flushBuffer = (buffer: Message[]) => {
|
||||
let ended = false
|
||||
const messagesBatch: Message[] = [buffer.shift() as unknown as Message]
|
||||
while (!ended) {
|
||||
const nextMsg = buffer[0]
|
||||
if (!nextMsg || nextMsg[0] === MType.Timestamp) {
|
||||
ended = true
|
||||
} else {
|
||||
messagesBatch.push(buffer.shift() as unknown as Message)
|
||||
}
|
||||
}
|
||||
this.postToWorker(messagesBatch)
|
||||
}
|
||||
// TODO: start as early as possible (before receiving the token)
|
||||
this.startCallbacks.forEach((cb) => cb(onStartInfo)) // MBTODO: callbacks after DOM "mounted" (observed)
|
||||
this.observer.observe()
|
||||
this.ticker.start()
|
||||
void this.featureFlags.reloadFlags()
|
||||
/** --------------- COLD START BUFFER ------------------*/
|
||||
this.activityState = ActivityState.Active
|
||||
if (isColdStart) {
|
||||
const biggestBurger =
|
||||
this.bufferedMessages1.length > this.bufferedMessages2.length
|
||||
? this.bufferedMessages1
|
||||
: this.bufferedMessages2
|
||||
while (biggestBurger.length > 0) {
|
||||
flushBuffer(biggestBurger)
|
||||
}
|
||||
this.clearBuffers()
|
||||
this.commit()
|
||||
/** --------------- COLD START BUFFER ------------------*/
|
||||
} else {
|
||||
this.observer.observe()
|
||||
this.ticker.start()
|
||||
}
|
||||
|
||||
if (canvasEnabled) {
|
||||
this.canvasRecorder =
|
||||
|
|
@ -722,9 +992,7 @@ export default class App {
|
|||
new CanvasRecorder(this, { fps: canvasFPS, quality: canvasQuality })
|
||||
this.canvasRecorder.startTracking()
|
||||
}
|
||||
this.activityState = ActivityState.Active
|
||||
|
||||
this.notify.log('OpenReplay tracking started.')
|
||||
// get rid of onStart ?
|
||||
if (typeof this.options.onStart === 'function') {
|
||||
this.options.onStart(onStartInfo)
|
||||
|
|
@ -767,11 +1035,12 @@ export default class App {
|
|||
this.stop()
|
||||
this.session.reset()
|
||||
if (reason === CANCELED) {
|
||||
this.signalError(CANCELED, [])
|
||||
return UnsuccessfulStart(CANCELED)
|
||||
}
|
||||
|
||||
this.notify.log('OpenReplay was unable to start. ', reason)
|
||||
this._debug('session_start', reason)
|
||||
this.signalError(START_ERROR, [])
|
||||
return UnsuccessfulStart(START_ERROR)
|
||||
})
|
||||
}
|
||||
|
|
@ -821,7 +1090,7 @@ export default class App {
|
|||
return this.session.getTabId()
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Creates a named hook that expects event name, data string and msg direction (up/down),
|
||||
* it will skip any message bigger than 5 mb or event name bigger than 255 symbols
|
||||
* @returns {(msgType: string, data: string, dir: 'up' | 'down') => void}
|
||||
|
|
@ -840,6 +1109,11 @@ export default class App {
|
|||
this.send(WSChannel('websocket', channel, data, this.timestamp(), dir, msgType))
|
||||
}
|
||||
}
|
||||
|
||||
clearBuffers() {
|
||||
this.bufferedMessages1.length = 0
|
||||
this.bufferedMessages2.length = 0
|
||||
}
|
||||
|
||||
stop(stopWorker = true): void {
|
||||
if (this.activityState !== ActivityState.NotActive) {
|
||||
|
|
@ -850,7 +1124,7 @@ export default class App {
|
|||
this.nodes.clear()
|
||||
this.ticker.stop()
|
||||
this.stopCallbacks.forEach((cb) => cb())
|
||||
this.notify.log('OpenReplay tracking stopped.')
|
||||
this.debug.log('OpenReplay tracking stopped.')
|
||||
if (this.worker && stopWorker) {
|
||||
this.worker.postMessage('stop')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,59 +5,37 @@ export const LogLevel = {
|
|||
Errors: 2,
|
||||
Silent: 0,
|
||||
} as const
|
||||
type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]
|
||||
|
||||
type CustomLevel = {
|
||||
error: boolean
|
||||
warn: boolean
|
||||
log: boolean
|
||||
}
|
||||
|
||||
function IsCustomLevel(l: LogLevel | CustomLevel): l is CustomLevel {
|
||||
return typeof l === 'object'
|
||||
}
|
||||
|
||||
interface _Options {
|
||||
level: LogLevel | CustomLevel
|
||||
messages?: number[]
|
||||
}
|
||||
|
||||
export type Options = true | _Options | LogLevel
|
||||
export type ILogLevel = (typeof LogLevel)[keyof typeof LogLevel]
|
||||
|
||||
export default class Logger {
|
||||
private readonly options: _Options
|
||||
constructor(options: Options = LogLevel.Silent) {
|
||||
this.options =
|
||||
options === true
|
||||
? { level: LogLevel.Verbose }
|
||||
: typeof options === 'number'
|
||||
? { level: options }
|
||||
: options
|
||||
private readonly level: ILogLevel
|
||||
|
||||
constructor(debugLevel: ILogLevel = LogLevel.Silent) {
|
||||
this.level = debugLevel
|
||||
}
|
||||
log(...args: any) {
|
||||
if (
|
||||
IsCustomLevel(this.options.level)
|
||||
? this.options.level.log
|
||||
: this.options.level >= LogLevel.Log
|
||||
) {
|
||||
|
||||
private shouldLog(level: ILogLevel): boolean {
|
||||
return this.level >= level
|
||||
}
|
||||
|
||||
log(...args: any[]) {
|
||||
if (this.shouldLog(LogLevel.Log)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
console.log(...args)
|
||||
}
|
||||
}
|
||||
warn(...args: any) {
|
||||
if (
|
||||
IsCustomLevel(this.options.level)
|
||||
? this.options.level.warn
|
||||
: this.options.level >= LogLevel.Warnings
|
||||
) {
|
||||
|
||||
warn(...args: any[]) {
|
||||
if (this.shouldLog(LogLevel.Warnings)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
console.warn(...args)
|
||||
}
|
||||
}
|
||||
error(...args: any) {
|
||||
if (
|
||||
IsCustomLevel(this.options.level)
|
||||
? this.options.level.error
|
||||
: this.options.level >= LogLevel.Errors
|
||||
) {
|
||||
|
||||
error(...args: any[]) {
|
||||
if (this.shouldLog(LogLevel.Errors)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
console.error(...args)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,90 +113,121 @@ export default class API {
|
|||
)
|
||||
return
|
||||
}
|
||||
const doNotTrack =
|
||||
options.respectDoNotTrack &&
|
||||
const doNotTrack = this.checkDoNotTrack()
|
||||
const failReason: string[] = []
|
||||
const conditions: string[] = [
|
||||
'Map',
|
||||
'Set',
|
||||
'MutationObserver',
|
||||
'performance',
|
||||
'timing',
|
||||
'startsWith',
|
||||
'Blob',
|
||||
'Worker',
|
||||
]
|
||||
|
||||
if (doNotTrack) {
|
||||
failReason.push('doNotTrack')
|
||||
} else {
|
||||
for (const condition of conditions) {
|
||||
if (condition === 'timing') {
|
||||
if ('performance' in window && !(condition in performance)) {
|
||||
failReason.push(condition)
|
||||
break
|
||||
}
|
||||
} else if (condition === 'startsWith') {
|
||||
if (!(condition in String.prototype)) {
|
||||
failReason.push(condition)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
if (!(condition in window)) {
|
||||
failReason.push(condition)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (failReason.length > 0) {
|
||||
const missingApi = failReason.join(',')
|
||||
console.error(
|
||||
`OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1. Reason: ${missingApi}`,
|
||||
)
|
||||
this.signalStartIssue('missing_api', failReason)
|
||||
return
|
||||
}
|
||||
|
||||
const app = new App(options.projectKey, options.sessionToken, options, this.signalStartIssue)
|
||||
this.app = app
|
||||
Viewport(app)
|
||||
CSSRules(app)
|
||||
ConstructedStyleSheets(app)
|
||||
Connection(app)
|
||||
Console(app, options)
|
||||
Exception(app, options)
|
||||
Img(app)
|
||||
Input(app, options)
|
||||
Mouse(app, options.mouse)
|
||||
Timing(app, options)
|
||||
Performance(app, options)
|
||||
Scroll(app)
|
||||
Focus(app)
|
||||
Fonts(app)
|
||||
Network(app, options.network)
|
||||
Selection(app)
|
||||
Tabs(app)
|
||||
;(window as any).__OPENREPLAY__ = this
|
||||
|
||||
if (options.flags?.onFlagsLoad) {
|
||||
this.onFlagsLoad(options.flags.onFlagsLoad)
|
||||
}
|
||||
const wOpen = window.open
|
||||
if (options.autoResetOnWindowOpen || options.resetTabOnWindowOpen) {
|
||||
app.attachStartCallback(() => {
|
||||
const tabId = app.getTabId()
|
||||
const sessStorage = app.sessionStorage ?? window.sessionStorage
|
||||
// @ts-ignore ?
|
||||
window.open = function (...args) {
|
||||
if (options.autoResetOnWindowOpen) {
|
||||
app.resetNextPageSession(true)
|
||||
}
|
||||
if (options.resetTabOnWindowOpen) {
|
||||
sessStorage.removeItem(options.session_tabid_key || '__openreplay_tabid')
|
||||
}
|
||||
wOpen.call(window, ...args)
|
||||
app.resetNextPageSession(false)
|
||||
sessStorage.setItem(options.session_tabid_key || '__openreplay_tabid', tabId)
|
||||
}
|
||||
})
|
||||
app.attachStopCallback(() => {
|
||||
window.open = wOpen
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
checkDoNotTrack = () => {
|
||||
return (
|
||||
this.options.respectDoNotTrack &&
|
||||
(navigator.doNotTrack == '1' ||
|
||||
// @ts-ignore
|
||||
window.doNotTrack == '1')
|
||||
const app = (this.app =
|
||||
doNotTrack ||
|
||||
!('Map' in window) ||
|
||||
!('Set' in window) ||
|
||||
!('MutationObserver' in window) ||
|
||||
!('performance' in window) ||
|
||||
!('timing' in performance) ||
|
||||
!('startsWith' in String.prototype) ||
|
||||
!('Blob' in window) ||
|
||||
!('Worker' in window)
|
||||
? null
|
||||
: new App(options.projectKey, options.sessionToken, options))
|
||||
if (app !== null) {
|
||||
Viewport(app)
|
||||
CSSRules(app)
|
||||
ConstructedStyleSheets(app)
|
||||
Connection(app)
|
||||
Console(app, options)
|
||||
Exception(app, options)
|
||||
Img(app)
|
||||
Input(app, options)
|
||||
Mouse(app, options.mouse)
|
||||
Timing(app, options)
|
||||
Performance(app, options)
|
||||
Scroll(app)
|
||||
Focus(app)
|
||||
Fonts(app)
|
||||
Network(app, options.network)
|
||||
Selection(app)
|
||||
Tabs(app)
|
||||
this.featureFlags = new FeatureFlags(app)
|
||||
;(window as any).__OPENREPLAY__ = this
|
||||
)
|
||||
}
|
||||
|
||||
app.attachStartCallback(() => {
|
||||
if (options.flags?.onFlagsLoad) {
|
||||
this.onFlagsLoad(options.flags.onFlagsLoad)
|
||||
}
|
||||
void this.featureFlags.reloadFlags()
|
||||
})
|
||||
const wOpen = window.open
|
||||
if (options.autoResetOnWindowOpen || options.resetTabOnWindowOpen) {
|
||||
app.attachStartCallback(() => {
|
||||
const tabId = app.getTabId()
|
||||
const sessStorage = app.sessionStorage ?? window.sessionStorage
|
||||
// @ts-ignore ?
|
||||
window.open = function (...args) {
|
||||
if (options.autoResetOnWindowOpen) {
|
||||
app.resetNextPageSession(true)
|
||||
}
|
||||
if (options.resetTabOnWindowOpen) {
|
||||
sessStorage.removeItem(options.session_tabid_key || '__openreplay_tabid')
|
||||
}
|
||||
wOpen.call(window, ...args)
|
||||
app.resetNextPageSession(false)
|
||||
sessStorage.setItem(options.session_tabid_key || '__openreplay_tabid', tabId)
|
||||
}
|
||||
})
|
||||
app.attachStopCallback(() => {
|
||||
window.open = wOpen
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"OpenReplay: browser doesn't support API required for tracking or doNotTrack is set to 1.",
|
||||
)
|
||||
const req = new XMLHttpRequest()
|
||||
const orig = options.ingestPoint || DEFAULT_INGEST_POINT
|
||||
req.open('POST', orig + '/v1/web/not-started')
|
||||
// no-cors issue only with text/plain or not-set Content-Type
|
||||
// req.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
trackerVersion: 'TRACKER_VERSION',
|
||||
projectKey: options.projectKey,
|
||||
doNotTrack,
|
||||
// TODO: add precise reason (an exact API missing)
|
||||
}),
|
||||
)
|
||||
}
|
||||
signalStartIssue = (reason: string, missingApi: string[]) => {
|
||||
const doNotTrack = this.checkDoNotTrack()
|
||||
const req = new XMLHttpRequest()
|
||||
const orig = this.options.ingestPoint || DEFAULT_INGEST_POINT
|
||||
req.open('POST', orig + '/v1/web/not-started')
|
||||
req.send(
|
||||
JSON.stringify({
|
||||
trackerVersion: 'TRACKER_VERSION',
|
||||
projectKey: this.options.projectKey,
|
||||
doNotTrack,
|
||||
reason,
|
||||
missingApi,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
isFlagEnabled(flagName: string): boolean {
|
||||
|
|
@ -204,23 +235,23 @@ export default class API {
|
|||
}
|
||||
|
||||
onFlagsLoad(callback: (flags: IFeatureFlag[]) => void): void {
|
||||
this.featureFlags.onFlagsLoad(callback)
|
||||
this.app?.featureFlags.onFlagsLoad(callback)
|
||||
}
|
||||
|
||||
clearPersistFlags() {
|
||||
this.featureFlags.clearPersistFlags()
|
||||
this.app?.featureFlags.clearPersistFlags()
|
||||
}
|
||||
|
||||
reloadFlags() {
|
||||
return this.featureFlags.reloadFlags()
|
||||
return this.app?.featureFlags.reloadFlags()
|
||||
}
|
||||
|
||||
getFeatureFlag(flagName: string): IFeatureFlag | undefined {
|
||||
return this.featureFlags.getFeatureFlag(flagName)
|
||||
return this.app?.featureFlags.getFeatureFlag(flagName)
|
||||
}
|
||||
|
||||
getAllFeatureFlags() {
|
||||
return this.featureFlags.flags
|
||||
return this.app?.featureFlags.flags
|
||||
}
|
||||
|
||||
use<T>(fn: (app: App | null, options?: Options) => T): T {
|
||||
|
|
@ -249,17 +280,39 @@ export default class API {
|
|||
}
|
||||
|
||||
start(startOpts?: Partial<StartOptions>): Promise<StartPromiseReturn> {
|
||||
if (this.browserEnvCheck()) {
|
||||
if (this.app === null) {
|
||||
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
|
||||
}
|
||||
return this.app.start(startOpts)
|
||||
} else {
|
||||
return Promise.reject('Trying to start not in browser.')
|
||||
}
|
||||
}
|
||||
|
||||
browserEnvCheck() {
|
||||
if (!IN_BROWSER) {
|
||||
console.error(
|
||||
`OpenReplay: you are trying to start Tracker on a node.js environment. If you want to use OpenReplay with SSR, please, use componentDidMount or useEffect API for placing the \`tracker.start()\` line. Check documentation on ${DOCS_HOST}${DOCS_SETUP}`,
|
||||
)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* start buffering messages without starting the actual session, which gives user 30 seconds to "activate" and record
|
||||
* session by calling start() on conditional trigger and we will then send buffered batch, so it won't get lost
|
||||
* */
|
||||
coldStart(startOpts?: Partial<StartOptions>, conditional?: boolean) {
|
||||
if (this.browserEnvCheck()) {
|
||||
if (this.app === null) {
|
||||
return Promise.reject('Tracker not initialized')
|
||||
}
|
||||
void this.app.coldStart(startOpts, conditional)
|
||||
} else {
|
||||
return Promise.reject('Trying to start not in browser.')
|
||||
}
|
||||
if (this.app === null) {
|
||||
return Promise.reject("Browser doesn't support required api, or doNotTrack is active.")
|
||||
}
|
||||
// TODO: check argument type
|
||||
return this.app.start(startOpts)
|
||||
}
|
||||
|
||||
stop(): string | undefined {
|
||||
|
|
|
|||
420
tracker/tracker/src/main/modules/conditionsManager.ts
Normal file
420
tracker/tracker/src/main/modules/conditionsManager.ts
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import Message, {
|
||||
CustomEvent,
|
||||
JSException,
|
||||
MouseClick,
|
||||
NetworkRequest,
|
||||
SetPageLocation,
|
||||
Type,
|
||||
} from '../../common/messages.gen.js'
|
||||
import App, { StartOptions } from '../app/index.js'
|
||||
import { IFeatureFlag } from './featureFlags.js'
|
||||
|
||||
interface Filter {
|
||||
filters: {
|
||||
operator: string
|
||||
value: string[]
|
||||
type: string
|
||||
source?: string
|
||||
}[]
|
||||
operator: string
|
||||
value: string[]
|
||||
type: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
capture_rate: number
|
||||
name: string
|
||||
filters: Filter[]
|
||||
}
|
||||
|
||||
export default class ConditionsManager {
|
||||
conditions: Condition[] = []
|
||||
hasStarted = false
|
||||
|
||||
constructor(
|
||||
private readonly app: App,
|
||||
private readonly startParams: StartOptions,
|
||||
) {}
|
||||
|
||||
setConditions(conditions: Condition[]) {
|
||||
this.conditions = conditions
|
||||
}
|
||||
|
||||
async fetchConditions(projectId: string, token: string) {
|
||||
try {
|
||||
const r = await fetch(`${this.app.options.ingestPoint}/v1/web/conditions/${projectId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
const { conditions } = (await r.json()) as { conditions: ApiResponse[] }
|
||||
const mappedConditions: Condition[] = []
|
||||
conditions.forEach((c) => {
|
||||
const filters = c.filters
|
||||
filters.forEach((filter) => {
|
||||
let cond: Condition | undefined
|
||||
if (filter.type === 'fetch') {
|
||||
filter.filters.forEach((f) => {
|
||||
cond = this.createConditionFromFilter(f as unknown as Filter)
|
||||
})
|
||||
} else {
|
||||
cond = this.createConditionFromFilter(filter)
|
||||
}
|
||||
if (cond) {
|
||||
if (cond.type === 'session_duration') {
|
||||
this.processDuration(cond.value[0], c.name)
|
||||
}
|
||||
mappedConditions.push({ ...cond, name: c.name })
|
||||
}
|
||||
})
|
||||
})
|
||||
this.conditions = mappedConditions
|
||||
} catch (e) {
|
||||
this.app.debug.error('Critical: cannot fetch start conditions')
|
||||
}
|
||||
}
|
||||
|
||||
createConditionFromFilter = (filter: Filter) => {
|
||||
if (filter.value.length) {
|
||||
const resultCondition = mapCondition(filter)
|
||||
if (resultCondition.type) {
|
||||
return resultCondition
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
trigger(conditionName: string) {
|
||||
if (this.hasStarted) return
|
||||
try {
|
||||
this.hasStarted = true
|
||||
void this.app.start(this.startParams, undefined, conditionName)
|
||||
} catch (e) {
|
||||
this.app.debug.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
processMessage(message: Message) {
|
||||
if (this.hasStarted) return
|
||||
|
||||
switch (message[0]) {
|
||||
case Type.JSException:
|
||||
this.jsExceptionEvent(message)
|
||||
break
|
||||
case Type.CustomEvent:
|
||||
this.customEvent(message)
|
||||
break
|
||||
case Type.MouseClick:
|
||||
this.clickEvent(message)
|
||||
break
|
||||
case Type.SetPageLocation:
|
||||
this.pageLocationEvent(message)
|
||||
break
|
||||
case Type.NetworkRequest:
|
||||
this.networkRequest(message)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
processFlags(flag: IFeatureFlag[]) {
|
||||
const flagConds = this.conditions.filter(
|
||||
(c) => c.type === 'feature_flag',
|
||||
) as FeatureFlagCondition[]
|
||||
if (flagConds.length) {
|
||||
flagConds.forEach((flagCond) => {
|
||||
const operator = operators[flagCond.operator]
|
||||
if (operator && flag.find((f) => operator(f.key, flagCond.value))) {
|
||||
this.trigger(flagCond.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
durationInt: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
processDuration(durationMs: number, condName: string) {
|
||||
this.durationInt = setInterval(() => {
|
||||
const sessionLength = performance.now()
|
||||
if (sessionLength > durationMs) {
|
||||
this.trigger(condName)
|
||||
}
|
||||
}, 1000)
|
||||
this.app.attachStopCallback(() => {
|
||||
if (this.durationInt) {
|
||||
clearInterval(this.durationInt)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
networkRequest(message: NetworkRequest) {
|
||||
// method - 2, url - 3, status - 6, duration - 8
|
||||
const reqConds = this.conditions.filter(
|
||||
(c) => c.type === 'network_request',
|
||||
) as NetworkRequestCondition[]
|
||||
const withoutAny = reqConds.filter((c) => c.operator !== 'isAny')
|
||||
if (withoutAny.length) {
|
||||
withoutAny.forEach((reqCond) => {
|
||||
let value
|
||||
switch (reqCond.key) {
|
||||
case 'url':
|
||||
value = message[3]
|
||||
break
|
||||
case 'status':
|
||||
value = message[6]
|
||||
break
|
||||
case 'method':
|
||||
value = message[2]
|
||||
break
|
||||
case 'duration':
|
||||
value = message[8]
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
const operator = operators[reqCond.operator] as (a: string, b: string[]) => boolean
|
||||
// @ts-ignore
|
||||
if (operator && operator(value, reqCond.value)) {
|
||||
this.trigger(reqCond.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (withoutAny.length === 0 && reqConds.length) {
|
||||
this.trigger(reqConds[0].name)
|
||||
}
|
||||
}
|
||||
|
||||
customEvent(message: CustomEvent) {
|
||||
// name - 1, payload - 2
|
||||
const evConds = this.conditions.filter((c) => c.type === 'custom_event') as CommonCondition[]
|
||||
console.log(message, evConds)
|
||||
if (evConds.length) {
|
||||
evConds.forEach((evCond) => {
|
||||
const operator = operators[evCond.operator] as (a: string, b: string[]) => boolean
|
||||
console.log(
|
||||
operator,
|
||||
evCond,
|
||||
operator(message[1], evCond.value),
|
||||
operator(message[2], evCond.value),
|
||||
)
|
||||
if (
|
||||
operator &&
|
||||
(operator(message[1], evCond.value) || operator(message[2], evCond.value))
|
||||
) {
|
||||
this.trigger(evCond.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
clickEvent(message: MouseClick) {
|
||||
// label - 3, selector - 4
|
||||
const clickCond = this.conditions.filter((c) => c.type === 'click') as CommonCondition[]
|
||||
if (clickCond.length) {
|
||||
clickCond.forEach((click) => {
|
||||
const operator = operators[click.operator] as (a: string, b: string[]) => boolean
|
||||
if (operator && (operator(message[3], click.value) || operator(message[4], click.value))) {
|
||||
this.trigger(click.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pageLocationEvent(message: SetPageLocation) {
|
||||
// url - 1
|
||||
const urlConds = this.conditions.filter((c) => c.type === 'visited_url') as CommonCondition[]
|
||||
if (urlConds) {
|
||||
urlConds.forEach((urlCond) => {
|
||||
const operator = operators[urlCond.operator] as (a: string, b: string[]) => boolean
|
||||
if (operator && operator(message[1], urlCond.value)) {
|
||||
this.trigger(urlCond.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
jsExceptionEvent(message: JSException) {
|
||||
// name - 1, message - 2, payload - 3
|
||||
const testedValues = [message[1], message[2], message[3]]
|
||||
const exceptionConds = this.conditions.filter(
|
||||
(c) => c.type === 'exception',
|
||||
) as ExceptionCondition[]
|
||||
if (exceptionConds) {
|
||||
exceptionConds.forEach((exceptionCond) => {
|
||||
const operator = operators[exceptionCond.operator]
|
||||
if (operator && testedValues.some((val) => operator(val, exceptionCond.value))) {
|
||||
this.trigger(exceptionCond.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// duration,
|
||||
type CommonCondition = {
|
||||
type: 'visited_url' | 'click' | 'custom_event'
|
||||
operator: keyof typeof operators
|
||||
value: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
type ExceptionCondition = {
|
||||
type: 'exception'
|
||||
operator: 'contains' | 'startsWith' | 'endsWith'
|
||||
value: string[]
|
||||
name: string
|
||||
}
|
||||
type FeatureFlagCondition = {
|
||||
type: 'feature_flag'
|
||||
operator: 'is'
|
||||
value: string[]
|
||||
name: string
|
||||
}
|
||||
type SessionDurationCondition = {
|
||||
type: 'session_duration'
|
||||
value: number[]
|
||||
name: string
|
||||
}
|
||||
type NetworkRequestCondition = {
|
||||
type: 'network_request'
|
||||
key: 'url' | 'status' | 'method' | 'duration'
|
||||
operator: keyof typeof operators
|
||||
value: string[]
|
||||
name: string
|
||||
}
|
||||
type Condition =
|
||||
| CommonCondition
|
||||
| ExceptionCondition
|
||||
| FeatureFlagCondition
|
||||
| SessionDurationCondition
|
||||
| NetworkRequestCondition
|
||||
|
||||
const operators = {
|
||||
is: (val: string, target: string[]) => target.some((t) => val.includes(t)),
|
||||
isAny: () => true,
|
||||
isNot: (val: string, target: string[]) => !target.some((t) => val.includes(t)),
|
||||
contains: (val: string, target: string[]) => target.some((t) => val.includes(t)),
|
||||
notContains: (val: string, target: string[]) => !target.some((t) => val.includes(t)),
|
||||
startsWith: (val: string, target: string[]) => target.some((t) => val.startsWith(t)),
|
||||
endsWith: (val: string, target: string[]) => target.some((t) => val.endsWith(t)),
|
||||
greaterThan: (val: number, target: number) => val > target,
|
||||
greaterOrEqual: (val: number, target: number) => val >= target,
|
||||
lessOrEqual: (val: number, target: number) => val <= target,
|
||||
lessThan: (val: number, target: number) => val < target,
|
||||
}
|
||||
|
||||
const mapCondition = (condition: Filter): Condition => {
|
||||
const opMap = {
|
||||
on: 'is',
|
||||
notOn: 'isNot',
|
||||
'\u003e': 'greaterThan',
|
||||
'\u003c': 'lessThan',
|
||||
'\u003d': 'is',
|
||||
'\u003c=': 'lessOrEqual',
|
||||
'\u003e=': 'greaterOrEqual',
|
||||
}
|
||||
|
||||
const mapOperator = (operator: string) => {
|
||||
const keys = Object.keys(opMap)
|
||||
// @ts-ignore
|
||||
if (keys.includes(operator)) return opMap[operator]
|
||||
}
|
||||
|
||||
let con = {
|
||||
type: '',
|
||||
operator: '',
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
switch (condition.type) {
|
||||
case 'click':
|
||||
con = {
|
||||
type: 'click',
|
||||
operator: mapOperator(condition.operator),
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'location':
|
||||
con = {
|
||||
type: 'visited_url',
|
||||
// @ts-ignore
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'custom':
|
||||
con = {
|
||||
type: 'custom_event',
|
||||
// @ts-ignore
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'metadata':
|
||||
con = {
|
||||
// @ts-ignore
|
||||
type: condition.source === 'featureFlag' ? 'feature_flag' : condition.type,
|
||||
// @ts-ignore
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'error':
|
||||
con = {
|
||||
type: 'exception',
|
||||
// @ts-ignore
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'duration':
|
||||
con = {
|
||||
type: 'session_duration',
|
||||
// @ts-ignore
|
||||
value: condition.value[0],
|
||||
key: '',
|
||||
}
|
||||
break
|
||||
case 'fetchUrl':
|
||||
con = {
|
||||
type: 'network_request',
|
||||
key: 'url',
|
||||
operator: condition.operator,
|
||||
value: condition.value,
|
||||
}
|
||||
break
|
||||
case 'fetchStatusCode':
|
||||
con = {
|
||||
type: 'network_request',
|
||||
key: 'status',
|
||||
operator: mapOperator(condition.operator),
|
||||
value: condition.value,
|
||||
}
|
||||
break
|
||||
case 'fetchMethod':
|
||||
con = {
|
||||
type: 'network_request',
|
||||
key: 'method',
|
||||
operator: mapOperator(condition.operator),
|
||||
value: condition.value,
|
||||
}
|
||||
break
|
||||
case 'fetchDuration':
|
||||
con = {
|
||||
type: 'network_request',
|
||||
key: 'duration',
|
||||
operator: mapOperator(condition.operator),
|
||||
value: condition.value,
|
||||
}
|
||||
break
|
||||
}
|
||||
// @ts-ignore
|
||||
return con
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ export default class FeatureFlags {
|
|||
this.onFlagsCb = cb
|
||||
}
|
||||
|
||||
async reloadFlags() {
|
||||
async reloadFlags(token?: string) {
|
||||
const persistFlagsStr = this.app.sessionStorage.getItem(this.storageKey)
|
||||
const persistFlags: Record<string, FetchPersistFlagsData> = {}
|
||||
if (persistFlagsStr) {
|
||||
|
|
@ -63,11 +63,12 @@ export default class FeatureFlags {
|
|||
persistFlags: persistFlags,
|
||||
}
|
||||
|
||||
const authToken = token ?? (this.app.session.getSessionToken() as string)
|
||||
const resp = await fetch(this.app.options.ingestPoint + '/v1/web/feature-flags', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.app.session.getSessionToken() as string}`,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(requestObject),
|
||||
})
|
||||
|
|
|
|||
194
tracker/tracker/src/tests/conditionsManager.test.ts
Normal file
194
tracker/tracker/src/tests/conditionsManager.test.ts
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
// @ts-nocheck
|
||||
import ConditionsManager from '../main/modules/conditionsManager'
|
||||
import { describe, expect, jest, afterEach, beforeEach, test } from '@jest/globals'
|
||||
|
||||
const Type = {
|
||||
JSException: 78,
|
||||
CustomEvent: 27,
|
||||
MouseClick: 69,
|
||||
SetPageLocation: 4,
|
||||
NetworkRequest: 83,
|
||||
}
|
||||
|
||||
describe('ConditionsManager', () => {
|
||||
// Mock dependencies
|
||||
let appMock
|
||||
let startOptionsMock
|
||||
|
||||
beforeEach(() => {
|
||||
appMock = {
|
||||
start: jest.fn(),
|
||||
debug: { error: jest.fn() },
|
||||
options: { ingestPoint: 'https://example.com' },
|
||||
attachStopCallback: jest.fn(),
|
||||
}
|
||||
startOptionsMock = {}
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
conditions: [
|
||||
{
|
||||
name: 'Condition Set',
|
||||
filters: [
|
||||
{
|
||||
filters: [],
|
||||
is_event: true,
|
||||
operator: 'is',
|
||||
source: null,
|
||||
sourceOperator: null,
|
||||
type: 'custom',
|
||||
value: ['event'],
|
||||
},
|
||||
{
|
||||
filters: [],
|
||||
is_event: true,
|
||||
operator: 'is',
|
||||
source: ['js_exception'],
|
||||
sourceOperator: null,
|
||||
type: 'error',
|
||||
value: ['err msg'],
|
||||
},
|
||||
],
|
||||
capture_rate: 100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
test('constructor initializes properties', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
expect(manager.conditions).toEqual([])
|
||||
expect(manager.hasStarted).toBeFalsy()
|
||||
})
|
||||
|
||||
test('setConditions sets the conditions', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
const conditions = [{ type: 'visited_url', operator: 'is', value: ['example.com'] }]
|
||||
manager.setConditions(conditions)
|
||||
expect(manager.conditions).toEqual(conditions)
|
||||
})
|
||||
|
||||
test('fetchConditions fetches and sets conditions', async () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
await manager.fetchConditions('token')
|
||||
expect(manager.conditions).toEqual([
|
||||
{
|
||||
type: 'custom_event',
|
||||
operator: 'is',
|
||||
value: ['event'],
|
||||
key: '',
|
||||
name: 'Condition Set',
|
||||
},
|
||||
{ type: 'exception', operator: 'is', value: ['err msg'], key: '', name: 'Condition Set' },
|
||||
])
|
||||
})
|
||||
|
||||
test('trigger method starts app when not started', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.trigger('test')
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
expect(appMock.start).toHaveBeenCalledWith(startOptionsMock, undefined, 'test')
|
||||
})
|
||||
|
||||
test('trigger method does nothing if already started', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.hasStarted = true
|
||||
manager.trigger()
|
||||
expect(appMock.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('processMessage correctly processes a JSException message', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.setConditions([{ type: 'exception', operator: 'contains', value: ['Error'] }])
|
||||
const jsExceptionMessage = [
|
||||
Type.JSException,
|
||||
'TypeError',
|
||||
'An Error occurred',
|
||||
'Payload',
|
||||
'Metadata',
|
||||
]
|
||||
manager.processMessage(jsExceptionMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
|
||||
test('processMessage correctly processes a CustomEvent message', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.setConditions([{ type: 'custom_event', operator: 'is', value: ['eventName'] }])
|
||||
const customEventMessage = [Type.CustomEvent, 'eventName', 'Payload']
|
||||
manager.processMessage(customEventMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
|
||||
test('processMessage correctly processes a MouseClick message', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.setConditions([{ type: 'click', operator: 'is', value: ['clickLabel'] }])
|
||||
const mouseClickMessage = [Type.MouseClick, 123, 200, 'clickLabel', 'selector']
|
||||
manager.processMessage(mouseClickMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
|
||||
test('processMessage correctly processes a SetPageLocation message', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.setConditions([{ type: 'visited_url', operator: 'is', value: ['https://example.com'] }])
|
||||
const setPageLocationMessage = [
|
||||
Type.SetPageLocation,
|
||||
'https://example.com',
|
||||
'referrer',
|
||||
Date.now(),
|
||||
]
|
||||
manager.processMessage(setPageLocationMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
|
||||
test('processMessage correctly processes a NetworkRequest message', () => {
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.setConditions([
|
||||
{ type: 'network_request', key: 'url', operator: 'is', value: ['https://api.example.com'] },
|
||||
{ type: 'network_request', key: 'status', operator: 'greaterThan', value: 200 },
|
||||
])
|
||||
const networkRequestMessage = [
|
||||
Type.NetworkRequest,
|
||||
'XHR',
|
||||
'GET',
|
||||
'https://api.example.com',
|
||||
'Request',
|
||||
'Response',
|
||||
200,
|
||||
Date.now(),
|
||||
4000,
|
||||
1024,
|
||||
]
|
||||
const failedNetworkRequestMessage = [
|
||||
Type.NetworkRequest,
|
||||
'XHR',
|
||||
'GET',
|
||||
'https://asdasd.test.eu',
|
||||
'Request',
|
||||
'Response',
|
||||
400,
|
||||
Date.now(),
|
||||
4000,
|
||||
1024,
|
||||
]
|
||||
manager.processMessage(networkRequestMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
manager.hasStarted = false
|
||||
manager.processMessage(failedNetworkRequestMessage)
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
|
||||
test('processDuration triggers after specified duration', () => {
|
||||
jest.useFakeTimers()
|
||||
const manager = new ConditionsManager(appMock, startOptionsMock)
|
||||
manager.processDuration(5, 'test') // 5 seconds
|
||||
jest.advanceTimersByTime(6000) // Advance timer by 5 seconds
|
||||
expect(manager.hasStarted).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
|
@ -34,7 +34,7 @@ describe('UserTestManager', () => {
|
|||
userTestManager.createGreeting('Hello', true, true)
|
||||
expect(document.body.innerHTML).toContain('Hello')
|
||||
expect(document.body.innerHTML).toContain(
|
||||
"We're recording this browser tab to learn from your experience.",
|
||||
`Welcome, you're here to help us improve, not to be judged.`,
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ function initiateRestart(): void {
|
|||
postMessage('restart')
|
||||
reset()
|
||||
}
|
||||
|
||||
function initiateFailure(reason: string): void {
|
||||
postMessage({ type: 'failure', reason })
|
||||
reset()
|
||||
|
|
@ -92,7 +93,7 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
|
|||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (writer !== null) {
|
||||
if (writer) {
|
||||
const w = writer
|
||||
data.forEach((message) => {
|
||||
if (message[0] === MType.SetPageVisibility) {
|
||||
|
|
@ -105,8 +106,7 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
|
|||
}
|
||||
w.writeMessage(message)
|
||||
})
|
||||
}
|
||||
if (!writer) {
|
||||
} else {
|
||||
postMessage('not_init')
|
||||
initiateRestart()
|
||||
}
|
||||
|
|
@ -152,7 +152,9 @@ self.onmessage = ({ data }: { data: ToWorkerData }): any => {
|
|||
data.pageNo,
|
||||
data.timestamp,
|
||||
data.url,
|
||||
(batch) => sender && sender.push(batch),
|
||||
(batch) => {
|
||||
sender && sender.push(batch)
|
||||
},
|
||||
data.tabId,
|
||||
)
|
||||
if (sendIntervalID === null) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue