Merge pull request #988 from openreplay/resource-timing-from-file

feat(frontend): use ResourceTiming from file instead of database
This commit is contained in:
Alex K 2023-02-24 18:22:45 +01:00 committed by GitHub
commit cf990dd7c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 165 additions and 395 deletions

View file

@ -1,148 +0,0 @@
import React from 'react';
import cn from 'classnames';
import { connectPlayer, } from 'Player';
import { Tooltip, TextEllipsis } from 'UI';
import { getRE } from 'App/utils';
import { TYPES } from 'Types/session/resource';
import stl from './network.module.css';
import NetworkContent from './NetworkContent';
import { connect } from 'react-redux';
import { setTimelinePointer } from 'Duck/sessions';
const ALL = 'ALL';
const XHR = 'xhr';
const JS = 'js';
const CSS = 'css';
const IMG = 'img';
const MEDIA = 'media';
const OTHER = 'other';
const TAB_TO_TYPE_MAP = {
[XHR]: TYPES.XHR,
[JS]: TYPES.JS,
[CSS]: TYPES.CSS,
[IMG]: TYPES.IMG,
[MEDIA]: TYPES.MEDIA,
[OTHER]: TYPES.OTHER,
};
export function renderName(r) {
return (
<div className="flex justify-between items-center grow-0 w-full">
<Tooltip
style={{ maxWidth: '75%' }}
title={<div className={stl.popupNameContent}>{r.url}</div>}
>
<TextEllipsis>{r.name}</TextEllipsis>
</Tooltip>
</div>
);
}
export function renderDuration(r) {
if (!r.success) return 'x';
const text = `${Math.round(r.duration)}ms`;
if (!r.isRed && !r.isYellow) return text;
let tooltipText;
let className = 'w-full h-full flex items-center ';
if (r.isYellow) {
tooltipText = 'Slower than average';
className += 'warn color-orange';
} else {
tooltipText = 'Much slower than average';
className += 'error color-red';
}
return (
<Tooltip title={tooltipText}>
<div className={cn(className, stl.duration)}> {text} </div>
</Tooltip>
);
}
@connectPlayer((state) => ({
location: state.location,
resources: state.resourceList,
domContentLoadedTime: state.domContentLoadedTime,
loadTime: state.loadTime,
// time: state.time,
playing: state.playing,
domBuildingTime: state.domBuildingTime,
fetchPresented: state.fetchList.length > 0,
listNow: state.resourceListNow,
}))
@connect(
(state) => ({
timelinePointer: state.getIn(['sessions', 'timelinePointer']),
}),
{ setTimelinePointer }
)
export default class Network extends React.PureComponent {
state = {
filter: '',
filteredList: this.props.resources,
activeTab: ALL,
currentIndex: 0,
};
onRowClick = (e, index) => {
// no action for direct click on network requests (so far), there is a jump button, and we don't have more information for than is already displayed in the table
};
onTabClick = (activeTab) => this.setState({ activeTab });
onFilterChange = (e, { value }) => {
const { resources } = this.props;
const filterRE = getRE(value, 'i');
const filtered = resources.filter(
({ type, name }) =>
filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab])
);
this.setState({ filter: value, filteredList: value ? filtered : resources, currentIndex: 0 });
};
static getDerivedStateFromProps(nextProps, prevState) {
const { filteredList } = prevState;
if (nextProps.timelinePointer) {
const activeItem = filteredList.find((r) => r.time >= nextProps.timelinePointer.time);
return {
currentIndex: activeItem ? filteredList.indexOf(activeItem) : filteredList.length - 1,
};
}
}
render() {
const { location, domContentLoadedTime, loadTime, domBuildingTime, fetchPresented, listNow } =
this.props;
const { filteredList } = this.state;
const resourcesSize = filteredList.reduce(
(sum, { decodedBodySize }) => sum + (decodedBodySize || 0),
0
);
const transferredSize = filteredList.reduce(
(sum, { headerSize, encodedBodySize }) => sum + (headerSize || 0) + (encodedBodySize || 0),
0
);
return (
<React.Fragment>
<NetworkContent
// time = { time }
location={location}
resources={filteredList}
domContentLoadedTime={domContentLoadedTime}
loadTime={loadTime}
domBuildingTime={domBuildingTime}
fetchPresented={fetchPresented}
resourcesSize={resourcesSize}
transferredSize={transferredSize}
onRowClick={this.onRowClick}
currentIndex={listNow.length - 1}
/>
</React.Fragment>
);
}
}

View file

@ -2,7 +2,7 @@ import React from 'react';
import cn from 'classnames';
import { QuestionMarkHint, Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
import { getRE } from 'App/utils';
import { TYPES } from 'Types/session/resource';
import { ResourceType } from 'Player';
import { formatBytes } from 'App/utils';
import { formatMs } from 'App/date';
@ -21,12 +21,12 @@ const MEDIA = 'media';
const OTHER = 'other';
const TAB_TO_TYPE_MAP = {
[XHR]: TYPES.XHR,
[JS]: TYPES.JS,
[CSS]: TYPES.CSS,
[IMG]: TYPES.IMG,
[MEDIA]: TYPES.MEDIA,
[OTHER]: TYPES.OTHER,
[XHR]: ResourceType.XHR,
[JS]: ResourceType.SCRIPT,
[CSS]: ResourceType.CSS,
[IMG]: ResourceType.IMG,
[MEDIA]: ResourceType.MEDIA,
[OTHER]: ResourceType.OTHER,
};
const TABS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER].map((tab) => ({
text: tab,

View file

@ -1,2 +0,0 @@
export { default } from './Network';
export * from './Network';

View file

@ -3,7 +3,7 @@ import { observer } from 'mobx-react-lite';
import { Duration } from 'luxon';
import { Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
import { TYPES } from 'Types/session/resource';
import { ResourceType } from 'Player';
import { formatBytes } from 'App/utils';
import { formatMs } from 'App/date';
import { useModal } from 'App/components/Modal';
@ -28,13 +28,13 @@ const MEDIA = 'media';
const OTHER = 'other';
const TYPE_TO_TAB = {
[TYPES.XHR]: XHR,
[TYPES.FETCH]: XHR,
[TYPES.JS]: JS,
[TYPES.CSS]: CSS,
[TYPES.IMG]: IMG,
[TYPES.MEDIA]: MEDIA,
[TYPES.OTHER]: OTHER,
[ResourceType.XHR]: XHR,
[ResourceType.FETCH]: XHR,
[ResourceType.SCRIPT]: JS,
[ResourceType.CSS]: CSS,
[ResourceType.IMG]: IMG,
[ResourceType.MEDIA]: MEDIA,
[ResourceType.OTHER]: OTHER,
}
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER] as const;
@ -154,7 +154,7 @@ function NetworkPanel({ startedAt }: { startedAt: number }) {
const activeIndex = devTools[INDEX_KEY].index;
const list = useMemo(() =>
// TODO: better merge (with body size info)
// TODO: better merge (with body size info) - do it in player
resourceList.filter(res => !fetchList.some(ft => {
// res.url !== ft.url doesn't work on relative URLs appearing within fetchList (to-fix in player)
if (res.name !== ft.name) { return false }

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import FetchBasicDetails from './components/FetchBasicDetails';
import { Button } from 'UI';
import { TYPES } from 'Types/session/resource';
import { ResourceType } from 'Player';
import FetchTabs from './components/FetchTabs/FetchTabs';
import { useStore } from 'App/mstore';
import { DateTime } from 'luxon';
@ -17,7 +17,7 @@ function FetchDetailsModal(props: Props) {
const [resource, setResource] = useState(props.resource);
const [first, setFirst] = useState(false);
const [last, setLast] = useState(false);
const isXHR = resource.type === TYPES.XHR || resource.type === TYPES.FETCH
const isXHR = resource.type === ResourceType.XHR || resource.type === ResourceType.FETCH
const {
sessionStore: { devTools },
settingsStore: { sessionSettings: { timezone }},

View file

@ -2,9 +2,9 @@
import { Decoder } from "syncod";
import logger from 'App/logger';
import Resource, { TYPES as RES_TYPES } from 'Types/session/resource';
import { TYPES as EVENT_TYPES } from 'Types/session/event';
import { Log } from './types';
import { Log } from './types/log';
import { Resource, ResourceType, getResourceFromResourceTiming, getResourceFromNetworkRequest } from './types/resource'
import { toast } from 'react-toastify';
@ -395,19 +395,13 @@ export default class MessageManager {
Log(msg)
)
break;
case MType.ResourceTiming:
// TODO: merge `resource` and `fetch` lists into one here instead of UI
this.lists.lists.resource.insert(getResourceFromResourceTiming(msg, this.sessionStart))
break;
case MType.Fetch:
case MType.NetworkRequest:
this.lists.lists.fetch.insert(new Resource({
method: msg.method,
url: msg.url,
request: msg.request,
response: msg.response,
status: msg.status,
duration: msg.duration,
type: msg.type === "xhr" ? RES_TYPES.XHR : RES_TYPES.FETCH,
time: Math.max(msg.timestamp - this.sessionStart, 0), // !!! doesn't look good. TODO: find solution to show negative timings
index,
}) as Timed)
this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart))
break;
case MType.Redux:
decoded = this.decodeStateMessage(msg, ["state", "action"]);

View file

@ -1,4 +1,4 @@
import { Log, LogLevel } from './types'
import { Log, LogLevel } from './types/log'
import type { Store } from 'App/player'
import Player from '../player/Player'
@ -30,7 +30,6 @@ export default class WebPlayer extends Player {
let initialLists = live ? {} : {
event: session.events || [],
stack: session.stackEvents || [],
resource: session.resources || [], // MBTODO: put ResourceTiming in file
exceptions: session.errors?.map(({ name, ...rest }: any) =>
Log({
level: LogLevel.ERROR,

View file

@ -1,47 +0,0 @@
export enum LogLevel {
INFO = 'info',
LOG = 'log',
//ASSERT = 'assert', //?
WARN = 'warn',
ERROR = 'error',
EXCEPTION = 'exception',
}
export interface ILog {
level: LogLevel
value: string
time: number
index?: number
errorId?: string
}
export const Log = (log: ILog) => ({
isRed: log.level === LogLevel.EXCEPTION || log.level === LogLevel.ERROR,
isYellow: log.level === LogLevel.WARN,
...log
})
// func getResourceType(initiator string, URL string) string {
// switch initiator {
// case "xmlhttprequest", "fetch":
// return "fetch"
// case "img":
// return "img"
// default:
// switch getURLExtention(URL) {
// case "css":
// return "stylesheet"
// case "js":
// return "script"
// case "png", "gif", "jpg", "jpeg", "svg":
// return "img"
// case "mp4", "mkv", "ogg", "webm", "avi", "mp3":
// return "media"
// default:
// return "other"
// }
// }
// }

View file

@ -0,0 +1,2 @@
export * from './log'
export * from './resource'

View file

@ -0,0 +1,23 @@
export const enum LogLevel {
INFO = 'info',
LOG = 'log',
//ASSERT = 'assert', //?
WARN = 'warn',
ERROR = 'error',
EXCEPTION = 'exception',
}
export interface ILog {
level: LogLevel
value: string
time: number
index?: number
errorId?: string
}
export const Log = (log: ILog) => ({
isRed: log.level === LogLevel.EXCEPTION || log.level === LogLevel.ERROR,
isYellow: log.level === LogLevel.WARN,
...log
})

View file

@ -0,0 +1,114 @@
import type { ResourceTiming, NetworkRequest, Fetch } from '../messages'
export const enum ResourceType {
XHR = 'xhr',
FETCH = 'fetch',
SCRIPT = 'script',
CSS = 'css',
IMG = 'img',
MEDIA = 'media',
OTHER = 'other',
}
function getURLExtention(url: string): string {
const pts = url.split("?")[0].split(".")
return pts[pts.length-1] || ""
}
// maybe move this thing to the tracker
function getResourceType(initiator: string, url: string): ResourceType {
switch (initiator) {
case "xmlhttprequest":
case "fetch":
return ResourceType.FETCH
case "img":
return ResourceType.IMG
default:
switch (getURLExtention(url)) {
case "css":
return ResourceType.CSS
case "js":
return ResourceType.SCRIPT
case "png":
case "gif":
case "jpg":
case "jpeg":
case "svg":
return ResourceType.IMG
case "mp4":
case "mkv":
case "ogg":
case "webm":
case "avi":
case "mp3":
return ResourceType.MEDIA
default:
return ResourceType.OTHER
}
}
}
function getResourceName(url: string) {
return url
.split('/')
.filter((s) => s !== '')
.pop();
}
const YELLOW_BOUND = 10;
const RED_BOUND = 80;
interface IResource {
//index: number,
time: number,
type: ResourceType,
url: string,
status: string,
method: string,
duration: number,
success: boolean,
ttfb?: number,
request?: string,
response?: string,
headerSize?: number,
encodedBodySize?: number,
decodedBodySize?: number,
responseBodySize?: number,
}
export const Resource = (resource: IResource) => ({
...resource,
name: getResourceName(resource.url),
isRed: !resource.success, //|| resource.score >= RED_BOUND,
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
})
export function getResourceFromResourceTiming(msg: ResourceTiming, sessStart: number) {
const success = msg.duration > 0 // might be duration=0 when cached
const type = getResourceType(msg.initiator, msg.url)
return Resource({
...msg,
type,
method: type === ResourceType.FETCH ? ".." : "GET", // should be GET for all non-XHR/Fetch resources, right?
success,
status: success ? '2xx-3xx' : '4xx-5xx',
time: Math.max(0, msg.timestamp - sessStart)
})
}
export function getResourceFromNetworkRequest(msg: NetworkRequest | Fetch, sessStart: number) {
return Resource({
...msg,
// @ts-ignore
type: msg?.type === "xhr" ? ResourceType.XHR : ResourceType.FETCH,
success: msg.status < 400,
status: String(msg.status),
time: Math.max(0, msg.timestamp - sessStart),
})
}

View file

@ -1,103 +0,0 @@
import Record from 'Types/Record';
import { getResourceName } from 'App/utils';
const XHR = 'xhr' as const;
const FETCH = 'fetch' as const;
const JS = 'script' as const;
const CSS = 'css' as const;
const IMG = 'img' as const;
const MEDIA = 'media' as const;
const OTHER = 'other' as const;
function getResourceStatus(status: number, success: boolean) {
if (status !== undefined) return String(status);
if (typeof success === 'boolean' || typeof success === 'number') {
return !!success
? '2xx-3xx'
: '4xx-5xx';
}
return '2xx-3xx';
}
export const TYPES = {
XHR,
FETCH,
JS,
CSS,
IMG,
MEDIA,
OTHER,
"stylesheet": CSS,
}
const YELLOW_BOUND = 10;
const RED_BOUND = 80;
export function isRed(r: IResource) {
return !r.success || r.score >= RED_BOUND;
}
interface IResource {
type: keyof typeof TYPES,
url: string,
name: string,
status: number,
duration: number,
index: number,
time: number,
ttfb: number,
timewidth: number,
success: boolean,
score: number,
method: string,
request: string,
response: string,
headerSize: number,
encodedBodySize: number,
decodedBodySize: number,
responseBodySize: number,
timings: Record<string, any>
datetime: number
timestamp: number
}
export default class Resource {
name = 'Resource'
type: IResource["type"]
status: string
success: IResource["success"]
time: IResource["time"]
ttfb: IResource["ttfb"]
url: IResource["url"]
duration: IResource["duration"]
index: IResource["index"]
timewidth: IResource["timewidth"]
score: IResource["score"]
method: IResource["method"]
request: IResource["request"]
response: IResource["response"]
headerSize: IResource["headerSize"]
encodedBodySize: IResource["encodedBodySize"]
decodedBodySize: IResource["decodedBodySize"]
responseBodySize: IResource["responseBodySize"]
timings: IResource["timings"]
constructor({ status, success, time, datetime, timestamp, timings, ...resource }: IResource) {
// adjusting for 201, 202 etc
const reqSuccess = 300 > status || success
Object.assign(this, {
...resource,
name: getResourceName(resource.url),
status: getResourceStatus(status, success),
success: reqSuccess,
time: typeof time === 'number' ? time : datetime || timestamp,
ttfb: timings && timings.ttfb,
timewidth: timings && timings.timewidth,
timings,
isRed: !reqSuccess || resource.score >= RED_BOUND,
isYellow: resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
})
}
}

View file

@ -1,7 +1,6 @@
import { Duration } from 'luxon';
import SessionEvent, { TYPES, EventData, InjectedEvent } from './event';
import StackEvent from './stackEvent';
import Resource from './resource';
import SessionError, { IError } from './error';
import Issue, { IIssue } from './issue';
import { Note } from 'App/services/NotesService'
@ -31,8 +30,6 @@ export interface ISession {
duration: number,
events: InjectedEvent[],
stackEvents: StackEvent[],
resources: Resource[],
missedResources: Resource[],
metadata: [],
favorite: boolean,
filterId?: string,
@ -119,7 +116,6 @@ export default class Session {
duration: ISession["duration"]
events: ISession["events"]
stackEvents: ISession["stackEvents"]
resources: ISession["resources"]
metadata: ISession["metadata"]
favorite: ISession["favorite"]
filterId?: ISession["filterId"]
@ -181,7 +177,6 @@ export default class Session {
devtoolsURL = [],
mobsUrl = [],
notes = [],
resources = [],
...session
} = sessionData
const duration = Duration.fromMillis(session.duration < 1000 ? 1000 : session.duration);
@ -208,13 +203,6 @@ export default class Session {
})
}
let resourcesList = resources.map((r) => new Resource(r as any));
resourcesList.forEach((r: Resource) => {
r.time = Math.max(0, r.time - startedAt)
})
resourcesList = resourcesList.sort((r1, r2) => r1.time - r2.time);
const missedResources = resourcesList.filter(({ success }) => !success);
const stackEventsList: StackEvent[] = []
if (stackEvents?.length || session.userEvents?.length) {
const mergedArrays = [...stackEvents, ...session.userEvents]
@ -245,8 +233,6 @@ export default class Session {
siteId: projectId,
events,
stackEvents: stackEventsList,
resources: resourcesList,
missedResources,
userDevice,
userDeviceType,
isMobile,

View file

@ -1,14 +0,0 @@
import { Record } from 'immutable';
const DomBuildingTime = Record({
avg: undefined,
chart: [],
});
function fromJS(data = {}) {
if (data instanceof DomBuildingTime) return data;
return new DomBuildingTime(data);
}
export default fromJS;

View file

@ -1,27 +0,0 @@
import { getChartFormatter } from './helper';
import DomBuildingTime from './domBuildingTime';
export const WIDGET_LIST = [
{
key: "resourcesLoadingTime",
name: "Resource Fetch Time",
description: 'List of resources that are slowing down your website, sorted by the number of impacted sessions.',
thumb: 'na.png',
type: 'resources',
dataWrapper: (list, period) => DomBuildingTime(list)
.update("chart", getChartFormatter(period))
},
];
export const WIDGET_KEYS = WIDGET_LIST.map(({ key }) => key);
const WIDGET_MAP = {};
WIDGET_LIST.forEach(w => { WIDGET_MAP[ w.key ] = w; });
const OVERVIEW_WIDGET_MAP = {};
WIDGET_LIST.filter(w => w.type === 'overview').forEach(w => { OVERVIEW_WIDGET_MAP[ w.key ] = w; });
export {
WIDGET_MAP,
OVERVIEW_WIDGET_MAP
};

View file

@ -17,13 +17,6 @@ export function debounce(callback, wait, context = this) {
};
}
export function getResourceName(url: string) {
return url
.split('/')
.filter((s) => s !== '')
.pop();
}
/* eslint-disable no-mixed-operators */
export function randomInt(a, b) {
const min = (b ? a : 0) - 0.5;