├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets └── preview.png ├── example ├── index.html ├── index.js └── server.js ├── package.json ├── scripts └── .eslintrc.js ├── src ├── components │ ├── apollo-trace-node.js │ ├── apollo-trace-type-link.js │ ├── apollo-trace-view.js │ ├── duration-indicator.js │ ├── expand-button.js │ └── graphiql-apollo-trace.js ├── index.js ├── lib.js └── tracing-link.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "react"], 3 | "plugins": [["transform-object-rest-spread", { "useBuiltIns": true }]] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: `babel-eslint`, 3 | plugins: [`prettier`, `react`], 4 | env: { 5 | browser: true, 6 | es6: true, 7 | node: false, 8 | }, 9 | extends: [`eslint:recommended`, `plugin:react/recommended`], 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Build 6 | dist 7 | lib 8 | 9 | # node.js 10 | # 11 | node_modules/ 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # npm/yarn 16 | # 17 | package-lock.json 18 | yarn.lock 19 | 20 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": false 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | COPYRIGHT (c) 2017-present Laurin Quast 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphiQL Apollo Tracing Support 2 | 3 | This is a prove of concept implementation that could be implemented in apollo-server by default. 4 | 5 | ![Preview Screenshot](assets/preview.png) 6 | 7 | ## Install 8 | 9 | 1. Clone this repository 10 | 2. cd into this repository 11 | 3. run `yarn install` 12 | 4. run `yarn dev` 13 | 5. visit `localhost:3000/graphiql` 14 | 15 | ## Roadmap 16 | 17 | - Better styling/visualisation of the data 18 | - Figure out how to implement with apollo-server 19 | -------------------------------------------------------------------------------- /assets/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n1ru4l/graphiql-apollo-tracing/7cb8572f7f9105161f2094375e882bbe0bc8e722/assets/preview.png -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | GraphiQL with Apollo Tracing 9 | 11 | 13 | 15 | 27 | 28 | 29 | 30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env browser */ 2 | import React from 'react' 3 | import ReactDOM from 'react-dom' 4 | 5 | import { execute, from } from 'apollo-link' 6 | import { HttpLink } from 'apollo-link-http' 7 | 8 | import GraphiQL from 'graphiql' 9 | import { GraphiQLApolloTrace, createTracingLink } from '../src' 10 | import gql from 'graphql-tag' 11 | 12 | const tracingLink = createTracingLink() 13 | 14 | const link = from([ 15 | tracingLink, 16 | new HttpLink({ 17 | uri: '/graphql', 18 | }), 19 | ]) 20 | 21 | class ApolloTracingGraphiQL extends React.Component { 22 | constructor(...args) { 23 | super(...args) 24 | this.state = { 25 | fetcher: this.props.fetcher, 26 | } 27 | } 28 | 29 | navigateToType(type) { 30 | if (!this.graphiql || !this.graphiql.docExplorerComponent) return 31 | 32 | this.graphiql.docExplorerComponent.showDoc( 33 | this.graphiql.state.schema._typeMap[type], 34 | ) 35 | } 36 | 37 | render() { 38 | return ( 39 | { 41 | this.graphiql = c 42 | }} 43 | {...this.state} 44 | > 45 | 46 | 47 | 48 | this.navigateToType(type)} 51 | /> 52 | 53 | 54 | ) 55 | } 56 | } 57 | 58 | ReactDOM.render( 59 | 61 | execute(link, { ...operation, query: gql`${operation.query}` }) 62 | } 63 | />, 64 | document.querySelector(`main`), 65 | ) 66 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import webpackMiddleware from 'webpack-dev-middleware' 3 | import express from 'express' 4 | import bodyParser from 'body-parser' 5 | import { graphqlExpress } from 'apollo-server-express' 6 | import gql from 'graphql-tag' 7 | import { makeExecutableSchema } from 'graphql-tools' 8 | import webpackConfig from '../webpack.config' 9 | import webpack from 'webpack' 10 | import morgan from 'morgan' 11 | import path from 'path' 12 | import times from 'lodash.times' 13 | import faker from 'faker' 14 | 15 | faker.seed(123) 16 | 17 | const graphiQlIndexPath = path.join(__dirname, `index.html`) 18 | 19 | const mockAuthors = times(50, () => ({ 20 | id: faker.random.uuid(), 21 | firstName: faker.name.firstName(), 22 | lastName: faker.name.lastName(), 23 | })) 24 | 25 | const mockPosts = times(100, () => ({ 26 | id: faker.random.uuid(), 27 | title: faker.lorem.words(), 28 | votes: faker.random.number({ min: 0, max: 10 }), 29 | authorId: 30 | mockAuthors[faker.random.number({ min: 0, max: mockAuthors.length - 1 })] 31 | .id, 32 | })) 33 | 34 | const typeDefs = gql` 35 | type Author { 36 | id: ID! # the ! means that every author object _must_ have an id 37 | firstName: String 38 | lastName: String 39 | posts(first: Int): [Post] # the list of Posts by this author 40 | } 41 | 42 | type Post { 43 | id: ID! 44 | title: String 45 | author: Author 46 | votes: Int 47 | } 48 | 49 | # the schema allows the following query: 50 | type Query { 51 | posts(first: Int): [Post] 52 | } 53 | 54 | # this schema allows the following mutation: 55 | type Mutation { 56 | upvotePost(postId: ID!): Post 57 | } 58 | 59 | # we need to tell the server which types represent the root query 60 | # and root mutation types. We call them RootQuery and RootMutation by convention. 61 | schema { 62 | query: Query 63 | mutation: Mutation 64 | } 65 | ` 66 | 67 | const sleep = (t = 1) => new Promise(resolve => setTimeout(resolve, t)) 68 | 69 | const resolvers = { 70 | Query: { 71 | posts: async (_, { first = 100 }) => { 72 | await sleep(first) 73 | return mockPosts.slice(0, Math.min(first, mockPosts.length - 1)) 74 | }, 75 | }, 76 | Post: { 77 | author: async post => { 78 | await sleep(1000) 79 | return mockAuthors.find(author => author.id === post.authorId) 80 | }, 81 | }, 82 | Author: { 83 | posts: async (author, { first = 2 }) => { 84 | await sleep(3000) 85 | return mockPosts 86 | .filter(post => post.authorId === author.id) 87 | .slice(0, first) 88 | }, 89 | }, 90 | } 91 | 92 | const schema = makeExecutableSchema({ typeDefs, resolvers }) 93 | 94 | const PORT = 3000 95 | 96 | const app = express() 97 | 98 | app.use(morgan(`tiny`)) 99 | app.use( 100 | '/graphql', 101 | bodyParser.json(), 102 | graphqlExpress({ schema, tracing: true }), 103 | ) 104 | 105 | app.use( 106 | webpackMiddleware(webpack(webpackConfig), { 107 | publicPath: '/graphiql', 108 | stats: { 109 | colors: true, 110 | }, 111 | }), 112 | ) 113 | 114 | app.get(`/graphiql`, (request, response) => { 115 | response.sendFile(graphiQlIndexPath) 116 | }) 117 | 118 | app.listen(PORT) 119 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphiql-apollo-tracing", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Laurin Quast ", 6 | "license": "MIT", 7 | "scripts": { 8 | "cm": "git-cz", 9 | "precommit": "lint-staged", 10 | "dev": "babel-node example/server.js" 11 | }, 12 | "devDependencies": { 13 | "apollo-link": "^1.0.0", 14 | "apollo-link-http": "^1.1.0", 15 | "apollo-server-express": "^1.2.0", 16 | "babel-cli": "^6.26.0", 17 | "babel-core": "^6.26.0", 18 | "babel-eslint": "^8.0.2", 19 | "babel-loader": "^7.1.2", 20 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 21 | "babel-preset-env": "^1.6.1", 22 | "babel-preset-react": "^6.24.1", 23 | "body-parser": "^1.18.2", 24 | "eslint": "^4.10.0", 25 | "eslint-plugin-jest": "^21.2.0", 26 | "eslint-plugin-prettier": "^2.3.1", 27 | "eslint-plugin-react": "^7.4.0", 28 | "faker": "^4.1.0", 29 | "graphiql": "^0.11.10", 30 | "graphql": "^0.11.7", 31 | "graphql-tag": "^2.5.0", 32 | "graphql-tools": "^2.7.2", 33 | "husky": "^0.14.3", 34 | "jest": "^21.2.1", 35 | "lint-staged": "^4.3.0", 36 | "lodash.times": "^4.3.2", 37 | "morgan": "^1.9.0", 38 | "prettier": "^1.8.1", 39 | "prop-types": "^15.6.0", 40 | "react": "^16.0.0", 41 | "react-dom": "^16.0.0", 42 | "webpack": "^3.8.1", 43 | "webpack-dev-middleware": "^1.12.0", 44 | "webpack-dev-server": "^2.9.4" 45 | }, 46 | "dependencies": { 47 | "cz-conventional-changelog": "^2.1.0", 48 | "d3-scale": "^1.0.6", 49 | "lodash.difference": "^4.5.0", 50 | "lodash.flattendeep": "^4.4.0", 51 | "lodash.isequal": "^4.5.0", 52 | "lodash.memoize": "^4.1.2", 53 | "styled-components": "^2.2.3" 54 | }, 55 | "resolutions": { 56 | "graphql": "^0.11.7" 57 | }, 58 | "lint-staged": { 59 | "*.js": [ 60 | "eslint --fix", 61 | "git add" 62 | ], 63 | "*.json": [ 64 | "prettier --write", 65 | "git add" 66 | ] 67 | }, 68 | "config": { 69 | "commitizen": { 70 | "path": "./node_modules/cz-conventional-changelog" 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /scripts/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: false, 4 | node: true, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/apollo-trace-node.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const Container = styled.div.attrs({ 5 | style: props => ({ 6 | marginLeft: props.offsetLeft + 5, 7 | }), 8 | })` 9 | position: relative; 10 | margin-top: 5px; 11 | height: 24px; 12 | ` 13 | 14 | const Label = styled.div` 15 | position: absolute; 16 | padding-left: 4px; 17 | padding-right: 4px; 18 | padding-top: 3px; 19 | padding-bottom: 3px; 20 | color: grey; 21 | background-color: lightgrey; 22 | border-radius: 5px; 23 | transform: translateX(calc(-100% - 4px)); 24 | ` 25 | 26 | const DurationTrace = styled.div.attrs({ 27 | style: props => ({ 28 | width: props.traceWidth, 29 | }), 30 | })` 31 | display: inline-block; 32 | top: 36%; 33 | min-width: 2px; 34 | height: 2px; 35 | background-color: red; 36 | opacity: 0.3; 37 | ` 38 | 39 | const DurationLabel = styled.div` 40 | display: inline-block; 41 | padding-left: 5px; 42 | padding-right: 5px; 43 | font-size: 12px; 44 | transform: translateY(2px); 45 | ` 46 | 47 | export class ApolloTraceNode extends React.Component { 48 | render() { 49 | const { label, offsetLeft, width, duration } = this.props 50 | 51 | return ( 52 | 53 | 54 | 55 | {duration} 56 | 57 | ) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/apollo-trace-type-link.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { parseType } from '../lib' 5 | 6 | const TypeName = styled.a` 7 | color: #ca9800; 8 | &:hover { 9 | text-decoration: underline; 10 | cursor: pointer; 11 | } 12 | ` 13 | 14 | export class ApolloTraceTypeLink extends React.Component { 15 | render() { 16 | const { typeName, onClick } = this.props 17 | const { pre, type, after } = parseType(typeName) 18 | 19 | return ( 20 | 21 | {pre} 22 | onClick(type)}>{type} 23 | {after} 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/apollo-trace-view.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { scaleLinear } from 'd3-scale' 3 | import flattenDeep from 'lodash.flattendeep' 4 | import { formatNano, getChildResolvers } from '../lib' 5 | import { ApolloTraceNode } from './apollo-trace-node' 6 | 7 | export const createGetChildResolvers = resolvers => resolver => { 8 | const { path, returnType } = resolver 9 | const isList = returnType.charAt(0) === `[` 10 | return getChildResolvers(resolvers, path, isList) 11 | } 12 | 13 | export class ApolloTraceView extends Component { 14 | render() { 15 | const { width, resolvers, duration } = this.props 16 | 17 | const xScale = scaleLinear() 18 | .domain([0, duration]) 19 | .range([0, width - 50]) 20 | 21 | const rootResolver = resolvers[0] 22 | if (!rootResolver) return null 23 | 24 | const getChildResolvers = createGetChildResolvers(resolvers) 25 | 26 | const buildTree = resolver => { 27 | const resolvers = getChildResolvers(resolver) 28 | return [resolver, resolvers.map(buildTree)] 29 | } 30 | 31 | const tree = flattenDeep(buildTree(rootResolver)) 32 | 33 | return ( 34 |
(this.node = node)} 36 | style={{ minWidth: width, marginBottom: 30 }} 37 | > 38 | {tree.map(resolver => ( 39 | 46 | ))} 47 |
48 | ) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/duration-indicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export class DurationIndicator extends React.Component { 4 | render() { 5 | const { duration: durationNano } = this.props 6 | const durationMicro = durationNano / 1000 7 | if (durationMicro < 1) { 8 | return ( 9 | {durationNano.toFixed(2)} ns 10 | ) 11 | } 12 | const durationMilli = durationMicro / 1000 13 | if (durationMilli < 1) { 14 | return ( 15 | {durationMicro.toFixed(2)} µs 16 | ) 17 | } 18 | const durationSecond = durationMilli / 1000 19 | if (durationSecond < 1) { 20 | return ( 21 | 22 | {durationMilli.toFixed(2)} ms 23 | 24 | ) 25 | } 26 | return {durationSecond.toFixed(2)} s 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/expand-button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const ExpandButtonView = styled.button` 5 | position: absolute; 6 | left: -15px; 7 | border: 0; 8 | height: 16px; 9 | line-height: 10px; 10 | width: 20px; 11 | background-color: transparent; 12 | ` 13 | 14 | export class ExpandButton extends React.Component { 15 | render() { 16 | const { isExpanded, onClick } = this.props 17 | return ( 18 | 19 | {isExpanded ? `▾` : `▸`} 20 | 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/components/graphiql-apollo-trace.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { DurationIndicator } from './duration-indicator' 5 | import { ApolloTraceView } from './apollo-trace-view' 6 | 7 | const Title = styled.div` 8 | background: #eeeeee; 9 | border-bottom: 1px solid #d6d6d6; 10 | border-top: 1px solid #e0e0e0; 11 | color: #777; 12 | font-variant: small-caps; 13 | font-weight: bold; 14 | letter-spacing: 1px; 15 | line-height: 14px; 16 | padding: 6px 0 8px 10px; 17 | text-transform: lowercase; 18 | cursor: pointer; 19 | ` 20 | 21 | const TitleDuration = styled.span` 22 | float: right; 23 | padding-right: 20px; 24 | font-size: 12px; 25 | ` 26 | 27 | const ApolloTraceContainer = styled.div` 28 | height: ${p => (p.isExpanded ? `calc((100vh * .5) - 48px)` : `0px`)}; 29 | overflow: scroll; 30 | padding-top: 10px; 31 | padding-bottom: 10px; 32 | padding-left: 10px; 33 | ` 34 | 35 | export class GraphiQLApolloTrace extends React.Component { 36 | constructor(...args) { 37 | super(...args) 38 | const { tracingLink } = this.props 39 | this.state = { 40 | tracing: null, 41 | isExpanded: localStorage.getItem(`graphiql:showTrace`) === `true`, 42 | } 43 | tracingLink.subscribe(tracing => { 44 | this.setState(state => ({ ...state, tracing })) 45 | }) 46 | } 47 | render() { 48 | const { tracing, isExpanded } = this.state 49 | 50 | if (!tracing) return null 51 | const { resolvers } = tracing.execution 52 | 53 | return ( 54 |
55 | { 57 | localStorage.setItem( 58 | `graphiql:showTrace`, 59 | `${!this.state.isExpanded}`, 60 | ) 61 | this.setState(state => ({ 62 | ...state, 63 | isExpanded: !state.isExpanded, 64 | })) 65 | }} 66 | > 67 | Apollo Tracing 68 | <TitleDuration> 69 | Request Duration: <DurationIndicator duration={tracing.duration} /> 70 | </TitleDuration> 71 | 72 | {isExpanded ? ( 73 | 74 | 79 | 80 | ) : null} 81 |
82 | ) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { GraphiQLApolloTrace } from './components/graphiql-apollo-trace' 2 | export { createTracingLink } from './tracing-link' 3 | -------------------------------------------------------------------------------- /src/lib.js: -------------------------------------------------------------------------------- 1 | import isEqual from 'lodash.isequal' 2 | import memoize from 'lodash.memoize' 3 | 4 | export const getChildResolvers = (resolvers, path, isList) => 5 | resolvers.filter(resolver => { 6 | if (path.length === resolver.path.length) return false 7 | 8 | const slicedPath = resolver.path.slice( 9 | 0, 10 | resolver.path.length - 1 - (isList ? 1 : 0), 11 | ) 12 | 13 | return slicedPath.length === path.length && isEqual(path, slicedPath) 14 | }) 15 | 16 | export const parseType = memoize(typeName => { 17 | let pre = `` 18 | let after = `` 19 | 20 | if (typeName.charAt(0) === `[`) { 21 | pre = `[` 22 | after = `]` 23 | } 24 | 25 | const lastChar = typeName.charAt(typeName.length - 1) 26 | if (lastChar === `!`) { 27 | after += `!` 28 | } 29 | 30 | const type = typeName.substr( 31 | pre.length, 32 | typeName.length - after.length - pre.length, 33 | ) 34 | 35 | return { pre, type, after } 36 | }) 37 | 38 | export const formatNano = nano => { 39 | const micro = nano / 1000 40 | if (micro < 1) return nano.toFixed(2) + ` ns` 41 | const milli = micro / 1000 42 | if (milli < 1) return micro.toFixed(2) + ` µs` 43 | const second = milli / 1000 44 | if (second < 1) return milli.toFixed(2) + ` ms` 45 | return second.toFixed(2) + ` s` 46 | } 47 | -------------------------------------------------------------------------------- /src/tracing-link.js: -------------------------------------------------------------------------------- 1 | import { ApolloLink, Observable } from 'apollo-link' 2 | 3 | class TracingLink extends ApolloLink { 4 | constructor(...args) { 5 | super(...args) 6 | this.subscribers = [] 7 | } 8 | 9 | request(operation, forward) { 10 | return new Observable(observer => { 11 | const _observable = forward(operation) 12 | _observable.subscribe({ 13 | next: res => { 14 | const extensions = { ...res.extension, tracing: undefined } 15 | const availableExtensions = Object.keys(extensions) 16 | this.subscribers.forEach(handler => handler(res.extensions.tracing)) 17 | observer.next({ 18 | ...res, 19 | extensions: availableExtensions.length ? undefined : extensions, 20 | }) 21 | observer.complete() 22 | }, 23 | error: err => observer.error(err), 24 | }) 25 | }) 26 | } 27 | 28 | subscribe(handler) { 29 | this.subscribers.push(handler) 30 | } 31 | } 32 | 33 | export const createTracingLink = () => new TracingLink() 34 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import path from 'path' 4 | import webpack from 'webpack' 5 | 6 | export default { 7 | entry: path.join(__dirname, `example/index.js`), 8 | output: { 9 | path: `/graphiql`, 10 | filename: 'bundle.js', 11 | }, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.js$/, 16 | exclude: /(node_modules)/, 17 | use: { 18 | loader: `babel-loader`, 19 | }, 20 | }, 21 | ], 22 | }, 23 | watch: true, 24 | plugins: [ 25 | new webpack.ContextReplacementPlugin( 26 | /graphql-language-service-interface[/\\]dist/, 27 | new RegExp(`^\\./.*\\.js$`), 28 | ), 29 | ], 30 | } 31 | --------------------------------------------------------------------------------