├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── jest.yml ├── .gitignore ├── .husky └── pre-push ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── _.babelrc ├── __tests__ └── unit │ ├── parseHTTP.jsx │ ├── parseMongoose.jsx │ └── parsePg.jsx ├── jest.config.mjs ├── next.config.js ├── otelController.js ├── package-lock.json ├── package.json ├── pages ├── _DetailList.tsx ├── _MainWaterfall.tsx ├── _Sidebar.tsx ├── _app.tsx ├── api │ ├── dummyDemo.ts │ ├── dummyFetch.ts │ ├── dummyMongoose.ts │ └── dummyPg.ts ├── functions │ ├── errColor.ts │ └── tooltip.js └── index.tsx ├── public └── images │ └── netpulseicon.png ├── styles ├── DetailList.module.css ├── Home.module.css ├── MainWaterfall.module.css ├── Sidebar.module.css ├── globals.css └── theme.ts ├── tracing.js ├── tsconfig.json └── types.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__/**/* 2 | next.config.js 3 | README.md 4 | *.babelrc 5 | *jest.config* 6 | package-lock.json 7 | package.json 8 | server.js 9 | pages/functions/tooltip.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | files: ['**/*.ts', '**/*.tsx'], 5 | parser: '@typescript-eslint/parser', 6 | extends: ['next/core-web-vitals', 'airbnb', 'airbnb-typescript', 'next', 'prettier'], 7 | parserOptions: { 8 | project: './tsconfig.json', 9 | sourceType: 'module', 10 | ecmaVersion: 'latest', 11 | tsconfigRootDir: __dirname, 12 | }, 13 | rules: { 14 | 'react/react-in-jsx-scope': 'off', 15 | 'react/jsx-props-no-spreading': 'off', 16 | 'no-plusplus': 'off', 17 | }, 18 | env: { 19 | es6: true, 20 | }, 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/jest.yml: -------------------------------------------------------------------------------- 1 | name: NextJS CI 2 | 3 | on: 4 | pull_request: 5 | branches: [dev] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Use Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18 17 | 18 | - name: install packages 19 | run: npm ci 20 | - run: npm test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # env 9 | .env 10 | 11 | # testing 12 | /coverage 13 | 14 | # next.js 15 | /.next/ 16 | /out/ 17 | 18 | # production 19 | /build 20 | /dist 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # vercel 36 | .vercel 37 | 38 | # typescript 39 | *.tsbuildinfo 40 | next-env.d.ts 41 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run fix:tsc && \ 5 | npm run fix:lint && \ 6 | npm run fix:format && \ 7 | npm test -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next 2 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "printWidth": 100, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "singleQuote": true, 7 | "semi": true 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 OSLabs Beta 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, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetPulse: Next.js Server Side Observability 2 | 3 | ## Getting Started 4 | 5 | 1. Install the following two NetPulse npm packages: 6 | 7 | ```bash 8 | npm install @netpulse/tracing @netpulse/dashboard 9 | ``` 10 | 11 | 2. Create a `tracing.js` file in the root directory of your project. 12 | 13 | 3. Add the following code to `tracing.js`: 14 | 15 | ```bash 16 | const tracing = require('@netpulse/tracing'); 17 | tracing(); 18 | ``` 19 | 20 | 4. Inside the app or pages directory (depending on if you are using beta) create a file `Dashboard.tsx` and add the following code: 21 | 22 | ```bash 23 | 'use client'; 24 | import dynamic from 'next/dynamic'; 25 | const DashboardUI = dynamic(() => import('@netpulse/dashboard'), { ssr: false }); 26 | export default function Home() { 27 | return ; 28 | } 29 | ``` 30 | 31 | 5. Finally, in your package.json add the following start script: 32 | 33 | ```bash 34 | "tracing": "node --require ./tracing.js & ./node_modules/.bin/next dev" 35 | ``` 36 | 37 | You can now run your Next.js application: 38 | 39 | ```bash 40 | npm run tracing 41 | ``` 42 | 43 | Open [http://localhost:3000/Dashboard](http://localhost:3000/Dashboard) in the browser to view traces related to server side api calls and NoSQL / SQL database calls. 44 | 45 | ## Notes 46 | 47 | API Compatibility: 48 | 49 | - node-fetch 50 | - xmlHttpRequest 51 | - Node HTTP 52 | 53 | _Note: The current version of Next.js (13.2.4) uses an older version of node-fetch. As such, node-fetch (>=3.3.1) must be manually installed and imported into components that require monitoring of fetch calls_ 54 | 55 | Database Compatibility: 56 | 57 | - MongoDB (Mongoose): >=5.9.7 <7 58 | - Postgresql (Pg): >=8 <9 59 | 60 | ## Coming soon 61 | 62 | As an open source project we are open to pull requests or feature requests from the developer community! 63 | 64 | Currently prioritized features: 65 | 66 | - Dashboard containerization through DockerHub 67 | - Compatiblity with additional databases / drivers 68 | - Compatability with native [Next.js fetch](https://beta.nextjs.org/docs/data-fetching/fundamentals) 69 | - Build tool update for better compatibility with ES modules (Non-dynamic import of Dashboard) 70 | -------------------------------------------------------------------------------- /_.babelrc: -------------------------------------------------------------------------------- 1 | // { 2 | // "presets": ["next/babel"], 3 | // "plugins": [] 4 | // } 5 | -------------------------------------------------------------------------------- /__tests__/unit/parseHTTP.jsx: -------------------------------------------------------------------------------- 1 | const { type } = require('os'); 2 | 3 | //identifies strings with substrings that match array elements 4 | const includesAny = (array, string) => { 5 | for (let i = 0; i < array.length; i++) { 6 | if (string.includes(array[i])) return true; 7 | } 8 | return false; 9 | }; 10 | 11 | //mock middleware to handle parsing HTTP requests 12 | const parseHTTP = (req) => { 13 | const clientData = []; 14 | const spans = req.body.resourceSpans[0].scopeSpans[0].spans; 15 | const ignoreEndpoints = ['localhost', 'socket', 'nextjs']; //endpoints to ignore 16 | 17 | //add specific span data to clientData array through deconstruction of span elements 18 | //spans is an array of span objects 19 | //attributes is an array of nested object with one key-value pair per array element 20 | //ex: attributes = [{key: 'http.url', value: {stringValue: 'wwww.api.com/'}}...] 21 | //el.attributes.find finds the array element with a matching key desired and returns the unnested value if 22 | //exists or null if doesn't exist 23 | spans.forEach((el) => { 24 | const clientObj = { 25 | spanId: el.spanId, 26 | traceId: el.traceId, 27 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 28 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 29 | contentLength: (() => { 30 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 31 | const size = packageObj ? packageObj.value.intValue : 0; 32 | return size; 33 | })(), 34 | statusCode: el.attributes.find((attr) => attr.key === 'http.status_code')?.value?.intValue, 35 | endPoint: el.attributes.find((attr) => attr.key === 'http.url')?.value?.stringValue, 36 | requestMethod: el.name, 37 | requestType: 'HTTPS', 38 | }; 39 | 40 | //if the endpoint is an external api add it to client data array 41 | if (clientObj.endPoint) { 42 | if (!includesAny(ignoreEndpoints, clientObj.endPoint)) { 43 | clientData.push(clientObj); 44 | } 45 | } 46 | }); 47 | return clientData; 48 | }; 49 | 50 | //create mock request body representing typical otel export data 51 | const fakeReq = { 52 | body: { 53 | resourceSpans: [ 54 | { 55 | scopeSpans: [ 56 | { 57 | spans: [ 58 | { 59 | spanId: '9asdv922as2', 60 | traceId: '5c263067fe3', 61 | startTimeUnixNano: 3323112231, 62 | endTimeUnixNano: 5323112231, 63 | name: 'GET', 64 | attributes: [ 65 | { key: 'http.request_content_length_uncompressed', value: { intValue: 53 } }, 66 | { key: 'http.url', value: { stringValue: 'https://swapi.dev/api/people/4' } }, 67 | { key: 'http.status_code', value: { intValue: 200 } }, 68 | { key: 'contentLength', value: { intValue: 57 } }, 69 | ], 70 | }, 71 | ], 72 | }, 73 | ], 74 | }, 75 | ], 76 | }, 77 | }; 78 | 79 | //testing overlapping string / array helper function 80 | describe('Testing includesAny helper function.', () => { 81 | test('Identifies overlapping substrings and array elements.', () => { 82 | const ignoreEndpoints = ['localhost', 'socket', 'nextjs']; 83 | const testOne = 'https://swapi.dev/api/people/4'; 84 | const testTwo = 'https://localhost:4000'; 85 | expect(includesAny(ignoreEndpoints, testOne)).toEqual(false); 86 | expect(includesAny(ignoreEndpoints, testTwo)).toEqual(true); 87 | }); 88 | }); 89 | 90 | //testing parseHTTP handler 91 | describe('Testing parseHTTP output.', () => { 92 | test('Request body is deconstructed correctly.', () => { 93 | const clientObj = parseHTTP(fakeReq)[0]; 94 | expect(clientObj.spanId).not.toBe(undefined); 95 | expect(clientObj.traceId).not.toBe(undefined); 96 | expect(clientObj.startTime).not.toBe(undefined); 97 | expect(clientObj.duration).not.toBe(undefined); 98 | expect(clientObj.contentLength).not.toBe(undefined); 99 | expect(clientObj.statusCode).not.toBe(undefined); 100 | expect(clientObj.endPoint).not.toBe(undefined); 101 | expect(clientObj.requestMethod).not.toBe(undefined); 102 | expect(clientObj.requestType).not.toBe(undefined); 103 | }); 104 | 105 | test('Data is of the correct type.', () => { 106 | const clientObj = parseHTTP(fakeReq)[0]; 107 | expect(typeof clientObj.spanId).toEqual('string'); 108 | expect(typeof clientObj.traceId).toEqual('string'); 109 | expect(typeof clientObj.startTime).toEqual('number'); 110 | expect(typeof clientObj.duration).toEqual('number'); 111 | expect(typeof clientObj.contentLength).toEqual('number'); 112 | expect(typeof clientObj.statusCode).toEqual('number'); 113 | expect(typeof clientObj.endPoint).toEqual('string'); 114 | expect(typeof clientObj.requestMethod).toEqual('string'); 115 | expect(typeof clientObj.requestType).toEqual('string'); 116 | }); 117 | 118 | test('Data has correct values.', () => { 119 | const clientObj = parseHTTP(fakeReq)[0]; 120 | expect(clientObj.spanId).toEqual('9asdv922as2'); 121 | expect(clientObj.traceId).toEqual('5c263067fe3'); 122 | expect(clientObj.startTime).toEqual(3323); 123 | expect(clientObj.duration).toEqual(2000); 124 | expect(clientObj.contentLength).toEqual(57); 125 | expect(clientObj.statusCode).toEqual(200); 126 | expect(clientObj.endPoint).toEqual('https://swapi.dev/api/people/4'); 127 | expect(clientObj.requestMethod).toEqual('GET'); 128 | expect(clientObj.requestType).toEqual('HTTPS'); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /__tests__/unit/parseMongoose.jsx: -------------------------------------------------------------------------------- 1 | const { type } = require('os'); 2 | 3 | //mock middleware to handle parsing mongoose requests 4 | const parseMongoose = (req) => { 5 | const clientData = []; 6 | const spans = req.body.resourceSpans[0].scopeSpans[0].spans; 7 | 8 | //iterate through array of OTLP objects pulling desired attri 9 | spans.forEach((el) => { 10 | //find package size of individual request 11 | let tempPackageSize; 12 | 13 | const clientObj = { 14 | spanId: el.spanId, 15 | traceId: el.traceId, 16 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 17 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 18 | contentLength: (() => { 19 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 20 | const size = packageObj ? packageObj.value.intValue : 0; 21 | tempPackageSize = size; 22 | return size; 23 | })(), 24 | statusCode: tempPackageSize ? 200 : 404, 25 | endPoint: el.attributes.find((attr) => attr.key === 'db.mongodb.collection')?.value 26 | ?.stringValue, 27 | requestMethod: el.attributes.find((attr) => attr.key === 'db.operation')?.value?.stringValue, 28 | requestType: 'Mongoose', 29 | }; 30 | clientData.push(clientObj); 31 | }); 32 | return clientData; 33 | }; 34 | 35 | //create mock request body representing typical otel export data 36 | const fakeReq = { 37 | body: { 38 | resourceSpans: [ 39 | { 40 | scopeSpans: [ 41 | { 42 | spans: [ 43 | { 44 | spanId: '9asdv922as2', 45 | traceId: '5c263067fe3', 46 | startTimeUnixNano: 3323112231, 47 | endTimeUnixNano: 5323112231, 48 | name: 'GET', 49 | attributes: [ 50 | { key: 'db.mongodb.collection', value: { stringValue: 'Movies' } }, 51 | { key: 'db.operation', value: { stringValue: 'find' } }, 52 | { key: 'contentLength', value: { intValue: 57 } }, 53 | ], 54 | }, 55 | ], 56 | }, 57 | ], 58 | }, 59 | ], 60 | }, 61 | }; 62 | 63 | //testing otelEndpointHandler handler 64 | describe('Testing parseMongoose output.', () => { 65 | test('Request body is deconstructed correctly.', () => { 66 | const clientObj = parseMongoose(fakeReq)[0]; 67 | expect(clientObj.spanId).not.toBe(undefined); 68 | expect(clientObj.traceId).not.toBe(undefined); 69 | expect(clientObj.startTime).not.toBe(undefined); 70 | expect(clientObj.duration).not.toBe(undefined); 71 | expect(clientObj.contentLength).not.toBe(undefined); 72 | expect(clientObj.statusCode).not.toBe(undefined); 73 | expect(clientObj.endPoint).not.toBe(undefined); 74 | expect(clientObj.requestMethod).not.toBe(undefined); 75 | expect(clientObj.requestType).not.toBe(undefined); 76 | }); 77 | 78 | test('Data is of the correct type.', () => { 79 | const clientObj = parseMongoose(fakeReq)[0]; 80 | expect(typeof clientObj.spanId).toEqual('string'); 81 | expect(typeof clientObj.traceId).toEqual('string'); 82 | expect(typeof clientObj.startTime).toEqual('number'); 83 | expect(typeof clientObj.duration).toEqual('number'); 84 | expect(typeof clientObj.contentLength).toEqual('number'); 85 | expect(typeof clientObj.statusCode).toEqual('number'); 86 | expect(typeof clientObj.endPoint).toEqual('string'); 87 | expect(typeof clientObj.requestMethod).toEqual('string'); 88 | expect(typeof clientObj.requestType).toEqual('string'); 89 | }); 90 | 91 | test('Data has correct values.', () => { 92 | const clientObj = parseMongoose(fakeReq)[0]; 93 | expect(clientObj.spanId).toEqual('9asdv922as2'); 94 | expect(clientObj.traceId).toEqual('5c263067fe3'); 95 | expect(clientObj.startTime).toEqual(3323); 96 | expect(clientObj.duration).toEqual(2000); 97 | expect(clientObj.contentLength).toEqual(57); 98 | expect(clientObj.statusCode).toEqual(200); 99 | expect(clientObj.endPoint).toEqual('Movies'); 100 | expect(clientObj.requestMethod).toEqual('find'); 101 | expect(clientObj.requestType).toEqual('Mongoose'); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /__tests__/unit/parsePg.jsx: -------------------------------------------------------------------------------- 1 | const { type } = require('os'); 2 | 3 | //mock middleware to handle parsing mongoose requests 4 | const parsePg = (req) => { 5 | const clientData = []; 6 | const spans = req.body.resourceSpans[0].scopeSpans[0].spans; 7 | 8 | //iterate through array of OTLP objects pulling desired attri 9 | spans.forEach((el) => { 10 | //find package size of individual request 11 | let tempPackageSize; 12 | 13 | const clientObj = { 14 | spanId: el.spanId, 15 | traceId: el.traceId, 16 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 17 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 18 | contentLength: (() => { 19 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 20 | const size = packageObj ? packageObj.value.intValue : 0; 21 | tempPackageSize = size; 22 | return size; 23 | })(), 24 | statusCode: tempPackageSize ? 200 : 404, 25 | endPoint: el.attributes.find((attr) => attr.key === 'db.name')?.value?.stringValue, 26 | requestMethod: el.attributes 27 | .find((attr) => attr.key === 'db.statement') 28 | ?.value?.stringValue.split(' ')[0], 29 | requestType: 'PostgreSQL', 30 | }; 31 | clientData.push(clientObj); 32 | }); 33 | return clientData; 34 | }; 35 | 36 | //create mock request body representing typical otel export data 37 | const fakeReq = { 38 | body: { 39 | resourceSpans: [ 40 | { 41 | scopeSpans: [ 42 | { 43 | spans: [ 44 | { 45 | spanId: '9asdv922as2', 46 | traceId: '5c263067fe3', 47 | startTimeUnixNano: 3323112231, 48 | endTimeUnixNano: 5323112231, 49 | name: 'GET', 50 | attributes: [ 51 | { key: 'db.name', value: { stringValue: 'gooxohsq' } }, 52 | { key: 'db.statement', value: { stringValue: 'SELECT * from TABLE' } }, 53 | { key: 'contentLength', value: { intValue: 57 } }, 54 | ], 55 | }, 56 | ], 57 | }, 58 | ], 59 | }, 60 | ], 61 | }, 62 | }; 63 | 64 | //testing otelEndpointHandler handler 65 | describe('Testing parseMongoose output.', () => { 66 | test('Request body is deconstructed correctly.', () => { 67 | const clientObj = parsePg(fakeReq)[0]; 68 | expect(clientObj.spanId).not.toBe(undefined); 69 | expect(clientObj.traceId).not.toBe(undefined); 70 | expect(clientObj.startTime).not.toBe(undefined); 71 | expect(clientObj.duration).not.toBe(undefined); 72 | expect(clientObj.contentLength).not.toBe(undefined); 73 | expect(clientObj.statusCode).not.toBe(undefined); 74 | expect(clientObj.endPoint).not.toBe(undefined); 75 | expect(clientObj.requestMethod).not.toBe(undefined); 76 | expect(clientObj.requestType).not.toBe(undefined); 77 | }); 78 | 79 | test('Data is of the correct type.', () => { 80 | const clientObj = parsePg(fakeReq)[0]; 81 | expect(typeof clientObj.spanId).toEqual('string'); 82 | expect(typeof clientObj.traceId).toEqual('string'); 83 | expect(typeof clientObj.startTime).toEqual('number'); 84 | expect(typeof clientObj.duration).toEqual('number'); 85 | expect(typeof clientObj.contentLength).toEqual('number'); 86 | expect(typeof clientObj.statusCode).toEqual('number'); 87 | expect(typeof clientObj.endPoint).toEqual('string'); 88 | expect(typeof clientObj.requestMethod).toEqual('string'); 89 | expect(typeof clientObj.requestType).toEqual('string'); 90 | }); 91 | 92 | test('Data has correct values.', () => { 93 | const clientObj = parsePg(fakeReq)[0]; 94 | expect(clientObj.spanId).toEqual('9asdv922as2'); 95 | expect(clientObj.traceId).toEqual('5c263067fe3'); 96 | expect(clientObj.startTime).toEqual(3323); 97 | expect(clientObj.duration).toEqual(2000); 98 | expect(clientObj.contentLength).toEqual(57); 99 | expect(clientObj.statusCode).toEqual(200); 100 | expect(clientObj.endPoint).toEqual('gooxohsq'); 101 | expect(clientObj.requestMethod).toEqual('SELECT'); 102 | expect(clientObj.requestType).toEqual('PostgreSQL'); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /jest.config.mjs: -------------------------------------------------------------------------------- 1 | // jest.config.mjs 2 | import nextJest from 'next/jest.js'; 3 | 4 | const createJestConfig = nextJest({ 5 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 6 | dir: './', 7 | }); 8 | 9 | // Add any custom config to be passed to Jest 10 | /** @type {import('jest').Config} */ 11 | const config = { 12 | // Add more setup options before each test is run 13 | // setupFilesAfterEnv: ['/jest.setup.js'], 14 | 15 | testEnvironment: 'jest-environment-jsdom', 16 | }; 17 | 18 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 19 | export default createJestConfig(config); 20 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | async headers() { 5 | return [ 6 | { 7 | // matching all API routes 8 | source: '/api/:path*', 9 | headers: [ 10 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 11 | { key: 'Access-Control-Allow-Origin', value: '*' }, 12 | { key: 'Access-Control-Allow-Methods', value: 'GET,OPTIONS,PATCH,DELETE,POST,PUT' }, 13 | { 14 | key: 'Access-Control-Allow-Headers', 15 | value: 16 | 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version', 17 | }, 18 | ], 19 | }, 20 | ]; 21 | }, 22 | }; 23 | 24 | module.exports = nextConfig; 25 | -------------------------------------------------------------------------------- /otelController.js: -------------------------------------------------------------------------------- 1 | const otelController = {}; 2 | 3 | //helper function - identifies strings with substrings that match array elements 4 | const includesAny = (array, string) => { 5 | for (let i = 0; i < array.length; i++) { 6 | if (string.includes(array[i])) return true; 7 | } 8 | return false; 9 | }; 10 | 11 | //middleware to handle parsing HTTP requests 12 | const parseHTTP = (clientData, spans) => { 13 | const ignoreEndpoints = ['localhost', 'socket', 'nextjs']; //endpoints to ignore 14 | 15 | //add specific span data to clientData array through deconstruction of span elements 16 | //spans is an array of span objects 17 | //attributes is an array of nested object with one key-value pair per array element 18 | //ex: attributes = [{key: 'http.url', value: {stringValue: 'wwww.api.com/'}}...] 19 | //el.attributes.find finds the array element with a matching key desired and returns the unnested value if 20 | //exists or null if doesn't exist 21 | spans.forEach((el) => { 22 | const clientObj = { 23 | spanId: el.spanId, 24 | traceId: el.traceId, 25 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 26 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 27 | contentLength: (() => { 28 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 29 | const size = packageObj ? packageObj.value.intValue : 0; 30 | return size; 31 | })(), 32 | statusCode: el.attributes.find((attr) => attr.key === 'http.status_code')?.value?.intValue, 33 | endPoint: el.attributes.find((attr) => attr.key === 'http.url')?.value?.stringValue, 34 | requestMethod: el.name, 35 | requestType: 'HTTPS', 36 | }; 37 | 38 | //if the endpoint is an external api add it to client data array 39 | if (clientObj.endPoint) { 40 | if (!includesAny(ignoreEndpoints, clientObj.endPoint)) { 41 | clientData.push(clientObj); 42 | } 43 | } 44 | }); 45 | return clientData; 46 | }; 47 | 48 | //middleware to handle parsing mongoose requests 49 | const parseMongoose = (clientData, spans) => { 50 | //iterate through array of OTLP objects pulling desired attri 51 | spans.forEach((el) => { 52 | //find package size of individual request 53 | let tempPackageSize; 54 | 55 | const clientObj = { 56 | spanId: el.spanId, 57 | traceId: el.traceId, 58 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 59 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 60 | contentLength: (() => { 61 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 62 | const size = packageObj ? packageObj.value.intValue : 0; 63 | tempPackageSize = size; 64 | return size; 65 | })(), 66 | statusCode: tempPackageSize ? 200 : 404, 67 | endPoint: el.attributes.find((attr) => attr.key === 'db.mongodb.collection')?.value 68 | ?.stringValue, 69 | requestMethod: el.attributes.find((attr) => attr.key === 'db.operation')?.value?.stringValue, 70 | requestType: 'Mongoose', 71 | }; 72 | clientData.push(clientObj); 73 | }); 74 | return clientData; 75 | }; 76 | 77 | //middleware to handle parsing pg requests 78 | const parsePg = (clientData, spans) => { 79 | //iterate through array of OTLP objects pulling desired attri 80 | spans.forEach((el) => { 81 | //find package size of individual request 82 | let tempPackageSize; 83 | 84 | const clientObj = { 85 | spanId: el.spanId, 86 | traceId: el.traceId, 87 | startTime: Math.floor(el.startTimeUnixNano / Math.pow(10, 6)), //[ms] 88 | duration: Math.floor((el.endTimeUnixNano - el.startTimeUnixNano) / Math.pow(10, 6)), //[ms] 89 | contentLength: (() => { 90 | const packageObj = el.attributes.find((attr) => attr.key === 'contentLength'); 91 | const size = packageObj ? packageObj.value.intValue : 0; 92 | tempPackageSize = size; 93 | return size; 94 | })(), 95 | statusCode: tempPackageSize ? 200 : 404, 96 | endPoint: el.attributes.find((attr) => attr.key === 'db.name')?.value?.stringValue, 97 | requestMethod: el.attributes 98 | .find((attr) => attr.key === 'db.statement') 99 | ?.value?.stringValue.split(' ')[0], 100 | requestType: 'PostgreSQL', 101 | }; 102 | clientData.push(clientObj); 103 | }); 104 | return clientData; 105 | }; 106 | 107 | otelController.parseTrace = (req, res, next) => { 108 | let clientData = []; 109 | const spans = req.body.resourceSpans[0].scopeSpans[0].spans; 110 | 111 | const instrumentationLibrary = spans[0].attributes.find( 112 | (attr) => attr.key === 'instrumentationLibrary' 113 | )?.value?.stringValue; 114 | 115 | //invoke different middleware function based on instrument used to collect incoming trace 116 | //middleware functions will deconstruct request body and built out clientData array 117 | switch (instrumentationLibrary) { 118 | case '@opentelemetry/instrumentation-mongoose': 119 | clientData = parseMongoose(clientData, spans); 120 | break; 121 | case '@opentelemetry/instrumentation-http': 122 | clientData = parseHTTP(clientData, spans); 123 | break; 124 | case '@opentelemetry/instrumentation-pg': 125 | clientData = parsePg(clientData, spans); 126 | break; 127 | default: 128 | break; 129 | } 130 | 131 | res.locals.clientData = clientData; 132 | return next(); 133 | }; 134 | 135 | module.exports = otelController; 136 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "datatrace", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "nodemon --require ./tracing.js ./node_modules/.bin/next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest --runInBand", 11 | "print": "echo 'hello all'", 12 | "prepare": "husky install", 13 | "fix:lint": "next lint -- --fix", 14 | "fix:format": "prettier --write .", 15 | "fix:tsc": "tsc --noEmit" 16 | }, 17 | "dependencies": { 18 | "@emotion/react": "^11.10.6", 19 | "@emotion/styled": "^11.10.6", 20 | "@mui/icons-material": "^5.11.11", 21 | "@mui/material": "^5.11.15", 22 | "@observablehq/plot": "^0.6.5", 23 | "@opentelemetry/exporter-trace-otlp-http": "^0.37.0", 24 | "@opentelemetry/instrumentation": "^0.37.0", 25 | "@opentelemetry/instrumentation-http": "^0.36.1", 26 | "@opentelemetry/instrumentation-mongoose": "^0.32.1", 27 | "@opentelemetry/instrumentation-pg": "^0.35.0", 28 | "@opentelemetry/sdk-trace-node": "^1.11.0", 29 | "@types/node": "^18.15.11", 30 | "@types/react": "^18.0.31", 31 | "@types/react-dom": "^18.0.11", 32 | "chart.js": "^4.2.1", 33 | "chartjs-adapter-date-fns": "^3.0.0", 34 | "cors": "^2.8.5", 35 | "d3": "^7.8.4", 36 | "date-fns": "^2.29.3", 37 | "dotenv": "^16.0.3", 38 | "express": "^4.18.2", 39 | "material-react-table": "^1.9.2", 40 | "mongoose": "^6.10.5", 41 | "next": "13.2.4", 42 | "node-fetch": "^3.3.1", 43 | "pg": "^8.10.0", 44 | "react": "18.2.0", 45 | "react-chartjs-2": "^5.2.0", 46 | "react-dom": "18.2.0", 47 | "socket.io": "^4.6.1", 48 | "socket.io-client": "^4.6.1" 49 | }, 50 | "devDependencies": { 51 | "@testing-library/jest-dom": "^5.16.5", 52 | "@testing-library/react": "^14.0.0", 53 | "@types/d3": "^7.4.0", 54 | "@types/node": "^18.15.11", 55 | "@types/react": "^18.0.31", 56 | "@types/react-dom": "^18.0.11", 57 | "@typescript-eslint/eslint-plugin": "^5.57.1", 58 | "@typescript-eslint/parser": "^5.57.1", 59 | "eslint": "^8.37.0", 60 | "eslint-config-airbnb": "^19.0.4", 61 | "eslint-config-airbnb-typescript": "^17.0.0", 62 | "eslint-config-next": "^13.2.4", 63 | "eslint-config-prettier": "^8.8.0", 64 | "eslint-plugin-import": "^2.27.5", 65 | "eslint-plugin-jsx-a11y": "^6.7.1", 66 | "eslint-plugin-react": "^7.32.2", 67 | "eslint-plugin-react-hooks": "^4.6.0", 68 | "husky": "^8.0.0", 69 | "jest": "^27.0.0", 70 | "jest-environment-jsdom": "^27.0.0", 71 | "prettier": "2.8.7", 72 | "typescript": "^5.0.3" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pages/_DetailList.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import MaterialReactTable from 'material-react-table'; // For resizing & auto sorting columns - Move to detail 4 | import styles from '@/styles/DetailList.module.css'; 5 | 6 | export default function DetailList({ columns, data }: any) { 7 | return ( 8 |
9 | {/* Data is passed via data, column info passed via columns */} 10 | 26 |
27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /pages/_MainWaterfall.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import styles from '@/styles/MainWaterfall.module.css'; 4 | import * as d3 from 'd3'; 5 | import * as Plot from '@observablehq/plot'; 6 | import { useEffect, useRef } from 'react'; 7 | import { DataType } from '../types'; 8 | import errColor from './functions/errColor'; 9 | import tooltips from './functions/tooltip'; 10 | 11 | // Component renders the main timeline chart 12 | export default function MainWaterfall(props: any) { 13 | const svgRef: any = useRef(null); 14 | 15 | let svgWidth: any; 16 | let svgHeight: any; 17 | 18 | // Function to create gantt chart - takes in the data passed down from state 19 | function makeGanttChart(data: DataType[]) { 20 | if (data.length === 0) { 21 | return; 22 | } 23 | 24 | // p Creates the plot object - is wrapped in another function that creates tooltip behavior based on info in 'title' 25 | const p: any = tooltips( 26 | Plot.plot({ 27 | width: svgWidth, 28 | height: svgHeight, 29 | marks: [ 30 | Plot.axisX({ color: '#ced4da', anchor: 'top' }), 31 | Plot.barX(data, { 32 | x1: (d) => d.startTime, 33 | x2: (d) => d.startTime + d.duration, 34 | y: (d) => d.spanId, 35 | rx: 1, 36 | fill: (d) => errColor(d.contentLength, d.statusCode), 37 | title: (d) => `${d.endPoint} | ${d.duration}ms`, 38 | stroke: '#212529', 39 | strokeWidth: 1, 40 | }), 41 | Plot.gridX({ stroke: '#ced4da', strokeOpacity: 0.2 }), 42 | ], 43 | x: { label: 'ms', tickFormat: (e) => `${e} ms` }, 44 | y: { axis: null, paddingOuter: 5 }, // 10 is as large as you should go for the padding base - if there are large numbers of the bars gets too small 45 | }) 46 | ); 47 | 48 | // Selects current timeline, removes it, then adds the newly created one on state updates 49 | d3.select(svgRef.current).selectAll('*').remove(); 50 | if (p) { 51 | d3.select(svgRef.current).append(() => p); 52 | } 53 | } 54 | // Bases size of svg from observable on the current div size 55 | useEffect(() => { 56 | const { data } = props; 57 | if (svgRef.current) { 58 | const dimensions = svgRef.current.getBoundingClientRect(); 59 | svgWidth = dimensions.width; 60 | svgHeight = dimensions.height; 61 | } 62 | makeGanttChart(data); 63 | }, [props?.data]); 64 | 65 | return ; 66 | } 67 | -------------------------------------------------------------------------------- /pages/_Sidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import styles from '@/styles/Sidebar.module.css'; 4 | 5 | export default function Sidebar() { 6 | return ( 7 | 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | import { createTheme, ThemeProvider } from '@mui/material'; 4 | 5 | export default function App({ Component, pageProps }: AppProps) { 6 | const theme = createTheme({ 7 | palette: { 8 | mode: 'dark', 9 | primary: { 10 | main: '#212529', 11 | }, 12 | secondary: { 13 | main: '#212529', 14 | }, 15 | background: { 16 | default: '#212529', 17 | paper: '#212529', 18 | }, 19 | success: { 20 | main: '#212529', 21 | }, 22 | }, 23 | }); 24 | 25 | return ( 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/dummyDemo.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | import fetch from 'node-fetch'; 5 | // coerce next.js 'fetch' to node-fetch so it is picked up by opentelemetry 6 | const { Movie } = require('../../tracing'); 7 | const { pool } = require('../../tracing'); 8 | 9 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 10 | // make many fetches at once 11 | const response = await fetch(`https://swapi.dev/api/people/4`); 12 | const response2 = await fetch(`https://zenquotes.io/api/today`); 13 | const response3 = await fetch(`https://api.adviceslip.com/advice`); 14 | const response16 = await fetch(`https://swapi.dev/api/blanker`); 15 | const response4 = await fetch(`https://api.adviceslip.com/advice`); 16 | const users1 = await pool.query('SELECT * FROM users'); 17 | const response5 = await fetch(`http://swapi.co/api/films`); 18 | const response6 = await fetch(`https://dog.ceo/api/breeds/image/random`); 19 | await Movie.find({}); 20 | const response7 = await fetch(`https://randombig.cat/roar.json`); 21 | const response8 = await fetch(`https://api.apis.guru/v2/list.json`); 22 | const users2 = await pool.query('SELECT * FROM users'); 23 | const response9 = await fetch(`https://www.gov.uk/bank-holidays.json`); 24 | const response10 = await fetch(`https://api.coinbase.com/v2/currencies`); 25 | const response11 = await fetch(`https://api.coinlore.net/api/tickers/`); 26 | const response12 = await fetch(`https://www.cryptingup.com/api/markets`); 27 | const response13 = await fetch(`https://api.exchangerate.host/latest`); 28 | await Movie.find({}); 29 | const response14 = await fetch(`https://api.kraken.com/0/public/Trades?pair=ltcusd`); 30 | const response15 = await fetch(`https://favicongrabber.com/api/grab/github.com`); 31 | 32 | const data1 = await response.json(); 33 | const data2 = await response2.json(); 34 | const data3 = await response3.json(); 35 | const data4 = await response4.json(); 36 | const data5 = await response5.json(); 37 | const data6 = await response6.json(); 38 | const data7 = await response7.json(); 39 | const data8 = await response8.json(); 40 | const data9 = await response9.json(); 41 | const data10 = await response10.json(); 42 | const data11 = await response11.json(); 43 | const data12 = await response12.json(); 44 | const data13 = await response13.json(); 45 | const data14 = await response14.json(); 46 | const data15 = await response15.json(); 47 | const data16 = await response16.json(); 48 | 49 | const data: any[] = [ 50 | data1, 51 | data2, 52 | data3, 53 | data4, 54 | data5, 55 | data6, 56 | data7, 57 | data8, 58 | data9, 59 | data10, 60 | data11, 61 | data12, 62 | data13, 63 | data14, 64 | data15, 65 | data16, 66 | users1, 67 | users2, 68 | ]; 69 | 70 | res.status(200).json(data); 71 | } 72 | -------------------------------------------------------------------------------- /pages/api/dummyFetch.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | import fetch from 'node-fetch'; // coerce next.js 'fetch' to node-fetch so it is picked up by opentelemetry 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | // make many fetches at once 7 | const response = await fetch(`https://swapi.dev/api/people/4`); 8 | const response2 = await fetch(`https://zenquotes.io/api/today`); 9 | const response3 = await fetch(`https://api.adviceslip.com/advice`); 10 | const response4 = await fetch(`https://api.adviceslip.com/advice`); 11 | const response5 = await fetch(`https://swapi.co/api/films`); 12 | const response6 = await fetch(`https://dog.ceo/api/breeds/image/random`); 13 | const response7 = await fetch(`https://randombig.cat/roar.json`); 14 | const response8 = await fetch(`https://api.apis.guru/v2/list.json`); 15 | const response9 = await fetch(`https://www.gov.uk/bank-holidays.json`); 16 | const response10 = await fetch(`https://api.coinbase.com/v2/currencies`); 17 | const response11 = await fetch(`https://api.coinlore.net/api/tickers/`); 18 | const response12 = await fetch(`https://www.cryptingup.com/api/markets`); 19 | const response13 = await fetch(`https://api.exchangerate.host/latest`); 20 | const response14 = await fetch(`https://api.kraken.com/0/public/Trades?pair=ltcusd`); 21 | const response15 = await fetch(`https://favicongrabber.com/api/grab/github.com`); 22 | 23 | const data1 = await response.json(); 24 | const data2 = await response2.json(); 25 | const data3 = await response3.json(); 26 | const data4 = await response4.json(); 27 | const data5 = await response5.json(); 28 | const data6 = await response6.json(); 29 | const data7 = await response7.json(); 30 | const data8 = await response8.json(); 31 | const data9 = await response9.json(); 32 | const data10 = await response10.json(); 33 | const data11 = await response11.json(); 34 | const data12 = await response12.json(); 35 | const data13 = await response13.json(); 36 | const data14 = await response14.json(); 37 | const data15 = await response15.json(); 38 | 39 | const data: any[] = [ 40 | data1, 41 | data2, 42 | data3, 43 | data4, 44 | data5, 45 | data6, 46 | data7, 47 | data8, 48 | data9, 49 | data10, 50 | data11, 51 | data12, 52 | data13, 53 | data14, 54 | data15, 55 | ]; 56 | 57 | res.status(200).json(data); 58 | } 59 | -------------------------------------------------------------------------------- /pages/api/dummyMongoose.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | const { Movie } = require('../../tracing'); 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | await Movie.find({}) 8 | .then((data: any) => res.status(200).json(data[0])) 9 | .catch((err: any) => { 10 | console.log('error:', err); 11 | return res.status(404).json('Mongoose Error'); 12 | }); 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/dummyPg.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | const { pool } = require('../../tracing'); 5 | 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const users = await pool.query('SELECT * FROM users'); 8 | res.status(200).json(users.rows); 9 | } 10 | -------------------------------------------------------------------------------- /pages/functions/errColor.ts: -------------------------------------------------------------------------------- 1 | export default function errColor(contentLength: number, statusCode: number): string { 2 | if (!statusCode) return 'red'; 3 | const strStatus: string = statusCode.toString(); 4 | if (contentLength > 1 && strStatus[0] === '2') return 'green'; 5 | if (strStatus[0] === '3') return '#34dbeb'; 6 | if (strStatus[0] === '4') return 'red'; 7 | if (strStatus[0] === '5') return 'red'; 8 | return 'red'; 9 | } 10 | -------------------------------------------------------------------------------- /pages/functions/tooltip.js: -------------------------------------------------------------------------------- 1 | import * as d3 from 'd3'; 2 | // Exported as function -> if waterfall chart is refactored into observable, can easily wrap Plot.plot() with this function to activate tooltips 3 | // NOTE - When observable native tooltip functionality is added, remove this and use the native functionality instead. 4 | 5 | export default function tooltips(chart) { 6 | // selects the whole chart 7 | const wrap = d3.select(chart); 8 | // creates a tooltip element, assigns it a hover class, joins it to a group of hover 9 | const tooltip = wrap.selectAll('.hover').data([1]).join('g').attr('class', 'hover'); 10 | 11 | wrap.selectAll('title').each(function () { 12 | // Takes text from title attr, sets it as an attr on parent node if it exists, then cleans up by removing it. 13 | const title = d3.select(this); 14 | const parentNode = d3.select(this.parentNode); 15 | 16 | if (title.text()) { 17 | parentNode.attr('titletext', title.text()).classed('has-title', true); 18 | title.remove(); 19 | } 20 | // Governs pointer movement behavior interaction w parent node of title 21 | parentNode 22 | .on('pointerenter pointermove', function (event) { 23 | const text = d3.select(this).attr('titletext'); 24 | const pointer = d3.pointer(event, wrap.node()); 25 | if (text) tooltip.call(hover, pointer, text.split('\n')); 26 | else tooltip.selectAll('*').remove(); 27 | 28 | // Raise it 29 | d3.select(this).raise(); 30 | // Disappears under toolbar without this 31 | 32 | // Checks position of the tip's x & y, translates box position if it would be outside the div 33 | const tipSize = tooltip.node().getBBox(); 34 | // Left side logic 35 | if (pointer[0] + tipSize.x < 0) 36 | tooltip.attr('transform', `translate(${tipSize.width / 2}, ${pointer[1] + 7})`); 37 | // Right side logic 38 | else if (pointer[0] + tipSize.width / 2 > wrap.attr('width')) 39 | tooltip.attr( 40 | 'transform', 41 | `translate(${wrap.attr('width') - tipSize.width / 2}, ${pointer[1] + 7})` 42 | ); 43 | }) 44 | .on('pointerout', function (event) { 45 | tooltip.selectAll('*').remove(); 46 | }); 47 | }); 48 | 49 | // Hover function 50 | const hover = (tooltip, pos, text) => { 51 | const side_padding = 5; 52 | const vertical_padding = 5; 53 | const vertical_offset = 17; 54 | 55 | // Removes hover element as it moves - endleslly expands without this 56 | tooltip.selectAll('*').remove(); 57 | 58 | // Creates text based on position 59 | tooltip 60 | .style('text-anchor', 'middle') 61 | .attr('transform', `translate(${pos[0]}, ${pos[1] + 7})`) 62 | .selectAll('text') 63 | .data(text) 64 | .join('text') 65 | .style('dominant-baseline', 'ideographic') 66 | .text((d) => d) 67 | .attr('y', (d, i) => (i - (text.length - 1)) * 15 - vertical_offset) 68 | .style('font-weight', (d, i) => (i === 0 ? 'bold' : 'normal')); 69 | 70 | // Grabs the size of the box created by the text 71 | 72 | const bbox = tooltip.node().getBBox(); 73 | 74 | // Governs tooltip rear -> currently creates a rectangle based on side & vertical positions 75 | // .style determines background, stroke determines text color 76 | // FUTURE ADDITION: option to change styles to preferences? 77 | tooltip 78 | .append('rect') 79 | .attr('x', bbox.x - side_padding) 80 | .attr('y', bbox.y - vertical_padding) 81 | .attr('width', bbox.width + side_padding * 2) 82 | .attr('height', bbox.height + vertical_padding * 2) 83 | .style('fill', 'white') 84 | .lower(); // Moves the rectangle below the text - DO NOT REMOVE 85 | }; 86 | 87 | return chart; 88 | } 89 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | // import react and nextjs packages 4 | import Head from 'next/head'; 5 | import React, { useCallback, useMemo, useState, useEffect } from 'react'; 6 | 7 | // import types 8 | import type { MRT_ColumnDef } from 'material-react-table'; 9 | 10 | // Material-UI Imports 11 | import { Box } from '@mui/material'; 12 | 13 | // import socket client 14 | import { io } from 'socket.io-client'; 15 | import styles from '@/styles/Home.module.css'; 16 | 17 | // import child components 18 | // import { Inter } from 'next/font/google'; 19 | import Sidebar from './_Sidebar'; 20 | import MainWaterfall from './_MainWaterfall'; 21 | import DetailList from './_DetailList'; 22 | 23 | // import type 24 | import { DataType } from '../types'; 25 | 26 | // import functions 27 | import errColor from './functions/errColor'; 28 | 29 | // const inter = Inter({ subsets: ['latin'] }); 30 | 31 | // Main Component - Home 32 | export default function Home() { 33 | // Hook for updating overall time and tying it to state 34 | // Time is determined by the difference between the final index's start+duration minus the initial index's start 35 | let initialStartTime: number; 36 | const [data, setData] = useState([]); 37 | 38 | // intialize socket connection 39 | // when data recieved update state here 40 | const socketInitializer = useCallback(async () => { 41 | const socket = await io('http://localhost:4000/'); 42 | socket.on('connect', () => { 43 | console.log('socket connected.'); 44 | }); 45 | socket.on('message', (msg) => { 46 | // when data recieved concat messages state with inbound traces 47 | const serverTraces: DataType[] = JSON.parse(msg); 48 | serverTraces.forEach((el: DataType) => { 49 | const newEl = { ...el }; 50 | // TODO: change the below to check for equal to 0 when we get rid of starter data 51 | if (initialStartTime === undefined) { 52 | initialStartTime = el.startTime; 53 | } 54 | if (el.contentLength === null) newEl.contentLength = 1; 55 | newEl.startTime -= initialStartTime; 56 | setData((prev: DataType[]) => [...prev, newEl]); 57 | }); 58 | }); 59 | }, [setData]); 60 | 61 | // when home component mounts initialize socket connection 62 | useEffect(() => { 63 | socketInitializer(); 64 | }, []); 65 | 66 | // an empty array that collects barData objects in the below for loop 67 | const barDataSet = []; 68 | 69 | // generates barData object for each sample data from data array with label being the endpoint 70 | // data takes in the exact start-time and total time 71 | for (let i = 0; i < data.length; i++) { 72 | barDataSet.push({ 73 | label: [data[i].endPoint], 74 | data: [ 75 | { 76 | x: [data[i].startTime, data[i].startTime + data[i].duration], 77 | y: 1, 78 | }, 79 | ], 80 | backgroundColor: ['green'], 81 | borderColor: ['limegreen'], 82 | }); 83 | } 84 | 85 | // Create columns -> later on, we can dynamically declare this based 86 | // on user options using a config file or object or state and only 87 | // rendering the things that are requested 88 | 89 | // Column declaration requires a flat array of objects with a header 90 | // which is the column's title, and an accessorKey, which is the 91 | // key in the data object. 92 | const columns = useMemo[]>( 93 | () => [ 94 | { 95 | header: 'Endpoint', 96 | accessorKey: 'endPoint', 97 | }, 98 | { 99 | header: 'Status', 100 | accessorKey: 'statusCode', 101 | }, 102 | { 103 | header: 'Type', 104 | accessorKey: 'requestType', 105 | }, 106 | { 107 | header: 'Method', 108 | accessorKey: 'requestMethod', 109 | }, 110 | { 111 | header: 'Size (B)', 112 | accessorKey: 'contentLength', 113 | }, 114 | { 115 | header: 'Start (ms)', 116 | accessorKey: 'startTime', 117 | }, 118 | { 119 | header: 'Duration (ms)', 120 | accessorKey: 'duration', 121 | }, 122 | { 123 | header: 'TraceID', 124 | accessorKey: 'traceId', 125 | }, 126 | { 127 | header: 'Waterfall', 128 | accessorKey: 'spanId', 129 | enablePinning: true, 130 | minSize: 200, // min size enforced during resizing 131 | maxSize: 1000, // max size enforced during resizing 132 | size: 300, // medium column 133 | // custom conditional format and styling 134 | // eslint-disable-next-line 135 | Cell: ({ cell, row }) => ( 136 | ({ 139 | // eslint-disable-next-line 140 | backgroundColor: errColor(row.original.contentLength!, row.original.statusCode), 141 | borderRadius: '0.1rem', 142 | color: 'transparent', 143 | // We first select the cell, then determine the left and right portions and make it a percentage 144 | // 145 | marginLeft: (() => { 146 | const cellStartTime = row.original.startTime; 147 | const totalTime = data.length 148 | ? data[data.length - 1].startTime + data[data.length - 1].duration 149 | : cellStartTime; 150 | const pCellTotal = (cellStartTime / totalTime) * 100; 151 | return `${pCellTotal}%`; 152 | })(), 153 | width: (() => { 154 | const cellDuration = row.original.duration; 155 | const totalTime = data.length 156 | ? data[data.length - 1].startTime + data[data.length - 1].duration 157 | : cellDuration; 158 | const pCellDuration = (cellDuration / totalTime) * 100; 159 | return `${pCellDuration}%`; 160 | })(), 161 | })} 162 | > 163 | {/* The | mark is required to mount & render the boxes */}| 164 | 165 | ), 166 | }, 167 | ], 168 | [data] 169 | ); 170 | 171 | return ( 172 | <> 173 | 174 | NetPulse Dashboard 175 | 176 | 177 | 178 |
179 | 180 |
181 | 182 | 183 |
184 |
185 | 186 | ); 187 | } 188 | -------------------------------------------------------------------------------- /public/images/netpulseicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oslabs-beta/NetPulse/0b2d81f1df05574e6d23c0c5a749a1b0863f7620/public/images/netpulseicon.png -------------------------------------------------------------------------------- /styles/DetailList.module.css: -------------------------------------------------------------------------------- 1 | .detailList { 2 | background-color: #212529; 3 | height: 85%; 4 | border: solid 1px #212529; 5 | /* border-radius: 1rem; */ 6 | /* overflow-y: scroll; */ 7 | } 8 | -------------------------------------------------------------------------------- /styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | /* flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; */ 6 | /* padding: 1rem; */ 7 | min-height: 100vh; 8 | background-color: #212529; 9 | } 10 | 11 | .networkContainer { 12 | width: 100%; 13 | /* padding: 1rem; */ 14 | /* border-radius: 1rem; */ 15 | } 16 | 17 | @media (min-width: 701px) and (max-width: 1120px) { 18 | .grid { 19 | grid-template-columns: repeat(2, 50%); 20 | } 21 | } 22 | 23 | @keyframes rotate { 24 | from { 25 | transform: rotate(360deg); 26 | } 27 | 28 | to { 29 | transform: rotate(0deg); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /styles/MainWaterfall.module.css: -------------------------------------------------------------------------------- 1 | .chart { 2 | width: 100%; 3 | height: 15%; 4 | } 5 | 6 | .highlight { 7 | stroke: white; 8 | stroke-width: 4; 9 | } 10 | -------------------------------------------------------------------------------- /styles/Sidebar.module.css: -------------------------------------------------------------------------------- 1 | .sidebar { 2 | font-family: Arial, Helvetica, sans-serif; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | /* justify-content: space-around; */ 7 | background-color: #212529; 8 | width: 10%; 9 | min-width: 120px; 10 | 11 | border: solid 1px #495057; 12 | color: #ced4da; 13 | } 14 | 15 | .Data { 16 | color: #266ed0; 17 | } 18 | 19 | .mainLogo { 20 | margin-top: 10%; 21 | display: flex; 22 | flex-direction: column; 23 | align-items: center; 24 | } 25 | 26 | /* .showMenu { 27 | color: #396087; 28 | font-weight: bold; 29 | display: none; 30 | width: 2rem; 31 | height: 2rem; 32 | } */ 33 | 34 | .sbLogo { 35 | margin-top: 0.5rem; 36 | width: 90%; 37 | padding: 2%; 38 | height: auto; 39 | /* border-radius: 1rem; */ 40 | /* margin-bottom: 10rem; */ 41 | } 42 | 43 | .sbContent { 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | justify-content: space-between; 48 | height: 100%; 49 | margin: 10%; 50 | } 51 | 52 | .mediumLogo { 53 | display: flex; 54 | width: 3rem; 55 | /* margin-top: 10rem; */ 56 | margin-bottom: 1rem; 57 | } 58 | 59 | .npmLogo { 60 | display: flex; 61 | width: 3rem; 62 | margin-top: 0.5rem; 63 | margin-bottom: 1rem; 64 | } 65 | 66 | .githubLogo { 67 | display: flex; 68 | width: 3rem; 69 | margin-top: 0.5rem; 70 | margin-bottom: 1rem; 71 | } 72 | 73 | .hideMenu { 74 | align-self: flex-end; 75 | justify-self: start; 76 | font-weight: bold; 77 | color: #495057; 78 | margin: 0.4rem; 79 | } 80 | 81 | .sidebarButtons { 82 | background-color: rgb(13, 24, 144); 83 | border-radius: 0.3rem; 84 | width: 90%; 85 | padding-bottom: 1rem; 86 | } 87 | 88 | .textLinks { 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | margin-top: -250%; 93 | } 94 | 95 | .sbLinks { 96 | margin: 30% 10%; 97 | display: block; 98 | font-size: 1rem; 99 | color: #ced4da; 100 | } 101 | 102 | .logoLinks { 103 | position: static; 104 | display: flex; 105 | flex-direction: column; 106 | justify-self: end; 107 | } 108 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | max-width: 100vw; 8 | overflow-x: hidden; 9 | max-height: 100vh; 10 | background-color: black; 11 | margin: 0; 12 | } 13 | 14 | a { 15 | color: inherit; 16 | text-decoration: none; 17 | } 18 | -------------------------------------------------------------------------------- /styles/theme.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOptions } from '@mui/material/styles'; 2 | 3 | export const themeOptions: any = { 4 | palette: { 5 | mode: 'dark', 6 | primary: { 7 | main: '#212529', 8 | }, 9 | secondary: { 10 | main: '#212529', 11 | }, 12 | background: { 13 | default: '#212529', 14 | paper: '#212529', 15 | }, 16 | success: { 17 | main: '#212529', 18 | }, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /tracing.js: -------------------------------------------------------------------------------- 1 | //open telemetry packages 2 | require('dotenv').config(); 3 | const { NodeTracerProvider, SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-node'); 4 | const { registerInstrumentations } = require('@opentelemetry/instrumentation'); 5 | const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http'); 6 | const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http'); 7 | 8 | //mongoose instrumentation 9 | const { MongooseInstrumentation } = require('@opentelemetry/instrumentation-mongoose'); 10 | const mongoose = require('mongoose'); 11 | 12 | //pg instrumentation 13 | const { PgInstrumentation } = require('@opentelemetry/instrumentation-pg'); 14 | const { Pool } = require('pg'); 15 | 16 | // --- OPEN TELEMETRY SETUP --- // 17 | 18 | const provider = new NodeTracerProvider(); 19 | 20 | //register instruments 21 | //inject custom custom attributes for package size and instrumentation library used 22 | //for use in otleController middlware 23 | registerInstrumentations({ 24 | instrumentations: [ 25 | new HttpInstrumentation({ 26 | responseHook: (span, res) => { 27 | span.setAttribute('instrumentationLibrary', span.instrumentationLibrary.name); 28 | 29 | // Get the length of the 8-bit byte array. Size indicated the number of bytes of data 30 | let size = 0; 31 | res.on('data', (chunk) => { 32 | size += chunk.length; 33 | }); 34 | 35 | res.on('end', () => { 36 | span.setAttribute('contentLength', size); 37 | }); 38 | }, 39 | }), 40 | new MongooseInstrumentation({ 41 | responseHook: (span, res) => { 42 | span.setAttribute('contentLength', Buffer.byteLength(JSON.stringify(res.response))); 43 | span.setAttribute('instrumentationLibrary', span.instrumentationLibrary.name); 44 | }, 45 | }), 46 | new PgInstrumentation({ 47 | responseHook: (span, res) => { 48 | span.setAttribute('contentLength', Buffer.byteLength(JSON.stringify(res.data.rows))); 49 | span.setAttribute('instrumentationLibrary', span.instrumentationLibrary.name); 50 | }, 51 | }), 52 | ], 53 | }); 54 | 55 | //export traces to custom express server running on port 4000 56 | const traceExporter = new OTLPTraceExporter({ 57 | url: 'http://localhost:4000/', //export traces as http req to custom express server on port 400 58 | }); 59 | 60 | //add exporter to provider / register provider 61 | provider.addSpanProcessor(new SimpleSpanProcessor(traceExporter)); 62 | provider.register(); 63 | 64 | // --- EXPRESS SERVER / SOCKET SETUP --- // 65 | 66 | //express configuration 67 | const express = require('express'); 68 | const app = express(); 69 | app.use(express.json()); 70 | app.use(express.urlencoded({ extended: true })); 71 | 72 | const otelController = require('./otelController'); //import middleware 73 | 74 | //custom express server running on port 4000 to send data to front end dashboard 75 | app.use('/', otelController.parseTrace, (req, res) => { 76 | if (res.locals.clientData.length > 0) io.emit('message', JSON.stringify(res.locals.clientData)); 77 | res.sendStatus(200); 78 | }); 79 | 80 | //start custom express server on port 4000 81 | const server = app 82 | .listen(4000, () => { 83 | console.log(`Custom trace listening server on port 4000`); 84 | }) 85 | .on('error', function (err) { 86 | process.once('SIGUSR2', function () { 87 | process.kill(process.pid, 'SIGUSR2'); 88 | }); 89 | process.on('SIGINT', function () { 90 | // this is only called on ctrl+c, not restart 91 | process.kill(process.pid, 'SIGINT'); 92 | }); 93 | }); 94 | 95 | //create socket running on top of express server + enable cors 96 | const io = require('socket.io')(server, { 97 | cors: { 98 | origin: 'http://localhost:3000', 99 | credentials: true, 100 | }, 101 | }); 102 | 103 | // // -- TESTING -- // 104 | // // --- MONGOOSE SETUP (FOR TESTING) --- // 105 | const myURI = process.env.mongoURI; 106 | 107 | // using older version of mongoose, so need to set strictQuery or else get warning 108 | mongoose.set('strictQuery', true); 109 | 110 | // TODO: Remove the below mongoose test code for production build 111 | // connection to mongoDB using mongoose + test schema 112 | mongoose 113 | .connect(myURI, { 114 | useNewUrlParser: true, 115 | useUnifiedTopology: true, 116 | dbName: 'Movies', 117 | }) 118 | .then(() => console.log('Connected to MongoDB')) 119 | .catch((err) => console.log('Error connecting to DB: ', err)); 120 | 121 | mongoose.models = {}; 122 | 123 | // deconstructed mongoose.Schema and mongoose.model 124 | const { Schema, model } = mongoose; 125 | 126 | // schema for movies 127 | const movieSchema = new Schema({ 128 | title: { 129 | type: String, 130 | required: true, 131 | }, 132 | watched: { 133 | type: Boolean, 134 | required: true, 135 | }, 136 | }); 137 | 138 | // model for movies using movieSchema 139 | const Movie = model('Movies', movieSchema, 'Movies'); 140 | 141 | // --- PG SETUP (FOR TESTING) --- // 142 | const pool = new Pool({ 143 | connectionString: process.env.pgURI, 144 | }); 145 | 146 | module.exports = { Movie, pool }; 147 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "pages/functions/tooltip.ts"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export interface DataType { 2 | spanId: string; 3 | traceId: string; 4 | startTime: number; 5 | duration: number; 6 | contentLength: number | null; 7 | statusCode: number; 8 | endPoint: string; 9 | requestType: string; 10 | requestMethod: string; 11 | } 12 | --------------------------------------------------------------------------------