17 |
--------------------------------------------------------------------------------
/client/src/trace/index.module.css:
--------------------------------------------------------------------------------
1 | .wrapper {
2 | text-align: center;
3 | margin-bottom: 40px;
4 | }
5 |
6 | .wrapper > div {
7 | margin-bottom: 10px;
8 | }
9 |
10 | .stat {
11 | display: flex;
12 | flex-direction: column;
13 | padding: 0 10px;
14 | text-align: center;
15 | justify-items: center;
16 | }
17 |
18 | .statNumber {
19 | font-size: 1.3rem;
20 | font-weight: 800;
21 | }
22 |
23 | .statTitle {
24 | color: var(--color-text-secondary);
25 | }
26 |
27 | .subTitle {
28 | color: var(--color-text-secondary);
29 | }
30 |
--------------------------------------------------------------------------------
/nginx/user.conf.d/ssl.conf:
--------------------------------------------------------------------------------
1 | server {
2 | resolver 127.0.0.11 valid=30s;
3 | listen 443 ssl;
4 | server_name ${DOMAIN};
5 | ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
6 | ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
7 |
8 | location ^~/api/ {
9 | set $api http://server:3000;
10 | proxy_pass $api;
11 | proxy_redirect off;
12 | }
13 |
14 | location / {
15 | autoindex on;
16 | root /home/www-data/clementine;
17 | try_files $uri /index.html;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | // If you want your app to work offline and load faster, you can change
15 | // unregister() to register() below. Note this comes with some pitfalls.
16 | // Learn more about service workers: https://bit.ly/CRA-PWA
17 | serviceWorker.unregister();
18 |
--------------------------------------------------------------------------------
/server/bin/migrate.js:
--------------------------------------------------------------------------------
1 | const { runMigration } = require('../src/persistence/migrator')
2 | const logger = require('loglevel')
3 | if (process.env.LOG_LEVEL != null) logger.setLevel(process.env.LOG_LEVEL)
4 |
5 | const [command] = process.argv.slice(2)
6 |
7 | runMigration(command)
8 | .then(() => {
9 | logger.info(`migrations "${command}" successfully ran`)
10 | // eslint-disable-next-line unicorn/no-process-exit
11 | process.exit(0)
12 | })
13 | .catch(error => {
14 | console.error(error.stack)
15 | // eslint-disable-next-line unicorn/no-process-exit
16 | process.exit(1)
17 | })
18 |
--------------------------------------------------------------------------------
/client/src/trace/TracingRow.module.css:
--------------------------------------------------------------------------------
1 | .row {
2 | position: relative;
3 | font-size: 16px;
4 | display: table;
5 | padding-right: 25px;
6 | }
7 |
8 | .bar {
9 | display: inline-block;
10 | position: relative;
11 | margin: 0 10px;
12 | height: 1.5px;
13 | bottom: 4px;
14 | background: var(--color-black);
15 | }
16 |
17 | .duration {
18 | font-size: 16px;
19 | }
20 |
21 | .wrapper {
22 | position: absolute;
23 | left: 0;
24 | transform: translateX(-100%);
25 | display: inline-flex;
26 | align-items: center;
27 | text-align: right;
28 | }
29 |
30 | .name {
31 | font-size: 16px;
32 | }
33 |
--------------------------------------------------------------------------------
/client/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/client/src/operation/utils.js:
--------------------------------------------------------------------------------
1 | import { getOperationName } from 'apollo-utilities'
2 |
3 | export function getOperationTypes(doc) {
4 | let operationTypes = []
5 |
6 | const definitions = doc.definitions.filter(
7 | definition => definition.kind === 'OperationDefinition'
8 | )
9 |
10 | const isQuery = definitions.some(def => def.operation === 'query')
11 | const isMutation = definitions.some(def => def.operation === 'mutation')
12 |
13 | if (isQuery) {
14 | operationTypes.push('query')
15 | }
16 |
17 | if (isMutation) {
18 | operationTypes.push('mutation')
19 | }
20 |
21 | return operationTypes
22 | }
23 |
24 | export {
25 | getOperationName
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/migrations/1550969025172-authentication.js:
--------------------------------------------------------------------------------
1 | const { db, sql } = require('../persistence')
2 |
3 | module.exports.up = async function(next) {
4 | await db.query(sql`
5 | CREATE TABLE IF NOT EXISTS users (
6 | id uuid PRIMARY KEY,
7 | email text UNIQUE,
8 | "createdAt" timestamp with time zone default (now() at time zone 'utc') NOT NULL,
9 | "isVerified" boolean default FALSE
10 | );
11 | CREATE INDEX IF NOT EXISTS "usersEmail" on users (email);
12 | `)
13 |
14 | next()
15 | }
16 |
17 | module.exports.down = async function(next) {
18 | await db.query(sql`
19 | DROP TABLE users;
20 | DROP INDEX IF EXISTS "usersEmail";
21 | `)
22 | next()
23 | }
24 |
--------------------------------------------------------------------------------
/ssl.yaml:
--------------------------------------------------------------------------------
1 | # docker-compose.yml
2 | version: "3"
3 | services:
4 | nginx:
5 | ports:
6 | - 443:443
7 | image: staticfloat/nginx-certbot
8 | environment:
9 | CERTBOT_EMAIL: ${LETSENCRYPT_EMAIL}
10 | DOMAIN: ${DOMAIN}
11 | ENVSUBST_VARS: DOMAIN
12 | IS_STAGING: ${LETSENCRYPT_IS_STAGING:-"1"}
13 | volumes:
14 | # nginx-certbot moves nginx.conf to ./conf.d/ and overwrites our thang
15 | - ./nginx/user.conf.d/ssl.conf:/etc/nginx/user.conf.d/default.conf
16 | - letsencrypt:/etc/letsencrypt
17 |
18 | postgres:
19 |
20 | redis:
21 |
22 | worker:
23 |
24 | server:
25 | environment:
26 | IS_SSL: "1"
27 |
28 | client:
29 |
30 | volumes:
31 | letsencrypt:
32 |
--------------------------------------------------------------------------------
/client/src/logout.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { useMutation } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import client from './client'
5 | import UserContext from './user'
6 | import { Link } from 'react-router-dom'
7 |
8 | const LOGOUT = gql`
9 | mutation logout {
10 | userLogout
11 | }
12 | `
13 |
14 | export default function Logout() {
15 | const { setUser } = useContext(UserContext)
16 | const [logout] = useMutation(LOGOUT)
17 |
18 | return (
19 | {
22 | await logout()
23 | client.resetStore()
24 | setUser(null)
25 | }}
26 | >
27 | Logout
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/server/src/migrations/1585141743246-graphs.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { db, sql } = require('../persistence')
4 |
5 | module.exports.up = async function(next) {
6 | await db.query(sql`
7 | CREATE TABLE IF NOT EXISTS graphs (
8 | id uuid PRIMARY KEY,
9 | "userId" uuid REFERENCES users (id) ON DELETE CASCADE,
10 | name text,
11 | "createdAt" timestamp with time zone default (now() at time zone 'utc') NOT NULL
12 | );
13 | CREATE INDEX IF NOT EXISTS "graphUser" on graphs ("userId");
14 | `)
15 |
16 | next()
17 | }
18 |
19 | module.exports.down = async function(next) {
20 | await db.query(sql`
21 | DROP TABLE graphs CASCADE;
22 | DROP INDEX IF EXISTS "graphUser";
23 | `)
24 |
25 | next()
26 | }
27 |
--------------------------------------------------------------------------------
/server/src/migrations/1585147972450-keys.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { db, sql } = require('../persistence')
4 |
5 | module.exports.up = async function(next) {
6 | await db.query(sql`
7 | CREATE TABLE IF NOT EXISTS keys (
8 | id uuid PRIMARY KEY,
9 | "createdAt" timestamp with time zone default (now() at time zone 'utc') NOT NULL,
10 | "graphId" uuid REFERENCES graphs (id) ON DELETE CASCADE,
11 | hash text UNIQUE,
12 | prefix text
13 | );
14 | CREATE INDEX IF NOT EXISTS "keyGraph" on keys ("graphId");
15 | `)
16 |
17 | next()
18 | }
19 |
20 | module.exports.down = async function(next) {
21 | await db.query(sql`
22 | DROP TABLE keys;
23 | DROP INDEX IF EXISTS "keyGraph";
24 | `)
25 | next()
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/nav/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styles from './index.module.css'
3 | import Link from '../link'
4 |
5 | export default ({ items }) => {
6 | const isActive = false
7 | return (
8 |
9 |
10 |
11 | {items.map(item => {
12 | return (
13 |
20 |
{item.title}
21 |
22 | )
23 | })}
24 |
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/server/src/persistence/graphs.js:
--------------------------------------------------------------------------------
1 | const { sql } = require('slonik')
2 | const uuid = require('uuid/v4')
3 | const db = require('./db')
4 |
5 | module.exports = {
6 | async create(name, userId) {
7 | const { rows } = await db.query(sql`
8 | INSERT INTO graphs (id, name, "userId")
9 | VALUES (${uuid()}, ${name}, ${userId})
10 | RETURNING id, name, "userId";
11 | `)
12 |
13 | const [graph] = rows
14 | return graph
15 | },
16 | async findById(id) {
17 | const { rows } = await db.query(sql`
18 | SELECT * FROM graphs WHERE id=${id} LIMIT 1;
19 | `)
20 |
21 | const [graph] = rows
22 | return graph
23 | },
24 | async findAll({ userId }) {
25 | const { rows } = await db.query(sql`
26 | SELECT * FROM graphs WHERE "userId"=${userId};
27 | `)
28 | return rows
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | test:
2 | docker-compose run server npm test
3 |
4 | ci:
5 | docker-compose run server npm ci
6 |
7 | build:
8 | docker-compose -f docker-compose.yaml -f dev.yaml build
9 |
10 | dev: build
11 | docker-compose -f docker-compose.yaml -f dev.yaml up --remove-orphans
12 |
13 | migrate:
14 | docker-compose run server npm run migrate up
15 |
16 | migrate-down:
17 | docker-compose run server npm run migrate down
18 |
19 | db-rm:
20 | docker-compose kill postgres && docker-compose rm postgres
21 |
22 | psql:
23 | psql postgres://user:pass@localhost:5432/db
24 |
25 | build-prod:
26 | docker-compose -f docker-compose.yaml -f prod.yaml build
27 |
28 | start: build-prod
29 | docker-compose -f docker-compose.yaml -f prod.yaml -f ssl-less.yaml up -d
30 |
31 | start_with_ssl: build-prod
32 | docker-compose -f docker-compose.yaml -f prod.yaml -f ssl.yaml up -d
33 |
--------------------------------------------------------------------------------
/server/src/persistence/migrator.js:
--------------------------------------------------------------------------------
1 | const path = require('path')
2 | const migrate = require('migrate')
3 |
4 | const stateStore = require('./postgres-state-storage')
5 | const migrationsDirectory = path.resolve(__dirname, '../migrations')
6 |
7 | const [command] = process.argv.slice(2)
8 |
9 | const runMigration = command => {
10 | return new Promise((resolve, reject) => {
11 | migrate.load(
12 | {
13 | stateStore,
14 | migrationsDirectory
15 | },
16 | (err, set) => {
17 | if (err) {
18 | reject(err)
19 | }
20 |
21 | if (typeof set[command] !== 'function') {
22 | reject(new Error('Command is not a function'))
23 | }
24 |
25 | set[command](err => {
26 | if (err) reject(err)
27 | resolve()
28 | })
29 | }
30 | )
31 | })
32 | }
33 |
34 | module.exports = { runMigration }
35 |
--------------------------------------------------------------------------------
/client/src/orderby/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react'
2 | import Pill from '../pill'
3 |
4 | export default function OrderBy({
5 | fields,
6 | orderAsc,
7 | setOrderAsc,
8 | setOrderField,
9 | orderField
10 | }) {
11 | const symbol = orderAsc
12 | ? String.fromCharCode(9652)
13 | : String.fromCharCode(9662)
14 |
15 | return (
16 |
17 | {fields.map(({ field, label }) => {
18 | return (
19 | {
23 | if (orderField === field) {
24 | setOrderAsc(prev => !prev)
25 | }
26 | setOrderField(field)
27 | }}
28 | >
29 | {label} {orderField === field && symbol}
30 |
31 | )
32 | })}
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/client/src/utils.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export function ErrorBanner({ error }) {
4 | return Error: {error.message}...
5 | }
6 |
7 | export function Loading() {
8 | return Loading...
9 | }
10 |
11 | export function NotFound() {
12 | return Not Found
13 | }
14 |
15 | export function printDuration(nanoSeconds) {
16 | const microSeconds = Math.round(nanoSeconds / 1000)
17 | if (microSeconds > 1000) {
18 | const ms = Math.round(microSeconds / 1000)
19 | return `${ms} ms`
20 | }
21 |
22 | return `${microSeconds} µs`
23 | }
24 |
25 | export function printDate(d) {
26 | return (
27 | d.getFullYear() +
28 | '-' +
29 | (d.getMonth() + 1) +
30 | '-' +
31 | d.getDate() +
32 | ' ' +
33 | d.getHours() +
34 | ':' +
35 | d.getMinutes() +
36 | ':' +
37 | d.getSeconds()
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/client/src/stats/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styles from './index.module.css'
3 | import { printDuration } from '../utils'
4 |
5 | export default function keyMetrics({
6 | count,
7 | errorCount,
8 | errorPercent,
9 | duration
10 | }) {
11 | return (
12 |
13 |
14 |
{count}
15 |
Requests
16 |
17 |
18 |
{errorCount}
19 |
Errors
20 |
21 |
22 |
{errorPercent}%
23 |
Error Rate
24 |
25 |
26 |
{printDuration(duration) || 0}
27 |
95 percentile
28 |
29 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | # This is a basic workflow to help you get started with Actions
2 |
3 | name: CI
4 |
5 | # Controls when the action will run. Triggers the workflow on push or pull request
6 | # events but only for the master branch
7 | on:
8 | push:
9 | branches: [ master ]
10 | pull_request:
11 | branches: [ master ]
12 |
13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
14 | jobs:
15 | # This workflow contains a single job called "build"
16 | build:
17 | # The type of runner that the job will run on
18 | runs-on: ubuntu-latest
19 |
20 | # Steps represent a sequence of tasks that will be executed as part of the job
21 | steps:
22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
23 | - uses: actions/checkout@v2
24 |
25 | # Runs a single command using the runners shell
26 | - name: build
27 | run: make build
28 |
29 | - name: migrate
30 | run: make migrate
31 |
32 | # Runs a set of commands using the runners shell
33 | - name: ci
34 | run: make ci
35 |
--------------------------------------------------------------------------------
/server/src/ingress/utils.js:
--------------------------------------------------------------------------------
1 | const uuidByString = require('uuid-by-string')
2 |
3 | const extractErrors = (node, acc = []) => {
4 | if (node.error.length > 0) {
5 | acc.push(...node.error)
6 | }
7 |
8 | if (node.child) {
9 | node.child.map(n => {
10 | extractErrors(n, acc)
11 | })
12 | }
13 |
14 | return acc
15 | }
16 |
17 | function parseTS(message) {
18 | return new Date(message.seconds * 1000 + message.nanos / 1000000)
19 | }
20 |
21 | function prepareTraces(report) {
22 | return Object.entries(report.tracesPerQuery).reduce((acc, [key, v]) => {
23 | return [
24 | ...acc,
25 | ...v.trace.map(trace => {
26 | return {
27 | schemaTag: report.header.schemaTag,
28 | key,
29 | operationId: uuidByString(key),
30 | ...trace,
31 | startTime: parseTS(trace.startTime),
32 | endTime: parseTS(trace.endTime),
33 | hasErrors: extractErrors(trace.root).length > 0
34 | }
35 | })
36 | ]
37 | }, [])
38 | }
39 |
40 | module.exports = {
41 | extractErrors,
42 | prepareTraces
43 | }
44 |
--------------------------------------------------------------------------------
/server/src/persistence/users.js:
--------------------------------------------------------------------------------
1 | const { sql } = require('slonik')
2 | const uuid = require('uuid/v4')
3 | const db = require('./db')
4 |
5 | module.exports = {
6 | async create(email, password) {
7 | try {
8 | const { rows } = await db.query(sql`
9 | INSERT INTO users (id, email)
10 | VALUES (${uuid()}, ${email})
11 | RETURNING id, email, "isVerified";
12 | `)
13 |
14 | const [user] = rows
15 | return user
16 | } catch (error) {
17 | throw error
18 | }
19 | },
20 | async find(email) {
21 | return await db.maybeOne(sql`
22 | SELECT * FROM users WHERE email=${email};
23 | `)
24 | },
25 | async findAll() {
26 | return await db.query(sql`
27 | SELECT * FROM users;
28 | `)
29 | },
30 | async markVerified(id) {
31 | return await db.query(sql`
32 | UPDATE users SET "isVerified" = true WHERE id=${id};
33 | `)
34 | },
35 | async findById(id) {
36 | if (!id) {
37 | return null
38 | }
39 |
40 | return db.maybeOne(sql`
41 | SELECT * FROM users WHERE id=${id};
42 | `)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/prod.yaml:
--------------------------------------------------------------------------------
1 | # docker-compose.yml
2 | version: "3"
3 | services:
4 | nginx:
5 | volumes:
6 | - static:/home/www-data/clementine:ro
7 |
8 | postgres:
9 |
10 | redis:
11 |
12 | worker:
13 | build:
14 | context: ./server
15 | args:
16 | NODE_ENV: production
17 | user: node
18 | environment:
19 | NODE_ENV: production
20 | LOG_LEVEL: info
21 | command: npm run start:worker
22 | restart: unless-stopped
23 |
24 | server:
25 | build:
26 | context: ./server
27 | args:
28 | NODE_ENV: production
29 | user: node
30 | environment:
31 | NODE_ENV: production
32 | LOG_LEVEL: info
33 | command: npm run start
34 | restart: unless-stopped
35 |
36 | client:
37 | build:
38 | context: ./client
39 | dockerfile: Dockerfile.prod
40 | args:
41 | NODE_ENV: production
42 | user: node
43 | volumes:
44 | - static:/app/build
45 | environment:
46 | NODE_ENV: production
47 | LOG_LEVEL: info
48 | command: echo "mounting static!"
49 |
50 | volumes:
51 | static:
52 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2019 Hugo Di Francesco
3 |
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
18 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
19 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
20 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
21 | OR OTHER DEALINGS IN THE SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/client/src/trace/TracingRow.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import styles from './TracingRow.module.css'
3 | import { printDuration } from '../utils'
4 |
5 | export default function TracingRow({
6 | path,
7 | startOffset,
8 | duration,
9 | totalDuration,
10 | screenWidth
11 | }) {
12 | const offsetLeft = (startOffset / totalDuration) * screenWidth * 0.9
13 | const barWidth = (duration / totalDuration) * screenWidth * 0.9
14 |
15 | return (
16 |
20 |
21 |
22 | {path.slice(-2).map((p, index) => (
23 |
29 | {`${index > 0 ? '.' : ''}${p}`}
30 |
31 | ))}
32 |
33 |
34 |
35 | {printDuration(duration)}
36 |
37 | )
38 | }
39 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@apollo/react-hooks": "^3.1.3",
7 | "@data-ui/theme": "0.0.84",
8 | "@data-ui/xy-chart": "0.0.84",
9 | "@testing-library/jest-dom": "^4.2.4",
10 | "@testing-library/react": "^9.3.2",
11 | "@testing-library/user-event": "^7.1.2",
12 | "apollo-boost": "^0.4.7",
13 | "graphql": "^14.6.0",
14 | "loglevel": "^1.6.7",
15 | "react": "^16.13.1",
16 | "react-dom": "^16.13.1",
17 | "react-router-dom": "^5.1.2",
18 | "react-scripts": "3.4.1",
19 | "react-visual-filter": "^1.0.9",
20 | "serve": "^11.3.0"
21 | },
22 | "scripts": {
23 | "start": "serve build",
24 | "dev": "react-scripts start",
25 | "build": "react-scripts build",
26 | "test": "react-scripts test",
27 | "eject": "react-scripts eject"
28 | },
29 | "eslintConfig": {
30 | "extends": "react-app"
31 | },
32 | "proxy": "http://localhost:3000",
33 | "browserslist": {
34 | "production": [
35 | ">0.2%",
36 | "not dead",
37 | "not op_mini all"
38 | ],
39 | "development": [
40 | "last 1 chrome version",
41 | "last 1 firefox version",
42 | "last 1 safari version"
43 | ]
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/dev.yaml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | nginx:
4 | volumes:
5 | - ./nginx/user.conf.d/dev.conf:/etc/nginx/conf.d/default.conf
6 |
7 |
8 | worker:
9 | build:
10 | context: ./server
11 | args:
12 | NODE_ENV: development
13 | environment:
14 | NODE_ENV: development
15 | LOG_LEVEL: debug
16 | command: npm run dev:worker
17 | volumes:
18 | - ./server/src:/app/src
19 | - ./server/bin:/app/bin
20 | networks:
21 | - clementine
22 |
23 | server:
24 | build:
25 | context: ./server
26 | args:
27 | NODE_ENV: development
28 | environment:
29 | LOGLEVEL: debug
30 | NODE_ENV: development
31 | command: npm run dev
32 | volumes:
33 | - ./server/src:/app/src
34 | - ./server/bin:/app/bin
35 | networks:
36 | - clementine
37 |
38 | client:
39 | build:
40 | context: ./client
41 | args:
42 | NODE_ENV: development
43 | environment:
44 | LOG_LEVEL: debug
45 | NODE_ENV: development
46 | stdin_open: true
47 | command: npm run dev
48 | volumes:
49 | - ./client/src:/app/src
50 | networks:
51 | - clementine
52 |
53 | postgres:
54 | ports:
55 | - "5432:5432"
56 |
57 | redis:
58 | ports:
59 | - "6379:6379"
60 |
--------------------------------------------------------------------------------
/server/src/magicLink.js:
--------------------------------------------------------------------------------
1 | const uuid = require('uuid/v4')
2 | const promisify = require('util').promisify
3 | const { redis } = require('./persistence')
4 | const email = require('./email')
5 | const sendEmail = promisify(email.send).bind(email)
6 | const crypto = require('crypto')
7 | const prefix = 'magicLink'
8 |
9 | function hash(str) {
10 | return crypto
11 | .createHash('sha256')
12 | .update(str)
13 | .digest('hex')
14 | }
15 |
16 | const EXPIRE = 3600 // 1 hr
17 |
18 | const domain = process.env.DOMAIN
19 | const protocol = process.env.IS_SSL === '1' ? 'https' : 'http'
20 |
21 | async function generate(data) {
22 | const token = uuid()
23 | await redis.set(
24 | `${prefix}:${hash(token)}`,
25 | JSON.stringify(data),
26 | 'EX',
27 | EXPIRE
28 | )
29 | return [token, `${protocol}://${domain}/api/verify?token=${token}`]
30 | }
31 |
32 | async function verify(token) {
33 | const data = await redis.get(`${prefix}:${hash(token)}`)
34 | return JSON.parse(data)
35 | }
36 |
37 | async function send(user) {
38 | const [token, link] = await generate(user)
39 |
40 | return sendEmail({
41 | text: `Follow this ${link} to login.`,
42 | subject: 'Clementine Signin',
43 | from: process.env.SMTP_EMAIL_FROM,
44 | to: user.email
45 | })
46 | }
47 |
48 | module.exports = {
49 | verify,
50 | generate,
51 | send
52 | }
53 |
--------------------------------------------------------------------------------
/client/src/menu.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import React, { useContext } from 'react'
3 | import { Link, useLocation, useRouteMatch } from 'react-router-dom'
4 | import Logout from './logout'
5 | import UserContext from './user'
6 | import { FiltersContext } from './trace'
7 | import Label from './label'
8 |
9 | function Menu() {
10 | const { user } = useContext(UserContext)
11 | const { rawFilters: filters } = useContext(FiltersContext)
12 | const location = useLocation()
13 | const match = useRouteMatch('/graph/:graphId')
14 | const search = new URLSearchParams(location.search)
15 |
16 | let path
17 |
18 | // toggle fitlers query
19 | if (!search.get('filters')) {
20 | search.set('filters', '1')
21 | path = location.pathname + '?' + search.toString()
22 | } else {
23 | search.delete('filters')
24 | path = location.pathname + '?' + search.toString()
25 | }
26 |
27 | if (user) {
28 | const label = filters.length > 0 ? : ''
29 | return (
30 |
31 |
32 | Graphs
33 | {match && Filters {label}}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | return (
41 |
42 |
43 |
44 | signin
45 |
46 |
47 |
48 | )
49 | }
50 |
51 | export default Menu
52 |
--------------------------------------------------------------------------------
/server/src/graphql/__tests__/utils.js:
--------------------------------------------------------------------------------
1 | const uuidByString = require('uuid-by-string')
2 | const uuid = require('uuid/v4')
3 | const { magicLink } = require('../../index')
4 |
5 | function randomInt(max) {
6 | return Math.floor(Math.random() * Math.floor(max))
7 | }
8 |
9 | function generateTraces(n) {
10 | const operationKeys = [...Array(11).keys()].map(() => uuid())
11 |
12 | return [...Array(n).keys()].map(i => {
13 | const startTime = Date.now()
14 | const durationNs = randomInt(4679795)
15 | const endTime = startTime - durationNs * 1000 * 1000
16 | const key = operationKeys[randomInt(11)]
17 |
18 | return {
19 | durationNs,
20 | key,
21 | operationId: uuidByString(key),
22 | startTime: new Date(startTime),
23 | endTime: new Date(endTime),
24 | root: {},
25 | clientName: uuid(),
26 | clientVersion: uuid(),
27 | schemaTag: uuid(),
28 | details: {},
29 | hasErrors: !!randomInt(1)
30 | }
31 | })
32 | }
33 |
34 | function raiseGqlErr(res) {
35 | if (res.body.errors) {
36 | throw Error(JSON.stringify(res.body.errors))
37 | }
38 |
39 | return res
40 | }
41 |
42 | async function generateToken(user) {
43 | const [token, _] = await magicLink.generate(user)
44 | return token
45 | }
46 |
47 | function login(request, token) {
48 | return request.get(`/api/verify?token=${token}`)
49 | }
50 |
51 | module.exports = { generateTraces, raiseGqlErr, generateToken, login }
52 |
--------------------------------------------------------------------------------
/client/src/trace/TracingReponse.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import TracingRow from './TracingRow'
3 | import { withParentSize } from '@data-ui/xy-chart'
4 | import styles from './TracingResponse.module.css'
5 |
6 | function prepareTracing(node, acc = []) {
7 | if (node.child.length === 0) {
8 | return acc
9 | }
10 |
11 | for (const child of node.child) {
12 | if ((child.parentType, child.responseName)) {
13 | acc.push({
14 | startTime: child.startTime,
15 | endTime: child.endTime,
16 | path: [child.parentType, child.responseName]
17 | })
18 | }
19 |
20 | // recurse
21 | prepareTracing(child, acc)
22 | }
23 |
24 | return acc
25 | }
26 |
27 | export default withParentSize(({ parentWidth, tracing, duration }) => {
28 | return (
29 |
30 |
31 |
38 | {prepareTracing(tracing).map((res, i) => (
39 |
47 | ))}
48 |
49 |
50 | )
51 | })
52 |
--------------------------------------------------------------------------------
/client/src/auth/index.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react'
2 | import { useMutation } from '@apollo/react-hooks'
3 | import { useHistory } from 'react-router-dom'
4 | import { gql } from 'apollo-boost'
5 | import client from '../client'
6 | import { Header } from '../header'
7 | import { Link } from 'react-router-dom'
8 |
9 | const LOGIN = gql`
10 | mutation login($email: String!) {
11 | userLogin(email: $email)
12 | }
13 | `
14 |
15 | export function Login() {
16 | const history = useHistory()
17 | const emailRef = useRef()
18 | const [login] = useMutation(LOGIN)
19 |
20 | return (
21 |
22 |
23 |
44 |
45 | )
46 | }
47 |
48 | export function CheckEmail() {
49 | return (
50 |
51 |
52 |
Check you inbox
53 |
54 | No email? Try again
55 |
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | user www-data;
2 | worker_processes auto;
3 | pid /run/nginx.pid;
4 | include /etc/nginx/modules-enabled/*.conf;
5 |
6 | events {
7 | worker_connections 768;
8 | # multi_accept on;
9 | }
10 |
11 | http {
12 | sendfile on;
13 | tcp_nopush on;
14 | tcp_nodelay on;
15 | keepalive_timeout 65;
16 | types_hash_max_size 2048;
17 |
18 | include /etc/nginx/mime.types;
19 | default_type application/octet-stream;
20 |
21 | ##
22 | # SSL Settings
23 | ##
24 |
25 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
26 | ssl_prefer_server_ciphers on;
27 |
28 | ##
29 | # Logging Settings
30 | ##
31 |
32 | access_log /var/log/nginx/access.log;
33 | error_log /var/log/nginx/error.log;
34 |
35 | ##
36 | # Gzip Settings
37 | ##
38 |
39 | gzip on;
40 |
41 | # gzip_vary on;
42 | # gzip_proxied any;
43 | # gzip_comp_level 6;
44 | # gzip_buffers 16 8k;
45 | # gzip_http_version 1.1;
46 | # gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
47 |
48 | proxy_http_version 1.1;
49 | proxy_set_header Upgrade $http_upgrade;
50 | proxy_set_header Connection "upgrade";
51 | proxy_set_header Host $host;
52 | proxy_set_header X-Real-IP $remote_addr;
53 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
54 | proxy_set_header X-Forwarded-Host $server_name;
55 |
56 | include /etc/nginx/conf.d/*.conf;
57 | }
58 |
--------------------------------------------------------------------------------
/server/src/persistence/postgres-state-storage.js:
--------------------------------------------------------------------------------
1 | const db = require('./db')
2 | const { sql } = require('slonik')
3 | const { cloneDeep } = require('lodash')
4 | const logger = require('loglevel')
5 |
6 | const ensureMigrationsTable = db =>
7 | db.query(
8 | sql`CREATE TABLE IF NOT EXISTS migrations (id integer PRIMARY KEY, data jsonb NOT NULL)`
9 | )
10 |
11 | const postgresStateStorage = {
12 | async load(fn) {
13 | await ensureMigrationsTable(db)
14 | // Load the single row of migration data from the database
15 | const { rows } = await db.query(sql`SELECT data FROM migrations`)
16 |
17 | if (rows.length !== 1) {
18 | logger.warn(
19 | 'Cannot read migrations from database. If this is the first time you run migrations, then this is normal.'
20 | )
21 |
22 | return fn(null, {})
23 | }
24 |
25 | // Call callback with new migration data object
26 | fn(null, rows[0].data)
27 | },
28 |
29 | async save(set, fn) {
30 | // Check if table 'migrations' exists and if not, create it.
31 | await ensureMigrationsTable(db)
32 |
33 | const migrationMetaData = cloneDeep({
34 | lastRun: set.lastRun,
35 | migrations: set.migrations
36 | })
37 |
38 | await db.query(sql`
39 | INSERT INTO migrations (id, data)
40 | VALUES (1, ${sql.json(migrationMetaData)})
41 | ON CONFLICT (id) DO UPDATE SET data = ${sql.json(migrationMetaData)}
42 | `)
43 |
44 | fn()
45 | }
46 | }
47 |
48 | module.exports = Object.assign(() => {
49 | return postgresStateStorage
50 | }, postgresStateStorage)
51 |
--------------------------------------------------------------------------------
/client/src/timeline/chart.js:
--------------------------------------------------------------------------------
1 | /* eslint react/prop-types: 0 */
2 | import React from 'react'
3 | import { XYChart, theme, withScreenSize, withTheme } from '@data-ui/xy-chart'
4 |
5 | // test that withTheme works
6 | const XYChartWithTheme = withTheme(theme)(XYChart)
7 |
8 | export function renderTooltip({ datum, seriesKey, color, data }) {
9 | const { x, x0, y, value } = datum
10 | let xVal = x || x0
11 | if (typeof xVal === 'string') {
12 | // noop
13 | } else if (typeof xVal !== 'string' && Number(xVal) > 1000000) {
14 | xVal = new Date(xVal).toUTCString()
15 | }
16 | const yVal =
17 | seriesKey && datum[seriesKey] ? datum[seriesKey] : y || value || '--'
18 |
19 | return (
20 |
21 | {seriesKey && (
22 |
23 | {seriesKey}
24 |
25 | )}
26 |
27 | x
28 | {xVal && xVal.toFixed ? xVal.toFixed(2) : xVal}
29 |
30 |
31 | y
32 | {yVal && yVal.toFixed ? yVal.toFixed(2) : yVal}
33 |
34 |
35 | )
36 | }
37 |
38 | function ResponsiveXYChart({ screenWidth, children, ...rest }) {
39 | return (
40 |
46 | {children}
47 |
48 | )
49 | }
50 |
51 | export default withScreenSize(ResponsiveXYChart)
52 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 |
24 | # nyc test coverage
25 | .nyc_output
26 |
27 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
28 | .grunt
29 |
30 | # Bower dependency directory (https://bower.io/)
31 | bower_components
32 |
33 | # node-waf configuration
34 | .lock-wscript
35 |
36 | # Compiled binary addons (https://nodejs.org/api/addons.html)
37 | build/Release
38 |
39 | # Dependency directories
40 | node_modules/
41 | jspm_packages/
42 |
43 | # TypeScript v1 declaration files
44 | typings/
45 |
46 | # Optional npm cache directory
47 | .npm
48 |
49 | # Optional eslint cache
50 | .eslintcache
51 |
52 | # Optional REPL history
53 | .node_repl_history
54 |
55 | # Output of 'npm pack'
56 | *.tgz
57 |
58 | # Yarn Integrity file
59 | .yarn-integrity
60 |
61 | # dotenv environment variables file
62 | .env.test
63 | .env
64 |
65 | # parcel-bundler cache (https://parceljs.org/)
66 | .cache
67 |
68 | # next.js build output
69 | .next
70 |
71 | # nuxt.js build output
72 | .nuxt
73 |
74 | # vuepress build output
75 | .vuepress/dist
76 |
77 | # Serverless directories
78 | .serverless/
79 |
80 | # FuseBox cache
81 | .fusebox/
82 |
83 | # DynamoDB Local files
84 | .dynamodb/
85 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | nginx:
4 | image: nginx
5 | volumes:
6 | - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
7 | networks:
8 | - clementine
9 | depends_on:
10 | - server
11 | - client
12 | ports:
13 | - 80:80
14 |
15 | worker:
16 | build: ./server
17 | depends_on:
18 | - postgres
19 | - redis
20 | environment:
21 | DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
22 | TRACE_THRESHOLD: ${TRACE_THRESHOLD:-500000}
23 | networks:
24 | - clementine
25 |
26 | server:
27 | build: ./server
28 | depends_on:
29 | - postgres
30 | - redis
31 | environment:
32 | DOMAIN: ${DOMAIN}
33 | PORT: 3000
34 | DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
35 | SESSION_SECRET: ${SESSION_SECRET}
36 | SMTP: ${SMTP}
37 | SMTP_EMAIL_FROM: ${SMTP_EMAIL_FROM}
38 | ENGINE_API_KEY: ${ENGINE_API_KEY}
39 | IS_SSL: "0"
40 | ports:
41 | - 3000
42 | networks:
43 | - clementine
44 |
45 | client:
46 | build: ./client
47 | environment:
48 | PORT: 3000
49 | ports:
50 | - 3000
51 | networks:
52 | - clementine
53 |
54 | postgres:
55 | image: postgres:10.4
56 | ports:
57 | - 5432
58 | environment:
59 | POSTGRES_USER: ${POSTGRES_USER}
60 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
61 | POSTGRES_DB: ${POSTGRES_DB}
62 | networks:
63 | - clementine
64 |
65 | redis:
66 | image: 'bitnami/redis:latest'
67 | ports:
68 | - 6379
69 | environment:
70 | ALLOW_EMPTY_PASSWORD: "yes"
71 | networks:
72 | - clementine
73 |
74 | networks:
75 | clementine:
76 | # Use a custom driver
77 | driver: bridge
78 |
--------------------------------------------------------------------------------
/server/src/ingress/utils.test.js:
--------------------------------------------------------------------------------
1 | const { FullTracesReport } = require('apollo-engine-reporting-protobuf')
2 | const proto = require('apollo-engine-reporting-protobuf')
3 | const { extractErrors } = require('./utils')
4 |
5 | describe('extractErrors', () => {
6 | const messageJSON = require('./__data__/traces-with-error.json')
7 | const instance = proto.FullTracesReport.fromObject(messageJSON)
8 | const message = proto.FullTracesReport.toObject(instance, {
9 | enums: String, // enums as string names
10 | longs: String, // longs as strings (requires long.js)
11 | bytes: String, // bytes as base64 encoded strings
12 | defaults: true, // includes default values
13 | arrays: true, // populates empty arrays (repeated fields) even if defaults=false
14 | objects: true, // populates empty objects (map fields) even if defaults=false
15 | oneofs: true // includes virtual oneof fields set to the present field's name
16 | })
17 | test('should find 1 error', () => {
18 | const key =
19 | '# showGraph\nquery showGraph($graphId:ID!){graph(graphId:$graphId){__typename id keys{__typename id secret}name operations{__typename count duration id}}}'
20 |
21 | const root = message.tracesPerQuery[key].trace[0].root
22 | const errors = extractErrors(root)
23 |
24 | expect(errors).toEqual([
25 | {
26 | message: 'Dummy Error',
27 | location: [{ line: 5, column: 5 }],
28 | json:
29 | '{"message":"Dummy Error","locations":[{"line":5,"column":5}],"path":["graph","keys"]}',
30 | timeNs: '0'
31 | }
32 | ])
33 | })
34 |
35 | test('should find 0 error', () => {
36 | const key = '# -\n{user{__typename graphs{__typename id name}id}}'
37 |
38 | const root = message.tracesPerQuery[key].trace[0].root
39 | const errors = extractErrors(root)
40 |
41 | expect(errors).toEqual([])
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/client/src/trace/filters.css:
--------------------------------------------------------------------------------
1 | /* FILTERS */
2 | .visual-filter,
3 | .visual-filter .visual-conditions {
4 | display: flex;
5 | align-items: flex-start;
6 | justify-content: center;
7 | }
8 |
9 | .visual-filter {
10 | position: relative;
11 | }
12 |
13 |
14 | .visual-options {
15 | position: absolute;
16 | }
17 |
18 | .visual-filter .add-filter {
19 | color: var(--color-white);
20 | font-weight: bold;
21 | cursor: pointer;
22 | padding: 0.75rem;
23 | }
24 |
25 | .visual-filter input {
26 | color: var(--color-white);
27 | padding: 0.75rem;
28 | }
29 |
30 | .visual-filter input[type='submit'] {
31 | padding: 5px 10px;
32 | border-radius: 0.1px;
33 | }
34 |
35 | .visual-filter .chip {
36 | border: 1px solid var(--color-white);
37 | background: none;
38 | color: var(--color-white);
39 | margin-right: 5px;
40 | padding: 0.75rem;
41 | cursor: pointer;
42 | border-radius: 4px;
43 | }
44 |
45 | .visual-filter .visual-selector,
46 | .visual-filter .visual-value {
47 | margin-right: 15px;
48 | }
49 |
50 | .visual-filter .visual-value {
51 | height: none;
52 | }
53 |
54 | .visual-filter .visual-options {
55 | padding: 0.75rem;
56 | border: 1px solid var(--color-white);
57 | color: var(--color-white);
58 | list-style-type: none;
59 | margin: 0;
60 | z-index: 10;
61 | border-radius: var(--border-radius);
62 | min-width: 50px;
63 | }
64 |
65 | .visual-filter .visual-options li {
66 | padding: 0;
67 | }
68 |
69 | .visual-filter .visual-options li:hover {
70 | background-color: var(--color-black);
71 | color: var(--color-white);
72 | text-decoration: line-through;
73 | }
74 |
75 | .visual-filter .visual-selector.expanded {
76 | box-shadow: none;
77 | cursor: pointer;
78 | border-radius: var(--border-radius);
79 | }
80 |
81 | .visual-filter .visual-selector .visual-label {
82 | padding: 0.75rem;
83 | color: var(--color-white);
84 | cursor: pointer;
85 | font-weight: bold;
86 | }
87 | /* FILTERS */
88 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
17 |
18 |
27 | Clementine
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "clementine",
3 | "private": true,
4 | "version": "1.0.0",
5 | "description": "in-progress",
6 | "main": "config.js",
7 | "scripts": {
8 | "wait:db": "wait-on tcp:postgres:5432 && echo 'postgres is up!'",
9 | "test": "npm run wait:db && jest --watchAll --runInBand",
10 | "ci": "npm run wait:db && npm test",
11 | "format": "prettier",
12 | "start:worker": "node ./bin/worker.js",
13 | "start": "npm run migrate up && node ./bin/start.js",
14 | "dev": "npm run migrate up && nodemon ./bin/start.js",
15 | "dev:worker": "nodemon ./bin/worker.js",
16 | "migrate": "npm run wait:db && node ./bin/migrate.js",
17 | "migrate:create": "npm run wait:db && migrate create --migrations-dir='./src/migrations'"
18 | },
19 | "keywords": ["graphql"],
20 | "author": "hobochild",
21 | "license": "MIT",
22 | "dependencies": {
23 | "apollo-server-express": "^2.11.0",
24 | "bull": "^3.13.0",
25 | "connect-redis": "^4.0.4",
26 | "cors": "^2.8.5",
27 | "emailjs": "^2.2.0",
28 | "express": "^4.17.1",
29 | "express-session": "^1.17.0",
30 | "graphql": "^14.6.0",
31 | "graphql-scalars": "^1.0.9",
32 | "helmet": "^3.16.0",
33 | "loglevel": "^1.6.7",
34 | "migrate": "^1.6.2",
35 | "morgan": "^1.9.1",
36 | "node-fetch": "^2.6.0",
37 | "redis": "^3.0.2",
38 | "slonik": "^22.4.4",
39 | "uuid": "^3.3.2",
40 | "uuid-by-string": "^3.0.2",
41 | "wait-on": "^4.0.1"
42 | },
43 | "devDependencies": {
44 | "jest": "^25.1.0",
45 | "nodemon": "^2.0.2",
46 | "supertest": "^4.0.2"
47 | },
48 | "repository": {
49 | "type": "git",
50 | "url": "git+https://github.com/hobochild/clementine.git"
51 | },
52 | "bugs": {
53 | "url": "https://github.com/hobochild/clementine/issues"
54 | },
55 | "homepage": "https://github.com/hobochild/clementine#readme",
56 | "jest": {
57 | "testPathIgnorePatterns": ["utils.js", "/node_modules"],
58 | "setupFilesAfterEnv": ["./src/setupTests.js"]
59 | },
60 | "bin": {
61 | "clementine": "migrate.js"
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/server/src/persistence/keys.js:
--------------------------------------------------------------------------------
1 | const { sql } = require('slonik')
2 | const uuid = require('uuid/v4')
3 | const db = require('./db')
4 | const redis = require('./redis')
5 | const crypto = require('crypto')
6 | const EXPIRE = 216000 // 1 Day (we revoke on delete)
7 |
8 | function hash(str) {
9 | return crypto
10 | .createHash('sha256')
11 | .update(str)
12 | .digest('hex')
13 | }
14 |
15 | module.exports = {
16 | hash,
17 | async create(graphId) {
18 | const secret = uuid()
19 | const hashedSecret = hash(secret)
20 | const { rows } = await db.query(sql`
21 | INSERT INTO keys (id, hash, "graphId", prefix)
22 | VALUES (${uuid()}, ${hashedSecret}, ${graphId}, ${secret.slice(0, 4)})
23 | RETURNING id, "graphId", prefix, "createdAt";
24 | `)
25 |
26 | const [key] = rows
27 |
28 | // we return the plain text secret only on create.
29 | return {
30 | ...key,
31 | secret
32 | }
33 | },
34 | findById(keyId) {
35 | return db.maybeOne(sql`
36 | SELECT * FROM keys WHERE "id"=${keyId};
37 | `)
38 | },
39 | async revoke(keyId) {
40 | const key = await this.findById(keyId)
41 | await db.maybeOne(sql`
42 | DELETE FROM keys WHERE "id"=${keyId};
43 | `)
44 |
45 | return redis.del(`key:${key.hash}`)
46 | },
47 | async findAll({ graphId }) {
48 | const { rows } = await db.query(sql`
49 | SELECT * FROM keys WHERE "graphId"=${graphId};
50 | `)
51 |
52 | return rows
53 | },
54 | async verify(secret, graphId) {
55 | const hashedSecret = hash(secret)
56 | const cachedKey = await redis.get(`key:${hashedSecret}`)
57 | if (cachedKey) {
58 | return true
59 | }
60 |
61 | const key = await db.maybeOne(sql`
62 | SELECT * FROM keys WHERE "graphId"=${graphId} and hash=${hashedSecret};
63 | `)
64 |
65 | if (key) {
66 | await redis.set(
67 | `key:${hashedSecret}`,
68 | JSON.stringify({ validated: true }),
69 | 'EX',
70 | EXPIRE
71 | )
72 |
73 | return true
74 | }
75 |
76 | return false
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/server/src/ingress/index.js:
--------------------------------------------------------------------------------
1 | const { Router } = require('express')
2 | const bodyParser = require('body-parser')
3 | const proto = require('apollo-engine-reporting-protobuf')
4 | const { Trace, Key } = require('../persistence')
5 | const { prepareTraces } = require('./utils')
6 | const { ingestQueue, forwardQueue } = require('./queue')
7 |
8 | const router = Router()
9 | // https://www.apollographql.com/docs/graph-manager/setup-analytics/#sending-metrics-to-the-reporting-endpoint
10 | // Some more cases to cover
11 | router.post(
12 | '/ingress/traces',
13 | bodyParser.raw({
14 | type: req => {
15 | return true
16 | }
17 | }),
18 | async (req, res) => {
19 | const apiKey = req.get('x-api-key')
20 | if (!apiKey) {
21 | return res.status(403).send('FORBIDDEN: Missing apiKey')
22 | }
23 |
24 | // verifyKey
25 | const [clementineApiKey, apolloApiKey] = apiKey.split('?')
26 | const [graphId, key] = clementineApiKey.split(':')
27 |
28 | const isVerified = await Key.verify(key, graphId)
29 |
30 | if (!isVerified) {
31 | return res.status(403).send('FORBIDDEN: Invalid apiKey')
32 | }
33 |
34 | const instance = proto.FullTracesReport.decode(req.body)
35 | const report = proto.FullTracesReport.toObject(instance, {
36 | enums: String, // enums as string names
37 | longs: String, // longs as strings (requires long.js)
38 | bytes: String, // bytes as base65 encoded strings
39 | defaults: true, // includes default values
40 | arrays: true, // populates empty arrays (repeated fields) even if defaults=false
41 | objects: true, // populates empty objects (map fields) even if defaults=false
42 | oneofs: true // includes virtual oneof fields set to the present field's name
43 | })
44 |
45 | const { id } = await ingestQueue.add({
46 | ...report,
47 | graphId,
48 | apolloApiKey
49 | })
50 |
51 | if (apolloApiKey) {
52 | await forwardQueue.add({
53 | report,
54 | apolloApiKey
55 | })
56 | }
57 |
58 | res.status(201).send({ id })
59 | }
60 | )
61 |
62 | module.exports = router
63 |
--------------------------------------------------------------------------------
/client/src/trace/filters.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner } from '../utils'
5 | import VisualFilter from 'react-visual-filter'
6 | import styles from './filters.module.css'
7 | import FiltersContext from './filtersContext'
8 | import './filters.css'
9 |
10 | const TRACE_FILTER_OPTIONS = gql`
11 | query traceFilterOptions($graphId: ID!) {
12 | traceFilterOptions(graphId: $graphId) {
13 | clientName
14 | clientVersion
15 | schemaTag
16 | hasErrors
17 | }
18 | }
19 | `
20 |
21 | export default function TraceFilters({ graphId, isVisible }) {
22 | const { rawFilters: conditions, setFilters } = useContext(FiltersContext)
23 |
24 | const { loading, error, data } = useQuery(TRACE_FILTER_OPTIONS, {
25 | variables: {
26 | graphId
27 | }
28 | })
29 |
30 | if (loading) return
31 | if (error) return
32 |
33 | // Todo we should just have an arbitrary way to select to - from.
34 | const fields = Object.entries({
35 | ...data.traceFilterOptions,
36 | interval: ['hour', 'day', 'month']
37 | })
38 | .filter(([k, v]) => {
39 | if (k === '__typename') {
40 | return false
41 | }
42 | return true
43 | })
44 | .map(([k, v]) => {
45 | let operators = ['eq', 'ne']
46 | if (k === 'interval') {
47 | operators = ['eq']
48 | }
49 |
50 | return {
51 | label: k,
52 | name: k,
53 | type: 'list',
54 | operators: operators,
55 | list: v
56 | ? v
57 | .filter(v => !!v)
58 | .map(v => ({
59 | name: v,
60 | label: v
61 | }))
62 | : []
63 | }
64 | })
65 |
66 | if (!isVisible) {
67 | return
68 | }
69 |
70 | return (
71 |
72 |
73 | {
77 | setFilters(data)
78 | }}
79 | />
80 |
81 |
82 | )
83 | }
84 |
--------------------------------------------------------------------------------
/server/src/ingress/__data__/traces.json:
--------------------------------------------------------------------------------
1 | {
2 | "header": {
3 | "hostname": "c69adee1be7e",
4 | "agentVersion": "apollo-engine-reporting@1.7.0",
5 | "runtimeVersion": "node v10.19.0",
6 | "uname": "linux, Linux, 5.3.0-42-generic, x64)",
7 | "schemaTag": "development",
8 | "schemaHash": "2c43572e296e959ee9f220972942bead8c1aa26129eb33edcd31394ad4662598174421b6d7a7235c7fded3ea6a682599c17a42ca11e79e7223403b0572d06345"
9 | },
10 | "tracesPerQuery": {
11 | "# GET_USER\nquery GET_USER{user{email id}}": {
12 | "trace": [
13 | {
14 | "endTime": { "seconds": "1585312277", "nanos": 258000000 },
15 | "startTime": { "seconds": "1585312277", "nanos": 189000000 },
16 | "clientName": "",
17 | "clientVersion": "",
18 | "http": { "method": "POST" },
19 | "durationNs": "67729237",
20 | "root": {
21 | "child": [
22 | {
23 | "responseName": "user",
24 | "type": "User",
25 | "startTime": "29032827",
26 | "endTime": "65005807",
27 | "parentType": "Query"
28 | }
29 | ]
30 | },
31 | "fullQueryCacheHit": false,
32 | "clientReferenceId": "",
33 | "registeredOperation": false,
34 | "forbiddenOperation": false
35 | },
36 | {
37 | "endTime": { "seconds": "1585312278", "nanos": 50000000 },
38 | "startTime": { "seconds": "1585312278", "nanos": 45000000 },
39 | "clientName": "",
40 | "clientVersion": "",
41 | "http": { "method": "POST" },
42 | "durationNs": "4679795",
43 | "root": {
44 | "child": [
45 | {
46 | "responseName": "user",
47 | "type": "User",
48 | "startTime": "1037483",
49 | "endTime": "4302402",
50 | "parentType": "Query"
51 | }
52 | ]
53 | },
54 | "fullQueryCacheHit": false,
55 | "clientReferenceId": "",
56 | "registeredOperation": false,
57 | "forbiddenOperation": false
58 | }
59 | ]
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/client/src/trace/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner, NotFound } from '../utils'
5 | import TracingReponse from './TracingReponse'
6 | import Filters from './filters'
7 | import FiltersContext, { FiltersProvider } from './filtersContext'
8 | import Source from './source'
9 | import TraceList from './list'
10 | import Details from './details'
11 | import { printDuration } from '../utils'
12 | import styles from './index.module.css'
13 | import { getOperationName } from '../operation/utils'
14 | import { printDate } from '../utils'
15 |
16 | const TRACE_SHOW = gql`
17 | query trace($traceId: ID!) {
18 | trace(traceId: $traceId) {
19 | id
20 | key
21 | duration
22 | startTime
23 | endTime
24 | root
25 | details
26 | }
27 | }
28 | `
29 |
30 | export function TraceShow({ traceId }) {
31 | const { loading, error, data } = useQuery(TRACE_SHOW, {
32 | variables: {
33 | traceId
34 | }
35 | })
36 |
37 | if (loading) return
38 | if (error) return
39 |
40 | const { trace } = data
41 |
42 | if (!trace) {
43 | return
44 | }
45 |
46 | return (
47 |
48 |
49 |
50 |
51 | {printDuration(trace.duration)}
52 |
53 |
Duration
54 |
55 |
{trace.id}
56 |
{getOperationName(gql(trace.key))}
57 |
58 | {printDate(new Date(trace.startTime))}
59 |
60 |
61 |
62 | View Query
63 | {trace.key}
64 | {trace.details}
65 |
66 |
71 |
72 | )
73 | }
74 |
75 | export { Filters, FiltersProvider, FiltersContext, TraceList }
76 |
--------------------------------------------------------------------------------
/client/src/trace/filtersContext.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import logger from 'loglevel'
3 |
4 | const FiltersContext = React.createContext()
5 |
6 | class FiltersProvider extends Component {
7 | // Context state
8 | state = {
9 | filters: []
10 | }
11 |
12 | backUp = () => {
13 | localStorage.setItem('__filters__', JSON.stringify(this.state))
14 | }
15 |
16 | processInterval = filters => {
17 | const filter = filters.find(f => f.field === 'interval')
18 |
19 | let value
20 | if (filter) {
21 | value = filter.value
22 | }
23 |
24 | let from = 0
25 | const to = Date.now()
26 | if (value === 'hour') {
27 | from = to - 1000 * 60 * 60
28 | }
29 |
30 | if (value === 'day') {
31 | from = to - 1000 * 60 * 60 * 24
32 | }
33 |
34 | if (value === 'month') {
35 | from = to - 1000 * 60 * 60 * 24 * 30
36 | }
37 |
38 | return { to, from }
39 | }
40 |
41 | // Method to update state
42 | setFilters = filters => {
43 | this.setState(
44 | {
45 | filters
46 | },
47 | this.backUp
48 | )
49 | }
50 |
51 | normalizeFilters = filters => {
52 | return filters.map(({ field, value, operator }) => ({
53 | field,
54 | value,
55 | operator
56 | }))
57 | }
58 |
59 | componentDidMount = () => {
60 | try {
61 | const newState = localStorage.getItem('__filters__')
62 | const hydratedState = JSON.parse(newState)
63 | this.setState(hydratedState)
64 | } catch (e) {
65 | logger.warn('Could not parse saved filters')
66 | }
67 | }
68 |
69 | render() {
70 | const { children } = this.props
71 | const { filters } = this.state
72 | const { setFilters } = this
73 | const { to, from } = this.processInterval(filters)
74 |
75 | return (
76 | x.field !== 'interval'
80 | ),
81 | rawFilters: filters,
82 | to,
83 | from,
84 | setFilters
85 | }}
86 | >
87 | {children}
88 |
89 | )
90 | }
91 | }
92 |
93 | export { FiltersProvider }
94 |
95 | export default FiltersContext
96 |
--------------------------------------------------------------------------------
/server/src/graphql/__tests__/user.test.js:
--------------------------------------------------------------------------------
1 | const { app } = require('../../index')
2 | const { User, Graph, Trace } = require('../../persistence')
3 | const supertest = require('supertest')
4 | const { generateTraces, generateToken, login, raiseGqlErr } = require('./utils')
5 |
6 | describe('userCreate', () => {
7 | test('can create user', async () => {
8 | const request = supertest.agent(app)
9 | const email = 'xyz@xyz.com'
10 | const query = `
11 | mutation ul {
12 | userLogin(email: "${email}")
13 | }
14 | `
15 |
16 | await request
17 | .post('/api/graphql')
18 | .send({ query })
19 | .set('Content-Type', 'application/json')
20 | .set('Accept', 'application/json')
21 | .then(raiseGqlErr)
22 |
23 | const user = await User.find(email)
24 | expect(user).toHaveProperty('email', email)
25 | })
26 | })
27 |
28 | describe('Session', () => {
29 | test('basics session', async () => {
30 | const request = supertest.agent(app)
31 | const email = 'xyz@xyz.com'
32 |
33 | const user = await User.create(email)
34 | expect(user).toHaveProperty('isVerified', false)
35 |
36 | const userQuery = `
37 | query testUser {
38 | user {
39 | id
40 | email
41 | createdAt
42 | isVerified
43 | }
44 | }
45 | `
46 |
47 | await request
48 | .post('/api/graphql')
49 | .send({ query: userQuery })
50 | .set('Content-Type', 'application/json')
51 | .set('Accept', 'application/json')
52 | .then(raiseGqlErr)
53 | .then(res => {
54 | const body = res.body
55 | expect(res.body.data.user).toBeNull()
56 | })
57 |
58 | const token = await generateToken(user)
59 | await login(request, token)
60 |
61 | await request
62 | .post('/api/graphql')
63 | .send({ query: userQuery })
64 | .set('Content-Type', 'application/json')
65 | .set('Accept', 'application/json')
66 | .then(raiseGqlErr)
67 | .then(res => {
68 | const body = res.body
69 | const u = res.body.data.user
70 | expect(u).toHaveProperty('email', email)
71 | expect(u).toHaveProperty('id', user.id)
72 | expect(u).toHaveProperty('createdAt')
73 | expect(u).toHaveProperty('isVerified', true)
74 | })
75 | })
76 | })
77 |
--------------------------------------------------------------------------------
/server/src/migrations/1585217960415-traces.js:
--------------------------------------------------------------------------------
1 | 'use strict'
2 |
3 | const { db, sql } = require('../persistence')
4 |
5 | module.exports.up = async function(next) {
6 | await db.query(sql`
7 | CREATE TABLE IF NOT EXISTS traces (
8 | id uuid PRIMARY KEY,
9 | "graphId" uuid REFERENCES graphs (id) ON DELETE CASCADE NOT NULL,
10 | key text NOT NULL,
11 | "operationId" uuid NOT NULL,
12 | duration float NOT NULL,
13 | "startTime" timestamp with time zone NOT NULL,
14 | "endTime" timestamp with time zone NOT NULL,
15 | root jsonb NOT NULL,
16 | "clientName" text,
17 | "clientVersion" text,
18 | "schemaTag" text,
19 | "details" jsonb,
20 | "createdAt" timestamp with time zone default (now() at time zone 'utc') NOT NULL,
21 | "hasErrors" boolean NOT NULL
22 | );
23 |
24 | CREATE INDEX IF NOT EXISTS "traceOperation" on traces ("operationId");
25 | CREATE INDEX IF NOT EXISTS "traceGraph" on traces ("graphId");
26 | CREATE INDEX IF NOT EXISTS "traceStartTime" on traces ("startTime" DESC);
27 | CREATE INDEX IF NOT EXISTS "traceDuration" on traces ("duration");
28 | CREATE INDEX IF NOT EXISTS "tracesClientName" on traces ("clientName");
29 | CREATE INDEX IF NOT EXISTS "tracesClientVersion" on traces ("clientVersion");
30 | CREATE INDEX IF NOT EXISTS "tracesSchemaTag" on traces ("schemaTag");
31 |
32 | CREATE OR REPLACE FUNCTION date_round(base_date timestamptz, round_interval interval)
33 | RETURNS timestamptz AS $BODY$
34 | SELECT '1970-01-01'::timestamptz
35 | + (EXTRACT(epoch FROM $1)::integer + EXTRACT(epoch FROM $2)::integer / 2)
36 | / EXTRACT(epoch FROM $2)::integer
37 | * EXTRACT(epoch FROM $2)::integer * interval '1 second';
38 | $BODY$ LANGUAGE SQL STABLE;
39 |
40 | `)
41 |
42 | next()
43 | }
44 |
45 | module.exports.down = async function(next) {
46 | await db.query(sql`
47 | DROP TABLE traces;
48 | DROP INDEX IF EXISTS "traceOperation";
49 | DROP INDEX IF EXISTS "traceGraph";
50 | DROP INDEX IF EXISTS "traceStartTime";
51 | DROP INDEX IF EXISTS "traceDuration";
52 | DROP INDEX IF EXISTS "tracesClientName";
53 | DROP INDEX IF EXISTS "tracesClientVersion";
54 | DROP INDEX IF EXISTS "tracesSchemaTag";
55 | DROP FUNCTION IF EXISTS date_round(timestamptz, interval);
56 | `)
57 |
58 | next()
59 | }
60 |
--------------------------------------------------------------------------------
/server/src/ingress/consumer.js:
--------------------------------------------------------------------------------
1 | const proto = require('apollo-engine-reporting-protobuf')
2 | const { Trace } = require('../persistence')
3 | const { prepareTraces } = require('./utils')
4 | const logger = require('loglevel')
5 | const { redis } = require('../persistence')
6 | const zlib = require('zlib')
7 | const promisify = require('util').promisify
8 | const gzip = promisify(zlib.gzip)
9 |
10 | const HOUR = 60 * 60 * 1000
11 | const TRACE_THRESHOLD = process.env.TRACE_THRESHOLD || 1
12 | const CULL_KEY = 'lastCull'
13 |
14 | function ingest(cullQueue) {
15 | return async job => {
16 | logger.info('ingest job starting', job.id)
17 | try {
18 | const traces = prepareTraces(job.data)
19 | const graphId = job.data.graphId
20 | const rowIds = await Trace.create(graphId, traces)
21 | const lastCull = await redis.get(CULL_KEY)
22 |
23 | if (!lastCull || new Date() - new Date(lastCull) > HOUR) {
24 | // here we check that they are not over the trace threshold
25 | try {
26 | await cullQueue.add({ threshold: TRACE_THRESHOLD })
27 | } catch (e) {
28 | logger.error(e)
29 | }
30 | }
31 |
32 | logger.info('ingest job complete', job.id)
33 |
34 | return rowIds
35 | } catch (err) {
36 | logger.error(err)
37 | throw err
38 | }
39 | }
40 | }
41 |
42 | async function cull(job) {
43 | logger.info('Cull job starting', job.id)
44 | const { threshold } = job.data
45 | const traces = await Trace.cull(threshold)
46 | await redis.set(CULL_KEY, new Date().toUTCString())
47 | logger.info('Cull job complete', job.id)
48 | return
49 | }
50 |
51 | function forward(fetch) {
52 | return async job => {
53 | // forwards traces to apollo
54 | const { apolloApiKey, report } = job.data
55 | const buffer = proto.FullTracesReport.encode(report).finish()
56 | const compressed = await gzip(buffer)
57 |
58 | const res = await fetch(
59 | 'https://engine-report.apollodata.com/api/ingress/traces',
60 | {
61 | method: 'POST',
62 | headers: {
63 | 'user-agent': 'apollo-engine-reporting',
64 | 'x-api-key': apolloApiKey,
65 | 'content-encoding': 'gzip'
66 | },
67 | body: compressed
68 | }
69 | )
70 |
71 | logger.info('Forward job complete', job.id)
72 | return compressed
73 | }
74 | }
75 |
76 | module.exports = {
77 | ingest,
78 | cull,
79 | forward
80 | }
81 |
--------------------------------------------------------------------------------
/client/src/operation/index.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner } from '../utils'
5 | import { Link } from 'react-router-dom'
6 | import Stats from '../stats'
7 | import { FiltersContext } from '../trace'
8 | import Nav from '../nav'
9 | import OperationList from './list'
10 | import { getOperationName } from './utils'
11 | import { Filters } from '../trace'
12 |
13 | const OPERATION_HEADER = gql`
14 | query operationHeader(
15 | $graphId: ID!
16 | $operationId: ID!
17 | $to: DateTime
18 | $from: DateTime
19 | $traceFilters: [TraceFilter]
20 | ) {
21 | operation(
22 | graphId: $graphId
23 | operationId: $operationId
24 | traceFilters: $traceFilters
25 | to: $to
26 | from: $from
27 | ) {
28 | id
29 | key
30 | stats {
31 | count
32 | errorCount
33 | errorPercent
34 | duration
35 | }
36 | }
37 | }
38 | `
39 |
40 | export function OperationHeader({ graphId, operationId, stats }) {
41 | const { filters, to, from } = useContext(FiltersContext)
42 | const { loading, error, data } = useQuery(OPERATION_HEADER, {
43 | variables: {
44 | graphId,
45 | operationId,
46 | to,
47 | from,
48 | traceFilters: filters
49 | }
50 | })
51 |
52 | if (loading) return
53 | if (error) return
54 |
55 | const items = [
56 | {
57 | title: 'Traces',
58 | to: `/graph/${graphId}/operation/${operationId}/trace`
59 | },
60 | {
61 | title: 'Requests over time',
62 | to: `/graph/${graphId}/operation/${operationId}/rpm`
63 | },
64 | {
65 | title: 'Latency Distribution',
66 | to: `/graph/${graphId}/operation/${operationId}/ld`
67 | }
68 | ]
69 |
70 | let name
71 | if (data.operation) {
72 | const doc = gql`
73 | ${data.operation.key}
74 | `
75 | name = getOperationName(doc)
76 | }
77 |
78 | const operationStats = data.operation ? data.operation.stats : {}
79 |
80 | return (
81 |
82 |
83 |
84 | {name ? name : operationId}
85 |
86 |
87 |
88 |
89 |
90 |
91 | )
92 | }
93 |
94 | export { OperationList }
95 |
--------------------------------------------------------------------------------
/client/src/timeline/rpm.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner } from '../utils'
5 | import Chart from './chart'
6 | import { CrossHair, XAxis, YAxis, LineSeries } from '@data-ui/xy-chart'
7 | import { FiltersContext } from '../trace'
8 |
9 | const TRACE_LIST = gql`
10 | query RPM(
11 | $graphId: ID!
12 | $operationId: ID
13 | $to: DateTime
14 | $from: DateTime
15 | $traceFilters: [TraceFilter]
16 | ) {
17 | rpm(
18 | graphId: $graphId
19 | operationId: $operationId
20 | to: $to
21 | from: $from
22 | traceFilters: $traceFilters
23 | ) {
24 | nodes {
25 | startTime
26 | count
27 | errorCount
28 | }
29 | cursor
30 | }
31 | }
32 | `
33 |
34 | export function renderTooltip({ datum, seriesKey, color, data }) {
35 | const { x, y } = datum
36 |
37 | return (
38 |
39 | {seriesKey && (
40 |
41 | {seriesKey}
42 |
43 | )}
44 |
45 | Time
46 | {new Date(x).toString()}
47 |
48 |
49 | Requests
50 | {y}
51 |
52 |
53 | )
54 | }
55 |
56 | export default function TimeLine({ graphId, operationId }) {
57 | const { filters, to, from } = useContext(FiltersContext)
58 | const { loading, error, data } = useQuery(TRACE_LIST, {
59 | variables: {
60 | graphId,
61 | operationId,
62 | to,
63 | from,
64 | traceFilters: filters
65 | }
66 | })
67 |
68 | if (loading) return
69 | if (error) return
70 |
71 | const dataCount = data.rpm.nodes.map(d => ({
72 | x: Date.parse(d.startTime),
73 | y: d.count
74 | }))
75 |
76 | const dataErrorCount = data.rpm.nodes.map(d => ({
77 | x: Date.parse(d.startTime),
78 | y: d.errorCount
79 | }))
80 |
81 | return (
82 |
83 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | )
97 | }
98 |
--------------------------------------------------------------------------------
/server/src/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express')
2 | const morgan = require('morgan')
3 | const helmet = require('helmet')
4 | const { typeDefs, resolvers } = require('./graphql')
5 | const { ApolloServer } = require('apollo-server-express')
6 | const { SESSION_SECRET, CLIENT_URL } = require('./config')
7 | const cors = require('cors')
8 | const session = require('express-session')
9 | const RedisStore = require('connect-redis')(session)
10 | const {
11 | redis: { client }
12 | } = require('./persistence')
13 | const logger = require('loglevel')
14 | const magicLink = require('./magicLink')
15 | const { User } = require('./persistence')
16 | const ingress = require('./ingress')
17 |
18 | const app = express()
19 | const api = express.Router()
20 |
21 | app.set('trust proxy', 'loopback')
22 |
23 | app.use(
24 | session({
25 | store: new RedisStore({ client }),
26 | secret: SESSION_SECRET,
27 | saveUninitialized: false,
28 | rolling: true,
29 | resave: false
30 | })
31 | )
32 | app.use(morgan('short'))
33 | app.use(helmet())
34 | app.use('/api', api)
35 |
36 | api.use('/', ingress)
37 | api.get('/health', (req, res) => res.sendStatus(200))
38 | api.get('/verify', async (req, res) => {
39 | const token = req.query.token
40 | if (!token) {
41 | res.redirect(302, '/login')
42 | }
43 |
44 | const data = await magicLink.verify(token)
45 |
46 | if (data) {
47 | req.session.userId = data.id
48 | req.session.userEmail = data.email
49 | }
50 |
51 | User.markVerified(data.id)
52 | res.redirect(302, '/graph')
53 | })
54 |
55 | const gql = new ApolloServer({
56 | typeDefs,
57 | resolvers,
58 | playground: true,
59 | introspection: true,
60 | engine: {
61 | logger,
62 | endpointUrl: 'http://server:3000',
63 | apiKey: process.env.ENGINE_API_KEY,
64 | debugPrintReports: true,
65 | schemaTag: 'dev',
66 | sendHeaders: { all: true },
67 | sendVariableValues: { all: true },
68 | reportErrorFunction: err => {
69 | logger.error(err)
70 | return err
71 | }
72 | },
73 | formatError: err => {
74 | logger.error(err)
75 | if (err.extensions.exception) {
76 | logger.error(err.extensions.exception)
77 | }
78 | return err
79 | },
80 | context: async ({ req, res }) => {
81 | return {
82 | magicLink,
83 | req,
84 | res
85 | }
86 | }
87 | })
88 |
89 | gql.applyMiddleware({
90 | app,
91 | path: '/api/graphql',
92 | cors: {
93 | origin: CLIENT_URL,
94 | credentials: true
95 | }
96 | })
97 |
98 | module.exports = {
99 | app,
100 | magicLink
101 | }
102 |
--------------------------------------------------------------------------------
/client/src/user.js:
--------------------------------------------------------------------------------
1 | import React, { Component, useContext } from 'react'
2 | import { gql } from 'apollo-boost'
3 | import { Redirect } from 'react-router-dom'
4 | import client from './client'
5 | import logger from 'loglevel'
6 | import { Loading, ErrorBanner } from './utils'
7 |
8 | const GET_USER = gql`
9 | {
10 | user {
11 | id
12 | email
13 | createdAt
14 | }
15 | }
16 | `
17 |
18 | const VERIFY_TOKEN = gql`
19 | mutation tokenVerify($token: String) {
20 | tokenVerify(token: $token) {
21 | id
22 | email
23 | createdAt
24 | }
25 | }
26 | `
27 |
28 | const UserContext = React.createContext()
29 |
30 | class UserProvider extends Component {
31 | state = {
32 | user: {},
33 | loading: true,
34 | error: null
35 | }
36 |
37 | setUser = user => {
38 | this.setState(prevState => ({ user }))
39 | }
40 |
41 | componentDidMount = async () => {
42 | const url = new URL(window.location.href)
43 | const token = url.searchParams.get('token')
44 |
45 | if (token) {
46 | try {
47 | const {
48 | data: { tokenVerify: user }
49 | } = await client.mutate({
50 | mutation: VERIFY_TOKEN,
51 | variables: { token }
52 | })
53 |
54 | this.setUser(user)
55 | this.setState({ loading: false })
56 | } catch (e) {
57 | logger.error(e)
58 | logger.warn('could find current user')
59 | this.setState({ loading: false, error: e })
60 | }
61 | }
62 |
63 | try {
64 | const {
65 | data: { user }
66 | } = await client.query({ query: GET_USER })
67 | this.setUser(user)
68 | this.setState({ loading: false })
69 | } catch (e) {
70 | logger.warn('could find current user')
71 | this.setState({ loading: false, error: e })
72 | }
73 | }
74 |
75 | render() {
76 | const { children } = this.props
77 | const { user, loading } = this.state
78 | const { setUser } = this
79 |
80 | return (
81 |
88 | {children}
89 |
90 | )
91 | }
92 | }
93 |
94 | export { UserProvider }
95 |
96 | export default UserContext
97 |
98 | export function UserRedirect({ children }) {
99 | const { user, loading, error } = useContext(UserContext)
100 |
101 | if (loading) {
102 | return
103 | }
104 |
105 | if (error) {
106 | return
107 | }
108 |
109 | if (!user) {
110 | return
111 | }
112 |
113 | return children
114 | }
115 |
--------------------------------------------------------------------------------
/client/src/timeline/latencyDistribution.js:
--------------------------------------------------------------------------------
1 | import React, { useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner } from '../utils'
5 | import Chart from './chart'
6 | import { CrossHair, XAxis, YAxis, BarSeries } from '@data-ui/xy-chart'
7 | import { FiltersContext } from '../trace'
8 |
9 | const LATENCY_DISTRIBUTION = gql`
10 | query latencyDistribution(
11 | $graphId: ID!
12 | $operationId: ID
13 | $traceFilters: [TraceFilter]
14 | $to: DateTime
15 | $from: DateTime
16 | ) {
17 | latencyDistribution(
18 | graphId: $graphId
19 | operationId: $operationId
20 | traceFilters: $traceFilters
21 | to: $to
22 | from: $from
23 | ) {
24 | nodes {
25 | duration
26 | count
27 | }
28 | cursor
29 | }
30 | }
31 | `
32 |
33 | export function renderTooltip({ datum, seriesKey, color, data }) {
34 | const { x, y } = datum
35 |
36 | return (
37 |
38 | {seriesKey && (
39 |
40 | {seriesKey}
41 |
42 | )}
43 |
44 | Duration
45 | {x.toFixed(2)}
46 |
47 |
48 | Requests
49 | {y}
50 |
51 |
52 | )
53 | }
54 |
55 | export default function TimeLine({ graphId, operationId }) {
56 | const { filters, to, from } = useContext(FiltersContext)
57 | const { loading, error, data } = useQuery(LATENCY_DISTRIBUTION, {
58 | variables: {
59 | graphId,
60 | operationId,
61 | traceFilters: filters,
62 | to,
63 | from
64 | }
65 | })
66 |
67 | if (loading) return
68 | if (error) return
69 |
70 | const dataCount = data.latencyDistribution.nodes.map(d => ({
71 | x: d.duration / 1000 / 1000,
72 | y: d.count
73 | }))
74 |
75 | return (
76 |
77 |
84 | {
86 | if (index % 2 === 0) {
87 | return tick.toFixed(2)
88 | }
89 | return null
90 | }}
91 | label="Duration"
92 | />
93 |
94 |
95 |
96 |
97 |
98 | )
99 | }
100 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
2 |
3 | ## Available Scripts
4 |
5 | In the project directory, you can run:
6 |
7 | ### `yarn start`
8 |
9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
11 |
12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console.
14 |
15 | ### `yarn test`
16 |
17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
19 |
20 | ### `yarn build`
21 |
22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance.
24 |
25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed!
27 |
28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
29 |
30 | ### `yarn eject`
31 |
32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!**
33 |
34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
35 |
36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
37 |
38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
39 |
40 | ## Learn More
41 |
42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
43 |
44 | To learn React, check out the [React documentation](https://reactjs.org/).
45 |
46 | ### Code Splitting
47 |
48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
49 |
50 | ### Analyzing the Bundle Size
51 |
52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
53 |
54 | ### Making a Progressive Web App
55 |
56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
57 |
58 | ### Advanced Configuration
59 |
60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
61 |
62 | ### Deployment
63 |
64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
65 |
66 | ### `yarn build` fails to minify
67 |
68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
69 |
--------------------------------------------------------------------------------
/client/src/key/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useMutation } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { GRAPH_SETTINGS } from '../graph'
5 | import { cloneDeep } from 'lodash'
6 | import styles from './index.module.css'
7 | import { printDate } from '../utils'
8 |
9 | const QUERY = gql`
10 | mutation createApiKey($graphId: ID!) {
11 | keyCreate(graphId: $graphId) {
12 | id
13 | secret
14 | prefix
15 | createdAt
16 | }
17 | }
18 | `
19 |
20 | const REVOKE_QUERY = gql`
21 | mutation revokeApiKey($keyId: ID!) {
22 | keyRevoke(keyId: $keyId)
23 | }
24 | `
25 |
26 | export function KeyCreate({ graphId }) {
27 | const [createKey] = useMutation(QUERY)
28 |
29 | return (
30 | {
32 | await createKey({
33 | variables: { graphId },
34 | update: (cache, { data: { keyCreate } }) => {
35 | const prevData = cache.readQuery({
36 | query: GRAPH_SETTINGS,
37 | variables: { graphId }
38 | })
39 |
40 | // cloneDeep is necessary for the cache to pickup the change
41 | // and have the observable components rerender
42 | const data = cloneDeep(prevData)
43 | data.graph.keys.push(keyCreate)
44 |
45 | cache.writeQuery({
46 | query: GRAPH_SETTINGS,
47 | variables: { graphId },
48 | data
49 | })
50 | }
51 | })
52 | }}
53 | >
54 | Create API Key
55 |
56 | )
57 | }
58 |
59 | export function KeyRevoke({ graphId, keyId }) {
60 | const [revokeKey] = useMutation(REVOKE_QUERY)
61 |
62 | return (
63 | {
65 | await revokeKey({
66 | variables: { keyId },
67 | update: (cache, { data: { keyRevoke } }) => {
68 | const prevData = cache.readQuery({
69 | query: GRAPH_SETTINGS,
70 | variables: { graphId }
71 | })
72 |
73 | const data = cloneDeep(prevData)
74 | data.graph.keys = data.graph.keys.filter(key => {
75 | return key.id !== keyId
76 | })
77 |
78 | cache.writeQuery({
79 | query: GRAPH_SETTINGS,
80 | variables: { graphId },
81 | data
82 | })
83 | }
84 | })
85 | }}
86 | >
87 | revoke
88 |
89 | )
90 | }
91 |
92 | export function KeyList({ keys, graphId }) {
93 | return (
94 |
116 | )
117 | }
118 |
--------------------------------------------------------------------------------
/server/src/ingress/index.test.js:
--------------------------------------------------------------------------------
1 | const { app } = require('../index')
2 | const proto = require('apollo-engine-reporting-protobuf')
3 | const zlib = require('zlib')
4 | const promisify = require('util').promisify
5 | const gzip = promisify(zlib.gzip)
6 | const { Trace, Graph, User, sql, Key, db } = require('../persistence')
7 | const { forwardQueue } = require('./queue')
8 | const supertest = require('supertest')
9 |
10 | function formatProto(path) {
11 | const messageJSON = require(path)
12 | const message = proto.FullTracesReport.fromObject(messageJSON)
13 | const buffer = proto.FullTracesReport.encode(message).finish()
14 | return gzip(buffer)
15 | }
16 |
17 | describe('/api/ingress', () => {
18 | test('No ApiKey', async () => {
19 | const compressed = await formatProto('./__data__/traces.json')
20 | const request = supertest.agent(app)
21 |
22 | // TODO create graph
23 | await request
24 | .post('/api/ingress/traces')
25 | .set('content-encoding', 'gzip')
26 | .send(compressed)
27 | .expect(403)
28 | })
29 |
30 | test('Invalid ApiKey', async () => {
31 | const compressed = await formatProto('./__data__/traces.json')
32 | const request = supertest.agent(app)
33 | const user = await User.create('email@email.com')
34 | const graph = await Graph.create('myGraph', user.id)
35 |
36 | // TODO create graph
37 | await request
38 | .post('/api/ingress/traces')
39 | .set('content-encoding', 'gzip')
40 | .set('x-api-key', graph.id + ':123')
41 | .send(compressed)
42 | .expect(403)
43 | })
44 |
45 | test('uncompressed', async () => {
46 | const request = supertest.agent(app)
47 | const messageJSON = require('./__data__/traces.json')
48 | const message = proto.FullTracesReport.fromObject(messageJSON)
49 | const buffer = proto.FullTracesReport.encode(message).finish()
50 |
51 | const user = await User.create('email@email.com')
52 | const graph = await Graph.create('myGraph', user.id)
53 | const key = await Key.create(graph.id)
54 |
55 | // TODO create graph
56 | await request
57 | .post('/api/ingress/traces')
58 | .set('x-api-key', graph.id + ':' + key.secret)
59 | .send(buffer)
60 | .expect(201)
61 | })
62 |
63 | test('Happy path', async () => {
64 | const compressed = await formatProto('./__data__/traces.json')
65 | const request = supertest.agent(app)
66 |
67 | const user = await User.create('email@email.com')
68 | const graph = await Graph.create('myGraph', user.id)
69 | const key = await Key.create(graph.id)
70 |
71 | const res = await request
72 | .post('/api/ingress/traces')
73 | .set('content-encoding', 'gzip')
74 | .set('x-api-key', graph.id + ':' + key.secret)
75 | .send(compressed)
76 | .expect(201)
77 |
78 | expect(res.body.id).toBeDefined()
79 | })
80 |
81 | test('Forward to Apollo Engine', async () => {
82 | const compressed = await formatProto('./__data__/traces.json')
83 | const request = supertest.agent(app)
84 |
85 | const user = await User.create('email@email.com')
86 | const graph = await Graph.create('myGraph', user.id)
87 | const key = await Key.create(graph.id)
88 |
89 | const apolloApiKey = '123:xxxx'
90 |
91 | const res = await request
92 | .post('/api/ingress/traces')
93 | .set('content-encoding', 'gzip')
94 | .set('x-api-key', graph.id + ':' + key.secret + '?' + apolloApiKey)
95 | .send(compressed)
96 | .expect(201)
97 |
98 | expect(res.body.id).toBeDefined()
99 | const jobs = await forwardQueue.getJobs()
100 | expect(jobs.length).toBe(1)
101 | })
102 | })
103 |
--------------------------------------------------------------------------------
/client/src/trace/list.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner, NotFound } from '../utils'
5 | import { useHistory } from 'react-router-dom'
6 | import FiltersContext from './filtersContext'
7 | import Chart from '../timeline/chart'
8 | import { CrossHair, XAxis, YAxis, BarSeries } from '@data-ui/xy-chart'
9 | import { printDuration } from '../utils'
10 | import OrderBy from '../orderby'
11 |
12 | const TRACE_LIST = gql`
13 | query traceList(
14 | $graphId: ID!
15 | $operationId: ID!
16 | $after: String
17 | $orderBy: TraceOrderBy
18 | $to: DateTime
19 | $from: DateTime
20 | $traceFilters: [TraceFilter]
21 | ) {
22 | traces(
23 | graphId: $graphId
24 | operationId: $operationId
25 | after: $after
26 | orderBy: $orderBy
27 | traceFilters: $traceFilters
28 | to: $to
29 | from: $from
30 | ) {
31 | nodes {
32 | id
33 | duration
34 | startTime
35 | endTime
36 | }
37 | cursor
38 | }
39 | }
40 | `
41 |
42 | export function renderTooltip({ datum, seriesKey, color, data }) {
43 | const { x, y, startTime } = datum
44 |
45 | return (
46 |
47 | {seriesKey && (
48 |
49 | {seriesKey}
50 |
51 | )}
52 |
53 | TraceId
54 | {x}
55 |
56 |
57 | Duration
58 | {y}
59 |
60 | {data && (
61 |
62 | time
63 | {new Date(startTime).toString()}
64 |
65 | )}
66 |
67 | )
68 | }
69 |
70 | export default function TraceList({ graphId, operationId }) {
71 | const history = useHistory()
72 | const [orderField, setOrderField] = useState('duration')
73 | const [orderAsc, setOrderAsc] = useState(false)
74 | const { filters, to, from } = useContext(FiltersContext)
75 |
76 | const { loading, error, data } = useQuery(TRACE_LIST, {
77 | variables: {
78 | graphId,
79 | operationId,
80 | traceFilters: filters,
81 | to: to,
82 | from: from,
83 | orderBy: {
84 | field: orderField,
85 | asc: orderAsc
86 | }
87 | }
88 | })
89 |
90 | if (loading) return
91 | if (error) return
92 |
93 | if (data.traces.nodes.length === 0) {
94 | return
95 | }
96 |
97 | const dataSeries = data.traces.nodes.map(d => ({
98 | startTime: d.startTime,
99 | label: printDuration(d.duration),
100 | x: d.id,
101 | y: d.duration / 1000 / 1000
102 | }))
103 |
104 | return (
105 |
106 |
116 |
117 |
123 | tick.slice(0, 4) + '...'} label="Traces" />
124 |
125 | {
129 | history.push(`trace/${datum.x}`)
130 | }}
131 | />
132 |
133 |
134 |
135 |
136 | )
137 | }
138 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Clementine 🍊
2 |
3 | > A self-hosted Graphql Analytics and Observability Platform
4 |
5 | Most analytics tools don't work well for graphql because they are tuned for standard REST based services. Clementine aims to provide a simple way to understand the performance of your graphql server. Allowing you to quickly find your slowest queries and bottleneck resolvers. Its simple to install and has drop in support for graphql servers that support [Apollo Tracing format](https://github.com/apollographql/apollo-tracing).
6 |
7 | **Note**: Clementine only implements a subset of [Apollo Graph Manager](https://www.apollographql.com/docs/graph-manager/) features, if you can afford it you should just use their service.
8 |
9 | Warning! Clementine is still alpha software. You can try on the [demo](https://clementine.hobochild.com). But you should really run your own instance if you care about uptime or reliability.
10 |
11 | - [Features](#features)
12 | - [Running an instance](#running-an-instance)
13 | - [Getting Started](#getting-started)
14 | - [RoadMap](#road-map)
15 |
16 | ### Features
17 |
18 |
19 |
20 | Find Operations by Popularity, Latency, Error count and Error rate.
21 |
22 |
23 |
24 |
25 | Requests and operations over time.
26 |
27 |
28 |
29 |
30 | Latency distribution of all requests or specific operations.
31 |
32 |
33 |
34 |
35 | Global Filters.
36 |
37 |
38 |
39 |
40 | Resolver level timings of for each Trace.
41 |
42 |
43 |
44 | ### Running an instance
45 |
46 | Add all required environment variables in `.env`.
47 |
48 | Then for development:
49 |
50 | ```
51 | make dev
52 | ```
53 |
54 | Then visit http://localhost:80
55 |
56 | Or for production:
57 |
58 | ```
59 | make start
60 | ```
61 |
62 | Or for production with letsEncrypt
63 |
64 | ```
65 | make start_with_ssl
66 | ```
67 |
68 | ### Getting Started
69 |
70 | First thing we need to do is create a graph and an ApiKey so you can start storing traces from your graphql server.
71 |
72 | ##### Create a graph
73 |
74 | Once you have a clementine instance running. Login in through the dashboard and create a new graph.
75 |
76 | 
77 |
78 | Then navigate to the graphs settings page and create an apiKey.
79 |
80 | 
81 |
82 | We only store a hash of the key so make sure you copy it down.
83 |
84 | ##### Configure your server
85 |
86 | Now configure your server to send traces to clementine.
87 |
88 | This is what it looks like with ApolloServer:
89 |
90 | ```javascript
91 | const { ApolloServer } = require('apollo-server')
92 |
93 | const server = new ApolloServer({
94 | typeDefs,
95 | resolvers,
96 | engine: {
97 | apiKey: '',
98 | endpointUrl: 'https://'
99 | }
100 | })
101 |
102 | server.listen().then(({ url }) => {
103 | console.log(`🚀 Server ready at ${url}`)
104 | })
105 | ```
106 |
107 | Now run some queries and the them populate in the clementine dashboard.
108 |
109 | 
110 |
111 | ##### Forwarding traces to Apollo Graph Manager.
112 |
113 | If are trailing clementine and want to forward your traces to Apollo you can pass both the apolloKey and the clementineKey seperated by a `?`. For example:
114 |
115 | ```javascript
116 | const { ApolloServer } = require('apollo-server')
117 |
118 | const server = new ApolloServer({
119 | typeDefs,
120 | resolvers,
121 | engine: {
122 | apiKey: '?',
123 | endpointUrl: 'https://'
124 | }
125 | })
126 |
127 | server.listen().then(({ url }) => {
128 | console.log(`🚀 Server ready at ${url}`)
129 | })
130 | ```
131 |
132 | ### Road Map
133 |
134 | - Team/Org support.
135 | - Add error distribution views.
136 | - helm chart
137 |
--------------------------------------------------------------------------------
/server/src/graphql/typedefs.js:
--------------------------------------------------------------------------------
1 | const { ApolloServer, gql } = require('apollo-server-express')
2 |
3 | module.exports = gql`
4 | scalar JSON
5 | scalar DateTime
6 |
7 | enum FilterOperator {
8 | eq
9 | ne
10 | }
11 |
12 | enum TraceFilterField {
13 | schemaTag
14 | clientName
15 | clientVersion
16 | hasErrors
17 | }
18 |
19 | input TraceFilter {
20 | operator: FilterOperator
21 | field: TraceFilterField
22 | value: String
23 | }
24 |
25 | enum OperationOrderFields {
26 | duration
27 | count
28 | errorCount
29 | errorPercent
30 | }
31 |
32 | type TraceFilterOptions {
33 | schemaTag: [String]
34 | clientName: [String]
35 | clientVersion: [String]
36 | hasErrors: [String]
37 | }
38 |
39 | input OperationOrderBy {
40 | asc: Boolean!
41 | field: OperationOrderFields!
42 | }
43 |
44 | enum TraceOrderFields {
45 | duration
46 | startTime
47 | }
48 |
49 | input TraceOrderBy {
50 | asc: Boolean!
51 | field: TraceOrderFields!
52 | }
53 |
54 | type User {
55 | id: ID!
56 | email: String!
57 | createdAt: DateTime!
58 | isVerified: Boolean!
59 | graphs: [Graph]
60 | }
61 |
62 | type Graph {
63 | id: ID!
64 | name: String!
65 | createdAt: DateTime!
66 | user: User
67 | keys: [Key]!
68 | stats(traceFilters: [TraceFilter], from: DateTime, to: DateTime): Stats
69 | }
70 |
71 | type Stats {
72 | count: Int!
73 | errorCount: Int!
74 | errorPercent: Int!
75 | duration: Float!
76 | }
77 |
78 | type Key {
79 | id: ID!
80 | "Secret is only returned on Create"
81 | secret: String
82 | prefix: String!
83 | createdAt: DateTime!
84 | graph: Graph!
85 | }
86 |
87 | type Operation {
88 | id: String!
89 | key: String!
90 | stats: Stats
91 | }
92 |
93 | type OperationConnection {
94 | nodes: [Operation]!
95 | cursor: String
96 | }
97 |
98 | type TraceConnection {
99 | nodes: [Trace]!
100 | cursor: String
101 | }
102 |
103 | type LatencyDistribution {
104 | duration: Float!
105 | count: Int!
106 | }
107 |
108 | type LatencyDistributionConnection {
109 | nodes: [LatencyDistribution]!
110 | cursor: String
111 | }
112 |
113 | type RPM {
114 | startTime: DateTime!
115 | count: Int!
116 | errorCount: Int!
117 | }
118 |
119 | type RPMConnection {
120 | nodes: [RPM]!
121 | cursor: String
122 | }
123 |
124 | type Trace {
125 | id: ID!
126 | key: String!
127 | duration: Float!
128 | startTime: DateTime!
129 | endTime: DateTime!
130 | createdAt: DateTime!
131 | root: JSON!
132 | details: JSON
133 | }
134 |
135 | type Query {
136 | user: User
137 | graph(graphId: ID!): Graph
138 | traces(
139 | graphId: ID!
140 | operationId: ID
141 | orderBy: TraceOrderBy
142 | from: DateTime
143 | to: DateTime
144 | after: String
145 | traceFilters: [TraceFilter]
146 | ): TraceConnection
147 | operations(
148 | graphId: ID!
149 | orderBy: OperationOrderBy
150 | after: String
151 | from: DateTime
152 | to: DateTime
153 | traceFilters: [TraceFilter]
154 | ): OperationConnection
155 | latencyDistribution(
156 | graphId: ID!
157 | operationId: ID
158 | from: DateTime
159 | to: DateTime
160 | traceFilters: [TraceFilter]
161 | ): LatencyDistributionConnection!
162 | rpm(
163 | graphId: ID!
164 | operationId: ID
165 | from: DateTime
166 | to: DateTime
167 | traceFilters: [TraceFilter]
168 | ): RPMConnection!
169 | traceFilterOptions(graphId: ID!): TraceFilterOptions
170 | stats(
171 | graphId: ID!
172 | operationId: ID
173 | from: DateTime
174 | to: DateTime
175 | traceFilters: [TraceFilter]
176 | ): Stats
177 | operation(
178 | graphId: ID!
179 | operationId: ID
180 | from: DateTime
181 | to: DateTime
182 | traceFilters: [TraceFilter]
183 | ): Operation
184 | trace(traceId: ID!): Trace
185 | }
186 |
187 | type Mutation {
188 | graphCreate(name: String!): Graph!
189 | keyCreate(graphId: ID!): Key!
190 | keyRevoke(keyId: ID!): Boolean!
191 | userLogout: Boolean!
192 | userLogin(email: String): Boolean!
193 | tokenVerify(token: String): User!
194 | }
195 | `
196 |
--------------------------------------------------------------------------------
/server/src/ingress/consumer.test.js:
--------------------------------------------------------------------------------
1 | const proto = require('apollo-engine-reporting-protobuf')
2 | const { thresholdQueue, ingestQueue, forwardQueue } = require('./queue')
3 | const { ingest, cull, forward } = require('./consumer')
4 | const { Trace, User, Graph } = require('../persistence')
5 |
6 | function nowTill(from, to) {
7 | if (!to) {
8 | to = new Date()
9 | }
10 | if (!from) {
11 | from = new Date(0)
12 | }
13 |
14 | return {
15 | from,
16 | to
17 | }
18 | }
19 |
20 | describe('ingress ingest', () => {
21 | test('Happy path', async () => {
22 | const user = await User.create('email@email.com')
23 | const graph = await Graph.create('myGraph', user.id)
24 | const messageJSON = require('./__data__/traces.json')
25 | const message = proto.FullTracesReport.fromObject(messageJSON)
26 |
27 | const job = await ingestQueue.add({
28 | ...message,
29 | graphId: graph.id
30 | })
31 |
32 | await ingest(thresholdQueue)(job)
33 |
34 | const to = new Date('2020-04-20')
35 | const from = new Date('2020-03-20')
36 |
37 | const traces = await Trace.findAll(
38 | [{ field: 'graphId', operator: 'eq', value: graph.id }],
39 | { to, from }
40 | )
41 | expect(traces.length).toBe(2)
42 | const t = traces[0]
43 | expect(t).toHaveProperty('id')
44 | expect(t).toHaveProperty('graphId')
45 | expect(t).toHaveProperty('duration')
46 | expect(t).toHaveProperty('startTime')
47 | expect(t).toHaveProperty('endTime')
48 | expect(t).toHaveProperty('root')
49 | expect(t).toHaveProperty('clientName')
50 | expect(t).toHaveProperty('clientVersion')
51 | })
52 |
53 | test('happy path - with errors', async () => {
54 | const user = await User.create('email@email.com')
55 | const graph = await Graph.create('myGraph', user.id)
56 | const messageJSON = require('./__data__/traces-with-error.json')
57 | const message = proto.FullTracesReport.fromObject(messageJSON)
58 | const job = await ingestQueue.add({
59 | ...message,
60 | graphId: graph.id
61 | })
62 |
63 | await ingest(thresholdQueue)(job)
64 | const to = new Date('2020-04-20')
65 | const from = new Date('2020-03-20')
66 |
67 | const traces = await Trace.findAll(
68 | [{ field: 'graphId', operator: 'eq', value: graph.id }],
69 | { to, from }
70 | )
71 | expect(traces.length).toBe(2)
72 | expect(traces[0]).toHaveProperty('hasErrors', false)
73 | expect(traces[1]).toHaveProperty('hasErrors', true)
74 | })
75 |
76 | test('cull should be called', async () => {
77 | const user = await User.create('email@email.com')
78 | const graph = await Graph.create('myGraph', user.id)
79 | const messageJSON = require('./__data__/traces.json')
80 | const message = proto.FullTracesReport.fromObject(messageJSON)
81 |
82 | const job = await ingestQueue.add({
83 | ...message,
84 | graphId: graph.id
85 | })
86 |
87 | const mock = {
88 | add: jest.fn()
89 | }
90 |
91 | await ingest(mock)(job)
92 | const expectedArgs = { threshold: 1 }
93 | expect(mock.add).toBeCalledWith(expectedArgs)
94 |
95 | // Cull is called async so lets call it ourselves
96 | await cull({ data: expectedArgs })
97 | const traces = await Trace.findAll(
98 | [{ field: 'graphId', operator: 'eq', value: graph.id }],
99 | { to: new Date(), from: new Date(0) }
100 | )
101 | expect(traces.length).toBe(1)
102 | // now that it is culled the mock should only be called once.
103 | await ingest(mock)(job)
104 |
105 | // Cull should have deleted traces.
106 |
107 | expect(mock.add.mock.calls.length).toBe(1)
108 | })
109 |
110 | test('forward should send to apollo', async () => {
111 | const user = await User.create('email@email.com')
112 | const graph = await Graph.create('myGraph', user.id)
113 | const messageJSON = require('./__data__/traces.json')
114 | const message = proto.FullTracesReport.fromObject(messageJSON)
115 |
116 | const job = await forwardQueue.add({
117 | report: message,
118 | apolloApiKey: '123'
119 | })
120 |
121 | const mock = jest.fn()
122 |
123 | const res = await forward(mock)(job)
124 |
125 | const expectedArgs = [
126 | 'https://engine-report.apollodata.com/api/ingress/traces',
127 | {
128 | method: 'POST',
129 | headers: {
130 | 'user-agent': 'apollo-engine-reporting',
131 | 'x-api-key': '123',
132 | 'content-encoding': 'gzip'
133 | },
134 | body: res
135 | }
136 | ]
137 | expect(mock).toBeCalledWith(...expectedArgs)
138 | })
139 | })
140 |
--------------------------------------------------------------------------------
/server/src/graphql/__tests__/operations.test.js:
--------------------------------------------------------------------------------
1 | const { app } = require('../../index')
2 | const { User, Graph, Trace } = require('../../persistence')
3 | const supertest = require('supertest')
4 | const { generateTraces, generateToken, login, raiseGqlErr } = require('./utils')
5 |
6 | describe('operations', () => {
7 | test('can list by graph', async () => {
8 | const email = 'xx@gmail.com'
9 | const request = supertest.agent(app)
10 | const user = await User.create(email)
11 | const graph = await Graph.create('myGraph', user.id)
12 | const token = await generateToken(user)
13 | await login(request, token)
14 |
15 | const traces = await generateTraces(50)
16 | await Trace.create(graph.id, traces)
17 |
18 | const query = `
19 | query cg {
20 | operations(graphId: "${graph.id}") {
21 | nodes {
22 | id
23 | key
24 | stats {
25 | count
26 | duration
27 | }
28 | }
29 | cursor
30 | }
31 | }
32 | `
33 |
34 | await request
35 | .post('/api/graphql')
36 | .send({ query })
37 | .set('Content-Type', 'application/json')
38 | .set('Accept', 'application/json')
39 | .then(raiseGqlErr)
40 | .then(async res => {
41 | const nodes = res.body.data.operations.nodes
42 | const firstNode = nodes[0]
43 | expect(nodes.length).toBe(10)
44 | expect(firstNode).toHaveProperty('key')
45 | expect(firstNode.stats).toHaveProperty('count')
46 | expect(firstNode.stats).toHaveProperty('duration')
47 | })
48 | })
49 | })
50 |
51 | test('can order by duration', async () => {
52 | const email = 'xx@gmail.com'
53 | const request = supertest.agent(app)
54 | const user = await User.create(email)
55 | const graph = await Graph.create('myGraph', user.id)
56 | const token = await generateToken(user)
57 | await login(request, token)
58 |
59 | const traces = await generateTraces(50)
60 | await Trace.create(graph.id, traces)
61 |
62 | const orderBy = {
63 | field: 'duration',
64 | asc: true
65 | }
66 |
67 | const query = `
68 | query cg {
69 | operations(graphId: "${graph.id}", orderBy: { field: duration, asc: false }) {
70 | nodes {
71 | id
72 | key
73 | stats {
74 | count
75 | duration
76 | }
77 | }
78 | cursor
79 | }
80 | }
81 | `
82 |
83 | await request
84 | .post('/api/graphql')
85 | .send({ query })
86 | .set('Content-Type', 'application/json')
87 | .set('Accept', 'application/json')
88 | .then(raiseGqlErr)
89 | .then(async res => {
90 | const nodes = res.body.data.operations.nodes
91 | expect(nodes.length).toBe(10)
92 |
93 | nodes.forEach((node, i) => {
94 | if (i !== nodes.length - 1) {
95 | expect(nodes[i].stats.duration).toBeGreaterThan(
96 | nodes[i + 1].stats.duration
97 | )
98 | }
99 | })
100 | })
101 | })
102 |
103 | test('can paginate with Cursor', async () => {
104 | const email = 'xx@gmail.com'
105 | const request = supertest.agent(app)
106 | const user = await User.create(email)
107 | const graph = await Graph.create('myGraph', user.id)
108 | const token = await generateToken(user)
109 | await login(request, token)
110 |
111 | const traces = await generateTraces(50)
112 | await Trace.create(graph.id, traces)
113 |
114 | const runQuery = cursor => {
115 | const query = `
116 | query cg {
117 | operations(graphId: "${graph.id}", after: "${cursor}") {
118 | nodes {
119 | id
120 | key
121 | stats {
122 | count
123 | duration
124 | }
125 | }
126 | cursor
127 | }
128 | }
129 | `
130 |
131 | return request
132 | .post('/api/graphql')
133 | .send({ query: query })
134 | .set('Content-Type', 'application/json')
135 | .set('Accept', 'application/json')
136 | .then(raiseGqlErr)
137 | }
138 |
139 | let done = false
140 | let cursor = ''
141 | let results = []
142 |
143 | while (!done) {
144 | const res = await runQuery(cursor)
145 | results.concat(res.body.data.operations.nodes)
146 | if (res.body.data.operations.cursor === '') {
147 | done = true
148 | } else {
149 | cursor = res.body.data.operations.cursor
150 | }
151 | }
152 |
153 | results.map(op => {
154 | // make sure there are no duplicates in our results
155 | const isDupe = results.filter(o => o.id === op.id).map(({ id }) => id)
156 | .length
157 | expect(isDupe).toBeFalsy()
158 | })
159 | })
160 |
--------------------------------------------------------------------------------
/client/src/operation/list.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useContext } from 'react'
2 | import { useQuery } from '@apollo/react-hooks'
3 | import { gql } from 'apollo-boost'
4 | import { Loading, ErrorBanner, NotFound } from '../utils'
5 | import { Link } from 'react-router-dom'
6 | import Stats from '../stats'
7 | import { FiltersContext } from '../trace'
8 | import { getOperationName, getOperationTypes } from './utils'
9 | import styles from './list.module.css'
10 | import Label from '../label'
11 | import OrderBy from '../orderby'
12 |
13 | const OPERATION_LIST = gql`
14 | query operationList(
15 | $graphId: ID!
16 | $orderBy: OperationOrderBy
17 | $after: String
18 | $to: DateTime
19 | $from: DateTime
20 | $traceFilters: [TraceFilter]
21 | ) {
22 | operations(
23 | graphId: $graphId
24 | orderBy: $orderBy
25 | after: $after
26 | traceFilters: $traceFilters
27 | to: $to
28 | from: $from
29 | ) {
30 | nodes {
31 | id
32 | key
33 | stats {
34 | count
35 | errorCount
36 | errorPercent
37 | duration
38 | }
39 | }
40 | cursor
41 | }
42 | }
43 | `
44 |
45 | export default function OperationList({ graphId }) {
46 | const { filters, to, from } = useContext(FiltersContext)
47 | const [orderField, setOrderField] = useState('count')
48 | const [orderAsc, setOrderAsc] = useState(false)
49 |
50 | const { loading, error, data, fetchMore } = useQuery(OPERATION_LIST, {
51 | variables: {
52 | graphId,
53 | orderBy: {
54 | field: orderField,
55 | asc: orderAsc
56 | },
57 | to,
58 | from,
59 | traceFilters: filters
60 | }
61 | })
62 |
63 | if (loading) return
64 | if (error) return
65 |
66 | if (data.operations.nodes.length === 0) {
67 | return
68 | }
69 |
70 | return (
71 |
72 |
73 |
85 |
86 | {data.operations.nodes.map(op => {
87 | const doc = gql`
88 | ${op.key}
89 | `
90 | const name = getOperationName(doc)
91 | const operationTypes = getOperationTypes(doc)
92 |
93 | return (
94 |
98 |
99 |
{op.id.substring(0, 5)}
100 |
101 | {name ? name : op.id}
102 |
103 |
104 |
105 |
110 |
111 |
112 |
113 | )
114 | })}
115 |
{
119 | fetchMore({
120 | variables: {
121 | graphId,
122 | orderBy: {
123 | field: orderField,
124 | asc: orderAsc
125 | },
126 | after: data.operations.cursor
127 | },
128 | updateQuery: (previousResult, { fetchMoreResult }) => {
129 | const prevOps = previousResult.operations.nodes
130 | const newOps = fetchMoreResult.operations.nodes
131 | const nodes = [...prevOps, ...newOps]
132 |
133 | return {
134 | operations: {
135 | // Put the new comments in the front of the list
136 | ...fetchMoreResult.operations,
137 | nodes
138 | }
139 | }
140 | }
141 | })
142 | }}
143 | >
144 | {data.operations.cursor.length === 0 ? 'no more' : 'more'}
145 |
146 |
147 |
148 |
149 | )
150 | }
151 |
--------------------------------------------------------------------------------
/server/src/graphql/__tests__/graphs.test.js:
--------------------------------------------------------------------------------
1 | const { app } = require('../../index')
2 | const { User, Graph } = require('../../persistence')
3 | const supertest = require('supertest')
4 | const { generateTraces, generateToken, login, raiseGqlErr } = require('./utils')
5 | const logger = require('loglevel')
6 |
7 | describe('graph', () => {
8 | test('create', async () => {
9 | const email = 'xx@gmail.com'
10 | const request = supertest.agent(app)
11 | const user = await User.create(email)
12 | const token = await generateToken(user)
13 | await login(request, token)
14 |
15 | const query = `
16 | mutation cg {
17 | graphCreate(name: "myGraph") {
18 | id
19 | user {
20 | id
21 | email
22 | }
23 | name
24 | }
25 | }
26 | `
27 |
28 | return request
29 | .post('/api/graphql')
30 | .send({ query })
31 | .set('Content-Type', 'application/json')
32 | .set('Accept', 'application/json')
33 | .then(raiseGqlErr)
34 | .then(res => {
35 | const body = res.body
36 | expect(res.body.data.graphCreate.user).toHaveProperty('id', user.id)
37 |
38 | const graph = res.body.data.graphCreate
39 | expect(graph).toHaveProperty('id')
40 | expect(graph).toHaveProperty('name')
41 | })
42 | })
43 |
44 | describe('show', () => {
45 | test('happy path :)', async () => {
46 | const email = 'xx@gmail.com'
47 | const request = supertest.agent(app)
48 | const user = await User.create(email)
49 | const token = await generateToken(user)
50 | await login(request, token)
51 |
52 | const graph1 = await Graph.create('myGraph', user.id)
53 | const graph2 = await Graph.create('mySecondGraph', user.id)
54 |
55 | const query = `
56 | query gv {
57 | graph(graphId: "${graph2.id}") {
58 | id
59 | name
60 | }
61 | }
62 | `
63 |
64 | await request
65 | .post('/api/graphql')
66 | .send({ query })
67 | .set('Content-Type', 'application/json')
68 | .set('Accept', 'application/json')
69 | .then(raiseGqlErr)
70 | .then(res => {
71 | const body = res.body
72 | const g = res.body.data.graph
73 | expect(g.id).toBe(graph2.id)
74 | expect(g).toHaveProperty('name', 'mySecondGraph')
75 | })
76 | })
77 |
78 | test('only member can view', async done => {
79 | const email = 'xx@gmail.com'
80 | const email2 = 'xy@gmail.com'
81 | const request = supertest.agent(app)
82 | const user = await User.create(email)
83 | const user2 = await User.create(email2)
84 | const token = await generateToken(user)
85 | await login(request, token)
86 |
87 | const graph1 = await Graph.create('myGraph', user.id)
88 | const graph2 = await Graph.create('mySecondGraph', user2.id)
89 |
90 | const query = `
91 | query gv {
92 | graph(graphId: "${graph2.id}") {
93 | id
94 | name
95 | }
96 | }
97 | `
98 |
99 | // hide error to output in tests
100 | jest.spyOn(logger, 'error').mockImplementation(() => {})
101 |
102 | await request
103 | .post('/api/graphql')
104 | .send({ query })
105 | .set('Content-Type', 'application/json')
106 | .set('Accept', 'application/json')
107 | .then(res => {
108 | const errors = res.body.errors
109 | expect(errors[0].extensions.code).toContain('FORBIDDEN')
110 | done()
111 | })
112 | })
113 | })
114 |
115 | test('list', async () => {
116 | const email = 'xx@gmail.com'
117 | const request = supertest.agent(app)
118 | const user = await User.create(email)
119 | const token = await generateToken(user)
120 | await login(request, token)
121 |
122 | const graph1 = await Graph.create('myGraph', user.id)
123 | const graph2 = await Graph.create('mySecondGraph', user.id)
124 |
125 | const VIEW_GRAPH = `
126 | query lg {
127 | user {
128 | id
129 | graphs {
130 | id
131 | name
132 | }
133 | }
134 | }
135 | `
136 |
137 | await request
138 | .post('/api/graphql')
139 | .send({ query: VIEW_GRAPH })
140 | .set('Content-Type', 'application/json')
141 | .set('Accept', 'application/json')
142 | .then(raiseGqlErr)
143 | .then(res => {
144 | const body = res.body
145 | expect(res.body.data.user.graphs.length).toBe(2)
146 |
147 | for (g of res.body.data.user.graphs) {
148 | expect(g).toHaveProperty('id')
149 | expect(g).toHaveProperty('name')
150 | }
151 | })
152 | })
153 | })
154 |
--------------------------------------------------------------------------------
/client/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { ApolloProvider } from '@apollo/react-hooks'
3 | import { Login, CheckEmail } from './auth'
4 | import { GraphSettings, GraphList, GraphHeader } from './graph'
5 | import { OperationList, OperationHeader } from './operation'
6 | import client from './client'
7 | import { Route, Switch, BrowserRouter as Router } from 'react-router-dom'
8 | import { UserProvider, UserRedirect } from './user'
9 | import Menu from './menu'
10 | import { TraceList, FiltersProvider, Filters, TraceShow } from './trace'
11 | import { Rpm, LatencyDistribution } from './timeline'
12 |
13 | function App() {
14 | return (
15 |
16 |
17 |
18 |
19 | {
22 | // this displays filters dropdown
23 | const isVisible = new URLSearchParams(location.search).get(
24 | 'filters'
25 | )
26 |
27 | return (
28 |
29 | )
30 | }}
31 | />
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | (
42 |
46 | )}
47 | />
48 | {
51 | return
52 | }}
53 | />
54 |
55 |
56 | (
60 |
61 | )}
62 | />
63 | (
67 |
68 | )}
69 | />
70 | (
74 |
75 | )}
76 | />
77 | (
81 |
82 | )}
83 | />
84 |
85 | (
89 |
93 | )}
94 | />
95 | (
99 |
103 | )}
104 | />
105 | (
109 |
113 | )}
114 | />
115 |
116 | (
120 |
121 | )}
122 | />
123 |
124 |
125 |
126 |
127 |
128 | )
129 | }
130 |
131 | export default App
132 |
--------------------------------------------------------------------------------
/server/src/ingress/__data__/traces-with-error.json:
--------------------------------------------------------------------------------
1 | {
2 | "header": {
3 | "hostname": "1ca2ad3872df",
4 | "agentVersion": "apollo-engine-reporting@1.7.0",
5 | "runtimeVersion": "node v10.19.0",
6 | "uname": "linux, Linux, 5.3.0-42-generic, x64)",
7 | "schemaTag": "development",
8 | "schemaHash": "f4559b15acd25c08d55f5dc69d8a473471ea9e77a63e1b16187a3ecebb803cc083f94f672492d36ccd472512e3fa3200b524b04f67855ccd5d6cb40e125c025b"
9 | },
10 | "tracesPerQuery": {
11 | "# -\n{user{__typename graphs{__typename id name}id}}": {
12 | "trace": [
13 | {
14 | "endTime": { "seconds": "1585490234", "nanos": 266000000 },
15 | "startTime": { "seconds": "1585490234", "nanos": 216000000 },
16 | "details": {},
17 | "clientName": "",
18 | "clientVersion": "",
19 | "http": { "method": "POST" },
20 | "durationNs": "49442326",
21 | "root": {
22 | "child": [
23 | {
24 | "responseName": "user",
25 | "type": "User",
26 | "startTime": "15158309",
27 | "endTime": "42217682",
28 | "child": [
29 | {
30 | "responseName": "id",
31 | "type": "ID!",
32 | "startTime": "42651606",
33 | "endTime": "42936253",
34 | "parentType": "User"
35 | },
36 | {
37 | "responseName": "graphs",
38 | "type": "[Graph]",
39 | "startTime": "43151137",
40 | "endTime": "47239959",
41 | "child": [
42 | {
43 | "index": 0,
44 | "child": [
45 | {
46 | "responseName": "id",
47 | "type": "ID!",
48 | "startTime": "47445519",
49 | "endTime": "47508160",
50 | "parentType": "Graph"
51 | },
52 | {
53 | "responseName": "name",
54 | "type": "String!",
55 | "startTime": "47549382",
56 | "endTime": "47568728",
57 | "parentType": "Graph"
58 | }
59 | ]
60 | }
61 | ],
62 | "parentType": "User"
63 | }
64 | ],
65 | "parentType": "Query"
66 | }
67 | ]
68 | },
69 | "fullQueryCacheHit": false,
70 | "clientReferenceId": "",
71 | "registeredOperation": false,
72 | "forbiddenOperation": false
73 | }
74 | ]
75 | },
76 | "# showGraph\nquery showGraph($graphId:ID!){graph(graphId:$graphId){__typename id keys{__typename id secret}name operations{__typename count duration id}}}": {
77 | "trace": [
78 | {
79 | "endTime": { "seconds": "1585490236", "nanos": 385000000 },
80 | "startTime": { "seconds": "1585490236", "nanos": 362000000 },
81 | "details": { "variablesJson": { "graphId": "" } },
82 | "clientName": "",
83 | "clientVersion": "",
84 | "http": { "method": "POST" },
85 | "durationNs": "23130253",
86 | "root": {
87 | "child": [
88 | {
89 | "responseName": "graph",
90 | "type": "Graph",
91 | "startTime": "9653026",
92 | "endTime": "12694593",
93 | "child": [
94 | {
95 | "responseName": "id",
96 | "type": "ID!",
97 | "startTime": "12861528",
98 | "endTime": "12894576",
99 | "parentType": "Graph"
100 | },
101 | {
102 | "responseName": "name",
103 | "type": "String!",
104 | "startTime": "12930030",
105 | "endTime": "12945498",
106 | "parentType": "Graph"
107 | },
108 | {
109 | "responseName": "keys",
110 | "type": "[Key]!",
111 | "startTime": "12986614",
112 | "endTime": "13236379",
113 | "error": [
114 | {
115 | "message": "Dummy Error",
116 | "location": [{ "line": 5, "column": 5 }],
117 | "json": "{\"message\":\"Dummy Error\",\"locations\":[{\"line\":5,\"column\":5}],\"path\":[\"graph\",\"keys\"]}"
118 | }
119 | ],
120 | "parentType": "Graph"
121 | }
122 | ],
123 | "parentType": "Query"
124 | }
125 | ]
126 | },
127 | "fullQueryCacheHit": false,
128 | "clientReferenceId": "",
129 | "registeredOperation": false,
130 | "forbiddenOperation": false
131 | }
132 | ]
133 | }
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/client/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://bit.ly/CRA-PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It's the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/server/src/graphql/__tests__/keys.test.js:
--------------------------------------------------------------------------------
1 | const { app } = require('../../index')
2 | const { User, Graph, Trace, Key } = require('../../persistence')
3 | const supertest = require('supertest')
4 | const { generateTraces, generateToken, login, raiseGqlErr } = require('./utils')
5 | const logger = require('loglevel')
6 |
7 | describe('keys', () => {
8 | describe('create', () => {
9 | test('happy path :)', async () => {
10 | const email = 'xx@gmail.com'
11 | const request = supertest.agent(app)
12 | const user = await User.create(email)
13 | const token = await generateToken(user)
14 | await login(request, token)
15 |
16 | const graph = await Graph.create('myGraph', user.id)
17 |
18 | const query = `
19 | mutation cg {
20 | keyCreate(graphId: "${graph.id}") {
21 | id
22 | secret
23 | graph {
24 | id
25 | }
26 | }
27 | }
28 | `
29 |
30 | return request
31 | .post('/api/graphql')
32 | .send({ query })
33 | .set('Content-Type', 'application/json')
34 | .set('Accept', 'application/json')
35 | .then(raiseGqlErr)
36 | .then(res => {
37 | const body = res.body
38 | const key = res.body.data.keyCreate
39 | expect(key).toHaveProperty('id')
40 | expect(key).toHaveProperty('graph.id', graph.id)
41 | expect(key).toHaveProperty('secret')
42 | expect(key.secret.split(':')[0]).toBe(graph.id)
43 | })
44 | })
45 |
46 | test('only member can create key', async done => {
47 | const email = 'xx@gmail.com'
48 | const email2 = 'xy@gmail.com'
49 | const request = supertest.agent(app)
50 | const user = await User.create(email)
51 | const user2 = await User.create(email2)
52 | const token = await generateToken(user)
53 | await login(request, token)
54 |
55 | const graph = await Graph.create('myGraph', user.id)
56 | const graph2 = await Graph.create('myGraph', user2.id)
57 |
58 | const query = `
59 | mutation cg($graphId: ID!) {
60 | keyCreate(graphId: $graphId) {
61 | id
62 | secret
63 | graph {
64 | id
65 | }
66 | }
67 | }
68 | `
69 |
70 | // hide error to output in tests
71 | jest.spyOn(logger, 'error').mockImplementation(() => {})
72 | return request
73 | .post('/api/graphql')
74 | .send({ query, variables: { graphId: graph2.id } })
75 | .set('Content-Type', 'application/json')
76 | .set('Accept', 'application/json')
77 | .then(res => {
78 | const errors = res.body.errors
79 | expect(errors[0].extensions.code).toContain('FORBIDDEN')
80 | done()
81 | })
82 | })
83 | })
84 |
85 | describe('revoke', () => {
86 | test('happy path :)', async () => {
87 | const email = 'xx@gmail.com'
88 | const request = supertest.agent(app)
89 | const user = await User.create(email)
90 | const token = await generateToken(user)
91 | await login(request, token)
92 |
93 | const graph = await Graph.create('myGraph', user.id)
94 | const graph2 = await Graph.create('mm', user.id)
95 | const key1 = await Key.create(graph.id)
96 | const key2 = await Key.create(graph.id)
97 |
98 | const query = `
99 | mutation revoke {
100 | keyRevoke(keyId: "${key1.id}")
101 | }
102 | `
103 |
104 | await request
105 | .post('/api/graphql')
106 | .send({ query })
107 | .set('Content-Type', 'application/json')
108 | .set('Accept', 'application/json')
109 | .then(raiseGqlErr)
110 |
111 | const revokedKey = await Key.findById(key1.id)
112 | expect(revokedKey).toBeNull()
113 | })
114 |
115 | test('revoke - no permissions', async () => {
116 | const email = 'xx@gmail.com'
117 | const email2 = 'xy@gmail.com'
118 | const request = supertest.agent(app)
119 | const user = await User.create(email)
120 | const user2 = await User.create(email2)
121 | const token = await generateToken(user)
122 | await login(request, token)
123 |
124 | const graph = await Graph.create('myGraph', user.id)
125 | const graph2 = await Graph.create('mm', user2.id)
126 | const key1 = await Key.create(graph.id)
127 | const key2 = await Key.create(graph2.id)
128 |
129 | const query = `
130 | mutation revoke {
131 | keyRevoke(keyId: "${key2.id}")
132 | }
133 | `
134 |
135 | jest.spyOn(logger, 'error').mockImplementation(() => {})
136 | await request
137 | .post('/api/graphql')
138 | .send({ query })
139 | .set('Content-Type', 'application/json')
140 | .set('Accept', 'application/json')
141 | .then(res => {
142 | const errors = res.body.errors
143 | expect(errors[0].extensions.code).toContain('FORBIDDEN')
144 | })
145 |
146 | // make sure it wasnt deleted
147 | const revokedKey = await Key.findById(key2.id)
148 | expect(revokedKey.id).toBeDefined()
149 | })
150 | })
151 |
152 | test('list', async () => {
153 | const email = 'xx@gmail.com'
154 | const request = supertest.agent(app)
155 | const user = await User.create(email)
156 | const token = await generateToken(user)
157 | await login(request, token)
158 |
159 | const graph = await Graph.create('myGraph', user.id)
160 | const graph2 = await Graph.create('mm', user.id)
161 | const key1 = await Key.create(graph.id)
162 | const key2 = await Key.create(graph.id)
163 | const key3 = await Key.create(graph2.id)
164 |
165 | const query = `
166 | query cg {
167 | user {
168 | graphs {
169 | id
170 | keys {
171 | id
172 | }
173 | }
174 | }
175 | }
176 | `
177 |
178 | await request
179 | .post('/api/graphql')
180 | .send({ query })
181 | .set('Content-Type', 'application/json')
182 | .set('Accept', 'application/json')
183 | .then(raiseGqlErr)
184 | .then(async res => {
185 | const body = res.body
186 | const g1 = res.body.data.user.graphs.find(g => g.id === graph.id)
187 | expect(g1.keys.length).toBe(2)
188 | expect(g1.keys).toContainEqual({ id: key1.id })
189 | expect(g1.keys).toContainEqual({ id: key2.id })
190 |
191 | const g2 = res.body.data.user.graphs.find(x => x.id === graph2.id)
192 | expect(g2.keys.length).toBe(1)
193 | expect(g2.keys).toContainEqual({ id: key3.id })
194 | })
195 | })
196 | })
197 |
--------------------------------------------------------------------------------
/client/src/graph/index.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-boost'
2 | import React, { useRef, useContext } from 'react'
3 | import { useMutation, useQuery } from '@apollo/react-hooks'
4 | import { Link } from 'react-router-dom'
5 | import { cloneDeep } from 'lodash'
6 | import { FiltersContext } from '../trace'
7 | import { ErrorBanner, Loading, NotFound } from '../utils'
8 | import Nav from '../nav'
9 | import { KeyList, KeyCreate } from '../key'
10 | import Stats from '../stats'
11 | import graphListStyles from './graph-list.module.css'
12 |
13 | const GET_GRAPHS = gql`
14 | query GRAPH_LIST(
15 | $traceFilters: [TraceFilter]
16 | $to: DateTime
17 | $from: DateTime
18 | ) {
19 | user {
20 | id
21 | graphs {
22 | id
23 | name
24 | stats(to: $to, from: $from, traceFilters: $traceFilters) {
25 | errorCount
26 | errorPercent
27 | count
28 | duration
29 | }
30 | }
31 | }
32 | }
33 | `
34 |
35 | export function GraphList() {
36 | const { filters, from, to } = useContext(FiltersContext)
37 | const { loading, error, data } = useQuery(GET_GRAPHS, {
38 | variables: {
39 | traceFilters: filters,
40 | to,
41 | from
42 | }
43 | })
44 |
45 | if (loading) return
46 | if (error) return
47 |
48 | if (!data.user) {
49 | return
50 | }
51 |
52 | return (
53 |
54 |
55 |
56 | {data.user.graphs.length > 0 ? (
57 | data.user.graphs.map(graph => {
58 | return (
59 |
60 |
61 | {graph.name}
62 |
63 |
64 |
65 | )
66 | })
67 | ) : (
68 |
69 | )}
70 |
71 |
72 | )
73 | }
74 |
75 | const GRAPH_CREATE = gql`
76 | mutation CREATE_GRAPH(
77 | $name: String!
78 | $traceFilters: [TraceFilter]
79 | $to: DateTime
80 | $from: DateTime
81 | ) {
82 | graphCreate(name: $name) {
83 | id
84 | name
85 | stats(to: $to, from: $from, traceFilters: $traceFilters) {
86 | errorCount
87 | errorPercent
88 | count
89 | duration
90 | }
91 | }
92 | }
93 | `
94 |
95 | export function GraphCreate() {
96 | const { filters, from, to } = useContext(FiltersContext)
97 | const nameRef = useRef()
98 | const [gc] = useMutation(GRAPH_CREATE)
99 |
100 | return (
101 |
102 |
154 |
155 | )
156 | }
157 |
158 | const SHOW_GRAPH = gql`
159 | query GRAPH_SHOW(
160 | $graphId: ID!
161 | $traceFilters: [TraceFilter]
162 | $from: DateTime
163 | $to: DateTime
164 | ) {
165 | graph(graphId: $graphId) {
166 | id
167 | name
168 | stats(traceFilters: $traceFilters, from: $from, to: $to) {
169 | count
170 | duration
171 | errorCount
172 | errorPercent
173 | }
174 | }
175 | }
176 | `
177 |
178 | export function GraphHeader({ graphId }) {
179 | const { filters, from, to } = useContext(FiltersContext)
180 | const { loading, error, data } = useQuery(SHOW_GRAPH, {
181 | variables: { graphId, traceFilters: filters, from, to }
182 | })
183 |
184 | const items = [
185 | {
186 | to: `/graph/${graphId}/operation`,
187 | title: 'Operations'
188 | },
189 | {
190 | to: `/graph/${graphId}/rpm`,
191 | title: 'Requests over time'
192 | },
193 | {
194 | to: `/graph/${graphId}/ld`,
195 | title: 'Latency Distribution'
196 | },
197 | {
198 | to: `/graph/${graphId}/settings`,
199 | title: 'Settings'
200 | }
201 | ]
202 |
203 | if (loading) return
204 | if (error) return
205 |
206 | if (!data.graph) {
207 | return
208 | }
209 |
210 | return (
211 |
212 |
213 |
214 | {data.graph.name}
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 | )
223 | }
224 |
225 | export const GRAPH_SETTINGS = gql`
226 | query GRAPH_SETTINGS($graphId: ID!) {
227 | graph(graphId: $graphId) {
228 | id
229 | name
230 | keys {
231 | id
232 | secret
233 | prefix
234 | createdAt
235 | }
236 | }
237 | }
238 | `
239 |
240 | export function GraphSettings({ graphId }) {
241 | const { loading, error, data } = useQuery(GRAPH_SETTINGS, {
242 | variables: { graphId }
243 | })
244 |
245 | if (loading) return
246 | if (error) return
247 |
248 | if (!data.graph) {
249 | return
250 | }
251 |
252 | return (
253 |
254 | API Keys
255 |
256 |
257 |
258 | )
259 | }
260 |
--------------------------------------------------------------------------------
/client/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | }
9 |
10 | code {
11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
12 | monospace;
13 | }
14 |
15 | a {
16 | cursor: pointer;
17 | }
18 |
19 | :root {
20 | --border-radius: 3px;
21 | --box-shadow: 2px 2px 10px;
22 | --color-black: #000;
23 | --color-white: #fff;
24 | --color-grey: #e9e9e9;
25 | --color-orange: #f5a623;
26 | --color-cyan: #50e3c2;
27 | --color: #118bee;
28 | --color-accent: #118bee0b;
29 | --color-bg: #fff;
30 | --color-bg-secondary: #e9e9e9;
31 | --color-secondary: #920de9;
32 | --color-secondary-accent: #920de90b;
33 | --color-shadow: #f4f4f4;
34 | --color-text: #000;
35 | --color-text-secondary: #999;
36 | --hover-brightness: 1.2;
37 | --justify-important: center;
38 | --justify-normal: left;
39 | --line-height: 150%;
40 | --width-card: 285px;
41 | --width-card-medium: 460px;
42 | --width-card-wide: 800px;
43 | --width-content: 1080px;
44 | }
45 |
46 | /* MVP.css v1.0 - by Andy Brewer */
47 | /* https://andybrewer.github.io/mvp/mvp.css */
48 |
49 | /* Layout */
50 | article aside {
51 | background: var(--color-secondary-accent);
52 | border-left: 4px solid var(--color-secondary);
53 | padding: 0.01rem 0.8rem;
54 | }
55 |
56 | body {
57 | background: var(--color-bg);
58 | color: var(--color-text);
59 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
60 | Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
61 | line-height: var(--line-height);
62 | margin: 0;
63 | overflow-x: hidden;
64 | }
65 |
66 | footer,
67 | header,
68 | main {
69 | margin: 0 auto;
70 | max-width: var(--width-content);
71 | padding: 2rem 1rem;
72 | }
73 |
74 | hr {
75 | background-color: var(--color-bg-secondary);
76 | border: none;
77 | height: 1px;
78 | margin: 4rem 0;
79 | }
80 |
81 | section {
82 | display: flex;
83 | flex-wrap: wrap;
84 | justify-content: var(--justify-important);
85 | }
86 |
87 | section aside {
88 | border: 1px solid var(--color-bg-secondary);
89 | border-radius: var(--border-radius);
90 | box-shadow: var(--box-shadow) var(--color-shadow);
91 | margin: 1rem;
92 | padding: 1.25rem;
93 | width: var(--width-card);
94 | }
95 |
96 | section aside:hover {
97 | box-shadow: var(--box-shadow) var(--color-bg-secondary);
98 | }
99 |
100 | section aside img {
101 | max-width: 100%;
102 | }
103 |
104 | /* Headers */
105 | article header,
106 | div header,
107 | main header {
108 | padding-top: 0;
109 | }
110 |
111 | header {
112 | text-align: var(--justify-important);
113 | }
114 |
115 | header a b,
116 | header a em,
117 | header a i,
118 | header a strong {
119 | margin-left: 1rem;
120 | margin-right: 1rem;
121 | }
122 |
123 | header nav img {
124 | margin: 1rem 0;
125 | }
126 |
127 | section header {
128 | padding-top: 0;
129 | width: 100%;
130 | }
131 |
132 | /* Nav */
133 | nav {
134 | align-items: center;
135 | display: flex;
136 | font-weight: bold;
137 | justify-content: space-between;
138 | margin-bottom: 7rem;
139 | }
140 |
141 | nav ul {
142 | list-style: none;
143 | padding: 0;
144 | }
145 |
146 | nav ul li {
147 | display: inline-block;
148 | margin: 0 0.5rem;
149 | }
150 |
151 | /* Typography */
152 | code {
153 | display: inline-block;
154 | margin: 0 0.1rem;
155 | padding: 0rem 0.5rem;
156 | }
157 |
158 | .code-block {
159 | display: block;
160 | padding: 1.5rem;
161 | overflow: auto;
162 | }
163 |
164 | code,
165 | samp {
166 | background-color: var(--color-accent);
167 | color: var(--color-text);
168 | border-radius: var(--border-radius);
169 | text-align: var(--justify-normal);
170 | }
171 |
172 | h1,
173 | h2,
174 | h3,
175 | h4,
176 | h5,
177 | h6 {
178 | line-height: var(--line-height);
179 | }
180 |
181 | mark {
182 | padding: 0.1rem;
183 | }
184 |
185 | ol li,
186 | ul li {
187 | padding: 0.2rem 0;
188 | }
189 |
190 | p {
191 | margin: 0.75rem 0;
192 | padding: 0;
193 | }
194 |
195 | samp {
196 | display: block;
197 | margin: 1rem 0;
198 | max-width: var(--width-card-wide);
199 | padding: 1rem;
200 | }
201 |
202 | small {
203 | color: var(--color-text-secondary);
204 | }
205 |
206 | sup {
207 | background-color: var(--color-secondary);
208 | border-radius: var(--border-radius);
209 | color: var(--color-bg);
210 | font-size: xx-small;
211 | font-weight: bold;
212 | margin: 0.2rem;
213 | padding: 0.2rem 0.3rem;
214 | position: relative;
215 | top: -2px;
216 | }
217 |
218 | /* Links */
219 | a {
220 | color: var(--color-text-secondary);
221 | font-weight: bold;
222 | text-decoration: none;
223 | }
224 |
225 | a.active {
226 | color: var(--color-text);
227 | }
228 |
229 | a:hover {
230 | filter: brightness(var(--hover-brightness));
231 | text-decoration: none;
232 | }
233 |
234 | a b,
235 | a em,
236 | a i,
237 | a strong,
238 | button {
239 | border-radius: var(--border-radius);
240 | display: inline-block;
241 | font-size: medium;
242 | font-weight: bold;
243 | margin: 1.5rem 0 0.5rem 0;
244 | padding: 0.75rem 1.5rem;
245 | }
246 |
247 | button:hover {
248 | cursor: pointer;
249 | filter: brightness(var(--hover-brightness));
250 | }
251 |
252 | a b,
253 | a strong,
254 | button {
255 | background-color: var(--color-black);
256 | border: 2px solid var(--color-black);
257 | color: var(--color-white);
258 | }
259 |
260 | a em,
261 | a i {
262 | border: 2px solid var(--color);
263 | border-radius: var(--border-radius);
264 | color: var(--color);
265 | display: inline-block;
266 | padding: 1rem 2rem;
267 | }
268 |
269 | /* Images */
270 | figure {
271 | margin: 0;
272 | padding: 0;
273 | }
274 |
275 | figure figcaption {
276 | color: var(--color-text-secondary);
277 | }
278 |
279 | /* Forms */
280 | form {
281 | display: block;
282 | min-width: var(--width-card);
283 | padding: 1.5rem;
284 | text-align: var(--justify-normal);
285 | margin: auto;
286 | }
287 |
288 | .form-inline {
289 | display: flex;
290 | flex-flow: row wrap;
291 | align-items: center;
292 | width: 50%;
293 | }
294 |
295 | .form-inline input {
296 | padding: 0.75rem;
297 | vertical-align: middle;
298 | margin: 0;
299 | flex-grow: 8;
300 | }
301 |
302 | .form-inline button {
303 | margin: 0;
304 | flex-grow: 2;
305 | }
306 |
307 | form header {
308 | margin: 1.5rem 0;
309 | padding: 1.5rem 0;
310 | }
311 |
312 | input,
313 | label,
314 | select,
315 | textarea {
316 | display: block;
317 | font-size: inherit;
318 | max-width: var(--width-card-wide);
319 | padding: 0.75rem;
320 | }
321 |
322 | input,
323 | select,
324 | textarea {
325 | margin-bottom: 1rem;
326 | }
327 |
328 | input,
329 | textarea {
330 | border: 1px solid var(--color-bg-secondary);
331 | border-radius: var(--border-radius);
332 | padding: 0.4rem 0.8rem;
333 | }
334 |
335 | label {
336 | font-weight: bold;
337 | margin-bottom: 0.2rem;
338 | }
339 |
340 | /* Tables */
341 | table {
342 | border: 1px solid var(--color-bg-secondary);
343 | border-radius: var(--border-radius);
344 | border-spacing: 0;
345 | max-width: 100%;
346 | overflow: hidden;
347 | padding: 0;
348 | }
349 |
350 | table td,
351 | table th,
352 | table tr {
353 | padding: 0.4rem 0.8rem;
354 | text-align: var(--justify-important);
355 | }
356 |
357 | table thead {
358 | background-color: var(--color);
359 | border-collapse: collapse;
360 | border-radius: var(--border-radius);
361 | color: var(--color-bg);
362 | margin: 0;
363 | padding: 0;
364 | }
365 |
366 | table thead th:first-child {
367 | border-top-left-radius: var(--border-radius);
368 | }
369 |
370 | table thead th:last-child {
371 | border-top-right-radius: var(--border-radius);
372 | }
373 |
374 | table thead th:first-child,
375 | table tr td:first-child {
376 | text-align: var(--justify-normal);
377 | }
378 |
379 | /* Quotes */
380 | blockquote {
381 | display: block;
382 | font-size: x-large;
383 | line-height: var(--line-height);
384 | margin: 1rem auto;
385 | max-width: var(--width-card-medium);
386 | padding: 1.5rem 1rem;
387 | text-align: var(--justify-important);
388 | }
389 |
390 | blockquote footer {
391 | color: var(--color-text-secondary);
392 | display: block;
393 | font-size: small;
394 | line-height: var(--line-height);
395 | padding: 1.5rem 0;
396 | }
397 |
398 | /* Custom styles */
399 | .w-100 {
400 | width: 100%;
401 | }
402 |
403 | .active {
404 | border-bottom: 1px solid var(--color-black);
405 | }
406 |
--------------------------------------------------------------------------------
/server/src/persistence/traces.js:
--------------------------------------------------------------------------------
1 | const { sql } = require('slonik')
2 | const uuid = require('uuid/v4')
3 | const db = require('./db')
4 |
5 | const Operators = {
6 | eq: sql` = `,
7 | ne: sql` <> `
8 | }
9 |
10 | function compileTraceFilters(filters) {
11 | const q = filters
12 | .filter(f => {
13 | if (!f) {
14 | return false
15 | }
16 | return !!(f.field && f.value && f.operator)
17 | })
18 | .map(f => {
19 | return sql.join(
20 | [sql`${sql.identifier([f.field])}`, sql`${f.value}`],
21 | Operators[f.operator]
22 | )
23 | })
24 |
25 | return sql.join(q, sql` AND `)
26 | }
27 |
28 | module.exports = {
29 | async create(graphId, traces) {
30 | const values = traces.map(trace => {
31 | const {
32 | durationNs,
33 | key,
34 | operationId,
35 | startTime,
36 | endTime,
37 | root,
38 | clientName,
39 | clientVersion,
40 | schemaTag,
41 | details = {},
42 | hasErrors
43 | } = trace
44 |
45 | return [
46 | uuid(),
47 | key,
48 | graphId,
49 | durationNs,
50 | startTime.toUTCString(),
51 | endTime.toUTCString(),
52 | JSON.stringify(root),
53 | !!hasErrors,
54 | clientName,
55 | clientVersion,
56 | schemaTag,
57 | operationId,
58 | JSON.stringify(details)
59 | ]
60 | })
61 |
62 | const query = sql`
63 | INSERT INTO traces (id, key, "graphId", "duration", "startTime", "endTime", "root", "hasErrors", "clientName", "clientVersion", "schemaTag", "operationId", "details")
64 | SELECT *
65 | FROM ${sql.unnest(values, [
66 | 'uuid',
67 | 'text',
68 | 'uuid',
69 | 'float8',
70 | 'timestamp',
71 | 'timestamp',
72 | 'jsonb',
73 | 'bool',
74 | 'text',
75 | 'text',
76 | 'text',
77 | 'uuid',
78 | 'jsonb'
79 | ])}
80 | RETURNING id
81 | ;
82 | `
83 |
84 | const { rows } = await db.query(query)
85 | return rows
86 | },
87 | async cull(threshold = 1) {
88 | return db.query(sql`
89 | delete from traces where id in (
90 | select id from (
91 | select * from (
92 | select id, row_number() over (partition by "graphId" order by "createdAt" desc) as seq
93 | from traces
94 | ) as t
95 | where seq > ${threshold}
96 | ) as tt
97 | )
98 | `)
99 | },
100 | async findAll(
101 | traceFilters = [],
102 | { to, from },
103 | orderBy = { field: 'duration', asc: false },
104 | cursor,
105 | limit = 7
106 | ) {
107 | // get slowest by 95 percentile, count and group by key.
108 | let cursorClause = sql``
109 | let orderDirection = sql``
110 |
111 | if (cursor) {
112 | if (orderBy.asc) {
113 | cursorClause = sql` where id >= ${cursor}`
114 | } else {
115 | cursorClause = sql` where id <= ${cursor}`
116 | }
117 | }
118 |
119 | if (orderBy.asc) {
120 | orderDirection = sql` asc`
121 | } else {
122 | orderDirection = sql` desc`
123 | }
124 |
125 | const query = sql`
126 | SELECT * from (
127 | SELECT * FROM traces
128 | WHERE ${compileTraceFilters(traceFilters)}
129 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
130 | order by ${sql.identifier([
131 | orderBy.field
132 | ])}${orderDirection}, key ${orderDirection}
133 | ) as orderedTraces
134 | ${cursorClause}
135 | limit ${limit}`
136 |
137 | const { rows } = await db.query(query)
138 | return rows
139 | },
140 | findById(traceId) {
141 | const query = sql`
142 | SELECT * FROM traces
143 | where "id" = ${traceId}
144 | `
145 |
146 | return db.one(query)
147 | },
148 | async findAllOperations(
149 | traceFilters = [],
150 | { to, from },
151 | orderBy = { field: 'count', asc: false },
152 | cursor = [],
153 | limit = 1
154 | ) {
155 | // TODO clean up this query had to use row_number for pagination because sort by uuid + other order was
156 | // giving inconsistent results
157 | // get slowest by 95 percentile, count and group by key.
158 | let cursorClause = sql``
159 | let orderDirection = sql``
160 |
161 | if (cursor.length > 0) {
162 | cursorClause = sql` where ${sql.identifier([cursor[1]])} >= ${cursor[0]}`
163 | }
164 |
165 | if (orderBy.asc) {
166 | orderDirection = sql` asc`
167 | } else {
168 | orderDirection = sql` desc`
169 | }
170 |
171 | const query = sql`
172 | with ops as (
173 | select *,
174 | (CASE WHEN count = 0 THEN 0 ELSE (100 * "errorCount"/count) END) as "errorPercent"
175 | from (
176 | SELECT key, "operationId" as id, PERCENTILE_CONT(0.95)
177 | within group (order by duration asc) as duration,
178 | count(CASE WHEN "hasErrors" THEN 1 END) as "errorCount",
179 | count(id) as count FROM traces
180 | WHERE ${compileTraceFilters(traceFilters)}
181 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
182 | group by key, "operationId"
183 | ) opsWithErrorRate
184 | )
185 |
186 | SELECT * from (
187 | SELECT
188 | *,
189 | row_number() over (
190 | order by ${sql.identifier(['ops', orderBy.field])}${orderDirection}
191 | ) as "rowNumber",
192 | (CASE WHEN count = 0 THEN 0 ELSE (100 * "errorCount"/count) END) as "errorPercent"
193 | from ops
194 | order by ${sql.identifier(['ops', orderBy.field])}${orderDirection}
195 | ) as orderedOps
196 | ${cursorClause}
197 | limit ${limit}`
198 |
199 | const { rows } = await db.query(query)
200 | return rows
201 | },
202 | findStats(traceFilters = [], { to, from }) {
203 | const query = sql`
204 | select *,
205 | (CASE WHEN duration IS NULL then 0 ELSE duration END) as "duration",
206 | (CASE WHEN count = 0 THEN 0 ELSE (100 * "errorCount"/count) END) as "errorPercent"
207 | from (
208 | select count(id) as count,
209 | count(CASE WHEN "hasErrors" THEN 1 END) as "errorCount",
210 | PERCENTILE_CONT(0.95) within group (order by duration asc) as duration
211 | FROM traces
212 | WHERE ${compileTraceFilters(traceFilters)}
213 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
214 | ) as graphStats;`
215 |
216 | return db.one(query)
217 | },
218 | async findRPM(traceFilters, { to, from }) {
219 | const gap = to - from
220 | // we always have 100 "intervals in the series".
221 | // probably a smart way to do this in postgres instead.
222 | const interval = Math.floor(gap / 1000 / 60 / 100) || 1
223 | const intervalMin = interval + ' minute'
224 |
225 | const query = sql`
226 | with series as (
227 | select interval from generate_series(date_round(${from.toUTCString()}, ${intervalMin}), date_round(${to.toUTCString()}, ${intervalMin}), (${intervalMin})::INTERVAL) as interval
228 | ),
229 | rpm as (
230 | select "startTime", "hasErrors" from traces
231 | WHERE ${compileTraceFilters(traceFilters)}
232 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
233 | )
234 |
235 | SELECT
236 | count("startTime") as count,
237 | count(CASE WHEN "hasErrors" THEN 1 END) as "errorCount",
238 | interval as "startTime"
239 | FROM series
240 | left outer JOIN rpm on date_round(rpm."startTime", ${intervalMin}) = interval
241 | group by interval
242 | order by interval;
243 | `
244 |
245 | const { rows } = await db.query(query)
246 | return rows
247 | },
248 | async latencyDistribution(traceFilters, { to, from }) {
249 | // this is not bound by time may have to do for bigger data sets.
250 | const tfs = compileTraceFilters(traceFilters)
251 |
252 | const query = sql`
253 | WITH min_max AS (
254 | SELECT
255 | min(duration) AS min_val,
256 | max(duration) AS max_val
257 | FROM traces
258 | where ${tfs}
259 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
260 | )
261 | SELECT
262 | min(duration) as min_duration,
263 | max(duration) as duration,
264 | count(*),
265 | width_bucket(duration, min_val, max_val, 50) AS bucket
266 | FROM traces, min_max
267 | WHERE ${tfs}
268 | AND "startTime" between ${from.toUTCString()} and ${to.toUTCString()}
269 | GROUP BY bucket
270 | ORDER BY bucket;
271 | `
272 |
273 | const { rows } = await db.query(query)
274 | return rows
275 | },
276 | findFilterOptions({ graphId }) {
277 | const query = sql`
278 | with cte as (
279 | select "schemaTag", "clientName", "clientVersion" from traces
280 | where "graphId"=${graphId}
281 | )
282 |
283 | select
284 | ARRAY(select distinct "schemaTag" from cte) as "schemaTag",
285 | ARRAY(select distinct "clientName" from cte) as "clientName",
286 | ARRAY(select distinct "clientVersion" from cte) as "clientVersion"
287 | `
288 |
289 | return db.one(query)
290 | }
291 | }
292 |
--------------------------------------------------------------------------------
/server/src/graphql/resolvers.js:
--------------------------------------------------------------------------------
1 | const {
2 | UserInputError,
3 | ForbiddenError,
4 | GraphQLError
5 | } = require('apollo-server-express')
6 | const { User, Graph, Key, Trace } = require('../persistence')
7 | const { DateTimeResolver, JSONResolver } = require('graphql-scalars')
8 | const { Cursor } = require('./utils')
9 |
10 | // todo this should be injected for testing.
11 | function processDates(from, to) {
12 | if (!to) {
13 | to = new Date()
14 | }
15 | if (!from) {
16 | from = new Date(0)
17 | }
18 |
19 | return {
20 | from,
21 | to
22 | }
23 | }
24 |
25 | module.exports = {
26 | DateTime: DateTimeResolver,
27 | JSON: JSONResolver,
28 | Query: {
29 | traceFilterOptions: async (_, { graphId }, { req }) => {
30 | const graph = await Graph.findById(graphId)
31 | if (graph.userId !== req.session.userId) {
32 | throw new ForbiddenError()
33 | }
34 |
35 | const options = await Trace.findFilterOptions({ graphId })
36 |
37 | return {
38 | ...options,
39 | hasErrors: ['true', 'false']
40 | }
41 | },
42 | user: async (_, _args, { req }) => {
43 | const user = await User.findById(req.session.userId)
44 |
45 | return user
46 | },
47 | graph: async (_, { graphId, ...rest }, { req }) => {
48 | const graph = await Graph.findById(graphId)
49 | if (graph.userId !== req.session.userId) {
50 | throw new ForbiddenError()
51 | }
52 |
53 | return graph
54 | },
55 | traces: async (
56 | _,
57 | { graphId, after, operationId, orderBy, to, from, traceFilters },
58 | { req }
59 | ) => {
60 | const graph = await Graph.findById(graphId)
61 | if (graph.userId !== req.session.userId) {
62 | throw new ForbiddenError()
63 | }
64 |
65 | if (!traceFilters) {
66 | traceFilters = []
67 | }
68 | if (!orderBy) {
69 | orderBy = { field: 'duration', asc: false }
70 | }
71 |
72 | const limit = 10
73 | const [cursor] = Cursor.decode(after)
74 | const nodes = await Trace.findAll(
75 | [
76 | ...traceFilters,
77 | { field: 'graphId', operator: 'eq', value: graphId },
78 | operationId && {
79 | field: 'operationId',
80 | operator: 'eq',
81 | value: operationId
82 | }
83 | ],
84 | processDates(from, to),
85 | orderBy,
86 | cursor,
87 | limit
88 | )
89 |
90 | // we always fetch one more than we need to calculate hasNextPage
91 | const hasNextPage = nodes.length >= limit
92 |
93 | return {
94 | cursor: hasNextPage
95 | ? Cursor.encode(nodes.pop(), 'id', orderBy.asc)
96 | : '',
97 | nodes
98 | }
99 | },
100 | trace: async (_, { graphId, traceId }, { req }) => {
101 | // TODO PERMISSIONS
102 | const trace = await Trace.findById(traceId)
103 | return trace
104 | },
105 | operations: async (
106 | _,
107 | { graphId, orderBy, after, traceFilters, to, from },
108 | { req }
109 | ) => {
110 | const graph = await Graph.findById(graphId)
111 |
112 | if (graph.userId !== req.session.userId) {
113 | throw new ForbiddenError()
114 | }
115 |
116 | if (!orderBy) {
117 | orderBy = { field: 'count', asc: false }
118 | }
119 |
120 | if (!traceFilters) {
121 | traceFilters = []
122 | }
123 |
124 | const limit = 11
125 | const cursor = Cursor.decode(after)
126 | const nodes = await Trace.findAllOperations(
127 | [...traceFilters, { field: 'graphId', operator: 'eq', value: graphId }],
128 | processDates(from, to),
129 | orderBy,
130 | cursor,
131 | limit
132 | )
133 |
134 | // we always fetch one more than we need to calculate hasNextPage
135 | const hasNextPage = nodes.length >= limit
136 |
137 | return {
138 | cursor: hasNextPage
139 | ? Cursor.encode(nodes.pop(), 'rowNumber', orderBy.asc)
140 | : '',
141 | nodes
142 | }
143 | },
144 | rpm: async (
145 | _,
146 | { graphId, operationId, to, from, traceFilters },
147 | { req }
148 | ) => {
149 | const graph = await Graph.findById(graphId)
150 | if (graph.userId !== req.session.userId) {
151 | throw new ForbiddenError()
152 | }
153 |
154 | if (!traceFilters) {
155 | traceFilters = []
156 | }
157 |
158 | const nodes = await Trace.findRPM(
159 | [
160 | ...traceFilters,
161 | { field: 'graphId', operator: 'eq', value: graphId },
162 | operationId && {
163 | field: 'operationId',
164 | operator: 'eq',
165 | value: operationId
166 | }
167 | ],
168 | processDates(from, to)
169 | )
170 |
171 | return {
172 | nodes,
173 | cursor: ''
174 | }
175 | },
176 | latencyDistribution: async (
177 | _,
178 | { graphId, operationId, traceFilters, to, from },
179 | { req }
180 | ) => {
181 | const graph = await Graph.findById(graphId)
182 | if (graph.userId !== req.session.userId) {
183 | throw new ForbiddenError()
184 | }
185 |
186 | if (!traceFilters) {
187 | traceFilters = []
188 | }
189 |
190 | const nodes = await Trace.latencyDistribution(
191 | [
192 | ...traceFilters,
193 | { field: 'graphId', operator: 'eq', value: graphId },
194 | operationId && {
195 | field: 'operationId',
196 | operator: 'eq',
197 | value: operationId
198 | }
199 | ],
200 | processDates(from, to)
201 | )
202 |
203 | return {
204 | nodes,
205 | cursor: ''
206 | }
207 | },
208 | stats: async (
209 | _,
210 | { graphId, operationId, traceFilters, to, from },
211 | { req }
212 | ) => {
213 | const graph = await Graph.findById(graphId)
214 | if (graph.userId !== req.session.userId) {
215 | throw new ForbiddenError()
216 | }
217 |
218 | if (!traceFilters) {
219 | traceFilters = []
220 | }
221 |
222 | return Trace.findStats(
223 | [
224 | ...traceFilters,
225 | { field: 'graphId', operator: 'eq', value: graphId },
226 | operationId && {
227 | field: 'operationId',
228 | operator: 'eq',
229 | value: operationId
230 | }
231 | ],
232 | processDates(from, to)
233 | )
234 | },
235 | operation: async (
236 | _,
237 | { graphId, operationId, traceFilters, to, from },
238 | { req }
239 | ) => {
240 | const graph = await Graph.findById(graphId)
241 | if (graph.userId !== req.session.userId) {
242 | throw new ForbiddenError()
243 | }
244 |
245 | if (!traceFilters) {
246 | traceFilters = []
247 | }
248 |
249 | const rows = await Trace.findAllOperations(
250 | [
251 | ...traceFilters,
252 | { field: 'graphId', operator: 'eq', value: graphId },
253 | operationId && {
254 | field: 'operationId',
255 | operator: 'eq',
256 | value: operationId
257 | }
258 | ],
259 | processDates(from, to)
260 | )
261 |
262 | return rows[0]
263 | }
264 | },
265 | Mutation: {
266 | userLogin: async (_, { email }, { req, magicLink }) => {
267 | let user = await User.find(email)
268 |
269 | if (!user) {
270 | try {
271 | user = await User.create(email)
272 | } catch (e) {
273 | throw new ForbiddenError()
274 | }
275 | }
276 |
277 | // fire and forget
278 | magicLink.send(user)
279 | return true
280 | },
281 | tokenVerify: async (_, { token }, { req, magicLink }) => {
282 | const user = await magicLink.verify(token)
283 |
284 | if (!user) {
285 | throw new ForbiddenError()
286 | }
287 |
288 | req.session.userId = user.id
289 | req.session.userEmail = user.email
290 |
291 | return user
292 | },
293 | userLogout: async (_, {}, { req }) => {
294 | try {
295 | req.session.destroy()
296 | return true
297 | } catch (error) {
298 | throw new GraphQLError(`DELETE session >> ${error.stack}`)
299 | }
300 | },
301 | graphCreate: async (_, { name }, { req }) => {
302 | const userId = req.session.userId
303 |
304 | if (name.length === 0) {
305 | throw new UserInputError('name cannot be empty')
306 | }
307 |
308 | return Graph.create(name, userId)
309 | },
310 | keyCreate: async (_, { graphId }, { req }) => {
311 | const graph = await Graph.findById(graphId)
312 | if (graph.userId !== req.session.userId) {
313 | throw new ForbiddenError()
314 | }
315 | return Key.create(graphId)
316 | },
317 | keyRevoke: async (_, { keyId }, { req }) => {
318 | const key = await Key.findById(keyId)
319 | const graph = await Graph.findById(key.graphId)
320 |
321 | if (graph.userId !== req.session.userId) {
322 | throw new ForbiddenError()
323 | }
324 | await Key.revoke(keyId)
325 | return true
326 | }
327 | },
328 | Graph: {
329 | user: ({ userId }) => {
330 | return User.findById(userId)
331 | },
332 | keys: ({ id }) => {
333 | return Key.findAll({ graphId: id })
334 | },
335 | stats: ({ id }, { traceFilters, from, to }) => {
336 | if (!traceFilters) {
337 | traceFilters = []
338 | }
339 | return Trace.findStats(
340 | [...traceFilters, { field: 'graphId', operator: 'eq', value: id }],
341 | processDates(from, to)
342 | )
343 | }
344 | },
345 | User: {
346 | graphs: ({ id }) => {
347 | return Graph.findAll({ userId: id })
348 | }
349 | },
350 | Key: {
351 | secret: ({ graphId, secret }) => {
352 | if (secret) {
353 | return `${graphId}:${secret}`
354 | }
355 | return null
356 | },
357 | graph: ({ graphId }) => {
358 | return Graph.findById(graphId)
359 | }
360 | },
361 | Operation: {
362 | stats: ({ duration, count, errorCount, errorPercent }) => {
363 | return { duration, count, errorCount, errorPercent }
364 | }
365 | }
366 | }
367 |
--------------------------------------------------------------------------------