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:
Delirium 2024-01-09 13:18:26 +01:00 committed by GitHub
parent 261239bd30
commit 5e21d88e8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2112 additions and 723 deletions

View file

@ -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));

View file

@ -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();

View file

@ -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);

View file

@ -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>

View file

@ -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>

View 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);

View 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);

View file

@ -0,0 +1 @@
export { default } from './Conditions'

View file

@ -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>
);
}

View file

@ -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}/>
)}
</>
)}

View file

@ -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
)}

View file

@ -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']),

View file

@ -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>
)}

View file

@ -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:

View file

@ -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));

View file

@ -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;

View file

@ -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="" /> */}

View file

@ -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
};

View file

@ -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))

View file

@ -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:

View file

@ -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(() => {

View file

@ -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())
};
}
}

View file

@ -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() {

View file

@ -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;
}
}

View file

@ -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);
}
};
}

View file

@ -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 {

View file

@ -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);
}
}

View file

@ -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',
}

View file

@ -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,

View file

@ -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),
}),
}
);

View file

@ -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(() => {

View file

@ -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"

View file

@ -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')
}

View file

@ -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)
}
}

View file

@ -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 {

View 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
}

View file

@ -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),
})

View 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()
})
})

View file

@ -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.`,
)
})

View file

@ -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) {