From 5d203aeabcb6839c57bc3790b69d38248c98900e Mon Sep 17 00:00:00 2001 From: Kevin Paxton Date: Fri, 15 Sep 2023 16:17:28 +0100 Subject: [PATCH] Added web sender and wired up to example/apollo-client --- examples/apollo-client/package.json | 3 +- .../apollo-client/src/components/Sanity.tsx | 2 +- examples/apollo-client/src/index.js | 3 + package.json | 2 +- packages/browser/src/model/CollectorClient.ts | 14 +--- packages/browser/src/model/mockData.ts | 12 +++- .../browser/src/scripts/startCollector.cjs | 13 ++-- packages/browser/src/systems/GraphQL.tsx | 2 +- packages/web/package.json | 15 +++++ packages/web/src/http.ts | 64 +++++++++++++++++++ packages/web/src/index.ts | 1 + packages/web/src/log.ts | 10 +++ packages/web/src/options.ts | 4 ++ packages/web/src/tracing.ts | 43 +++++++++++++ packages/web/tsconfig.json | 9 +++ 15 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 packages/web/package.json create mode 100644 packages/web/src/http.ts create mode 100644 packages/web/src/index.ts create mode 100644 packages/web/src/log.ts create mode 100644 packages/web/src/options.ts create mode 100644 packages/web/src/tracing.ts create mode 100644 packages/web/tsconfig.json diff --git a/examples/apollo-client/package.json b/examples/apollo-client/package.json index 9cc381e..5f4bafd 100644 --- a/examples/apollo-client/package.json +++ b/examples/apollo-client/package.json @@ -10,9 +10,10 @@ "license": "MIT", "private": true, "scripts": { - "start": "parcel ./src/index.html --port 4001" + "start": "parcel ./src/index.html --port 4001 --no-cache" }, "dependencies": { + "@envy/web": "*", "react": "^18.2.0", "react-dom": "^18.2.0", "urql": "^4.0.5" diff --git a/examples/apollo-client/src/components/Sanity.tsx b/examples/apollo-client/src/components/Sanity.tsx index be233a5..6154f08 100644 --- a/examples/apollo-client/src/components/Sanity.tsx +++ b/examples/apollo-client/src/components/Sanity.tsx @@ -26,7 +26,7 @@ export default function Sanity() {
{category.products.map((product: any) => { return product.variants.map((variant: any) => ( -
+
{variant.name}
${variant.price.toFixed(2)}
diff --git a/examples/apollo-client/src/index.js b/examples/apollo-client/src/index.js index 3e05c2b..da344c9 100644 --- a/examples/apollo-client/src/index.js +++ b/examples/apollo-client/src/index.js @@ -1,5 +1,8 @@ +import { enableTracing } from '@envy/web'; import { createRoot } from 'react-dom/client'; +enableTracing({ serviceName: 'examples/apollo', debug: true, port: 9999 }); + import { App } from './App'; const container = document.getElementById('app'); diff --git a/package.json b/package.json index f6ad556..a16b820 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,11 @@ "examples/*" ], "devDependencies": { + "@changesets/cli": "2.26.2", "@tsconfig/node16": "^16.1.1", "@types/jest": "^29.5.4", "@typescript-eslint/eslint-plugin": "^6.6.0", "@typescript-eslint/parser": "^6.6.0", - "@changesets/cli": "2.26.2", "concurrently": "^8.2.1", "eslint": "^8.48.0", "eslint-config-prettier": "^9.0.0", diff --git a/packages/browser/src/model/CollectorClient.ts b/packages/browser/src/model/CollectorClient.ts index eb7a660..38e9599 100644 --- a/packages/browser/src/model/CollectorClient.ts +++ b/packages/browser/src/model/CollectorClient.ts @@ -1,4 +1,4 @@ -import { Event, EventType, HttpRequest } from '@envy/core'; +import { DEFAULT_WEB_SOCKET_PORT, Event, EventType, HttpRequest } from '@envy/core'; import { Traces } from '@/types'; import { safeParseJson } from '@/utils'; @@ -44,7 +44,8 @@ export default class CollectorClient { } private _connect() { - const socket = new WebSocket(`ws://localhost:${this._port}/viewer`); + const port = this._port ?? DEFAULT_WEB_SOCKET_PORT; + const socket = new WebSocket(`ws://localhost:${port}/viewer`); socket.onopen = () => { this._connecting = false; @@ -57,15 +58,6 @@ export default class CollectorClient { this._connected = false; this._connecting = true; this._signalChange(); - this._retryCount += 1; - if (this._shouldRetry && this._retryCount < 3) { - // TODO: implement incremental back-off? - setTimeout(this._connect, 3000); - } else { - this._shouldRetry = false; - this._connecting = false; - this._signalChange(); - } }; socket.onmessage = ({ data }) => { diff --git a/packages/browser/src/model/mockData.ts b/packages/browser/src/model/mockData.ts index 2552630..57ecdfb 100644 --- a/packages/browser/src/model/mockData.ts +++ b/packages/browser/src/model/mockData.ts @@ -14,12 +14,11 @@ function requestData( host: Trace['host'], port: Trace['port'], path: Trace['path'], -): Pick { +): Pick { const protocol = port === 433 ? 'https://' : 'http://'; const hostString = port === 80 || port === 443 ? `${host}` : `${host}:${port.toString()}`; return { - httpVersion: '1.1', method, host, port, @@ -47,6 +46,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -128,6 +128,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -158,6 +159,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 404, statusMessage: 'Not found', responseHeaders: { @@ -191,6 +193,7 @@ const mockTraces: Trace[] = [ lastName: 'Bear', }), // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -205,7 +208,6 @@ const mockTraces: Trace[] = [ 'connection': 'keep-alive', 'keep-alive': 'timeout=5', }, - httpVersion: '1.1', responseBody: JSON.stringify({ id: '4', }), @@ -239,6 +241,7 @@ const mockTraces: Trace[] = [ }, }), // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -276,6 +279,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 500, statusMessage: 'Internal Server Error', responseHeaders: { @@ -303,6 +307,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: 200, statusMessage: 'OK', responseHeaders: { @@ -330,6 +335,7 @@ const mockTraces: Trace[] = [ }, requestBody: undefined, // --------- + httpVersion: '1.1', statusCode: undefined, statusMessage: undefined, responseHeaders: undefined, diff --git a/packages/browser/src/scripts/startCollector.cjs b/packages/browser/src/scripts/startCollector.cjs index 725ea1d..30fa471 100644 --- a/packages/browser/src/scripts/startCollector.cjs +++ b/packages/browser/src/scripts/startCollector.cjs @@ -18,21 +18,18 @@ wss.on('listening', () => { }); wss.on('connection', (ws, request) => { - if (request.url === '/viewer' && !viewer) { + if (request.url === '/viewer') { log(chalk.green('✅ Envy viewer client connected')); viewer = ws; } - if (request.url === '/node' && !viewer) { + if (request.startsWith === '/node' && !viewer) { log(chalk.green('✅ Envy node sender connected')); } - ws.on('close', () => { - if (viewer !== null) { - log(chalk.red('❌ Envy viewer client disconnected')); - viewer = null; - } - }); + if (request.startsWith === '/web' && !viewer) { + log(chalk.green('✅ Envy web sender connected')); + } ws.on('message', data => { if (!viewer || viewer.readyState !== WebSocket.OPEN) { diff --git a/packages/browser/src/systems/GraphQL.tsx b/packages/browser/src/systems/GraphQL.tsx index 7c640e2..5217178 100644 --- a/packages/browser/src/systems/GraphQL.tsx +++ b/packages/browser/src/systems/GraphQL.tsx @@ -21,7 +21,7 @@ export default class GraphQL implements System { name = 'GraphQL'; isMatch(trace: Trace) { - return trace.path === '/api/graphql'; + return trace.path?.endsWith('/graphql') ?? false; } getData(trace: Trace) { diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..7c38720 --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "@envy/web", + "version": "0.1.0", + "description": "Node.js Network & Telemetry Viewer", + "main": "dist/index.js", + "repository": "https://github.com/FormidableLabs/envy.git", + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "tsc" + }, + "dependencies": { + "@envy/core": "0.2.0" + } +} diff --git a/packages/web/src/http.ts b/packages/web/src/http.ts new file mode 100644 index 0000000..b65cf61 --- /dev/null +++ b/packages/web/src/http.ts @@ -0,0 +1,64 @@ +import { EventType, HttpRequest } from '@envy/core'; + +function formatHeaders(headers: HeadersInit | Headers | undefined): HttpRequest['requestHeaders'] { + if (headers) { + if (Array.isArray(headers)) { + return headers.reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {}); + } else if (headers instanceof Headers) { + return Object.fromEntries(headers.entries()); + } else { + return headers; + } + } + + return {}; +} + +export function fetchRequestToEvent( + timestamp: number, + id: string, + input: RequestInfo | URL, + init?: RequestInit, +): HttpRequest { + let url: URL; + if (typeof input === 'string') { + url = new URL(input); + } else if (input instanceof Request) { + url = new URL(input.url); + } else { + url = input; + } + + return { + id, + parentId: undefined, + timestamp, + type: EventType.HttpRequest, + method: (init?.method ?? 'GET') as HttpRequest['method'], + host: url.host, + port: parseInt(url.port, 10), + path: url.pathname, + url: url.toString(), + requestHeaders: formatHeaders(init?.headers), + requestBody: init?.body?.toString() ?? undefined, + }; +} + +export async function fetchResponseToEvent( + timestamp: number, + req: HttpRequest, + response: Response, +): Promise { + return { + ...req, + httpVersion: response.type, + statusCode: response.status, + statusMessage: response.statusText, + responseHeaders: formatHeaders(response.headers), + responseBody: await response.text(), + duration: timestamp - req.timestamp, + }; +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts new file mode 100644 index 0000000..8af7bc5 --- /dev/null +++ b/packages/web/src/index.ts @@ -0,0 +1 @@ +export * from './tracing'; diff --git a/packages/web/src/log.ts b/packages/web/src/log.ts new file mode 100644 index 0000000..1383570 --- /dev/null +++ b/packages/web/src/log.ts @@ -0,0 +1,10 @@ +/* eslint-disable no-console */ + +const { name } = require('../package.json'); + +export default { + info: (msg: string, ...args: unknown[]) => console.log(`✅ %c${name} ${msg}`, 'color: green', ...args), + warn: (msg: string, ...args: unknown[]) => console.log(`🚸 %c${name} ${msg}`, 'color: yellow', ...args), + error: (msg: string, ...args: unknown[]) => console.log(`❌ %c${name} ${msg}`, 'color: red', ...args), + debug: (msg: string, ...args: unknown[]) => console.log(`🔧 %c$${name} ${msg}`, 'color: cyan', ...args), +}; diff --git a/packages/web/src/options.ts b/packages/web/src/options.ts new file mode 100644 index 0000000..1156db7 --- /dev/null +++ b/packages/web/src/options.ts @@ -0,0 +1,4 @@ +export interface Options { + serviceName: string; + debug?: boolean; +} diff --git a/packages/web/src/tracing.ts b/packages/web/src/tracing.ts new file mode 100644 index 0000000..aa304fc --- /dev/null +++ b/packages/web/src/tracing.ts @@ -0,0 +1,43 @@ +import { DEFAULT_WEB_SOCKET_PORT } from '@envy/core'; + +import { fetchRequestToEvent, fetchResponseToEvent } from './http'; +import log from './log'; +import { Options } from './options'; + +export interface TracingOptions extends Options { + port?: number; +} + +export function enableTracing(options: TracingOptions) { + if (typeof window === 'undefined') { + log.error('Attempted to use @envy/web in a non-browser environment'); + return; + } + + if (options.debug) log.info('Starting in debug mode'); + + const wsUri = `ws://127.0.0.1:${options.port ?? DEFAULT_WEB_SOCKET_PORT}/web/${options.serviceName}`; + const ws = new WebSocket(wsUri); + ws.onopen = () => { + if (options.debug) log.info(`Connected to ${wsUri}`); + + const { fetch: originalFetch } = window; + + window.fetch = async (...args) => { + // TODO: better unique id + const tsReq = Date.now(); + const id = `${tsReq}`; + + const reqEvent = fetchRequestToEvent(tsReq, id, ...args); + ws.send(JSON.stringify(reqEvent)); + + const response = await originalFetch(...args); + const tsRes = Date.now(); + const responseClone = response.clone(); + const resEvent = await fetchResponseToEvent(tsRes, reqEvent, responseClone); + ws.send(JSON.stringify(resEvent)); + + return response; + }; + }; +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..2f366c8 --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["**/*.spec.ts"], + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist" + } +}