├── .github └── workflows │ ├── checks.yml │ └── release-npm-package.yml ├── .gitignore ├── .prettierrc ├── README.md ├── package.json ├── packages ├── plugin │ ├── .eslintignore │ ├── .eslintrc.json │ ├── examples │ │ ├── apollo │ │ │ └── index.ts │ │ ├── express-graphql │ │ │ └── index.ts │ │ ├── graphql-http │ │ │ ├── express.ts │ │ │ └── http.ts │ │ ├── operation.graphql │ │ ├── schema.graphql │ │ └── schema.ts │ ├── jest.config.ts │ ├── package.json │ ├── src │ │ ├── cli.ts │ │ ├── help.ts │ │ ├── index.ts │ │ ├── types.ts │ │ └── util.ts │ ├── test │ │ ├── help.spec.ts │ │ └── util.spec.ts │ └── tsconfig.json └── visualizer │ ├── .eslintrc.json │ ├── index.html │ ├── package.json │ ├── src │ ├── App.tsx │ ├── components │ │ ├── AboutDialog │ │ │ ├── AboutDialog.module.css │ │ │ └── AboutDialog.tsx │ │ ├── Chart │ │ │ ├── Chart.module.css │ │ │ └── Chart.tsx │ │ ├── Segment │ │ │ ├── Segment.module.css │ │ │ └── Segment.tsx │ │ ├── Timeline │ │ │ ├── Timeline.module.css │ │ │ └── Timeline.tsx │ │ ├── Toolbar │ │ │ ├── Toolbar.module.css │ │ │ └── Toolbar.tsx │ │ └── Tooltip │ │ │ ├── Tooltip.module.css │ │ │ └── Tooltip.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ ├── types.ts │ └── util │ │ ├── colorMap.ts │ │ ├── colorScale.ts │ │ ├── percentage.ts │ │ └── sort.ts │ ├── tsconfig.json │ └── vite.config.ts ├── sample.png ├── turbo.json └── yarn.lock /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Status Checks 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | name: Publish Dry Run 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: '18.x' 24 | registry-url: 'https://registry.npmjs.org' 25 | 26 | - name: Get yarn cache directory path 27 | id: yarn-cache-dir-path 28 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 29 | 30 | - uses: actions/cache@v2 31 | id: yarn-cache 32 | with: 33 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 34 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 35 | restore-keys: | 36 | ${{ runner.os }}-yarn- 37 | 38 | - name: Install Dependencies 39 | run: yarn install --frozen-lockfile 40 | 41 | - name: Get version 42 | id: version 43 | run: echo "newTag=$(node -p "require('./package.json').version")" 44 | 45 | - name: Run package (build, prune) 46 | run: yarn run package 47 | 48 | - name: Publish Package 49 | run: yarn run publish:dry 50 | 51 | lint: 52 | name: Lint 53 | runs-on: ubuntu-latest 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v3 57 | with: 58 | fetch-depth: 0 59 | 60 | - uses: actions/setup-node@v3 61 | with: 62 | node-version: '18.x' 63 | registry-url: 'https://registry.npmjs.org' 64 | 65 | - name: Get yarn cache directory path 66 | id: yarn-cache-dir-path 67 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 68 | 69 | - uses: actions/cache@v2 70 | id: yarn-cache 71 | with: 72 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 73 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 74 | restore-keys: | 75 | ${{ runner.os }}-yarn- 76 | 77 | - name: Install Dependencies 78 | run: yarn install --frozen-lockfile 79 | 80 | - name: Build 81 | run: yarn run lint 82 | test: 83 | name: Unit Tests 84 | runs-on: ubuntu-latest 85 | steps: 86 | - name: Checkout 87 | uses: actions/checkout@v3 88 | with: 89 | fetch-depth: 0 90 | 91 | - uses: actions/setup-node@v3 92 | with: 93 | node-version: '18.x' 94 | registry-url: 'https://registry.npmjs.org' 95 | 96 | - name: Get yarn cache directory path 97 | id: yarn-cache-dir-path 98 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 99 | 100 | - uses: actions/cache@v2 101 | id: yarn-cache 102 | with: 103 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 104 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 105 | restore-keys: | 106 | ${{ runner.os }}-yarn- 107 | 108 | - name: Install Dependencies 109 | run: yarn install --frozen-lockfile 110 | 111 | - name: Test 112 | run: yarn run test 113 | -------------------------------------------------------------------------------- /.github/workflows/release-npm-package.yml: -------------------------------------------------------------------------------- 1 | name: Release NPM Package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | publish_npm_package: 8 | name: Create Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: '18.x' 19 | registry-url: 'https://registry.npmjs.org' 20 | 21 | - name: Get yarn cache directory path 22 | id: yarn-cache-dir-path 23 | run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT 24 | 25 | - uses: actions/cache@v2 26 | id: yarn-cache 27 | with: 28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 30 | restore-keys: | 31 | ${{ runner.os }}-yarn- 32 | 33 | - name: Install Dependencies 34 | run: yarn install --frozen-lockfile 35 | 36 | - name: Run Unit Tests 37 | run: yarn test 38 | 39 | - name: Run package (build, prune) 40 | run: yarn run package 41 | 42 | - name: Create Changelog 43 | id: changelog 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | uses: gandarez/changelog-action@v1.2.0 47 | with: 48 | exclude: | 49 | /version bump*/i 50 | 51 | - name: Get version 52 | id: version 53 | run: echo "tag=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 54 | 55 | - name: Create Release 56 | uses: ncipollo/release-action@v1 57 | with: 58 | tag: ${{ steps.version.outputs.tag }} 59 | name: ${{ steps.version.outputs.tag }} 60 | body: ${{ steps.changelog.outputs.changelog }} 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | - name: Publish Package 64 | run: npm publish --access public 65 | working-directory: out/packages/plugin 66 | env: 67 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.js 3 | !viz/chart.js 4 | .DS_Store 5 | *.log 6 | dist 7 | .turbo 8 | docs 9 | out 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSameLine": true, 4 | "bracketSpacing": true, 5 | "jsxSingleQuote": true, 6 | "printWidth": 80, 7 | "quoteProps": "consistent", 8 | "semi": true, 9 | "singleQuote": true, 10 | "tabWidth": 2, 11 | "trailingComma": "es5", 12 | "useTabs": false 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-request-profiler 2 | 3 | ![Status Checks](https://github.com/Econify/graphql-request-profiler/actions/workflows/checks.yml/badge.svg) 4 | 5 | Easy to use GraphQL performance analysis utility for profiling resolver execution time. Observe resolver execution time in your API with a visualization tool. 6 | 7 | ## Example 8 | 9 | ```sh 10 | graphql-request-profiler -s examples/operation.graphql -e http://localhost:4000/graphql 11 | ``` 12 | 13 | ![Sample Visualizer](https://github.com/Econify/graphql-request-profiler/raw/main/sample.png) 14 | 15 | ## Installation 16 | 17 | For CLI usage with API that has the plugin installed: 18 | 19 | ```sh 20 | npm i -g @econify/graphql-request-profiler 21 | ``` 22 | 23 | Within a project: 24 | 25 | ```sh 26 | npm install --save @econify/graphql-request-profiler 27 | ``` 28 | 29 | ```sh 30 | yarn add @econify/graphql-request-profiler 31 | ``` 32 | 33 | ### CLI Usage 34 | 35 | ``` 36 | $ graphql-request-profiler --help 37 | graphql-request-profiler: Visualize your GraphQL resolver execution time - Version 0.2.0 38 | 39 | Usage: 40 | 41 | graphql-request-profiler --data 42 | graphql-request-profiler --schema operation.graphql --endpoint=localhost:4000/graphql 43 | graphql-request-profiler --help 44 | 45 | Arguments: 46 | 47 | --schema, -s (file path) requesting schema file location 48 | --output, -o (file path) output request data to file location 49 | --endpoint, -e (string) the endpoint of the GraphQL server to request 50 | --variables, -v (file path) variables to pass to the GraphQL server 51 | --operationName, -n (string) optional, name of the operation to use in the schema 52 | --headerName, -h (string) optional, the name of the header to activate 53 | 54 | --data, -d (string) display an existing trace file 55 | --help (boolean) displays this help text 56 | 57 | ``` 58 | 59 | ### graphql-http 60 | 61 | ```js 62 | import { createHandler } from 'graphql-http/lib/use/http'; 63 | import { createHttpHandlerProfilerPlugin } from '@econify/graphql-request-profiler'; 64 | 65 | const server = http.createServer((req, res) => { 66 | if (req.url?.startsWith('/graphql')) { 67 | createHandler( 68 | createHttpHandlerProfilerPlugin(req, { 69 | schema: buildSchema(), 70 | }) 71 | )(req, res); 72 | } else { 73 | res.writeHead(404).end(); 74 | } 75 | }); 76 | 77 | server.listen(4000); 78 | console.log('Listening to port 4000'); 79 | ``` 80 | 81 | See [full running example here](https://github.com/Econify/graphql-request-profiler/blob/main/packages/plugin/examples/graphql-http/http.ts) 82 | See [example of graphql-http with express](https://github.com/Econify/graphql-request-profiler/blob/main/packages/plugin/examples/graphql-http/express.ts) 83 | 84 | ### apollo-server 85 | 86 | ```js 87 | import { createApolloProfilerPlugin } from '@econify/graphql-request-profiler'; 88 | 89 | const server = new ApolloServer({ 90 | schema: buildSchema(), 91 | plugins: [createApolloProfilerPlugin()], 92 | }); 93 | 94 | server.listen().then(({ url }) => { 95 | console.log(`Listening on ${url}`); 96 | }); 97 | ``` 98 | 99 | See [full running example here](https://github.com/Econify/graphql-request-profiler/blob/main/packages/plugin/examples/apollo/index.ts) 100 | 101 | #### Deprecated 102 | 103 | ### express-graphql 104 | 105 | ```js 106 | import { createExpressProfilerPlugin } from '@econify/graphql-request-profiler'; 107 | 108 | const app = express(); 109 | 110 | app.use( 111 | '/graphql', 112 | graphqlHTTP((req) => 113 | createExpressProfilerPlugin(req, { 114 | schema, 115 | graphiql: true, 116 | }) 117 | ) 118 | ); 119 | ``` 120 | 121 | See [full running example here](https://github.com/Econify/graphql-request-profiler/blob/main/packages/plugin/examples/express-graphql/index.ts) 122 | 123 | ### Custom Activation Header 124 | 125 | If the server requires a different HTTP header to activate the plugin besides `x-trace`, a custom header name can be specified in the configuration to the plugin. 126 | 127 | ```js 128 | createApolloProfilerPlugin({ headerName: 'x-custom-header' }); 129 | createExpressProfilerPlugin(req, options, { headerName: 'x-custom-header' }); 130 | ``` 131 | 132 | A custom plugin activation HTTP header may be specified when using the CLI tool. 133 | 134 | ```sh 135 | graphql-request-profiler --headerName x-custom-header [...] 136 | ``` 137 | 138 | ## Like this package? 139 | 140 | Check out Econify's other GraphQL package, [graphql-rest-router](https://www.github.com/Econify/graphql-rest-router), that allows routing to and caching an internal GraphQL API as a self-documenting REST API without exposing the schema! 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-request-profiler-monorepo", 3 | "version": "0.2.4", 4 | "private": true, 5 | "description": "Easy to use GraphQL performance analysis utility for tracing resolver execution time", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Econify/graphql-request-profiler.git" 9 | }, 10 | "keywords": [ 11 | "graphql", 12 | "profiler", 13 | "performance", 14 | "resolvers", 15 | "apollo", 16 | "express" 17 | ], 18 | "author": "Regan Karlewicz ", 19 | "license": "ISC", 20 | "workspaces": [ 21 | "packages/*" 22 | ], 23 | "scripts": { 24 | "build": "turbo build", 25 | "lint": "turbo run lint", 26 | "test": "turbo run test", 27 | "prettier": "npx prettier --write .", 28 | "prune": "turbo prune --scope=\"@econify/graphql-request-profiler\" && cp README.md out/packages/plugin/README.md", 29 | "publish": "cd out/packages/plugin && npm publish --access public", 30 | "publish:dry": "cd out/packages/plugin && npm publish --access public --dry-run", 31 | "clean": "rm -rf out && find . -name \"node_modules\" -type d -exec rm -rf {} + && find . -name \"dist\" -type d -exec rm -rf {} +", 32 | "package": "yarn run build && yarn run prune" 33 | }, 34 | "dependencies": {}, 35 | "devDependencies": { 36 | "@types/node": "^20.4.0", 37 | "prettier": "^3.0.0", 38 | "turbo": "^1.10.7", 39 | "typescript": "^5.1.6" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/plugin/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .turbo -------------------------------------------------------------------------------- /packages/plugin/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | "ignorePatterns": ["**/dist/*", "**/node_modules/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/plugin/examples/apollo/index.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server'; 2 | import { buildSchema } from '../schema'; 3 | import { createApolloProfilerPlugin } from '../../src/index'; 4 | 5 | const server = new ApolloServer({ 6 | schema: buildSchema(), 7 | plugins: [createApolloProfilerPlugin()], 8 | }); 9 | 10 | server.listen().then(({ url }) => { 11 | console.log(`Listening on ${url}`); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/plugin/examples/express-graphql/index.ts: -------------------------------------------------------------------------------- 1 | import { buildSchema } from '../schema'; 2 | import { createExpressProfilerPlugin } from '../../src/index'; 3 | 4 | import express from 'express'; 5 | import { graphqlHTTP } from 'express-graphql'; 6 | 7 | const app = express(); 8 | const schema = buildSchema(); 9 | 10 | app.use( 11 | '/graphql', 12 | graphqlHTTP((req) => 13 | createExpressProfilerPlugin(req, { 14 | schema, 15 | graphiql: true, 16 | }) 17 | ) 18 | ); 19 | 20 | app.listen(4000, () => 21 | console.log('Listening on http://localhost:4000/graphql') 22 | ); 23 | -------------------------------------------------------------------------------- /packages/plugin/examples/graphql-http/express.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { createHandler } from 'graphql-http/lib/use/express'; 3 | import { buildSchema } from '../schema'; 4 | import { createHttpHandlerProfilerPlugin } from '../../src/index'; 5 | 6 | const app = express(); 7 | app.all('/graphql', (req) => 8 | createHandler( 9 | createHttpHandlerProfilerPlugin(req, { 10 | schema: buildSchema(), 11 | }) 12 | ) 13 | ); 14 | 15 | app.listen({ port: 4000 }); 16 | console.log('Listening to port 4000'); 17 | -------------------------------------------------------------------------------- /packages/plugin/examples/graphql-http/http.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import { createHandler } from 'graphql-http/lib/use/http'; 3 | import { buildSchema } from '../schema'; 4 | import { createHttpHandlerProfilerPlugin } from '../../src/index'; 5 | 6 | const server = http.createServer((req, res) => { 7 | if (req.url?.startsWith('/graphql')) { 8 | createHandler( 9 | createHttpHandlerProfilerPlugin(req, { 10 | schema: buildSchema(), 11 | }) 12 | )(req, res); 13 | } else { 14 | res.writeHead(404).end(); 15 | } 16 | }); 17 | 18 | server.listen(4000); 19 | console.log('Listening to port 4000'); 20 | -------------------------------------------------------------------------------- /packages/plugin/examples/operation.graphql: -------------------------------------------------------------------------------- 1 | query q { 2 | sodas { 3 | sugar 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/plugin/examples/schema.graphql: -------------------------------------------------------------------------------- 1 | type DietCoke { 2 | sugar: Int 3 | } 4 | 5 | type Query { 6 | sodas: [DietCoke] 7 | } 8 | -------------------------------------------------------------------------------- /packages/plugin/examples/schema.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | import { makeExecutableSchema } from '@graphql-tools/schema'; 4 | 5 | export const buildSchema = () => { 6 | return makeExecutableSchema({ 7 | typeDefs: readFileSync(join(__dirname, 'schema.graphql')).toString(), 8 | resolvers: { 9 | Query: { 10 | sodas: async () => { 11 | await new Promise((res) => setTimeout(res, Math.random() * 1000)); 12 | return [{}, {}, {}, {}, {}]; 13 | }, 14 | }, 15 | DietCoke: { 16 | sugar: async () => { 17 | await new Promise((res) => setTimeout(res, Math.random() * 1000)); 18 | return 0; 19 | }, 20 | }, 21 | }, 22 | }); 23 | }; 24 | -------------------------------------------------------------------------------- /packages/plugin/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /packages/plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@econify/graphql-request-profiler", 3 | "version": "0.2.4", 4 | "description": "Easy to use GraphQL performance analysis utility for tracing resolver execution time", 5 | "main": "dist/index.js", 6 | "bin": { 7 | "graphql-request-profiler": "dist/cli.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/Econify/graphql-request-profiler.git" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "keywords": [ 17 | "graphql", 18 | "profiler", 19 | "performance", 20 | "resolvers", 21 | "apollo", 22 | "express" 23 | ], 24 | "author": "Regan Karlewicz ", 25 | "license": "ISC", 26 | "scripts": { 27 | "test": "jest", 28 | "build": "tsc && cp -r ../visualizer/dist ./dist/public", 29 | "postbuild": "cd dist && perl -i -pe 's,#!/usr/bin/env ts-node,#!/usr/bin/env node,g' cli.js && chmod +x cli.js", 30 | "lint": "npx eslint .", 31 | "dev:cli": "./src/cli.ts", 32 | "dev:express": "npx nodemon -e \"ts\" -x \"npm run example:express\"", 33 | "dev:apollo": "npx nodemon -e \"ts\" -x \"npm run example:apollo\"", 34 | "dev:graphql-http": "npx nodemon -e \"ts\" -x \"npm run example:graphql-http\"", 35 | "dev:graphql-http-express": "npx nodemon -e \"ts\" -x \"npm run example:graphql-http-express\"", 36 | "example:express": "ts-node ./examples/express-graphql/index.ts", 37 | "example:apollo": "ts-node ./examples/apollo/index.ts", 38 | "example:graphql-http": "ts-node ./examples/graphql-http/http.ts", 39 | "example:graphql-http-express": "ts-node ./examples/graphql-http/express.ts" 40 | }, 41 | "dependencies": { 42 | "axios": "^1.4.0", 43 | "command-line-args": "^5.2.1", 44 | "graphql": "^16.5.0" 45 | }, 46 | "devDependencies": { 47 | "@econify/graphql-request-visualizer": "*", 48 | "@graphql-tools/schema": "^10.0.0", 49 | "@jest/types": "^29.6.1", 50 | "@types/command-line-args": "^5.2.0", 51 | "@types/jest": "^29.5.2", 52 | "@types/node": "^20.4.0", 53 | "@typescript-eslint/eslint-plugin": "^5.25.0", 54 | "@typescript-eslint/parser": "^5.25.0", 55 | "apollo-server": "^3.7.0", 56 | "apollo-server-express": "^3.7.0", 57 | "eslint": "^8.44.0", 58 | "express": "^4.18.1", 59 | "express-graphql": "^0.12.0", 60 | "graphql-http": "^1.19.0", 61 | "jest": "^29.6.1", 62 | "ts-jest": "^29.1.1", 63 | "ts-node": "^10.7.0", 64 | "typescript": "^5.1.6" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /packages/plugin/src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node 2 | 3 | import commandLineArgs from 'command-line-args'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { IOptionData } from './types'; 7 | import childProcess from 'child_process'; 8 | import { 9 | getOpenCommand, 10 | getRequestBody, 11 | printHelp, 12 | requestGraphQL, 13 | } from './util'; 14 | 15 | async function makeRequestAndOpenData(options: IOptionData) { 16 | const requestBody = await getRequestBody(options); 17 | const response = await requestGraphQL(requestBody, options); 18 | 19 | if (!response.data?.extensions?.traces) { 20 | throw new Error('No traces found, is the plugin installed properly?'); 21 | } 22 | 23 | const fileName = 24 | options.output || `tmp-${Math.random().toString().replace('.', '')}.json`; 25 | 26 | await fs.promises.writeFile( 27 | fileName, 28 | JSON.stringify(response.data.extensions.traces) 29 | ); 30 | 31 | await openData({ ...options, data: fileName } as IOptionData); 32 | } 33 | 34 | function webserver() { 35 | return (res: (value: unknown) => void, rej: (error: Error) => void) => { 36 | const port = String(options.port || 8080); 37 | 38 | const server = childProcess.spawn('npx', [ 39 | 'serve', 40 | path.join(__dirname, '../dist/public'), 41 | '-l', 42 | port, 43 | ]); 44 | 45 | server.on('close', (code) => { 46 | if (code !== 0) { 47 | rej(new Error(`Server exited with code ${code}`)); 48 | } else { 49 | res(code); 50 | } 51 | }); 52 | 53 | server.stdout.on('data', (data) => { 54 | if (data.toString().match(/Accepting connections/)) { 55 | childProcess.exec(`${getOpenCommand()} http://localhost:${port}`); 56 | } 57 | }); 58 | 59 | server.stderr.on('data', (data) => { 60 | console.error(data.toString()); 61 | }); 62 | }; 63 | } 64 | 65 | async function openData(options: IOptionData) { 66 | if (!options.data) { 67 | throw new Error('No data file specified'); 68 | } 69 | 70 | const pathToWrite = path.join(__dirname, '../dist/public/data.js'); 71 | 72 | const dataContents = await fs.promises.readFile(options.data); 73 | 74 | await fs.promises.writeFile( 75 | pathToWrite, 76 | `/* eslint-disable no-undef */\nwindow.data = ${dataContents.toString()}` 77 | ); 78 | 79 | if (!options.output) { 80 | await fs.promises.rm(options.data); 81 | } 82 | 83 | return new Promise(webserver()); 84 | } 85 | 86 | const options = commandLineArgs([ 87 | { name: 'output', alias: 'o', type: String }, 88 | { name: 'endpoint', alias: 'e', type: String }, 89 | { name: 'schema', alias: 's', type: String }, 90 | { name: 'operationName', alias: 'n', type: String }, 91 | { name: 'variables', alias: 'v', type: String }, 92 | { name: 'data', alias: 'd', type: String }, 93 | { name: 'headerName', alias: 'h', type: String }, 94 | { name: 'port', alias: 'p', type: Number }, 95 | { name: 'help', type: Boolean }, 96 | ]) as IOptionData; 97 | 98 | (async () => { 99 | try { 100 | if (options.data) { 101 | openData(options); 102 | process.exit(0); 103 | } 104 | 105 | if (options.schema && options.endpoint) { 106 | await makeRequestAndOpenData(options); 107 | process.exit(0); 108 | } 109 | 110 | await printHelp(); 111 | process.exit(0); 112 | } catch (e) { 113 | console.error(`Error: ${(e as Error).message}`); 114 | process.exit(1); 115 | } 116 | })(); 117 | -------------------------------------------------------------------------------- /packages/plugin/src/help.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | 4 | export async function helpText() { 5 | const pkg = await fs.promises.readFile( 6 | path.join(__dirname, '../package.json'), 7 | 'utf8' 8 | ); 9 | const version = JSON.parse(pkg).version; 10 | return `graphql-request-profiler: Visualize your GraphQL resolver execution time - Version ${version} 11 | 12 | Usage: 13 | 14 | graphql-request-profiler --data 15 | graphql-request-profiler --schema operation.graphql --endpoint=localhost:4000/graphql 16 | graphql-request-profiler --help 17 | 18 | Arguments: 19 | 20 | --schema, -s (file path) requesting schema file location 21 | --output, -o (file path) output request data to file location 22 | --endpoint, -e (string) the endpoint of the GraphQL server to request 23 | --variables, -v (file path) variables to pass to the GraphQL server 24 | --operationName, -n (string) optional, name of the operation to use in the schema 25 | --headerName, -h (string) optional, the name of the header to activate 26 | --port, -p (number) optional, the port for the visualizer web server to run on, defaults to 8080 27 | 28 | --data, -d (string) display an existing trace file 29 | --help (boolean) displays this help text\n`; 30 | } 31 | -------------------------------------------------------------------------------- /packages/plugin/src/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SymbolObject, 3 | IPluginOptions, 4 | ResolverFunction, 5 | IExpressGraphQLRequest, 6 | } from './types'; 7 | import type { GraphQLSchema } from 'graphql'; 8 | import type { OptionsData, RequestInfo } from 'express-graphql'; 9 | import type { HandlerOptions } from 'graphql-http'; 10 | import type { IncomingMessage } from 'http'; 11 | import type { 12 | BaseContext, 13 | GraphQLServiceContext, 14 | GraphQLRequestContext, 15 | GraphQLRequestContextWillSendResponse, 16 | } from 'apollo-server-types'; 17 | 18 | import { responsePathAsArray } from 'graphql'; 19 | import { nsToMs, useResolverDecorator } from './util'; 20 | 21 | const SYMBOL_START_TIME = Symbol('SYMBOL_START_TIME'); 22 | const SYMBOL_TRACES = Symbol('SYMBOL_TRACES'); 23 | const SYMBOL_WRAPPED = Symbol('SYMBOL_WRAPPED'); 24 | const state: SymbolObject = {}; 25 | 26 | export function createExpressProfilerPlugin( 27 | req: IExpressGraphQLRequest, 28 | options: OptionsData, 29 | config?: IPluginOptions 30 | ) { 31 | if (req.headers[config?.headerName || 'x-trace'] === 'true') { 32 | options.extensions = decorateExtensions(options.extensions); 33 | decorateResolvers(options.schema); 34 | createContext(options); 35 | addStartTime(options); 36 | } 37 | 38 | return options; 39 | } 40 | 41 | export function createHttpHandlerProfilerPlugin( 42 | req: IncomingMessage, 43 | { 44 | schema, 45 | context, 46 | onOperation, 47 | ...rest 48 | }: HandlerOptions, 49 | config?: IPluginOptions 50 | ) { 51 | if (req.headers[config?.headerName || 'x-trace'] === 'true') { 52 | useResolverDecorator(schema, trace); 53 | 54 | return >{ 55 | ...rest, 56 | schema, 57 | context: createHttpContext(context), 58 | onOperation: async (req, args, result) => { 59 | await onOperation?.(req, args, result); 60 | 61 | if (args.contextValue) { 62 | result.extensions = getResolverTraces(args.contextValue); 63 | } 64 | }, 65 | }; 66 | } 67 | 68 | return { ...rest, schema, context, onOperation }; 69 | } 70 | 71 | export function createHttpContext(base: SymbolObject) { 72 | const context: SymbolObject = { 73 | [SYMBOL_START_TIME]: process.hrtime.bigint(), 74 | }; 75 | 76 | if (base && typeof base === 'object') { 77 | Object.assign(context, base); 78 | } 79 | 80 | return context; 81 | } 82 | 83 | export function createApolloProfilerPlugin(options?: IPluginOptions) { 84 | return { 85 | headerName: options?.headerName || 'x-trace', 86 | 87 | async serverWillStart(options: GraphQLServiceContext) { 88 | createApolloProfilerOptions(options); 89 | }, 90 | 91 | async requestDidStart(options: GraphQLRequestContext) { 92 | if (options?.request?.operationName === 'IntrospectionQuery') { 93 | return; 94 | } 95 | 96 | if (options?.request?.http?.headers.get(this.headerName) === 'true') { 97 | addStartTime(options); 98 | 99 | return { 100 | async willSendResponse( 101 | options: GraphQLRequestContextWillSendResponse 102 | ) { 103 | options.response.extensions = { 104 | ...options.response.extensions, 105 | ...getResolverTraces(options.context), 106 | }; 107 | }, 108 | }; 109 | } 110 | }, 111 | }; 112 | } 113 | 114 | function createContext(options: OptionsData) { 115 | if (!options.context) { 116 | options.context = {} as SymbolObject; 117 | } 118 | } 119 | 120 | function createApolloProfilerOptions(options: GraphQLServiceContext) { 121 | useResolverDecorator(options.schema, trace); 122 | return options; 123 | } 124 | 125 | export function getResolverTraces(context: SymbolObject) { 126 | if (!context[SYMBOL_TRACES]) { 127 | return undefined; 128 | } 129 | 130 | return { 131 | totalTimeMs: nsToMs(process.hrtime.bigint() - context[SYMBOL_START_TIME]), 132 | traces: context[SYMBOL_TRACES], 133 | }; 134 | } 135 | 136 | function addStartTime(options: OptionsData | GraphQLRequestContext) { 137 | const { context } = options as { context: SymbolObject }; 138 | 139 | context[SYMBOL_START_TIME] = process.hrtime.bigint(); 140 | } 141 | 142 | function decorateResolvers(schema: GraphQLSchema) { 143 | if (!state[SYMBOL_WRAPPED]) { 144 | state[SYMBOL_WRAPPED] = true; 145 | useResolverDecorator(schema, trace); 146 | } 147 | } 148 | 149 | function decorateExtensions(fn?: OptionsData['extensions']) { 150 | if (fn && fn.name !== 'graphQLRequestProfilerExtensionsWrapperFn') { 151 | return function graphQLRequestProfilerExtensionsWrapperFn( 152 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 153 | this: any, 154 | info: RequestInfo 155 | ) { 156 | const baseExtensionsData = fn.call(this, info); 157 | const { context } = info; 158 | return { 159 | ...baseExtensionsData, 160 | ...getResolverTraces(context as SymbolObject), 161 | }; 162 | }; 163 | } 164 | 165 | return ({ context }: RequestInfo) => ({ 166 | ...getResolverTraces(context as SymbolObject), 167 | }); 168 | } 169 | 170 | function trace(fn: ResolverFunction): ResolverFunction { 171 | if (fn.name !== 'graphQLRequestProfilerWrapperFn') { 172 | return async function graphQLRequestProfilerWrapperFn( 173 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 174 | this: any, 175 | data, 176 | args, 177 | context, 178 | info 179 | ) { 180 | const reqStartTime = context[SYMBOL_START_TIME]; 181 | 182 | if (!reqStartTime) { 183 | return fn.call(this, data, args, context, info); 184 | } 185 | 186 | const startTime = process.hrtime.bigint(); 187 | const result = await fn.call(this, data, args, context, info); 188 | const endTime = process.hrtime.bigint(); 189 | 190 | if (!context[SYMBOL_TRACES]) { 191 | context[SYMBOL_TRACES] = []; 192 | } 193 | 194 | const execTimeMs = nsToMs(endTime - startTime); 195 | 196 | if (execTimeMs > 0) { 197 | context[SYMBOL_TRACES].push({ 198 | execTimeMs, 199 | execStartTimeMs: nsToMs(startTime - reqStartTime), 200 | execEndTimeMs: nsToMs(endTime - reqStartTime), 201 | location: responsePathAsArray(info.path).join('.'), 202 | parentType: info.parentType.toString(), 203 | }); 204 | } 205 | 206 | return result; 207 | }; 208 | } 209 | 210 | return fn; 211 | } 212 | -------------------------------------------------------------------------------- /packages/plugin/src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { 3 | GraphQLFieldResolver, 4 | GraphQLInputFieldMap, 5 | GraphQLNamedType, 6 | } from 'graphql'; 7 | import type { IncomingMessage } from 'http'; 8 | import type { getResolverTraces } from './index'; 9 | export interface IResolverTrace { 10 | execTimeMs: number; 11 | execStartTimeMs: number; 12 | execEndTimeMs: number; 13 | location: string; 14 | parentType: string; 15 | } 16 | 17 | export type IGraphQLNamedType = GraphQLNamedType & { 18 | getFields?: () => GraphQLInputFieldMap; 19 | }; 20 | 21 | export interface IPluginOptions { 22 | headerName?: string; 23 | } 24 | 25 | export type TraceFunction = (fn: ResolverFunction) => ResolverFunction; 26 | 27 | export type ResolverFunction = GraphQLFieldResolver; 28 | 29 | export type SymbolObject = Record; 30 | 31 | export interface IGraphQLRequestData { 32 | query: string; 33 | operationName?: string; 34 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 35 | variables?: Record; 36 | } 37 | 38 | export interface IOptionData { 39 | endpoint?: string; 40 | schema?: string; 41 | operationName?: string; 42 | variables?: string; 43 | output?: string; 44 | data?: string; 45 | help?: boolean; 46 | port?: number; 47 | headerName?: string; 48 | } 49 | 50 | export interface IGraphQLResponse { 51 | data: any; 52 | extensions?: ReturnType; 53 | } 54 | 55 | export type IExpressGraphQLRequest = IncomingMessage & { 56 | url: string; 57 | }; 58 | -------------------------------------------------------------------------------- /packages/plugin/src/util.ts: -------------------------------------------------------------------------------- 1 | import type { AxiosResponse } from 'axios'; 2 | import type { GraphQLField, GraphQLSchema } from 'graphql'; 3 | import type { 4 | IGraphQLNamedType, 5 | IGraphQLRequestData, 6 | IGraphQLResponse, 7 | IOptionData, 8 | TraceFunction, 9 | } from './types'; 10 | 11 | import { isIntrospectionType } from 'graphql'; 12 | import axios from 'axios'; 13 | import fs from 'fs'; 14 | 15 | import { helpText } from './help'; 16 | 17 | export const nsToMs = (nanoseconds: bigint) => { 18 | return Number(nanoseconds / BigInt(1000000)); 19 | }; 20 | 21 | export function useResolverDecorator(schema: GraphQLSchema, fn: TraceFunction) { 22 | for (const typeName in schema.getTypeMap()) { 23 | const type = schema.getType(typeName) as IGraphQLNamedType; 24 | 25 | if (!isIntrospectionType(type)) { 26 | applyResolverToType(type, fn); 27 | } 28 | } 29 | } 30 | 31 | function applyResolverToType(type: IGraphQLNamedType, fn: TraceFunction) { 32 | if (type.getFields) { 33 | const fields = type.getFields(); 34 | 35 | for (const fieldName in fields) { 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | const field = fields[fieldName] as GraphQLField; 38 | 39 | if (field.resolve) { 40 | field.resolve = fn(field.resolve); 41 | } 42 | } 43 | } 44 | } 45 | 46 | export function getOpenCommand() { 47 | switch (process.platform) { 48 | case 'darwin': 49 | return 'open'; 50 | case 'win32': 51 | return 'start'; 52 | default: 53 | return 'xdg-open'; 54 | } 55 | } 56 | 57 | export async function requestGraphQL( 58 | data: IGraphQLRequestData, 59 | options: IOptionData 60 | ): Promise> { 61 | return axios({ 62 | method: 'POST', 63 | url: options.endpoint, 64 | data, 65 | headers: { 66 | [options.headerName || 'x-trace']: 'true', 67 | }, 68 | }); 69 | } 70 | 71 | async function parseVariables(data: IGraphQLRequestData, options: IOptionData) { 72 | if (!options.variables) { 73 | throw new Error('No variables provided'); 74 | } 75 | 76 | const variables = await fs.promises.readFile(options.variables); 77 | data.variables = JSON.parse(variables.toString()); 78 | } 79 | 80 | export async function getRequestBody(options: IOptionData) { 81 | if (!options.schema) { 82 | throw new Error('No schema provided'); 83 | } 84 | 85 | const data: IGraphQLRequestData = { 86 | query: (await fs.promises.readFile(options.schema)).toString(), 87 | }; 88 | 89 | if (options.operationName) { 90 | data.operationName = options.operationName; 91 | } 92 | 93 | if (options.variables) { 94 | await parseVariables(data, options); 95 | } 96 | 97 | return data; 98 | } 99 | 100 | export async function printHelp() { 101 | console.log(await helpText()); 102 | } 103 | -------------------------------------------------------------------------------- /packages/plugin/test/help.spec.ts: -------------------------------------------------------------------------------- 1 | import { helpText } from '../src/help'; 2 | 3 | describe('help', () => { 4 | it('returns a string', async () => { 5 | expect(typeof (await helpText())).toBe('string'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /packages/plugin/test/util.spec.ts: -------------------------------------------------------------------------------- 1 | import type { IOptionData, IGraphQLRequestData } from '../src/types'; 2 | import { requestGraphQL } from '../src/util'; 3 | import axios from 'axios'; 4 | 5 | jest.mock('axios', () => jest.fn()); 6 | 7 | const body = { 8 | query: Math.floor(Math.random() * 1000).toString(), 9 | } as IGraphQLRequestData; 10 | 11 | const options = { 12 | endpoint: Math.floor(Math.random() * 1000).toString(), 13 | headerName: 'x-header', 14 | } as IOptionData; 15 | 16 | describe('util', () => { 17 | describe('requestGraphQL', () => { 18 | it('calls API with correct options', async () => { 19 | await requestGraphQL(body, options); 20 | 21 | expect(axios).toHaveBeenCalledWith({ 22 | method: 'POST', 23 | url: options.endpoint, 24 | data: body, 25 | headers: { 26 | [options.headerName]: 'true', 27 | }, 28 | }); 29 | }); 30 | 31 | it('bubbles errors', () => { 32 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 33 | // @ts-ignore 34 | axios.mockImplementationOnce(() => { 35 | throw new Error(); 36 | }); 37 | 38 | expect(() => requestGraphQL(body, options)).rejects.toThrow(Error); 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /packages/plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": [ 3 | "node_modules/*/**", 4 | "examples/*/**", 5 | "dist/**/*", 6 | "jest*", 7 | "test/**/*" 8 | ], 9 | "compilerOptions": { 10 | "target": "es2020", 11 | "module": "commonjs", 12 | "esModuleInterop": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "strict": true, 15 | "skipLibCheck": true, 16 | "resolveJsonModule": true, 17 | "outDir": "./dist", 18 | "rootDir": "./src", 19 | "declaration": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/visualizer/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | "ignorePatterns": ["**/dist/*", "**/node_modules/*"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/visualizer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | GraphQL Request Visualizer 5 | 6 | 7 | 8 | 9 | 13 | 17 | 21 | 25 | 29 | 33 | 37 | 41 | 45 | 46 | 47 | 48 |
49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /packages/visualizer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@econify/graphql-request-visualizer", 3 | "version": "0.2.4", 4 | "private": true, 5 | "description": "Easy to use GraphQL performance analysis utility for tracing resolver execution time", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Econify/graphql-request-profiler.git" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "keywords": [ 14 | "graphql", 15 | "profiler", 16 | "performance", 17 | "resolvers", 18 | "apollo", 19 | "express" 20 | ], 21 | "author": "Regan Karlewicz ", 22 | "license": "ISC", 23 | "scripts": { 24 | "start": "vite", 25 | "dev": "vite", 26 | "build": "vite build --base=\"./\"", 27 | "serve": "vite preview", 28 | "lint": "npx eslint ." 29 | }, 30 | "dependencies": { 31 | "classnames": "^2.3.2", 32 | "solid-js": "^1.6.10" 33 | }, 34 | "devDependencies": { 35 | "typescript": "^5.1.6", 36 | "vite": "^4.4.1", 37 | "vite-plugin-solid": "^2.5.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/visualizer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import type { Component } from 'solid-js'; 2 | import { createSignal } from 'solid-js'; 3 | 4 | import Toolbar from './components/Toolbar/Toolbar'; 5 | import { TColorsOption, TSortOption } from './types'; 6 | import Chart from './components/Chart/Chart'; 7 | 8 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 9 | // @ts-ignore 10 | const data = window.data; 11 | 12 | const App: Component = () => { 13 | const [filter, setFilter] = createSignal(''); 14 | const [sort, setSort] = createSignal('time'); 15 | const [colors, setColors] = createSignal('time'); 16 | const [scale, setScale] = createSignal(1); 17 | 18 | if (!data) return
No data found
; 19 | 20 | return ( 21 | <> 22 | 29 | 36 | 37 | ); 38 | }; 39 | 40 | export default App; 41 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/AboutDialog/AboutDialog.module.css: -------------------------------------------------------------------------------- 1 | .text { 2 | margin-bottom: 16px; 3 | } 4 | 5 | .text a { 6 | color: var(--black); 7 | } 8 | 9 | .text a:hover { 10 | color: var(--hover); 11 | } 12 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/AboutDialog/AboutDialog.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | import styles from './AboutDialog.module.css'; 3 | 4 | export interface IAboutProps { 5 | ref: HTMLDialogElement | undefined; 6 | } 7 | 8 | export const AboutDialog: Component = (props) => { 9 | return ( 10 | 11 |

12 | Written by Regan Karlewicz for{' '} 13 | Econify 14 |

15 |
16 | 17 |
18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Chart/Chart.module.css: -------------------------------------------------------------------------------- 1 | .chart { 2 | cursor: crosshair; 3 | gap: 4px; 4 | margin-top: 120px; 5 | display: flex; 6 | flex-direction: column; 7 | height: 100vh; 8 | width: 100%; 9 | overflow: scroll; 10 | background-color: var(--background); 11 | } 12 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Chart/Chart.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | import type { TColorsOption, TDataPoint, TSortOption } from '../../types'; 3 | 4 | import styles from './Chart.module.css'; 5 | import { Segment } from '../Segment/Segment'; 6 | import { Timeline } from '../Timeline/Timeline'; 7 | import { sortFunctions } from '../../util/sort'; 8 | 9 | export interface IChartProps { 10 | filter: string; 11 | scale: number; 12 | sort: TSortOption; 13 | colors: TColorsOption; 14 | data: TDataPoint[]; 15 | } 16 | 17 | function getFilterString(item: TDataPoint) { 18 | return ( 19 | JSON.stringify(item) + 20 | Object.entries(item) 21 | .map(([key, value]) => `${key}:${value}`) 22 | .join(' ') + 23 | Object.entries(item) 24 | .map(([key, value]) => `${key}: ${value}`) 25 | .join(' ') 26 | ).toLowerCase(); 27 | } 28 | 29 | const Chart: Component = (props) => { 30 | let containerRef: HTMLDivElement | undefined; 31 | 32 | const calcSegmentData = () => { 33 | const filteredData = props.data.filter((item) => 34 | getFilterString(item).includes(props.filter.toLowerCase()) 35 | ); 36 | return filteredData.sort(sortFunctions[props.sort]); 37 | }; 38 | 39 | const calcTotalTime = () => { 40 | return Math.max(...props.data.map((item) => item.execEndTimeMs)); 41 | }; 42 | 43 | return ( 44 |
45 | 49 | {calcSegmentData().map((item) => ( 50 | 56 | ))} 57 | 58 |
59 | ); 60 | }; 61 | 62 | export default Chart; 63 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Segment/Segment.module.css: -------------------------------------------------------------------------------- 1 | .segment { 2 | position: relative; 3 | box-sizing: border-box; 4 | 5 | border: 2px solid var(--primary); 6 | transition: border 0.1s ease-in-out; 7 | 8 | background-color: var(--white); 9 | border-radius: 2px; 10 | } 11 | 12 | .label { 13 | margin: 12px; 14 | overflow: hidden; 15 | white-space: nowrap; 16 | text-overflow: ellipsis; 17 | } 18 | 19 | .hover { 20 | border: 3px solid var(--primary); 21 | } 22 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Segment/Segment.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, type Component, type JSX } from 'solid-js'; 2 | import type { TColorsOption, TDataPoint } from '../../types'; 3 | 4 | import cx from 'classnames'; 5 | import { getColor as getTimePerformanceColor } from '../../util/colorScale'; 6 | import { getColor as getMapColor } from '../../util/colorMap'; 7 | import styles from './Segment.module.css'; 8 | import { Tooltip } from '../Tooltip/Tooltip'; 9 | 10 | export interface ISegmentProps { 11 | data: TDataPoint; 12 | totalTimeMs: number; 13 | colors: TColorsOption; 14 | scale: number; 15 | } 16 | 17 | export interface IHoverPosition { 18 | x: number; 19 | y: number; 20 | } 21 | 22 | export const Segment: Component = (props) => { 23 | let segmentRef: HTMLDivElement | undefined; 24 | let tooltipRef: HTMLDivElement | undefined; 25 | 26 | const [position, setPosition] = createSignal(null); 27 | 28 | const calcPositionStyles = () => { 29 | const styles: JSX.CSSProperties = {}; 30 | 31 | if (props.colors === 'time') { 32 | styles['background-color'] = getTimePerformanceColor( 33 | props.data.execTimeMs, 34 | props.totalTimeMs 35 | ); 36 | } 37 | 38 | if (props.colors === 'type') { 39 | styles['background-color'] = getMapColor(props.data.parentType); 40 | } 41 | 42 | styles['width'] = `${ 43 | (props.data.execTimeMs / props.totalTimeMs) * 100 * props.scale 44 | }%`; 45 | 46 | styles['margin-left'] = `${ 47 | (props.data.execStartTimeMs / props.totalTimeMs) * 100 * props.scale 48 | }%`; 49 | 50 | return styles; 51 | }; 52 | 53 | const onMouseMove = (e: MouseEvent) => { 54 | const segment = segmentRef?.getBoundingClientRect(); 55 | const windowWidth = window.innerWidth; 56 | const tooltipWidth = tooltipRef?.clientWidth || 0; 57 | 58 | const { left, top } = segment || { left: 0, top: 0 }; 59 | 60 | const x = e.clientX - left; 61 | const y = e.clientY - top; 62 | const endPosition = e.pageX + tooltipWidth; 63 | 64 | if (endPosition > windowWidth) { 65 | setPosition({ x: x - tooltipWidth - 5, y }); 66 | } else { 67 | setPosition({ x, y }); 68 | } 69 | }; 70 | 71 | return ( 72 |
setPosition(null)} 75 | style={calcPositionStyles()} 76 | ref={segmentRef} 77 | class={cx(styles.segment, { [styles.hover]: position() })}> 78 |
{props.data.parentType}
79 | 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Timeline/Timeline.module.css: -------------------------------------------------------------------------------- 1 | .timeCursor { 2 | position: absolute; 3 | height: 100vh; 4 | width: 1px; 5 | box-sizing: border-box; 6 | pointer-events: none; 7 | border: 1px dashed var(--hover); 8 | } 9 | 10 | .label { 11 | top: 90px; 12 | position: absolute; 13 | background-color: var(--white); 14 | padding: 4px; 15 | border-radius: 4px; 16 | border: 1px solid var(--black); 17 | z-index: 99; 18 | margin: 0 4px; 19 | } 20 | 21 | .container { 22 | position: fixed; 23 | top: 0; 24 | } 25 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Timeline/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal, type Component, ParentProps, onMount } from 'solid-js'; 2 | 3 | import styles from './Timeline.module.css'; 4 | 5 | export type TTimelineProps = ParentProps<{ 6 | containerRef?: HTMLDivElement; 7 | totalTimeMs: number; 8 | scale: number; 9 | }>; 10 | 11 | export const Timeline: Component = (props) => { 12 | let timeCursorLabelRef: HTMLSpanElement | undefined; 13 | 14 | const [timeCursor, setTimeCursor] = createSignal( 15 | undefined 16 | ); 17 | 18 | const toggleTimeCursor = (e: MouseEvent) => { 19 | e.preventDefault(); 20 | 21 | if ( 22 | typeof props.containerRef === 'undefined' || 23 | typeof timeCursorLabelRef === 'undefined' 24 | ) { 25 | return; 26 | } 27 | 28 | const rect = props.containerRef.getBoundingClientRect(); 29 | const { width: screenWidth } = rect || { left: 0 }; 30 | 31 | const x = e.clientX; 32 | 33 | setTimeCursor(x); 34 | 35 | const scrollLeft = props.containerRef.scrollLeft; 36 | const scrollWidth = props.containerRef.scrollWidth; 37 | 38 | const widthToUse = 39 | scrollWidth <= screenWidth ? scrollWidth * props.scale : scrollWidth; 40 | 41 | const ms = Math.round(((x + scrollLeft) * props.totalTimeMs) / widthToUse); 42 | timeCursorLabelRef.innerHTML = `${ms}ms`; 43 | 44 | const timeCursorOffset = `calc(-${timeCursorLabelRef.clientWidth}px - 10px)`; 45 | 46 | if ( 47 | x > screenWidth / 2 && 48 | timeCursorLabelRef.style.left !== timeCursorOffset 49 | ) { 50 | timeCursorLabelRef.style.left = timeCursorOffset; 51 | } else if (x < screenWidth / 2 && timeCursorLabelRef.style.left !== '0') { 52 | timeCursorLabelRef.style.left = '0'; 53 | } 54 | }; 55 | 56 | onMount(() => { 57 | if (typeof props.containerRef === 'undefined') { 58 | return; 59 | } 60 | 61 | props.containerRef.addEventListener('mousemove', toggleTimeCursor); 62 | props.containerRef.addEventListener('mouseleave', () => 63 | setTimeCursor(undefined) 64 | ); 65 | props.containerRef.addEventListener('scroll', () => 66 | setTimeCursor(undefined) 67 | ); 68 | 69 | return () => { 70 | props.containerRef?.removeEventListener('mousemove', toggleTimeCursor); 71 | props.containerRef?.removeEventListener('mouseleave', () => 72 | setTimeCursor(undefined) 73 | ); 74 | props.containerRef?.removeEventListener('scroll', () => 75 | setTimeCursor(undefined) 76 | ); 77 | }; 78 | }); 79 | 80 | return ( 81 | <> 82 | {props.children} 83 |
84 |
90 | 91 |
92 |
93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Toolbar/Toolbar.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: fixed; 3 | z-index: 999; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | height: 60px; 8 | display: flex; 9 | flex-direction: row; 10 | gap: 8px; 11 | padding: 8px; 12 | background-color: var(--white); 13 | } 14 | 15 | .container button { 16 | border: 1px solid var(--primary); 17 | border-radius: 2px; 18 | padding: 8px; 19 | background-color: var(--white); 20 | cursor: pointer; 21 | } 22 | 23 | .container input { 24 | border: 1px solid var(--primary); 25 | border-radius: 2px; 26 | padding: 8px; 27 | } 28 | 29 | .container select { 30 | border: 1px solid var(--primary); 31 | border-radius: 2px; 32 | padding: 8px; 33 | cursor: pointer; 34 | } 35 | 36 | .container input[type='range'] { 37 | cursor: pointer; 38 | } 39 | 40 | .container input[type='checkbox'] { 41 | cursor: pointer; 42 | } 43 | 44 | .container button:hover { 45 | background-color: var(--primary); 46 | color: var(--white); 47 | } 48 | 49 | .column { 50 | display: flex; 51 | flex-direction: column; 52 | gap: 2px; 53 | } 54 | 55 | .group { 56 | border: 1px solid var(--primary); 57 | border-radius: 2px; 58 | padding: 8px; 59 | display: flex; 60 | align-items: center; 61 | flex-direction: row; 62 | gap: 8px; 63 | } 64 | 65 | @media (max-width: 1031px) { 66 | .container { 67 | flex-direction: column; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Toolbar/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { type Component, type Setter } from 'solid-js'; 2 | 3 | import styles from './Toolbar.module.css'; 4 | import { TColorsOption, TSortOption } from '../../types'; 5 | import { toPercentage } from '../../util/percentage'; 6 | import { AboutDialog } from '../AboutDialog/AboutDialog'; 7 | 8 | export interface IToolbarProps { 9 | setFilter: Setter; 10 | setSort: Setter; 11 | setColors: Setter; 12 | setScale: Setter; 13 | scale: number; 14 | } 15 | 16 | const Toolbar: Component = (props) => { 17 | let filterRef: HTMLInputElement | undefined; 18 | let sortRef: HTMLSelectElement | undefined; 19 | let typeColorRef: HTMLInputElement | undefined; 20 | let timeColorRef: HTMLInputElement | undefined; 21 | let scaleRef: HTMLInputElement | undefined; 22 | let dialogRef: HTMLDialogElement | undefined; 23 | 24 | function onSortChange(e: Event & { target: HTMLSelectElement }) { 25 | props.setSort( 26 | e.target.options[e.target.options.selectedIndex].id as TSortOption 27 | ); 28 | } 29 | 30 | function onFilterChange(e: Event & { target: HTMLInputElement }) { 31 | props.setFilter(e.target.value); 32 | } 33 | 34 | function onColorChange(e: Event & { target: HTMLInputElement }) { 35 | if (e.target.checked) { 36 | switch (e.target.id) { 37 | case 'time': 38 | props.setColors('time'); 39 | if (typeColorRef) typeColorRef.checked = false; 40 | break; 41 | default: 42 | case 'type': 43 | props.setColors('type'); 44 | if (timeColorRef) timeColorRef.checked = false; 45 | } 46 | } else { 47 | props.setColors('none'); 48 | } 49 | } 50 | 51 | function onScaleChange(e: Event & { target: HTMLInputElement }) { 52 | props.setScale(Number(e.target.value) / 100); 53 | } 54 | 55 | function toggleModal() { 56 | dialogRef?.showModal(); 57 | } 58 | 59 | function reset() { 60 | props.setFilter(''); 61 | props.setSort('time'); 62 | props.setColors('time'); 63 | props.setScale(1); 64 | 65 | if (filterRef) filterRef.value = ''; 66 | if (sortRef) sortRef.selectedIndex = 0; 67 | if (timeColorRef) timeColorRef.checked = true; 68 | if (typeColorRef) typeColorRef.checked = false; 69 | if (scaleRef) scaleRef.value = '100'; 70 | } 71 | 72 | return ( 73 | 131 | ); 132 | }; 133 | 134 | export default Toolbar; 135 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Tooltip/Tooltip.module.css: -------------------------------------------------------------------------------- 1 | .tooltip { 2 | z-index: 999; 3 | position: absolute; 4 | border: 1px solid var(--primary); 5 | border-radius: 2px; 6 | background-color: var(--white); 7 | padding: 8px; 8 | pointer-events: none; 9 | } 10 | 11 | .tooltipLine { 12 | white-space: nowrap; 13 | } 14 | 15 | .tooltipKey { 16 | font-weight: 700; 17 | } 18 | -------------------------------------------------------------------------------- /packages/visualizer/src/components/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { type Component } from 'solid-js'; 2 | import type { TDataPoint } from '../../types'; 3 | 4 | import styles from './Tooltip.module.css'; 5 | 6 | export interface ITooltipProps { 7 | data: TDataPoint; 8 | ref?: HTMLDivElement; 9 | x?: number; 10 | y?: number; 11 | opacity: string; 12 | } 13 | 14 | export const Tooltip: Component = (props) => { 15 | return ( 16 |
25 | 26 | {Object.entries(props.data).map(([key, value]) => ( 27 |
28 | {key}: {value} 29 |
30 | ))} 31 |
32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /packages/visualizer/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: var(--background); 4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 13 | monospace; 14 | } 15 | 16 | :root { 17 | --primary: #2c2c2c; 18 | --background: #dcdcdc; 19 | --gutter: #f5f5f5; 20 | --white: #eee; 21 | --black: #111; 22 | --hover: #3a3a3a; 23 | } 24 | -------------------------------------------------------------------------------- /packages/visualizer/src/index.tsx: -------------------------------------------------------------------------------- 1 | /* @refresh reload */ 2 | import { render } from 'solid-js/web'; 3 | 4 | import './index.css'; 5 | import App from './App'; 6 | 7 | const root = document.getElementById('root'); 8 | 9 | if (import.meta.env.DEV && !(root instanceof HTMLElement)) { 10 | throw new Error( 11 | 'Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?' 12 | ); 13 | } 14 | 15 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 16 | render(() => , root!); 17 | -------------------------------------------------------------------------------- /packages/visualizer/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/visualizer/src/types.ts: -------------------------------------------------------------------------------- 1 | export type TSortOption = 'asc' | 'desc' | 'time'; 2 | 3 | export type TColorsOption = 'time' | 'type' | 'none'; 4 | 5 | export type TSortFunction = (a: TDataPoint, b: TDataPoint) => number; 6 | 7 | export type TDataPoint = { 8 | execTimeMs: number; 9 | execStartTimeMs: number; 10 | execEndTimeMs: number; 11 | location: string; 12 | parentType: string; 13 | }; 14 | -------------------------------------------------------------------------------- /packages/visualizer/src/util/colorMap.ts: -------------------------------------------------------------------------------- 1 | const colors = [ 2 | '#147df5', 3 | '#ff0000', 4 | '#ffd300', 5 | '#a1ff0a', 6 | '#0aefff', 7 | '#2d00f7', 8 | '#ff4800', 9 | '#ffb600', 10 | '#f20089', 11 | '#6a00f4', 12 | '#ff9e00', 13 | '#ff5400', 14 | '#8900f2', 15 | '#ff6000', 16 | '#a100f2', 17 | '#ff6d00', 18 | '#b100e8', 19 | '#d100d1', 20 | '#ff9100', 21 | ]; 22 | 23 | const colorMap = new Map(); 24 | 25 | export function getColor(key: string): string { 26 | if (!colorMap.has(key)) { 27 | colorMap.set(key, colors[colorMap.size % colors.length]); 28 | } 29 | 30 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 31 | return colorMap.get(key)!; 32 | } 33 | -------------------------------------------------------------------------------- /packages/visualizer/src/util/colorScale.ts: -------------------------------------------------------------------------------- 1 | const colorMap = { 2 | fast: '#00ff00', 3 | medium: '#ffff00', 4 | slow: '#ff0000', 5 | }; 6 | 7 | export function getColor(segmentTime: number, totalTime: number) { 8 | return interpolateColor(segmentTime / totalTime); 9 | } 10 | 11 | export function interpolateColor(value: number): string { 12 | if (value < 0 || value > 1) { 13 | throw new Error('Value must be between 0 and 1'); 14 | } 15 | 16 | if (value <= 0.5) { 17 | const ratio = value / 0.5; 18 | return interpolateHexColor(colorMap.fast, colorMap.medium, ratio); 19 | } else { 20 | const ratio = (value - 0.5) / 0.5; 21 | return interpolateHexColor(colorMap.medium, colorMap.slow, ratio); 22 | } 23 | } 24 | 25 | function interpolateHexColor( 26 | start: string, 27 | end: string, 28 | ratio: number 29 | ): string { 30 | const hex = (n: number) => Math.round(n).toString(16).padStart(2, '0'); 31 | const r = Math.max( 32 | Math.min( 33 | parseInt(start.slice(1, 3), 16) * (1 - ratio) + 34 | parseInt(end.slice(1, 3), 16) * ratio, 35 | 255 36 | ), 37 | 0 38 | ); 39 | const g = Math.max( 40 | Math.min( 41 | parseInt(start.slice(3, 5), 16) * (1 - ratio) + 42 | parseInt(end.slice(3, 5), 16) * ratio, 43 | 255 44 | ), 45 | 0 46 | ); 47 | const b = Math.max( 48 | Math.min( 49 | parseInt(start.slice(5, 7), 16) * (1 - ratio) + 50 | parseInt(end.slice(5, 7), 16) * ratio, 51 | 255 52 | ), 53 | 0 54 | ); 55 | return `#${hex(r)}${hex(g)}${hex(b)}`; 56 | } 57 | -------------------------------------------------------------------------------- /packages/visualizer/src/util/percentage.ts: -------------------------------------------------------------------------------- 1 | export function toPercentage(value: number) { 2 | return Math.ceil(value * 100); 3 | } 4 | -------------------------------------------------------------------------------- /packages/visualizer/src/util/sort.ts: -------------------------------------------------------------------------------- 1 | import type { TDataPoint, TSortFunction, TSortOption } from '../types'; 2 | 3 | export const sortFunctions: Record = { 4 | asc: (a: TDataPoint, b: TDataPoint) => (a.execTimeMs > b.execTimeMs ? 1 : -1), 5 | desc: (a: TDataPoint, b: TDataPoint) => 6 | a.execTimeMs < b.execTimeMs ? 1 : -1, 7 | time: (a: TDataPoint, b: TDataPoint) => 8 | a.execStartTimeMs > b.execStartTimeMs ? 1 : -1, 9 | }; 10 | -------------------------------------------------------------------------------- /packages/visualizer/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "allowSyntheticDefaultImports": true, 8 | "esModuleInterop": true, 9 | "jsx": "preserve", 10 | "jsxImportSource": "solid-js", 11 | "types": ["vite/client"], 12 | "noEmit": true, 13 | "isolatedModules": true, 14 | "resolveJsonModule": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/visualizer/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import solidPlugin from 'vite-plugin-solid'; 3 | 4 | export default defineConfig({ 5 | plugins: [solidPlugin()], 6 | server: { 7 | port: 3000, 8 | }, 9 | build: { 10 | target: 'esnext', 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /sample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Econify/graphql-request-profiler/25fed06e600386c75d47d52a865def217cc9fa53/sample.png -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "outputs": ["dist/**"], 6 | "dependsOn": ["^build"] 7 | }, 8 | "lint": {}, 9 | 10 | "test": {} 11 | } 12 | } 13 | --------------------------------------------------------------------------------