feat(tracker): add beacon proxy and request body size (#1389)

* feat(tracker): add beacon proxy and body size

* feat(ui): remove unused components

* feat(ui): generate new messages, add body size to resource parser

* feat(ui): fix tooltip text, fix size detection (ts safe)

* feat(ui): cover resource with tests

* feat(ui): enable test coverage for player, utils and mstore

* fix(tracker): adjust test to support new message

* fix(tracker): fix tracker version for back compat

* feat(backend): added new column to network requests

* fix(tracker): fix unit tests

* fix(backend): fix msg gen

* fix(tracker): ci fun

* fix(tracker): changelog

* fix(tracker): fix some test

---------

Co-authored-by: Alexander Zavorotynskiy <zavorotynskiy@pm.me>
This commit is contained in:
Delirium 2023-10-17 12:25:20 +02:00 committed by GitHub
parent deba51833c
commit c7e5145282
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 2580 additions and 2600 deletions

View file

@ -193,9 +193,9 @@ func (conn *BulkSet) initBulks() {
}
conn.webNetworkRequest, err = NewBulk(conn.c,
"events_common.requests",
"(session_id, timestamp, seq_index, url, host, path, query, request_body, response_body, status_code, method, duration, success)",
"($%d, $%d, $%d, LEFT($%d, 8000), LEFT($%d, 300), LEFT($%d, 2000), LEFT($%d, 8000), $%d, $%d, $%d::smallint, NULLIF($%d, '')::http_method, $%d, $%d)",
13, 200)
"(session_id, timestamp, seq_index, url, host, path, query, request_body, response_body, status_code, method, duration, success, transfer_size)",
"($%d, $%d, $%d, LEFT($%d, 8000), LEFT($%d, 300), LEFT($%d, 2000), LEFT($%d, 8000), $%d, $%d, $%d::smallint, NULLIF($%d, '')::http_method, $%d, $%d, $%d)",
14, 200)
if err != nil {
log.Fatalf("can't create webNetworkRequest bulk: %s", err)
}

View file

@ -183,7 +183,7 @@ func (conn *Conn) InsertWebNetworkRequest(sess *sessions.Session, e *messages.Ne
return err
}
conn.bulks.Get("webNetworkRequest").Append(sess.SessionID, e.Meta().Timestamp, truncSqIdx(e.Meta().Index), e.URL, host, path, query,
request, response, e.Status, url.EnsureMethod(e.Method), e.Duration, e.Status < 400)
request, response, e.Status, url.EnsureMethod(e.Method), e.Duration, e.Status < 400, e.TransferredBodySize)
return nil
}

View file

@ -22,7 +22,7 @@ const (
MsgSetInputValue = 18
MsgSetInputChecked = 19
MsgMouseMove = 20
MsgNetworkRequest = 21
MsgNetworkRequestDeprecated = 21
MsgConsoleLog = 22
MsgPageLoadTiming = 23
MsgPageRenderTiming = 24
@ -76,6 +76,7 @@ const (
MsgBatchMeta = 80
MsgBatchMetadata = 81
MsgPartitionedMessage = 82
MsgNetworkRequest = 83
MsgInputChange = 112
MsgSelectionChange = 113
MsgMouseThrashing = 114
@ -107,7 +108,6 @@ const (
MsgIOSIssueEvent = 111
)
type Timestamp struct {
message
Timestamp uint64
@ -274,7 +274,6 @@ func (msg *SetViewportScroll) TypeID() int {
type CreateDocument struct {
message
}
func (msg *CreateDocument) Encode() []byte {
@ -606,7 +605,7 @@ func (msg *MouseMove) TypeID() int {
return 20
}
type NetworkRequest struct {
type NetworkRequestDeprecated struct {
message
Type string
Method string
@ -618,7 +617,7 @@ type NetworkRequest struct {
Duration uint64
}
func (msg *NetworkRequest) Encode() []byte {
func (msg *NetworkRequestDeprecated) Encode() []byte {
buf := make([]byte, 81+len(msg.Type)+len(msg.Method)+len(msg.URL)+len(msg.Request)+len(msg.Response))
buf[0] = 21
p := 1
@ -633,11 +632,11 @@ func (msg *NetworkRequest) Encode() []byte {
return buf[:p]
}
func (msg *NetworkRequest) Decode() Message {
func (msg *NetworkRequestDeprecated) Decode() Message {
return msg
}
func (msg *NetworkRequest) TypeID() int {
func (msg *NetworkRequestDeprecated) TypeID() int {
return 21
}
@ -2030,6 +2029,43 @@ func (msg *PartitionedMessage) TypeID() int {
return 82
}
type NetworkRequest struct {
message
Type string
Method string
URL string
Request string
Response string
Status uint64
Timestamp uint64
Duration uint64
TransferredBodySize uint64
}
func (msg *NetworkRequest) Encode() []byte {
buf := make([]byte, 91+len(msg.Type)+len(msg.Method)+len(msg.URL)+len(msg.Request)+len(msg.Response))
buf[0] = 83
p := 1
p = WriteString(msg.Type, buf, p)
p = WriteString(msg.Method, buf, p)
p = WriteString(msg.URL, buf, p)
p = WriteString(msg.Request, buf, p)
p = WriteString(msg.Response, buf, p)
p = WriteUint(msg.Status, buf, p)
p = WriteUint(msg.Timestamp, buf, p)
p = WriteUint(msg.Duration, buf, p)
p = WriteUint(msg.TransferredBodySize, buf, p)
return buf[:p]
}
func (msg *NetworkRequest) Decode() Message {
return msg
}
func (msg *NetworkRequest) TypeID() int {
return 83
}
type InputChange struct {
message
ID uint64
@ -2846,4 +2882,3 @@ func (msg *IOSIssueEvent) Decode() Message {
func (msg *IOSIssueEvent) TypeID() int {
return 111
}

View file

@ -300,9 +300,9 @@ func DecodeMouseMove(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeNetworkRequest(reader BytesReader) (Message, error) {
func DecodeNetworkRequestDeprecated(reader BytesReader) (Message, error) {
var err error = nil
msg := &NetworkRequest{}
msg := &NetworkRequestDeprecated{}
if msg.Type, err = reader.ReadString(); err != nil {
return nil, err
}
@ -1221,6 +1221,39 @@ func DecodePartitionedMessage(reader BytesReader) (Message, error) {
return msg, err
}
func DecodeNetworkRequest(reader BytesReader) (Message, error) {
var err error = nil
msg := &NetworkRequest{}
if msg.Type, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Method, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.URL, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Request, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Response, err = reader.ReadString(); err != nil {
return nil, err
}
if msg.Status, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.Timestamp, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.Duration, err = reader.ReadUint(); err != nil {
return nil, err
}
if msg.TransferredBodySize, err = reader.ReadUint(); err != nil {
return nil, err
}
return msg, err
}
func DecodeInputChange(reader BytesReader) (Message, error) {
var err error = nil
msg := &InputChange{}
@ -1837,7 +1870,7 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
case 20:
return DecodeMouseMove(reader)
case 21:
return DecodeNetworkRequest(reader)
return DecodeNetworkRequestDeprecated(reader)
case 22:
return DecodeConsoleLog(reader)
case 23:
@ -1944,6 +1977,8 @@ func ReadMessage(t uint64, reader BytesReader) (Message, error) {
return DecodeBatchMetadata(reader)
case 82:
return DecodePartitionedMessage(reader)
case 83:
return DecodeNetworkRequest(reader)
case 112:
return DecodeInputChange(reader)
case 113:

View file

@ -113,7 +113,7 @@ var batches = map[string]string{
"inputs": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, label, event_type, duration, hesitation_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"errors": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, source, name, message, error_id, event_type, error_tags_keys, error_tags_values) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"performance": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, min_fps, avg_fps, max_fps, min_cpu, avg_cpu, max_cpu, min_total_js_heap_size, avg_total_js_heap_size, max_total_js_heap_size, min_used_js_heap_size, avg_used_js_heap_size, max_used_js_heap_size, event_type) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"requests": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, request_body, response_body, status, method, duration, success, event_type) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?)",
"requests": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, url, request_body, response_body, status, method, duration, success, event_type, transfer_size) VALUES (?, ?, ?, ?, SUBSTR(?, 1, 8000), ?, ?, ?, ?, ?, ?, ?, ?)",
"custom": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, name, payload, event_type) VALUES (?, ?, ?, ?, ?, ?, ?)",
"graphql": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, name, request_body, response_body, event_type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
"issuesEvents": "INSERT INTO experimental.events (session_id, project_id, message_id, datetime, issue_id, issue_type, event_type, url) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
@ -474,6 +474,7 @@ func (c *connectorImpl) InsertRequest(session *sessions.Session, msg *messages.N
uint16(msg.Duration),
msg.Status < 400,
"REQUEST",
msg.TransferredBodySize,
); err != nil {
c.checkError("requests", err)
return fmt.Errorf("can't append to requests batch: %s", err)

View file

@ -185,7 +185,7 @@ class MouseMove(Message):
self.y = y
class NetworkRequest(Message):
class NetworkRequestDeprecated(Message):
__id__ = 21
def __init__(self, type, method, url, request, response, status, timestamp, duration):
@ -708,6 +708,21 @@ class PartitionedMessage(Message):
self.part_total = part_total
class NetworkRequest(Message):
__id__ = 83
def __init__(self, type, method, url, request, response, status, timestamp, duration, transferred_body_size):
self.type = type
self.method = method
self.url = url
self.request = request
self.response = response
self.status = status
self.timestamp = timestamp
self.duration = duration
self.transferred_body_size = transferred_body_size
class InputChange(Message):
__id__ = 112

View file

@ -268,7 +268,7 @@ cdef class MouseMove(PyMessage):
self.y = y
cdef class NetworkRequest(PyMessage):
cdef class NetworkRequestDeprecated(PyMessage):
cdef public int __id__
cdef public str type
cdef public str method
@ -1044,6 +1044,31 @@ cdef class PartitionedMessage(PyMessage):
self.part_total = part_total
cdef class NetworkRequest(PyMessage):
cdef public int __id__
cdef public str type
cdef public str method
cdef public str url
cdef public str request
cdef public str response
cdef public unsigned long status
cdef public unsigned long timestamp
cdef public unsigned long duration
cdef public unsigned long transferred_body_size
def __init__(self, str type, str method, str url, str request, str response, unsigned long status, unsigned long timestamp, unsigned long duration, unsigned long transferred_body_size):
self.__id__ = 83
self.type = type
self.method = method
self.url = url
self.request = request
self.response = response
self.status = status
self.timestamp = timestamp
self.duration = duration
self.transferred_body_size = transferred_body_size
cdef class InputChange(PyMessage):
cdef public int __id__
cdef public unsigned long id

View file

@ -233,7 +233,7 @@ class MessageCodec(Codec):
)
if message_id == 21:
return NetworkRequest(
return NetworkRequestDeprecated(
type=self.read_string(reader),
method=self.read_string(reader),
url=self.read_string(reader),
@ -647,6 +647,19 @@ class MessageCodec(Codec):
part_total=self.read_uint(reader)
)
if message_id == 83:
return NetworkRequest(
type=self.read_string(reader),
method=self.read_string(reader),
url=self.read_string(reader),
request=self.read_string(reader),
response=self.read_string(reader),
status=self.read_uint(reader),
timestamp=self.read_uint(reader),
duration=self.read_uint(reader),
transferred_body_size=self.read_uint(reader)
)
if message_id == 112:
return InputChange(
id=self.read_uint(reader),

View file

@ -331,7 +331,7 @@ cdef class MessageCodec:
)
if message_id == 21:
return NetworkRequest(
return NetworkRequestDeprecated(
type=self.read_string(reader),
method=self.read_string(reader),
url=self.read_string(reader),
@ -745,6 +745,19 @@ cdef class MessageCodec:
part_total=self.read_uint(reader)
)
if message_id == 83:
return NetworkRequest(
type=self.read_string(reader),
method=self.read_string(reader),
url=self.read_string(reader),
request=self.read_string(reader),
response=self.read_string(reader),
status=self.read_uint(reader),
timestamp=self.read_uint(reader),
duration=self.read_uint(reader),
transferred_body_size=self.read_uint(reader)
)
if message_id == 112:
return InputChange(
id=self.read_uint(reader),

1
frontend/.gitignore vendored
View file

@ -19,3 +19,4 @@ cypress.env.json
**/__diff_output__/*
*.diff.png
cypress/videos/
coverage

View file

@ -1,192 +0,0 @@
import React from 'react';
import { getRE } from 'App/utils';
import { Label, NoContent, Input, SlideModal, CloseButton, Icon } from 'UI';
import { connectPlayer, pause, jump } from 'Player';
// import Autoscroll from '../Autoscroll';
import BottomBlock from '../BottomBlock';
import TimeTable from '../TimeTable';
import FetchDetails from './FetchDetails';
import { renderName, renderDuration } from '../Network';
import { connect } from 'react-redux';
import { setTimelinePointer } from 'Duck/sessions';
import { renderStart } from 'Components/Session_/Network/NetworkContent';
@connectPlayer((state) => ({
list: state.fetchList,
listNow: state.fetchListNow,
livePlay: state.livePlay,
}))
@connect(
(state) => ({
timelinePointer: state.getIn(['sessions', 'timelinePointer']),
}),
{ setTimelinePointer }
)
export default class Fetch extends React.PureComponent {
state = {
filter: '',
filteredList: this.props.list,
current: null,
currentIndex: 0,
showFetchDetails: false,
hasNextError: false,
hasPreviousError: false,
};
onFilterChange = (e) => {
const value = e.target.value;
const { list } = this.props;
const filterRE = getRE(value, 'i');
const filtered = list.filter(
(r) =>
filterRE.test(r.name) ||
filterRE.test(r.url) ||
filterRE.test(r.method) ||
filterRE.test(r.status)
);
this.setState({ filter: value, filteredList: value ? filtered : list, currentIndex: 0 });
};
setCurrent = (item, index) => {
if (!this.props.livePlay) {
pause();
jump(item.time);
}
this.setState({ current: item, currentIndex: index });
};
onRowClick = (item, index) => {
if (!this.props.livePlay) {
pause();
}
this.setState({ current: item, currentIndex: index, showFetchDetails: true });
this.props.setTimelinePointer(null);
};
closeModal = () => this.setState({ current: null, showFetchDetails: false });
nextClickHander = () => {
// const { list } = this.props;
const { currentIndex, filteredList } = this.state;
if (currentIndex === filteredList.length - 1) return;
const newIndex = currentIndex + 1;
this.setCurrent(filteredList[newIndex], newIndex);
this.setState({ showFetchDetails: true });
};
prevClickHander = () => {
// const { list } = this.props;
const { currentIndex, filteredList } = this.state;
if (currentIndex === 0) return;
const newIndex = currentIndex - 1;
this.setCurrent(filteredList[newIndex], newIndex);
this.setState({ showFetchDetails: true });
};
render() {
const { listNow } = this.props;
const { current, currentIndex, showFetchDetails, filteredList } = this.state;
// const hasErrors = filteredList.some((r) => r.status >= 400);
return (
<React.Fragment>
<SlideModal
right
size="middle"
title={
<div className="flex justify-between">
<h1>Fetch Request</h1>
<div className="flex items-center">
<div className="flex items-center">
<span className="mr-2 color-gray-medium uppercase text-base">Status</span>
<Label
data-red={current && current.status >= 400}
data-green={current && current.status < 400}
>
<div className="uppercase w-16 justify-center code-font text-lg">
{current && current.status}
</div>
</Label>
</div>
<CloseButton onClick={this.closeModal} size="18" className="ml-2" />
</div>
</div>
}
isDisplayed={current != null && showFetchDetails}
content={
current &&
showFetchDetails && (
<FetchDetails
resource={current}
nextClick={this.nextClickHander}
prevClick={this.prevClickHander}
first={currentIndex === 0}
last={currentIndex === filteredList.length - 1}
/>
)
}
onClose={this.closeModal}
/>
<BottomBlock>
<BottomBlock.Header>
<span className="font-semibold color-gray-medium mr-4">Fetch</span>
<div className="flex items-center">
<Input
className="input-small"
placeholder="Filter"
icon="search"
iconPosition="left"
name="filter"
onChange={this.onFilterChange}
/>
</div>
</BottomBlock.Header>
<BottomBlock.Content>
<NoContent title={
<div className="capitalize flex items-center mt-16">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
} show={filteredList.length === 0}>
<TimeTable
rows={filteredList}
onRowClick={this.onRowClick}
hoverable
activeIndex={listNow.length - 1}
>
{[
{
label: 'Start',
width: 120,
render: renderStart,
},
{
label: 'Status',
dataKey: 'status',
width: 70,
},
{
label: 'Method',
dataKey: 'method',
width: 60,
},
{
label: 'Name',
width: 240,
render: renderName,
},
{
label: 'Time',
width: 80,
render: renderDuration,
},
]}
</TimeTable>
</NoContent>
</BottomBlock.Content>
</BottomBlock>
</React.Fragment>
);
}
}

View file

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

View file

@ -1,13 +1,32 @@
import {Duration} from "luxon";
import React, { useEffect } from 'react';
import { NoContent, Input, SlideModal, CloseButton, Button } from 'UI';
import { getRE } from 'App/utils';
import BottomBlock from '../BottomBlock';
import TimeTable from '../TimeTable';
import GQLDetails from './GQLDetails';
import { renderStart } from 'Components/Session_/Network/NetworkContent';
import { PlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite';
export function renderStart(r) {
return (
<div className="flex justify-between items-center grow-0 w-full">
<span>{Duration.fromMillis(r.time).toFormat('mm:ss.SSS')}</span>
{/* <Button
variant="text"
className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal"
onClick={(e) => {
e.stopPropagation();
jump(r.time);
}}
>
Jump
</Button> */}
</div>
);
}
function renderDefaultStatus() {
return '2xx-3xx';
}

View file

@ -1,334 +0,0 @@
import React from 'react';
import cn from 'classnames';
import { QuestionMarkHint, Tooltip, Tabs, Input, NoContent, Icon, Toggler } from 'UI';
import { getRE } from 'App/utils';
import { ResourceType } from 'Player';
import { formatBytes } from 'App/utils';
import { formatMs } from 'App/date';
import TimeTable from '../TimeTable';
import BottomBlock from '../BottomBlock';
import InfoLine from '../BottomBlock/InfoLine';
import stl from './network.module.css';
import { Duration } from 'luxon';
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]: 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,
key: tab,
}));
const DOM_LOADED_TIME_COLOR = 'teal';
const LOAD_TIME_COLOR = 'red';
export function renderType(r) {
return (
<Tooltip style={{ width: '100%' }} title={<div className={stl.popupNameContent}>{r.type}</div>}>
<div className={stl.popupNameTrigger}>{r.type}</div>
</Tooltip>
);
}
export function renderName(r) {
return (
<Tooltip style={{ width: '100%' }} title={<div className={stl.popupNameContent}>{r.url}</div>}>
<div className={stl.popupNameTrigger}>{r.name}</div>
</Tooltip>
);
}
export function renderStart(r) {
return (
<div className="flex justify-between items-center grow-0 w-full">
<span>{Duration.fromMillis(r.time).toFormat('mm:ss.SSS')}</span>
{/* <Button
variant="text"
className="right-0 text-xs uppercase p-2 color-gray-500 hover:color-teal"
onClick={(e) => {
e.stopPropagation();
jump(r.time);
}}
>
Jump
</Button> */}
</div>
);
}
const renderXHRText = () => (
<span className="flex items-center">
{XHR}
<QuestionMarkHint
content={
<>
Configure{' '}
<a
className="color-teal underline"
target="_blank"
href="https://docs.openreplay.com/installation/network-options"
>
Configure
</a>
network capturing
{' to see fetch/XHR requests and response payloads.'} <br />
We also provide{' '}
<a
className="color-teal underline"
target="_blank"
href="https://docs.openreplay.com/plugins/graphql"
>
support for GraphQL
</a>
{' for easy debugging of your queries.'}
</>
}
className="ml-1"
/>
</span>
);
function renderSize(r) {
if (r.responseBodySize) return formatBytes(r.responseBodySize);
let triggerText;
let content;
if (r.decodedBodySize == null) {
triggerText = 'x';
content = 'Not captured';
} else {
const headerSize = r.headerSize || 0;
const showTransferred = r.headerSize != null;
triggerText = formatBytes(r.decodedBodySize);
content = (
<ul>
{showTransferred && (
<li>{`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}</li>
)}
<li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li>
</ul>
);
}
return (
<Tooltip style={{ width: '100%' }} content={content}>
<div>{triggerText}</div>
</Tooltip>
);
}
export function renderDuration(r) {
if (!r.success) return 'x';
const text = `${Math.floor(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 style={{ width: '100%' }} content={tooltipText}>
<div className={cn(className, stl.duration)}> {text} </div>
</Tooltip>
);
}
export default class NetworkContent extends React.PureComponent {
state = {
filter: '',
activeTab: ALL,
};
onTabClick = (activeTab) => this.setState({ activeTab });
onFilterChange = ({ target: { value } }) => this.setState({ filter: value });
render() {
const {
location,
resources,
domContentLoadedTime,
loadTime,
domBuildingTime,
fetchPresented,
onRowClick,
isResult = false,
additionalHeight = 0,
resourcesSize,
transferredSize,
time,
currentIndex,
} = this.props;
const { filter, activeTab } = this.state;
const filterRE = getRE(filter, 'i');
let filtered = resources.filter(
({ type, name }) =>
filterRE.test(name) && (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab])
);
const lastIndex = currentIndex || filtered.filter((item) => item.time <= time).length - 1;
const referenceLines = [];
if (domContentLoadedTime != null) {
referenceLines.push({
time: domContentLoadedTime.time,
color: DOM_LOADED_TIME_COLOR,
});
}
if (loadTime != null) {
referenceLines.push({
time: loadTime.time,
color: LOAD_TIME_COLOR,
});
}
let tabs = TABS;
if (!fetchPresented) {
tabs = TABS.map((tab) =>
!isResult && tab.key === XHR
? {
text: renderXHRText(),
key: XHR,
}
: tab
);
}
return (
<React.Fragment>
<BottomBlock style={{ height: 300 + additionalHeight + 'px' }} className="border">
<BottomBlock.Header showClose={!isResult}>
<div className="flex items-center">
<span className="font-semibold color-gray-medium mr-4">Network</span>
<Tabs
className="uppercase"
tabs={tabs}
active={activeTab}
onClick={this.onTabClick}
border={false}
/>
</div>
<Input
className="input-small"
placeholder="Filter by name"
icon="search"
name="filter"
onChange={this.onFilterChange}
height={28}
/>
</BottomBlock.Header>
<BottomBlock.Content>
<div className="flex items-center justify-between px-4">
<div>
<Toggler checked={true} name="test" onChange={() => {}} label="4xx-5xx Only" />
</div>
<InfoLine>
<InfoLine.Point label={filtered.length} value=" requests" />
<InfoLine.Point
label={formatBytes(transferredSize)}
value="transferred"
display={transferredSize > 0}
/>
<InfoLine.Point
label={formatBytes(resourcesSize)}
value="resources"
display={resourcesSize > 0}
/>
<InfoLine.Point
label={formatMs(domBuildingTime)}
value="DOM Building Time"
display={domBuildingTime != null}
/>
<InfoLine.Point
label={domContentLoadedTime && formatMs(domContentLoadedTime.value)}
value="DOMContentLoaded"
display={domContentLoadedTime != null}
dotColor={DOM_LOADED_TIME_COLOR}
/>
<InfoLine.Point
label={loadTime && formatMs(loadTime.value)}
value="Load"
display={loadTime != null}
dotColor={LOAD_TIME_COLOR}
/>
</InfoLine>
</div>
<NoContent
title={
<div className="capitalize flex items-center mt-16">
<Icon name="info-circle" className="mr-2" size="18" />
No Data
</div>
}
size="small"
show={filtered.length === 0}
>
<TimeTable
rows={filtered}
referenceLines={referenceLines}
renderPopup
// navigation
onRowClick={onRowClick}
additionalHeight={additionalHeight}
activeIndex={lastIndex}
>
{[
// {
// label: 'Start',
// width: 120,
// render: renderStart,
// },
{
label: 'Status',
dataKey: 'status',
width: 70,
},
{
label: 'Type',
dataKey: 'type',
width: 90,
render: renderType,
},
{
label: 'Name',
width: 240,
render: renderName,
},
{
label: 'Size',
width: 60,
render: renderSize,
},
{
label: 'Time',
width: 80,
render: renderDuration,
},
]}
</TimeTable>
</NoContent>
</BottomBlock.Content>
</BottomBlock>
</React.Fragment>
);
}
}

View file

@ -1,31 +0,0 @@
.location {
min-height: 32px;
display: flex;
align-items: center;
padding: 5px 10px;
font-size: 12px;
line-height: 12px;
& > div:last-child {
font-weight: 300;
max-width: 80%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
padding: 5px 0;
}
}
.popupNameTrigger {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
max-width: 80%;
width: fit-content;
}
.popupNameContent {
max-width: 600px;
overflow-wrap: break-word;
}

View file

@ -92,7 +92,7 @@ function renderSize(r: any) {
content = (
<ul>
{showTransferred && (
<li>{`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}</li>
<li>{`${formatBytes(r.encodedBodySize + headerSize)} transferred over network`}</li>
)}
<li>{`Resource size: ${formatBytes(r.decodedBodySize)} `}</li>
</ul>

View file

@ -211,7 +211,7 @@ export default class RawMessageReader extends PrimitiveReader {
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const duration = this.readUint(); if (duration === null) { return resetPointer() }
return {
tp: MType.NetworkRequest,
tp: MType.NetworkRequestDeprecated,
type,
method,
url,
@ -627,6 +627,30 @@ export default class RawMessageReader extends PrimitiveReader {
};
}
case 83: {
const type = this.readString(); if (type === null) { return resetPointer() }
const method = this.readString(); if (method === null) { return resetPointer() }
const url = this.readString(); if (url === null) { return resetPointer() }
const request = this.readString(); if (request === null) { return resetPointer() }
const response = this.readString(); if (response === null) { return resetPointer() }
const status = this.readUint(); if (status === null) { return resetPointer() }
const timestamp = this.readUint(); if (timestamp === null) { return resetPointer() }
const duration = this.readUint(); if (duration === null) { return resetPointer() }
const transferredBodySize = this.readUint(); if (transferredBodySize === null) { return resetPointer() }
return {
tp: MType.NetworkRequest,
type,
method,
url,
request,
response,
status,
timestamp,
duration,
transferredBodySize,
};
}
case 113: {
const selectionStart = this.readUint(); if (selectionStart === null) { return resetPointer() }
const selectionEnd = this.readUint(); if (selectionEnd === null) { return resetPointer() }

View file

@ -21,7 +21,7 @@ import type {
RawSetInputValue,
RawSetInputChecked,
RawMouseMove,
RawNetworkRequest,
RawNetworkRequestDeprecated,
RawConsoleLog,
RawCssInsertRule,
RawCssDeleteRule,
@ -55,6 +55,7 @@ import type {
RawAdoptedSsAddOwner,
RawAdoptedSsRemoveOwner,
RawZustand,
RawNetworkRequest,
RawSelectionChange,
RawMouseThrashing,
RawResourceTiming,
@ -107,7 +108,7 @@ export type SetInputChecked = RawSetInputChecked & Timed
export type MouseMove = RawMouseMove & Timed
export type NetworkRequest = RawNetworkRequest & Timed
export type NetworkRequestDeprecated = RawNetworkRequestDeprecated & Timed
export type ConsoleLog = RawConsoleLog & Timed
@ -175,6 +176,8 @@ export type AdoptedSsRemoveOwner = RawAdoptedSsRemoveOwner & Timed
export type Zustand = RawZustand & Timed
export type NetworkRequest = RawNetworkRequest & Timed
export type SelectionChange = RawSelectionChange & Timed
export type MouseThrashing = RawMouseThrashing & Timed

View file

@ -19,7 +19,7 @@ export const enum MType {
SetInputValue = 18,
SetInputChecked = 19,
MouseMove = 20,
NetworkRequest = 21,
NetworkRequestDeprecated = 21,
ConsoleLog = 22,
CssInsertRule = 37,
CssDeleteRule = 38,
@ -53,6 +53,7 @@ export const enum MType {
AdoptedSsAddOwner = 76,
AdoptedSsRemoveOwner = 77,
Zustand = 79,
NetworkRequest = 83,
SelectionChange = 113,
MouseThrashing = 114,
ResourceTiming = 116,
@ -177,8 +178,8 @@ export interface RawMouseMove {
y: number,
}
export interface RawNetworkRequest {
tp: MType.NetworkRequest,
export interface RawNetworkRequestDeprecated {
tp: MType.NetworkRequestDeprecated,
type: string,
method: string,
url: string,
@ -424,6 +425,19 @@ export interface RawZustand {
state: string,
}
export interface RawNetworkRequest {
tp: MType.NetworkRequest,
type: string,
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
transferredBodySize: number,
}
export interface RawSelectionChange {
tp: MType.SelectionChange,
selectionStart: number,
@ -536,4 +550,4 @@ export interface RawIosSwipeEvent {
}
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequest | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall | RawIosSwipeEvent;
export type RawMessage = RawTimestamp | RawSetPageLocation | RawSetViewportSize | RawSetViewportScroll | RawCreateDocument | RawCreateElementNode | RawCreateTextNode | RawMoveNode | RawRemoveNode | RawSetNodeAttribute | RawRemoveNodeAttribute | RawSetNodeData | RawSetCssData | RawSetNodeScroll | RawSetInputValue | RawSetInputChecked | RawMouseMove | RawNetworkRequestDeprecated | RawConsoleLog | RawCssInsertRule | RawCssDeleteRule | RawFetch | RawProfiler | RawOTable | RawRedux | RawVuex | RawMobX | RawNgRx | RawGraphQl | RawPerformanceTrack | RawStringDict | RawSetNodeAttributeDict | RawResourceTimingDeprecated | RawConnectionInformation | RawSetPageVisibility | RawLoadFontFace | RawSetNodeFocus | RawLongTask | RawSetNodeAttributeURLBased | RawSetCssDataURLBased | RawCssInsertRuleURLBased | RawMouseClick | RawCreateIFrameDocument | RawAdoptedSsReplaceURLBased | RawAdoptedSsReplace | RawAdoptedSsInsertRuleURLBased | RawAdoptedSsInsertRule | RawAdoptedSsDeleteRule | RawAdoptedSsAddOwner | RawAdoptedSsRemoveOwner | RawZustand | RawNetworkRequest | RawSelectionChange | RawMouseThrashing | RawResourceTiming | RawTabChange | RawTabData | RawIosCustomEvent | RawIosScreenChanges | RawIosClickEvent | RawIosInputEvent | RawIosPerformanceEvent | RawIosLog | RawIosNetworkCall | RawIosSwipeEvent;

View file

@ -20,7 +20,7 @@ export const TP_MAP = {
18: MType.SetInputValue,
19: MType.SetInputChecked,
20: MType.MouseMove,
21: MType.NetworkRequest,
21: MType.NetworkRequestDeprecated,
22: MType.ConsoleLog,
37: MType.CssInsertRule,
38: MType.CssDeleteRule,
@ -54,6 +54,7 @@ export const TP_MAP = {
76: MType.AdoptedSsAddOwner,
77: MType.AdoptedSsRemoveOwner,
79: MType.Zustand,
83: MType.NetworkRequest,
113: MType.SelectionChange,
114: MType.MouseThrashing,
116: MType.ResourceTiming,

View file

@ -113,7 +113,7 @@ type TrMouseMove = [
y: number,
]
type TrNetworkRequest = [
type TrNetworkRequestDeprecated = [
type: 21,
type: string,
method: string,
@ -429,6 +429,19 @@ type TrPartitionedMessage = [
partTotal: number,
]
type TrNetworkRequest = [
type: 83,
type: string,
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
transferredBodySize: number,
]
type TrInputChange = [
type: 112,
id: number,
@ -481,7 +494,7 @@ type TrTabData = [
]
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequest | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData
export type TrackerMessage = TrTimestamp | TrSetPageLocation | TrSetViewportSize | TrSetViewportScroll | TrCreateDocument | TrCreateElementNode | TrCreateTextNode | TrMoveNode | TrRemoveNode | TrSetNodeAttribute | TrRemoveNodeAttribute | TrSetNodeData | TrSetNodeScroll | TrSetInputTarget | TrSetInputValue | TrSetInputChecked | TrMouseMove | TrNetworkRequestDeprecated | TrConsoleLog | TrPageLoadTiming | TrPageRenderTiming | TrCustomEvent | TrUserID | TrUserAnonymousID | TrMetadata | TrCSSInsertRule | TrCSSDeleteRule | TrFetch | TrProfiler | TrOTable | TrStateAction | TrRedux | TrVuex | TrMobX | TrNgRx | TrGraphQL | TrPerformanceTrack | TrStringDict | TrSetNodeAttributeDict | TrResourceTimingDeprecated | TrConnectionInformation | TrSetPageVisibility | TrLoadFontFace | TrSetNodeFocus | TrLongTask | TrSetNodeAttributeURLBased | TrSetCSSDataURLBased | TrTechnicalInfo | TrCustomIssue | TrCSSInsertRuleURLBased | TrMouseClick | TrCreateIFrameDocument | TrAdoptedSSReplaceURLBased | TrAdoptedSSInsertRuleURLBased | TrAdoptedSSDeleteRule | TrAdoptedSSAddOwner | TrAdoptedSSRemoveOwner | TrJSException | TrZustand | TrBatchMetadata | TrPartitionedMessage | TrNetworkRequest | TrInputChange | TrSelectionChange | TrMouseThrashing | TrUnbindNodes | TrResourceTiming | TrTabChange | TrTabData
export default function translate(tMsg: TrackerMessage): RawMessage | null {
switch(tMsg[0]) {
@ -622,7 +635,7 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null {
case 21: {
return {
tp: MType.NetworkRequest,
tp: MType.NetworkRequestDeprecated,
type: tMsg[1],
method: tMsg[2],
url: tMsg[3],
@ -918,6 +931,21 @@ export default function translate(tMsg: TrackerMessage): RawMessage | null {
}
}
case 83: {
return {
tp: MType.NetworkRequest,
type: tMsg[1],
method: tMsg[2],
url: tMsg[3],
request: tMsg[4],
response: tMsg[5],
status: tMsg[6],
timestamp: tMsg[7],
duration: tMsg[8],
transferredBodySize: tMsg[9],
}
}
case 113: {
return {
tp: MType.SelectionChange,

View file

@ -3,6 +3,7 @@ import type { ResourceTiming, NetworkRequest, Fetch } from '../messages'
export const enum ResourceType {
XHR = 'xhr',
FETCH = 'fetch',
BEACON = 'beacon',
SCRIPT = 'script',
CSS = 'css',
IMG = 'img',
@ -10,17 +11,19 @@ export const enum ResourceType {
OTHER = 'other',
}
function getURLExtention(url: string): string {
export 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 {
export function getResourceType(initiator: string, url: string): ResourceType {
switch (initiator) {
case "xmlhttprequest":
case "fetch":
return ResourceType.FETCH
case "beacon":
return ResourceType.BEACON
case "img":
return ResourceType.IMG
default:
@ -48,7 +51,7 @@ function getResourceType(initiator: string, url: string): ResourceType {
}
}
function getResourceName(url: string) {
export function getResourceName(url: string) {
return url
.split('/')
.filter((s) => s !== '')
@ -104,10 +107,11 @@ export function getResourceFromNetworkRequest(msg: NetworkRequest | Fetch, sessS
return Resource({
...msg,
// @ts-ignore
type: msg?.type === "xhr" ? ResourceType.XHR : ResourceType.FETCH,
type: msg?.type ? msg?.type : ResourceType.XHR,
success: msg.status < 400,
status: String(msg.status),
time: Math.max(0, msg.timestamp - sessStart),
decodedBodySize: 'transferredBodySize' in msg ? msg.transferredBodySize : undefined
})
}

View file

@ -1,34 +1,26 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
console.log(__dirname)
const dir = __dirname
module.exports = {
preset: 'ts-jest',
rootDir: './',
testEnvironment: 'jsdom',
moduleNameMapper: {
'^Types/session/(.+)$': '<rootDir>/app/types/session/$1',
'^App/(.+)$': '<rootDir>/app/$1',
},
collectCoverage: true,
verbose: true,
collectCoverageFrom: [
// '<rootDir>/app/**/*.{ts,tsx,js,jsx}',
'<rootDir>/app/player/**/*.{ts,tsx,js,jsx}',
'<rootDir>/app/mstore/**/*.{ts,tsx,js,jsx}',
'<rootDir>/app/utils/**/*.{ts,tsx,js,jsx}',
'!<rootDir>/app/**/*.d.ts',
'!<rootDir>/node_modules'
],
transform: {
'^.+\\.(ts|tsx)?$': ['ts-jest', { isolatedModules: true, diagnostics: { warnOnly: true } }],
'^.+\\.(js|jsx)$': 'babel-jest',
},
moduleDirectories: ['node_modules', 'app'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
moduleDirectories: ['node_modules'],
};
//
// module.exports = {
// globals: {
// "ts-jest": {
// tsConfig: "tsconfig.json",
// diagnostics: true
// },
// NODE_ENV: "test"
// },
// moduleNameMapper: {
// "^Types/(.+)$": "<rootDir>/app/types/$1"
// },
// moduleDirectories: ["node_modules", 'app'],
// moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
// verbose: true
// };

View file

@ -16,7 +16,8 @@
"flow": "flow",
"gen:static": "yarn gen:icons && yarn gen:colors",
"build-storybook": "build-storybook",
"test": "jest",
"test:ci": "jest --maxWorkers=1 --no-cache --coverage",
"test": "jest --watch",
"cy:open": "cypress open",
"cy:test": "cypress run --browser chrome",
"cy:test-firefox": "cypress run --browser firefox",
@ -94,7 +95,7 @@
"@babel/preset-react": "^7.17.12",
"@babel/preset-typescript": "^7.17.12",
"@babel/runtime": "^7.17.9",
"@jest/globals": "^29.3.1",
"@jest/globals": "^29.5.0",
"@openreplay/sourcemap-uploader": "^3.0.0",
"@storybook/addon-actions": "^6.5.12",
"@storybook/addon-docs": "^6.5.12",
@ -132,7 +133,7 @@
"file-loader": "^6.2.0",
"flow-bin": "^0.115.0",
"html-webpack-plugin": "^5.5.0",
"jest": "^29.3.1",
"jest": "^29.5.0",
"mini-css-extract-plugin": "^2.6.0",
"minio": "^7.0.18",
"moment-locales-webpack-plugin": "^1.2.0",

View file

@ -0,0 +1,118 @@
import {
ResourceType,
getURLExtention,
getResourceType,
getResourceName,
Resource,
getResourceFromResourceTiming,
getResourceFromNetworkRequest,
} from '../app/player/web/types/resource';
import type { ResourceTiming, NetworkRequest } from '../app/player/web/messages';
import { test, describe, expect } from "@jest/globals";
describe('getURLExtention', () => {
test('should return the correct extension', () => {
expect(getURLExtention('https://test.com/image.png')).toBe('png');
expect(getURLExtention('https://test.com/script.js')).toBe('js');
expect(getURLExtention('https://test.com/style.css')).toBe('css');
expect(getURLExtention('https://test.com')).toBe('com');
});
});
describe('getResourceType', () => {
test('should return the correct resource type based on initiator and URL', () => {
expect(getResourceType('fetch', 'https://test.com')).toBe(ResourceType.FETCH);
expect(getResourceType('beacon', 'https://test.com')).toBe(ResourceType.BEACON);
expect(getResourceType('img', 'https://test.com')).toBe(ResourceType.IMG);
expect(getResourceType('unknown', 'https://test.com/script.js')).toBe(ResourceType.SCRIPT);
expect(getResourceType('unknown', 'https://test.com/style.css')).toBe(ResourceType.CSS);
expect(getResourceType('unknown', 'https://test.com/image.png')).toBe(ResourceType.IMG);
expect(getResourceType('unknown', 'https://test.com/video.mp4')).toBe(ResourceType.MEDIA);
expect(getResourceType('unknown', 'https://test.com')).toBe(ResourceType.OTHER);
});
});
describe('getResourceName', () => {
test('should return the last non-empty section of a URL', () => {
expect(getResourceName('https://test.com/path/to/resource')).toBe('resource');
expect(getResourceName('https://test.com/another/path/')).toBe('path');
expect(getResourceName('https://test.com/singlepath')).toBe('singlepath');
expect(getResourceName('https://test.com/')).toBe('test.com');
});
});
describe('Resource', () => {
test('should return the correct resource object', () => {
const testResource = {
time: 123,
type: ResourceType.SCRIPT,
url: 'https://test.com/script.js',
status: '2xx-3xx',
method: 'GET',
duration: 1,
success: true,
};
const expectedResult = {
...testResource,
name: 'script.js',
isRed: false,
isYellow: false,
};
expect(Resource(testResource)).toEqual(expectedResult);
});
});
describe('getResourceFromResourceTiming', () => {
test('should return the correct resource from a ResourceTiming object', () => {
const testResourceTiming: ResourceTiming = {
tp: 116,
timestamp: 123,
duration: 1,
ttfb: 100,
headerSize: 200,
encodedBodySize: 300,
decodedBodySize: 400,
url: 'https://test.com/script.js',
initiator: 'fetch',
transferredSize: 500,
cached: false,
time: 123
};
const expectedResult = Resource({
...testResourceTiming,
type: ResourceType.FETCH,
method: '..',
success: true,
status: '2xx-3xx',
time: 123,
});
expect(getResourceFromResourceTiming(testResourceTiming, 0)).toEqual(expectedResult);
});
});
describe('getResourceFromNetworkRequest', () => {
test('should return the correct resource from a NetworkRequest or Fetch object', () => {
const testNetworkRequest: NetworkRequest = {
tp: 83,
type: 'fetch',
method: 'POST',
url: 'https://test.com/data',
request: 'test',
response: 'test',
status: 200,
timestamp: 123,
duration: 1,
transferredBodySize: 100,
time: 123
} as const;
// @ts-ignore
const expectedResult = Resource({
...testNetworkRequest,
success: true,
status: '200',
time: 123,
decodedBodySize: 100,
});
expect(getResourceFromNetworkRequest(testNetworkRequest, 0)).toEqual(expectedResult);
});
});

File diff suppressed because it is too large Load diff

View file

@ -104,7 +104,7 @@ message 20, 'MouseMove' do
uint 'X'
uint 'Y'
end
message 21, 'NetworkRequest', :replayer => :devtools do
message 21, 'NetworkRequestDeprecated', :replayer => :devtools do
string 'Type' # fetch/xhr/anythingElse(axios,gql,fonts,image?)
string 'Method'
string 'URL'
@ -446,6 +446,18 @@ message 82, 'PartitionedMessage', :replayer => false do
uint 'PartTotal'
end
message 83, 'NetworkRequest', :replayer => :devtools do
string 'Type' # fetch/xhr/anythingElse(axios,gql,fonts,image?)
string 'Method'
string 'URL'
string 'Request'
string 'Response'
uint 'Status'
uint 'Timestamp'
uint 'Duration'
uint 'TransferredBodySize'
end
# 90-111 reserved iOS
message 112, 'InputChange', :replayer => false do

View file

@ -1,3 +1,8 @@
# 10.0.0
- networkRequest message changed to include `TransferredBodySize`
- tracker now attempts to create proxy for beacon api as well (if its in scope)
# 9.0.9
- Fix for `{disableStringDict: true}` behavior

View file

@ -1,7 +1,7 @@
{
"name": "@openreplay/tracker",
"description": "The OpenReplay tracker main package",
"version": "9.0.9",
"version": "10.0.0",
"keywords": [
"logging",
"replay"

View file

@ -19,7 +19,7 @@ export declare const enum Type {
SetInputValue = 18,
SetInputChecked = 19,
MouseMove = 20,
NetworkRequest = 21,
NetworkRequestDeprecated = 21,
ConsoleLog = 22,
PageLoadTiming = 23,
PageRenderTiming = 24,
@ -63,6 +63,7 @@ export declare const enum Type {
Zustand = 79,
BatchMetadata = 81,
PartitionedMessage = 82,
NetworkRequest = 83,
InputChange = 112,
SelectionChange = 113,
MouseThrashing = 114,
@ -181,8 +182,8 @@ export type MouseMove = [
/*y:*/ number,
]
export type NetworkRequest = [
/*type:*/ Type.NetworkRequest,
export type NetworkRequestDeprecated = [
/*type:*/ Type.NetworkRequestDeprecated,
/*type:*/ string,
/*method:*/ string,
/*url:*/ string,
@ -497,6 +498,19 @@ export type PartitionedMessage = [
/*partTotal:*/ number,
]
export type NetworkRequest = [
/*type:*/ Type.NetworkRequest,
/*type:*/ string,
/*method:*/ string,
/*url:*/ string,
/*request:*/ string,
/*response:*/ string,
/*status:*/ number,
/*timestamp:*/ number,
/*duration:*/ number,
/*transferredBodySize:*/ number,
]
export type InputChange = [
/*type:*/ Type.InputChange,
/*id:*/ number,
@ -549,5 +563,5 @@ export type TabData = [
]
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequest | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData
type Message = Timestamp | SetPageLocation | SetViewportSize | SetViewportScroll | CreateDocument | CreateElementNode | CreateTextNode | MoveNode | RemoveNode | SetNodeAttribute | RemoveNodeAttribute | SetNodeData | SetNodeScroll | SetInputTarget | SetInputValue | SetInputChecked | MouseMove | NetworkRequestDeprecated | ConsoleLog | PageLoadTiming | PageRenderTiming | CustomEvent | UserID | UserAnonymousID | Metadata | CSSInsertRule | CSSDeleteRule | Fetch | Profiler | OTable | StateAction | Redux | Vuex | MobX | NgRx | GraphQL | PerformanceTrack | StringDict | SetNodeAttributeDict | ResourceTimingDeprecated | ConnectionInformation | SetPageVisibility | LoadFontFace | SetNodeFocus | LongTask | SetNodeAttributeURLBased | SetCSSDataURLBased | TechnicalInfo | CustomIssue | CSSInsertRuleURLBased | MouseClick | CreateIFrameDocument | AdoptedSSReplaceURLBased | AdoptedSSInsertRuleURLBased | AdoptedSSDeleteRule | AdoptedSSAddOwner | AdoptedSSRemoveOwner | JSException | Zustand | BatchMetadata | PartitionedMessage | NetworkRequest | InputChange | SelectionChange | MouseThrashing | UnbindNodes | ResourceTiming | TabChange | TabData
export default Message

View file

@ -204,7 +204,7 @@ export function MouseMove(
]
}
export function NetworkRequest(
export function NetworkRequestDeprecated(
type: string,
method: string,
url: string,
@ -213,9 +213,9 @@ export function NetworkRequest(
status: number,
timestamp: number,
duration: number,
): Messages.NetworkRequest {
): Messages.NetworkRequestDeprecated {
return [
Messages.Type.NetworkRequest,
Messages.Type.NetworkRequestDeprecated,
type,
method,
url,
@ -792,6 +792,31 @@ export function PartitionedMessage(
]
}
export function NetworkRequest(
type: string,
method: string,
url: string,
request: string,
response: string,
status: number,
timestamp: number,
duration: number,
transferredBodySize: number,
): Messages.NetworkRequest {
return [
Messages.Type.NetworkRequest,
type,
method,
url,
request,
response,
status,
timestamp,
duration,
transferredBodySize,
]
}
export function InputChange(
id: number,
value: string,

View file

@ -0,0 +1,102 @@
import { NetworkRequest } from '../../../common/messages.gen.js'
import NetworkMessage from './networkMessage.js'
import { RequestResponseData } from './types.js'
import { genStringBody, getURL } from './utils.js'
// https://fetch.spec.whatwg.org/#concept-bodyinit-extract
const getContentType = (data?: BodyInit) => {
if (data instanceof Blob) {
return data.type
}
if (data instanceof FormData) {
return 'multipart/form-data'
}
if (data instanceof URLSearchParams) {
return 'application/x-www-form-urlencoded;charset=UTF-8'
}
return 'text/plain;charset=UTF-8'
}
export class BeaconProxyHandler<T extends typeof navigator.sendBeacon> implements ProxyHandler<T> {
constructor(
private readonly ignoredHeaders: boolean | string[],
private readonly setSessionTokenHeader: (cb: (name: string, value: string) => void) => void,
private readonly sanitize: (data: RequestResponseData) => RequestResponseData,
private readonly sendMessage: (item: NetworkRequest) => void,
private readonly isServiceUrl: (url: string) => boolean,
) {}
public apply(target: T, thisArg: T, argsList: any[]) {
const urlString: string = argsList[0]
const data: BodyInit = argsList[1]
const item = new NetworkMessage(this.ignoredHeaders, this.setSessionTokenHeader, this.sanitize)
if (this.isServiceUrl(urlString)) {
return target.apply(thisArg, argsList)
}
const url = getURL(urlString)
item.method = 'POST'
item.url = urlString
item.name = (url.pathname.split('/').pop() || '') + url.search
item.requestType = 'beacon'
item.requestHeader = { 'Content-Type': getContentType(data) }
item.status = 0
item.statusText = 'Pending'
if (url.search && url.searchParams) {
item.getData = {}
for (const [key, value] of url.searchParams) {
item.getData[key] = value
}
}
item.requestData = genStringBody(data)
if (!item.startTime) {
item.startTime = performance.now()
}
const isSuccess = target.apply(thisArg, argsList)
if (isSuccess) {
item.endTime = performance.now()
item.duration = item.endTime - (item.startTime || item.endTime)
item.status = 0
item.statusText = 'Sent'
item.readyState = 4
} else {
item.status = 500
item.statusText = 'Unknown'
}
this.sendMessage(item.getMessage())
return isSuccess
}
}
export default class BeaconProxy {
public static origSendBeacon = window?.navigator?.sendBeacon
public static hasSendBeacon() {
return !!BeaconProxy.origSendBeacon
}
public static create(
ignoredHeaders: boolean | string[],
setSessionTokenHeader: (cb: (name: string, value: string) => void) => void,
sanitize: (data: RequestResponseData) => RequestResponseData,
sendMessage: (item: NetworkRequest) => void,
isServiceUrl: (url: string) => boolean,
) {
if (!BeaconProxy.hasSendBeacon()) {
return undefined
}
return new Proxy(
BeaconProxy.origSendBeacon,
new BeaconProxyHandler(
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
),
)
}
}

View file

@ -291,8 +291,6 @@ export class FetchProxyHandler<T extends typeof fetch> implements ProxyHandler<T
}
export default class FetchProxy {
public static origFetch = fetch
public static create(
ignoredHeaders: boolean | string[],
setSessionTokenHeader: (cb: (name: string, value: string) => void) => void,

View file

@ -1,5 +1,6 @@
import FetchProxy from './fetchProxy.js'
import XHRProxy from './xhrProxy.js'
import BeaconProxy from './beaconProxy.js'
import { RequestResponseData } from './types.js'
import { NetworkRequest } from '../../../common/messages.gen.js'
@ -40,4 +41,13 @@ export default function setProxy(
} else {
getWarning('fetch')
}
if (context?.navigator?.sendBeacon) {
context.navigator.sendBeacon = BeaconProxy.create(
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
)
}
}

View file

@ -30,7 +30,7 @@ export default class NetworkMessage {
readyState?: RequestState = 0
header: { [key: string]: string } = {}
responseType: XMLHttpRequest['responseType'] = ''
requestType: 'xhr' | 'fetch' | 'ping' | 'custom'
requestType: 'xhr' | 'fetch' | 'ping' | 'custom' | 'beacon'
requestHeader: HeadersInit = {}
response: any
responseSize = 0 // bytes
@ -72,6 +72,7 @@ export default class NetworkMessage {
messageInfo.status,
this.startTime + getTimeOrigin(),
this.duration,
this.responseSize,
)
}

View file

@ -226,8 +226,6 @@ export class XHRProxyHandler<T extends XMLHttpRequest> implements ProxyHandler<T
}
export default class XHRProxy {
public static origXMLHttpRequest = XMLHttpRequest
public static create(
ignoredHeaders: boolean | string[],
setSessionTokenHeader: (cb: (name: string, value: string) => void) => void,

View file

@ -71,6 +71,7 @@ export default function (
stringify: (data: { headers: Record<string, string>; body: any }) => string,
) {
app.debug.log('Openreplay: attaching axios spy to instance', instance)
function captureResponseData(axiosResponseObj: AxiosResponse) {
app.debug.log('Openreplay: capturing axios response data', axiosResponseObj)
const { headers: reqHs, data: reqData, method, url, baseURL } = axiosResponseObj.config
@ -144,6 +145,7 @@ export default function (
reqResInfo.status,
requestStart + getTimeOrigin(),
duration,
0,
),
)
}
@ -183,6 +185,7 @@ export default function (
function logRequestError(ev: any) {
app.debug.log('Openreplay: failed API request, skipping', ev)
}
const reqInt = instance.interceptors.request.use(getStartTime, logRequestError, {
synchronous: true,
})

View file

@ -54,7 +54,6 @@ export default class FeatureFlags {
userID: sessionInfo.userID,
metadata: sessionInfo.metadata,
referrer: document.referrer,
// todo: get from backend
os: userInfo.userOS,
device: userInfo.userDevice,
country: userInfo.userCountry,

View file

@ -222,6 +222,7 @@ export default function (app: App, opts: Partial<Options> = {}) {
r.status,
startTime + getTimeOrigin(),
duration,
0,
),
)
})
@ -230,6 +231,7 @@ export default function (app: App, opts: Partial<Options> = {}) {
return response
})
}
// @ts-ignore
context.fetch = trackFetch
/* ====== <> ====== */
@ -293,6 +295,7 @@ export default function (app: App, opts: Partial<Options> = {}) {
xhr.status,
startTime + getTimeOrigin(),
duration,
0,
),
)
}),

View file

@ -46,6 +46,7 @@ describe('NetworkMessage', () => {
// yeah
result[7],
500,
0,
)
expect(result).toBeDefined()
expect(result).toEqual(expected)

View file

@ -0,0 +1,97 @@
// @ts-nocheck
import { describe, it, expect, beforeEach, jest } from '@jest/globals'
import setProxy from '../main/modules/Network/index.js'
import FetchProxy from '../main/modules/Network/fetchProxy.js'
import XHRProxy from '../main/modules/Network/xhrProxy.js'
import BeaconProxy from '../main/modules/Network/beaconProxy.js'
globalThis.fetch = jest.fn()
jest.mock('../main/modules/Network/fetchProxy.js')
jest.mock('../main/modules/Network/xhrProxy.js')
jest.mock('../main/modules/Network/beaconProxy.js')
describe('Network Proxy', () => {
let context
const ignoredHeaders = []
const setSessionTokenHeader = jest.fn()
const sanitize = jest.fn()
const sendMessage = jest.fn()
const isServiceUrl = jest.fn()
const tokenUrlMatcher = jest.fn()
beforeEach(() => {
context = {
fetch: jest.fn(),
XMLHttpRequest: jest.fn(),
navigator: {
sendBeacon: jest.fn(),
},
}
FetchProxy.create.mockReturnValue(jest.fn())
XHRProxy.create.mockReturnValue(jest.fn())
BeaconProxy.create.mockReturnValue(jest.fn())
})
it('should not replace fetch if not present', () => {
context = {
XMLHttpRequest: jest.fn(),
navigator: {
sendBeacon: jest.fn(),
},
}
setProxy(
context,
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
tokenUrlMatcher,
)
expect(context.fetch).toBeUndefined()
expect(FetchProxy.create).toHaveBeenCalledTimes(0)
expect(XHRProxy.create).toHaveBeenCalled()
expect(BeaconProxy.create).toHaveBeenCalled()
})
it('should replace XMLHttpRequest if present', () => {
setProxy(
context,
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
tokenUrlMatcher,
)
expect(context.XMLHttpRequest).toEqual(expect.any(Function))
expect(XHRProxy.create).toHaveBeenCalled()
})
it('should replace fetch if present', () => {
setProxy(
context,
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
tokenUrlMatcher,
)
expect(context.fetch).toEqual(expect.any(Function))
expect(FetchProxy.create).toHaveBeenCalled()
})
it('should replace navigator.sendBeacon if present', () => {
setProxy(
context,
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
tokenUrlMatcher,
)
expect(context.navigator.sendBeacon).toEqual(expect.any(Function))
expect(BeaconProxy.create).toHaveBeenCalled()
})
})

View file

@ -78,7 +78,7 @@ export default class MessageEncoder extends PrimitiveEncoder {
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.NetworkRequest:
case Messages.Type.NetworkRequestDeprecated:
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.string(msg[5]) && this.uint(msg[6]) && this.uint(msg[7]) && this.uint(msg[8])
break
@ -254,6 +254,10 @@ export default class MessageEncoder extends PrimitiveEncoder {
return this.uint(msg[1]) && this.uint(msg[2])
break
case Messages.Type.NetworkRequest:
return this.string(msg[1]) && this.string(msg[2]) && this.string(msg[3]) && this.string(msg[4]) && this.string(msg[5]) && this.uint(msg[6]) && this.uint(msg[7]) && this.uint(msg[8]) && this.uint(msg[9])
break
case Messages.Type.InputChange:
return this.uint(msg[1]) && this.string(msg[2]) && this.boolean(msg[3]) && this.string(msg[4]) && this.int(msg[5]) && this.int(msg[6])
break