diff --git a/frontend/app/components/Session_/GraphQL/GQLDetails.js b/frontend/app/components/Session_/GraphQL/GQLDetails.js index 686cba418..dbc42ceeb 100644 --- a/frontend/app/components/Session_/GraphQL/GQLDetails.js +++ b/frontend/app/components/Session_/GraphQL/GQLDetails.js @@ -35,9 +35,9 @@ export default class GQLDetails extends React.PureComponent { -
+
-
+
{'Variables'}
diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx index 2ad92eb8a..ce9c4153f 100644 --- a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx +++ b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx @@ -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, diff --git a/frontend/app/player/web/MessageLoader.ts b/frontend/app/player/web/MessageLoader.ts index 4ff59cbf6..e52c31f6f 100644 --- a/frontend/app/player/web/MessageLoader.ts +++ b/frontend/app/player/web/MessageLoader.ts @@ -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 = {}; + 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, parser: DOMParser, msg: Record, 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))); diff --git a/frontend/app/player/web/MessageManager.ts b/frontend/app/player/web/MessageManager.ts index ba074a1cc..d09c3f241 100644 --- a/frontend/app/player/web/MessageManager.ts +++ b/frontend/app/player/web/MessageManager.ts @@ -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; @@ -99,7 +99,7 @@ export default class MessageManager { closedTabs: [], sessionStart: 0, tabNames: {}, -}; + }; private clickManager: ListWalker = new ListWalker(); private mouseThrashingManager: ListWalker = new ListWalker(); @@ -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,9 +404,11 @@ 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 - this.state.update({ messagesLoading, ready: isReady}); + const cssLoading = Object.values(this.state.get().tabStates).some( + (tab) => tab.cssLoading + ); + const isReady = !messagesLoading && !cssLoading; + this.state.update({ messagesLoading, ready: isReady }); }; decodeMessage(msg: Message) { diff --git a/frontend/app/player/web/Screen/Screen.ts b/frontend/app/player/web/Screen/Screen.ts index d643206f4..8399ab867 100644 --- a/frontend/app/player/web/Screen/Screen.ts +++ b/frontend/app/player/web/Screen/Screen.ts @@ -181,7 +181,7 @@ export default class Screen { getElementFromInternalPoint({ x, y }: Point): Element | null { // elementFromPoint && elementFromPoints require viewpoint-related coordinates, - // not document-related + // not document-related return this.document?.elementFromPoint(x, y) || null; } diff --git a/frontend/app/player/web/TabManager.ts b/frontend/app/player/web/TabManager.ts index 6785678b3..4e2cb61d3 100644 --- a/frontend/app/player/web/TabManager.ts +++ b/frontend/app/player/web/TabManager.ts @@ -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) { Object.keys(lists).forEach((key: 'event' | 'stack' | 'exceptions') => { const currentList = this.lists.lists[key]; diff --git a/frontend/app/player/web/managers/DOM/DOMManager.ts b/frontend/app/player/web/managers/DOM/DOMManager.ts index 3003f0b79..a89831c57 100644 --- a/frontend/app/player/web/managers/DOM/DOMManager.ts +++ b/frontend/app/player/web/managers/DOM/DOMManager.ts @@ -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 { 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`, diff --git a/frontend/app/player/web/managers/DOM/VirtualDOM.ts b/frontend/app/player/web/managers/DOM/VirtualDOM.ts index be3516f79..0677af117 100644 --- a/frontend/app/player/web/managers/DOM/VirtualDOM.ts +++ b/frontend/app/player/web/managers/DOM/VirtualDOM.ts @@ -53,7 +53,7 @@ export abstract class VNode { public abstract applyChanges(): void } -type VChild = VElement | VText +type VChild = VElement | VText | VSpriteMap abstract class VParent extends VNode{ /** */ @@ -140,6 +140,44 @@ export class VShadowRoot extends VParent { export type VRoot = VDocument | VShadowRoot +export class VSpriteMap extends VParent { + parentNode: VParent | null = + null; /** Should be modified only by he parent itself */ + private newAttributes: Map = 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 { parentNode: VParent | null = null /** Should be modified only by he parent itself */ private newAttributes: Map = new Map() diff --git a/frontend/app/player/web/managers/PagesManager.ts b/frontend/app/player/web/managers/PagesManager.ts index 4ec537b67..8e5650d71 100644 --- a/frontend/app/player/web/managers/PagesManager.ts +++ b/frontend/app/player/web/managers/PagesManager.ts @@ -79,15 +79,41 @@ export default class PagesManager extends ListWalker { 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 { 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(); } diff --git a/frontend/app/player/web/types/resource.ts b/frontend/app/player/web/types/resource.ts index 86873a3bb..53944ab4a 100644 --- a/frontend/app/player/web/types/resource.ts +++ b/frontend/app/player/web/types/resource.ts @@ -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) => ({ - ...resource, - name: getResourceName(resource.url), - isRed: !resource.success || resource.error, //|| resource.score >= RED_BOUND, - isYellow: false, // resource.score < RED_BOUND && resource.score >= YELLOW_BOUND, -}) +export const Resource = (resource: IResource) => { + const name = resource.type === 'graphql' ? getGraphqlReqName(resource) : getResourceName(resource.url) + return { + ...resource, + 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) { diff --git a/tracker/tracker-graphql/README.md b/tracker/tracker-graphql/README.md index 9c68794c5..07a0b8678 100644 --- a/tracker/tracker-graphql/README.md +++ b/tracker/tracker-graphql/README.md @@ -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, ]); ``` diff --git a/tracker/tracker-graphql/package.json b/tracker/tracker-graphql/package.json index e8c7d9981..cb5c8dd2e 100644 --- a/tracker/tracker-graphql/package.json +++ b/tracker/tracker-graphql/package.json @@ -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" } diff --git a/tracker/tracker-graphql/src/index.ts b/tracker/tracker-graphql/src/index.ts index 5bf81f24c..d09fba2ac 100644 --- a/tracker/tracker-graphql/src/index.ts +++ b/tracker/tracker-graphql/src/index.ts @@ -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, diff --git a/tracker/tracker-graphql/src/relayMiddleware.ts b/tracker/tracker-graphql/src/relayMiddleware.ts index c3aac9d98..6e02c6545 100644 --- a/tracker/tracker-graphql/src/relayMiddleware.ts +++ b/tracker/tracker-graphql/src/relayMiddleware.ts @@ -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; +} + +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>) { + return (app: App | null) => { + return (originalFetch: FetchFunction) => (operation: GraphQLOperation, variables: GraphQLVariables, cacheConfig: GraphQLCacheConfig, uploadables?: any): Observable => { + 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>) => { 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 }; diff --git a/tracker/tracker-graphql/tsconfig.json b/tracker/tracker-graphql/tsconfig.json index 31699449b..82ef5c790 100644 --- a/tracker/tracker-graphql/tsconfig.json +++ b/tracker/tracker-graphql/tsconfig.json @@ -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"], } diff --git a/tracker/tracker/.yarn/install-state.gz b/tracker/tracker/.yarn/install-state.gz index 7c84f37ef..773e1c417 100644 Binary files a/tracker/tracker/.yarn/install-state.gz and b/tracker/tracker/.yarn/install-state.gz differ diff --git a/tracker/tracker/package.json b/tracker/tracker/package.json index 625da24a7..d0c141ac7 100644 --- a/tracker/tracker/package.json +++ b/tracker/tracker/package.json @@ -1,7 +1,7 @@ { "name": "@openreplay/tracker", "description": "The OpenReplay tracker main package", - "version": "15.0.3", + "version": "15.0.4", "keywords": [ "logging", "replay" diff --git a/tracker/tracker/src/main/app/guards.ts b/tracker/tracker/src/main/app/guards.ts index 11edb1dd1..f7a7065d3 100644 --- a/tracker/tracker/src/main/app/guards.ts +++ b/tracker/tracker/src/main/app/guards.ts @@ -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 { diff --git a/tracker/tracker/src/main/app/observer/observer.ts b/tracker/tracker/src/main/app/observer/observer.ts index 51eeb6d48..3c2b3bd68 100644 --- a/tracker/tracker/src/main/app/observer/observer.ts +++ b/tracker/tracker/src/main/app/observer/observer.ts @@ -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 .') + return + } + + const [url, symbolId] = href.split('#') + if (!url || !symbolId) { + console.debug('Openreplay: Invalid xlink:href or href found on .') + 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 = ` + + ${symbol.innerHTML} + + ` + 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 element:', error) + } +} + function isIgnored(node: Node): boolean { if (isCommentNode(node)) { return true @@ -146,8 +204,8 @@ export default abstract class Observer { { acceptNode: (node) => isIgnored(node) || this.app.nodes.getID(node) === undefined - ? NodeFilter.FILTER_REJECT - : NodeFilter.FILTER_ACCEPT, + ? NodeFilter.FILTER_REJECT + : NodeFilter.FILTER_ACCEPT, }, // @ts-ignore false, @@ -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 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 }