tracker: add use el / sprite map support, change graphql relay plugin
This commit is contained in:
parent
d7f810809e
commit
f791d06ecd
19 changed files with 427 additions and 59 deletions
|
|
@ -35,9 +35,9 @@ export default class GQLDetails extends React.PureComponent {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 'calc(100vh - 364px)', overflowY: 'auto' }}>
|
||||
<div style={{ height: 'calc(100vh - 264px)', overflowY: 'auto' }} className={'border-t border-t-gray-light mt-2 py-2'}>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mt-6 mb-2">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h5 className="mt-1 mr-1">{'Variables'}</h5>
|
||||
</div>
|
||||
<div className={dataClass}>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ const IMG = 'img';
|
|||
const MEDIA = 'media';
|
||||
const OTHER = 'other';
|
||||
const WS = 'websocket';
|
||||
const GRAPHQL = 'graphql';
|
||||
|
||||
const TYPE_TO_TAB = {
|
||||
[ResourceType.XHR]: XHR,
|
||||
|
|
@ -47,9 +48,10 @@ const TYPE_TO_TAB = {
|
|||
[ResourceType.MEDIA]: MEDIA,
|
||||
[ResourceType.WS]: WS,
|
||||
[ResourceType.OTHER]: OTHER,
|
||||
[ResourceType.GRAPHQL]: GRAPHQL,
|
||||
};
|
||||
|
||||
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS] as const;
|
||||
const TAP_KEYS = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER, WS, GRAPHQL] as const;
|
||||
export const NETWORK_TABS = TAP_KEYS.map((tab) => ({
|
||||
text: tab === 'xhr' ? 'Fetch/XHR' : tab,
|
||||
key: tab,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,21 @@ export default class MessageLoader {
|
|||
this.session = session
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: has to be moved out of messageLoader logic somehow
|
||||
* */
|
||||
spriteMapSvg: SVGElement | null = null;
|
||||
potentialSpriteMap: Record<string, any> = {};
|
||||
domParser: DOMParser | null = null;
|
||||
createSpriteMap = () => {
|
||||
if (!this.spriteMapSvg) {
|
||||
this.domParser = new DOMParser();
|
||||
this.spriteMapSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
this.spriteMapSvg.setAttribute("style", "display: none;");
|
||||
this.spriteMapSvg.setAttribute("id", "reconstructed-sprite");
|
||||
}
|
||||
}
|
||||
|
||||
createNewParser(
|
||||
shouldDecrypt = true,
|
||||
onMessagesDone: (msgs: PlayerMsg[], file?: string) => void,
|
||||
|
|
@ -78,7 +93,22 @@ export default class MessageLoader {
|
|||
let artificialStartTime = Infinity;
|
||||
let startTimeSet = false;
|
||||
|
||||
msgs.forEach((msg) => {
|
||||
msgs.forEach((msg, i) => {
|
||||
if (msg.tp === MType.SetNodeAttribute) {
|
||||
if (msg.value.includes('_$OPENREPLAY_SPRITE$_')) {
|
||||
this.createSpriteMap()
|
||||
if (!this.domParser) {
|
||||
return console.error('DOM parser is not initialized?');
|
||||
}
|
||||
handleSprites(
|
||||
this.potentialSpriteMap,
|
||||
this.domParser,
|
||||
msg,
|
||||
this.spriteMapSvg!,
|
||||
i
|
||||
);
|
||||
}
|
||||
}
|
||||
if (msg.tp === MType.Redux || msg.tp === MType.ReduxDeprecated) {
|
||||
if ('actionTime' in msg && msg.actionTime) {
|
||||
msg.time = msg.actionTime - this.session.startedAt;
|
||||
|
|
@ -293,6 +323,10 @@ export default class MessageLoader {
|
|||
|
||||
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]);
|
||||
this.messageManager.onFileReadSuccess();
|
||||
// no sprites for mobile
|
||||
if (this.spriteMapSvg && 'injectSpriteMap' in this.messageManager) {
|
||||
this.messageManager.injectSpriteMap(this.spriteMapSvg);
|
||||
}
|
||||
};
|
||||
|
||||
loadEFSMobs = async () => {
|
||||
|
|
@ -420,5 +454,28 @@ function findBrokenNodes(nodes: any[]) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function handleSprites(potentialSpriteMap: Record<string, any>, parser: DOMParser, msg: Record<string, any>, spriteMapSvg: SVGElement, i: number) {
|
||||
const [_, dataUrl] = msg.value.split('_$OPENREPLAY_SPRITE$_');
|
||||
const potentialSprite = potentialSpriteMap[dataUrl];
|
||||
if (potentialSprite) {
|
||||
msg.value = potentialSprite;
|
||||
} else {
|
||||
const svgText = atob(dataUrl.split(",")[1]);
|
||||
const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
|
||||
const originalSvg = svgDoc.querySelector("svg");
|
||||
if (originalSvg) {
|
||||
const symbol = document.createElementNS("http://www.w3.org/2000/svg", "symbol");
|
||||
const symbolId = `symbol-${msg.id || 'ind-' + i}`; // Generate an ID if missing
|
||||
symbol.setAttribute("id", symbolId);
|
||||
symbol.setAttribute("viewBox", originalSvg.getAttribute("viewBox") || "0 0 24 24");
|
||||
symbol.innerHTML = originalSvg.innerHTML;
|
||||
|
||||
spriteMapSvg.appendChild(symbol);
|
||||
msg.value = `#${symbolId}`;
|
||||
potentialSpriteMap[dataUrl] = `#${symbolId}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.searchOrphans = (msgs) => findBrokenNodes(msgs.filter(m => [8,9,10,70].includes(m.tp)));
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import ListWalker from '../common/ListWalker';
|
|||
import MouseMoveManager from './managers/MouseMoveManager';
|
||||
|
||||
import ActivityManager from './managers/ActivityManager';
|
||||
import TabClosingManager from "./managers/TabClosingManager";
|
||||
import TabClosingManager from './managers/TabClosingManager';
|
||||
|
||||
import { MouseThrashing, MType } from './messages';
|
||||
import type { Message, MouseClick } from './messages';
|
||||
|
|
@ -52,7 +52,7 @@ export interface State extends ScreenState {
|
|||
};
|
||||
tabNames: {
|
||||
[tabId: string]: string;
|
||||
}
|
||||
};
|
||||
|
||||
domContentLoadedTime?: { time: number; value: number };
|
||||
domBuildingTime?: number;
|
||||
|
|
@ -128,7 +128,9 @@ export default class MessageManager {
|
|||
this.mouseMoveManager = new MouseMoveManager(screen);
|
||||
this.sessionStart = this.session.startedAt;
|
||||
state.update({ sessionStart: this.sessionStart });
|
||||
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
|
||||
this.activityManager = new ActivityManager(
|
||||
this.session.duration.milliseconds
|
||||
); // only if not-live
|
||||
}
|
||||
|
||||
public getListsFullState = () => {
|
||||
|
|
@ -139,12 +141,18 @@ export default class MessageManager {
|
|||
return Object.values(this.tabs)[0].getListsFullState();
|
||||
};
|
||||
|
||||
public injectSpriteMap = (spriteEl: SVGElement) => {
|
||||
Object.values(this.tabs).forEach((tab) => {
|
||||
tab.injectSpriteMap(spriteEl)
|
||||
})
|
||||
};
|
||||
|
||||
public setSession = (session: SessionFilesInfo) => {
|
||||
this.session = session;
|
||||
this.sessionStart = this.session.startedAt;
|
||||
this.state.update({ sessionStart: this.sessionStart });
|
||||
Object.values(this.tabs).forEach((tab) => tab.setSession(session));
|
||||
}
|
||||
};
|
||||
|
||||
public updateLists(lists: RawList) {
|
||||
Object.keys(this.tabs).forEach((tab) => {
|
||||
|
|
@ -198,26 +206,26 @@ export default class MessageManager {
|
|||
* Scan tab managers for last message ts
|
||||
* */
|
||||
public createTabCloseEvents = () => {
|
||||
const lastMsgArr: [string, number][] = []
|
||||
const lastMsgArr: [string, number][] = [];
|
||||
if (this.tabsAmount === 1) {
|
||||
return this.tabCloseManager.append({
|
||||
tabId: Object.keys(this.tabs)[0],
|
||||
time: this.session.durationMs - 100
|
||||
})
|
||||
time: this.session.durationMs - 100,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [tabId, tab] of Object.entries(this.tabs)) {
|
||||
const { lastMessageTs } = tab
|
||||
const { lastMessageTs } = tab;
|
||||
if (lastMessageTs && tabId) {
|
||||
lastMsgArr.push([tabId, lastMessageTs])
|
||||
lastMsgArr.push([tabId, lastMessageTs]);
|
||||
}
|
||||
}
|
||||
|
||||
lastMsgArr.sort((a, b) => a[1] - b[1])
|
||||
lastMsgArr.sort((a, b) => a[1] - b[1]);
|
||||
lastMsgArr.forEach(([tabId, lastMessageTs]) => {
|
||||
this.tabCloseManager.append({ tabId, time: lastMessageTs })
|
||||
})
|
||||
}
|
||||
this.tabCloseManager.append({ tabId, time: lastMessageTs });
|
||||
});
|
||||
};
|
||||
|
||||
public startLoading = () => {
|
||||
this.waitingForFiles = true;
|
||||
|
|
@ -238,15 +246,15 @@ export default class MessageManager {
|
|||
// usually means waiting for messages from live session
|
||||
if (Object.keys(this.tabs).length === 0) return;
|
||||
this.activeTabManager.moveReady(t).then(async (tabId) => {
|
||||
const closeMessage = await this.tabCloseManager.moveReady(t)
|
||||
const closeMessage = await this.tabCloseManager.moveReady(t);
|
||||
if (closeMessage) {
|
||||
const closedTabs = this.tabCloseManager.closedTabs
|
||||
const closedTabs = this.tabCloseManager.closedTabs;
|
||||
if (closedTabs.size === this.tabsAmount) {
|
||||
if (this.session.durationMs - t < 250) {
|
||||
this.state.update({ closedTabs: Array.from(closedTabs) })
|
||||
this.state.update({ closedTabs: Array.from(closedTabs) });
|
||||
}
|
||||
} else {
|
||||
this.state.update({ closedTabs: Array.from(closedTabs) })
|
||||
this.state.update({ closedTabs: Array.from(closedTabs) });
|
||||
}
|
||||
}
|
||||
// Moving mouse and setting :hover classes on ready view
|
||||
|
|
@ -261,7 +269,8 @@ export default class MessageManager {
|
|||
this.screen.cursor.shake();
|
||||
}
|
||||
if (!this.activeTab) {
|
||||
this.activeTab = this.state.get().currentTab ?? Object.keys(this.tabs)[0];
|
||||
this.activeTab =
|
||||
this.state.get().currentTab ?? Object.keys(this.tabs)[0];
|
||||
}
|
||||
|
||||
if (tabId) {
|
||||
|
|
@ -291,8 +300,7 @@ export default class MessageManager {
|
|||
});
|
||||
if (
|
||||
this.waitingForFiles ||
|
||||
(this.lastMessageTime <= t &&
|
||||
t < this.session.durationMs)
|
||||
(this.lastMessageTime <= t && t < this.session.durationMs)
|
||||
) {
|
||||
this.setMessagesLoading(true);
|
||||
}
|
||||
|
|
@ -318,7 +326,12 @@ export default class MessageManager {
|
|||
if (msg.tp === 9999) return;
|
||||
if (!this.tabs[msg.tabId]) {
|
||||
this.tabsAmount++;
|
||||
this.state.update({ tabStates: { ...this.state.get().tabStates, [msg.tabId]: TabSessionManager.INITIAL_STATE } });
|
||||
this.state.update({
|
||||
tabStates: {
|
||||
...this.state.get().tabStates,
|
||||
[msg.tabId]: TabSessionManager.INITIAL_STATE,
|
||||
},
|
||||
});
|
||||
this.tabs[msg.tabId] = new TabSessionManager(
|
||||
this.session,
|
||||
this.state,
|
||||
|
|
@ -368,7 +381,11 @@ export default class MessageManager {
|
|||
switch (msg.tp) {
|
||||
case MType.CreateDocument:
|
||||
if (!this.firstVisualEventSet) {
|
||||
this.activeTabManager.unshift({ tp: MType.TabChange, tabId: msg.tabId, time: 0 });
|
||||
this.activeTabManager.unshift({
|
||||
tp: MType.TabChange,
|
||||
tabId: msg.tabId,
|
||||
time: 0,
|
||||
});
|
||||
this.state.update({
|
||||
firstVisualEvent: msg.time,
|
||||
currentTab: msg.tabId,
|
||||
|
|
@ -387,8 +404,10 @@ export default class MessageManager {
|
|||
this.updateChangeEvents();
|
||||
}
|
||||
this.screen.display(!messagesLoading);
|
||||
const cssLoading = Object.values(this.state.get().tabStates).some((tab) => tab.cssLoading);
|
||||
const isReady = !messagesLoading && !cssLoading
|
||||
const cssLoading = Object.values(this.state.get().tabStates).some(
|
||||
(tab) => tab.cssLoading
|
||||
);
|
||||
const isReady = !messagesLoading && !cssLoading;
|
||||
this.state.update({ messagesLoading, ready: isReady });
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,10 @@ export default class TabSessionManager {
|
|||
return this.pagesManager.getNode(id);
|
||||
};
|
||||
|
||||
public injectSpriteMap = (spriteMapEl: SVGElement) => {
|
||||
this.pagesManager.injectSpriteMap(spriteMapEl);
|
||||
}
|
||||
|
||||
public updateLists(lists: Partial<InitialLists>) {
|
||||
Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => {
|
||||
const currentList = this.lists.lists[key];
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import ListWalker from '../../../common/ListWalker';
|
|||
import StylesManager from './StylesManager';
|
||||
import FocusManager from './FocusManager';
|
||||
import SelectionManager from './SelectionManager';
|
||||
import type { StyleElement } from './VirtualDOM';
|
||||
import { StyleElement, VSpriteMap } from "./VirtualDOM";
|
||||
import {
|
||||
OnloadStyleSheet,
|
||||
VDocument,
|
||||
|
|
@ -157,6 +157,12 @@ export default class DOMManager extends ListWalker<Message> {
|
|||
return;
|
||||
}
|
||||
const parent = this.vElements.get(parentID) || this.olVRoots.get(parentID);
|
||||
if ('tagName' in child && child.tagName === 'BODY') {
|
||||
const spriteMap = new VSpriteMap('svg', true, Number.MAX_SAFE_INTEGER - 100, Number.MAX_SAFE_INTEGER - 100);
|
||||
spriteMap.node.setAttribute('id', 'OPENREPLAY_SPRITES_MAP');
|
||||
spriteMap.node.setAttribute('style', 'display: none;');
|
||||
child.insertChildAt(spriteMap, Number.MAX_SAFE_INTEGER - 100);
|
||||
}
|
||||
if (!parent) {
|
||||
logger.error(
|
||||
`${id} Insert error. Parent vNode ${parentID} not found`,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export abstract class VNode<T extends Node = Node> {
|
|||
public abstract applyChanges(): void
|
||||
}
|
||||
|
||||
type VChild = VElement | VText
|
||||
type VChild = VElement | VText | VSpriteMap
|
||||
abstract class VParent<T extends Node = Node> extends VNode<T>{
|
||||
/**
|
||||
*/
|
||||
|
|
@ -140,6 +140,44 @@ export class VShadowRoot extends VParent<ShadowRoot> {
|
|||
|
||||
export type VRoot = VDocument | VShadowRoot
|
||||
|
||||
export class VSpriteMap extends VParent<Element> {
|
||||
parentNode: VParent | null =
|
||||
null; /** Should be modified only by he parent itself */
|
||||
private newAttributes: Map<string, string | false> = new Map();
|
||||
|
||||
constructor(
|
||||
readonly tagName: string,
|
||||
readonly isSVG = true,
|
||||
public readonly index: number,
|
||||
private readonly nodeId: number
|
||||
) {
|
||||
super();
|
||||
this.createNode();
|
||||
}
|
||||
protected createNode() {
|
||||
try {
|
||||
const element = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
this.tagName
|
||||
);
|
||||
element.dataset['openreplayId'] = this.nodeId.toString();
|
||||
return element;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Openreplay: Player received invalid html tag',
|
||||
this.tagName,
|
||||
e
|
||||
);
|
||||
return document.createElement(this.tagName.replace(/[^a-z]/gi, ''));
|
||||
}
|
||||
}
|
||||
|
||||
applyChanges() {
|
||||
// this is a hack to prevent the sprite map from being removed from the DOM
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class VElement extends VParent<Element> {
|
||||
parentNode: VParent | null = null /** Should be modified only by he parent itself */
|
||||
private newAttributes: Map<string, string | false> = new Map()
|
||||
|
|
|
|||
|
|
@ -79,15 +79,41 @@ export default class PagesManager extends ListWalker<DOMManager> {
|
|||
return this.currentPage?.getNode(id);
|
||||
}
|
||||
|
||||
spriteMapEl: SVGElement | null = null;
|
||||
injectSpriteMap = (spriteEl: SVGElement) => {
|
||||
this.spriteMapEl = spriteEl;
|
||||
this.refreshSprites();
|
||||
};
|
||||
|
||||
refreshSprites = () => {
|
||||
const int = setInterval(() => {
|
||||
const potential = this.screen.document?.body.querySelector(
|
||||
'#OPENREPLAY_SPRITES_MAP'
|
||||
);
|
||||
if (potential) {
|
||||
potential.innerHTML = this.spriteMapEl!.innerHTML;
|
||||
clearInterval(int);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
moveReady(t: number): Promise<void> {
|
||||
const requiredPage = this.moveGetLast(t);
|
||||
let changed = false;
|
||||
if (requiredPage != null) {
|
||||
this.currentPage?.clearSelectionManager();
|
||||
this.currentPage = requiredPage;
|
||||
this.currentPage.reset(); // Otherwise it won't apply create_document
|
||||
changed = true;
|
||||
}
|
||||
if (this.currentPage != null) {
|
||||
return this.currentPage.moveReady(t);
|
||||
return this.currentPage.moveReady(t).then(() => {
|
||||
if (changed && this.spriteMapEl) {
|
||||
setTimeout(() => {
|
||||
this.refreshSprites();
|
||||
}, 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,13 +105,26 @@ export interface IResourceRequest extends IResource {
|
|||
decodedBodySize?: number,
|
||||
}
|
||||
|
||||
const getGraphqlReqName = (resource: IResource) => {
|
||||
try {
|
||||
if (!resource.request) return getResourceName(resource.url)
|
||||
const req = JSON.parse(resource.request)
|
||||
const body = JSON.parse(req.body)
|
||||
return /query (\w+)/.exec(body.query)?.[1]
|
||||
} catch (e) {
|
||||
return getResourceName(resource.url)
|
||||
}
|
||||
}
|
||||
|
||||
export const Resource = (resource: IResource) => ({
|
||||
export const Resource = (resource: IResource) => {
|
||||
const name = resource.type === 'graphql' ? getGraphqlReqName(resource) : getResourceName(resource.url)
|
||||
return {
|
||||
...resource,
|
||||
name: getResourceName(resource.url),
|
||||
name,
|
||||
isRed: !resource.success || resource.error, //|| resource.score >= RED_BOUND,
|
||||
isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function getResourceFromResourceTiming(msg: ResourceTiming, sessStart: number) {
|
||||
|
|
|
|||
|
|
@ -30,16 +30,50 @@ export const recordGraphQL = tracker.use(createGraphqlMiddleware());
|
|||
### Relay
|
||||
|
||||
If you're using [Relay network tools](https://github.com/relay-tools/react-relay-network-modern),
|
||||
you can simply [create a middleware](https://github.com/relay-tools/react-relay-network-modern/tree/master?tab=readme-ov-file#example-of-injecting-networklayer-with-middlewares-on-the-client-side)
|
||||
you can simply [create a middleware](https://github.com/relay-tools/react-relay-network-modern/tree/master?tab=readme-ov-file#example-of-injecting-networklayer-with-middlewares-on-the-client-side) (async based); otherwise this will require wrapping fetch function with Observable.
|
||||
|
||||
```js
|
||||
import { createRelayMiddleware } from '@openreplay/tracker-graphql';
|
||||
import { Observable } from 'relay-runtime';
|
||||
|
||||
const trackerMiddleware = tracker.use(createRelayMiddleware());
|
||||
const withTracker = tracker.use(createRelayMiddleware())
|
||||
function createFetchObservable(operation, variables) {
|
||||
return Observable.create(sink => {
|
||||
fetch(`YOUR URL`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query: operation.text, variables }),
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
sink.next(data);
|
||||
sink.complete();
|
||||
})
|
||||
.catch(error => {
|
||||
sink.error(error);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const network = Network.create(withTracker(createFetchObservable));
|
||||
|
||||
const environment = new Environment({
|
||||
network,
|
||||
store: new Store(new RecordSource()),
|
||||
});
|
||||
```
|
||||
|
||||
```js
|
||||
import { createRelayToolsMiddleware } from '@openreplay/tracker-graphql';
|
||||
|
||||
const trackerMiddleware = tracker.use(createRelayToolsMiddleware());
|
||||
|
||||
const network = new RelayNetworkLayer([
|
||||
// your middleware
|
||||
// ,
|
||||
trackerMiddleware,
|
||||
]);
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker-graphql",
|
||||
"description": "Tracker plugin for GraphQL requests recording",
|
||||
"version": "4.1.0",
|
||||
"version": "4.2.0",
|
||||
"keywords": [
|
||||
"graphql",
|
||||
"logging",
|
||||
|
|
@ -31,7 +31,6 @@
|
|||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.5",
|
||||
"@types/zen-observable": "^0.8.7",
|
||||
"zen-observable": "^0.10.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import createTrackerLink from './apolloMiddleware.js';
|
||||
import createRelayMiddleware from './relayMiddleware.js';
|
||||
import { createRelayObserver, createRelayMiddleware } from './relayMiddleware.js';
|
||||
import createGraphqlMiddleware from './graphqlMiddleware.js';
|
||||
import { Sanitizer } from './types.js';
|
||||
|
||||
export {
|
||||
createTrackerLink,
|
||||
createRelayObserver,
|
||||
createRelayMiddleware,
|
||||
createGraphqlMiddleware,
|
||||
Sanitizer,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,95 @@
|
|||
import { App, Messages } from '@openreplay/tracker';
|
||||
import type { Middleware, RelayRequest } from './relaytypes';
|
||||
import { Sanitizer } from './types';
|
||||
import Observable from 'zen-observable';
|
||||
|
||||
interface GraphQLOperation {
|
||||
name: string;
|
||||
operationKind: string;
|
||||
text?: string | null;
|
||||
}
|
||||
|
||||
interface GraphQLVariables {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface GraphQLCacheConfig {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface FetchFunction {
|
||||
(
|
||||
operation: GraphQLOperation,
|
||||
variables: GraphQLVariables,
|
||||
cacheConfig: GraphQLCacheConfig,
|
||||
uploadables?: any
|
||||
): Observable<unknown>;
|
||||
}
|
||||
|
||||
function safeStringify(value: unknown) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
// If we can’t stringify (e.g., cyclic object), return a placeholder
|
||||
return '"[unserializable]"';
|
||||
}
|
||||
}
|
||||
|
||||
function createRelayObserver(sanitizer?: Sanitizer<Record<string, any>>) {
|
||||
return (app: App | null) => {
|
||||
return (originalFetch: FetchFunction) => (operation: GraphQLOperation, variables: GraphQLVariables, cacheConfig: GraphQLCacheConfig, uploadables?: any): Observable<any> => {
|
||||
const startTime = Date.now();
|
||||
const observable = originalFetch(operation, variables, cacheConfig, uploadables);
|
||||
|
||||
if (!app || !app.active()) {
|
||||
return observable;
|
||||
}
|
||||
|
||||
return new Observable(observer =>
|
||||
observable.subscribe({
|
||||
next: (data: any) => {
|
||||
const duration = Date.now() - startTime;
|
||||
const opName = operation.name;
|
||||
const opKind = operation.operationKind;
|
||||
const vars = JSON.stringify(sanitizer ? sanitizer(variables) : variables);
|
||||
if (data.errors && data.errors.length > 0) {
|
||||
const opResp = safeStringify(sanitizer ? sanitizer(data.errors) : data.errors);
|
||||
app.send(Messages.GraphQL(
|
||||
opKind,
|
||||
`ERROR: ${opName}`,
|
||||
vars,
|
||||
opResp,
|
||||
duration
|
||||
));
|
||||
} else {
|
||||
const opResp = safeStringify(sanitizer ? sanitizer(data) : data);
|
||||
app.send(Messages.GraphQL(
|
||||
opKind,
|
||||
opName,
|
||||
vars,
|
||||
opResp,
|
||||
duration
|
||||
));
|
||||
}
|
||||
observer.next(data);
|
||||
},
|
||||
error: err => {
|
||||
const duration = Date.now() - startTime;
|
||||
const opName = 'ERROR: ' + operation.name;
|
||||
const opKind = operation.operationKind;
|
||||
const vars = safeStringify(sanitizer ? sanitizer(variables) : variables);
|
||||
const opResp = safeStringify(err);
|
||||
app.send(Messages.GraphQL(opKind, opName, vars, opResp, duration));
|
||||
observer.error(err);
|
||||
},
|
||||
complete: () => {
|
||||
observer.complete();
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createRelayMiddleware = (sanitizer?: Sanitizer<Record<string, any>>) => {
|
||||
return (app: App | null): Middleware => {
|
||||
|
|
@ -52,4 +141,4 @@ function getMessage(
|
|||
return Messages.GraphQL(opKind, opName, vars, opResp, duration);
|
||||
}
|
||||
|
||||
export default createRelayMiddleware;
|
||||
export { createRelayMiddleware, createRelayObserver };
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@
|
|||
"noImplicitThis": true,
|
||||
"strictNullChecks": true,
|
||||
"alwaysStrict": true,
|
||||
"target": "es6",
|
||||
"module": "es6",
|
||||
"target": "es2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"outDir": "./lib",
|
||||
"allowSyntheticDefaultImports": true
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@openreplay/tracker",
|
||||
"description": "The OpenReplay tracker main package",
|
||||
"version": "15.0.3",
|
||||
"version": "15.0.4",
|
||||
"keywords": [
|
||||
"logging",
|
||||
"replay"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,13 @@ export function isNode(sth: any): sth is Node {
|
|||
}
|
||||
|
||||
export function isSVGElement(node: Element): node is SVGElement {
|
||||
return node.namespaceURI === 'http://www.w3.org/2000/svg'
|
||||
return (
|
||||
node.namespaceURI === 'http://www.w3.org/2000/svg' || node.localName === 'svg'
|
||||
)
|
||||
}
|
||||
|
||||
export function isUseElement(node: Element): node is SVGUseElement {
|
||||
return node.localName === 'use'
|
||||
}
|
||||
|
||||
export function isElementNode(node: Node): node is Element {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
MoveNode,
|
||||
RemoveNode,
|
||||
UnbindNodes,
|
||||
SetNodeAttribute,
|
||||
} from '../messages.gen.js'
|
||||
import App from '../index.js'
|
||||
import {
|
||||
|
|
@ -16,10 +17,67 @@ import {
|
|||
isTextNode,
|
||||
isElementNode,
|
||||
isSVGElement,
|
||||
isUseElement,
|
||||
hasTag,
|
||||
isCommentNode,
|
||||
} from '../guards.js'
|
||||
|
||||
const iconCache = {}
|
||||
const domParser = new DOMParser()
|
||||
|
||||
async function parseUseEl(useElement: SVGUseElement, mode: 'inline' | 'dataurl') {
|
||||
try {
|
||||
const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href')
|
||||
if (!href) {
|
||||
console.debug('Openreplay: xlink:href or href not found on <use>.')
|
||||
return
|
||||
}
|
||||
|
||||
const [url, symbolId] = href.split('#')
|
||||
if (!url || !symbolId) {
|
||||
console.debug('Openreplay: Invalid xlink:href or href found on <use>.')
|
||||
return
|
||||
}
|
||||
|
||||
if (iconCache[symbolId]) {
|
||||
return iconCache[symbolId]
|
||||
}
|
||||
|
||||
const response = await fetch(url)
|
||||
const svgText = await response.text()
|
||||
|
||||
const svgDoc = domParser.parseFromString(svgText, 'image/svg+xml')
|
||||
const symbol = svgDoc.getElementById(symbolId)
|
||||
|
||||
if (!symbol) {
|
||||
console.debug('Openreplay: Symbol not found in SVG.')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'inline') {
|
||||
const res = { paths: symbol.innerHTML, vbox: symbol.getAttribute('viewBox') || '0 0 24 24' }
|
||||
iconCache[symbolId] = res
|
||||
return res
|
||||
} else if (mode === 'dataurl') {
|
||||
const inlineSvg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="${symbol.getAttribute('viewBox') || '0 0 24 24'}">
|
||||
${symbol.innerHTML}
|
||||
</svg>
|
||||
`
|
||||
const encodedSvg = btoa(inlineSvg)
|
||||
const dataUrl = `data:image/svg+xml;base64,${encodedSvg}`
|
||||
|
||||
iconCache[symbolId] = dataUrl
|
||||
|
||||
return dataUrl
|
||||
} else {
|
||||
console.debug(`Openreplay: Unknown mode: ${mode}. Use "inline" or "dataurl".`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Openreplay: Error processing <use> element:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function isIgnored(node: Node): boolean {
|
||||
if (isCommentNode(node)) {
|
||||
return true
|
||||
|
|
@ -178,13 +236,28 @@ export default abstract class Observer {
|
|||
}
|
||||
if (value === null) {
|
||||
this.app.send(RemoveNodeAttribute(id, name))
|
||||
} else if (name === 'href') {
|
||||
if (value.length > 1e5) {
|
||||
}
|
||||
|
||||
if (isUseElement(node) && name === 'href') {
|
||||
parseUseEl(node, 'dataurl')
|
||||
.then((dataUrl) => {
|
||||
if (dataUrl) {
|
||||
this.app.send(SetNodeAttribute(id, name, `_$OPENREPLAY_SPRITE$_${dataUrl}`))
|
||||
}
|
||||
})
|
||||
.catch((e: any) => {
|
||||
console.error('Openreplay: Error parsing <use> element:', e)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (name === 'href') {
|
||||
if (value!.length > 1e5) {
|
||||
value = ''
|
||||
}
|
||||
this.app.send(SetNodeAttributeURLBased(id, name, value, this.app.getBaseHref()))
|
||||
this.app.send(SetNodeAttributeURLBased(id, name, value!, this.app.getBaseHref()))
|
||||
} else {
|
||||
this.app.attributeSender.sendSetAttribute(id, name, value)
|
||||
this.app.attributeSender.sendSetAttribute(id, name, value!)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue