├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.yml ├── .storybook ├── .babelrc ├── addons.js ├── config.js └── webpack.config.js ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── images └── stats-demo.gif ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── Panel.ts ├── Stats.ts ├── StatsGraph.tsx ├── createStats.ts ├── index.ts └── utils.ts ├── stories └── index.stories.js └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | {"presets": ["@helpscout/zero/babel"]} -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | src/typings/**/* 2 | stories 3 | docs 4 | dist 5 | coverage 6 | other 7 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | {"extends": "./node_modules/@helpscout/zero/eslint.js"} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .eslintcache 4 | dist 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .storybook 3 | .vscode 4 | stories 5 | node_modules 6 | coverage 7 | src 8 | .babelrc 9 | .eslintrc 10 | .eslintignore 11 | .prettierignore 12 | .prettierrc.yml 13 | .travis.yml 14 | rollup.config.js 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | dist/ 4 | coverage/ 5 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | trailingComma: es5 -------------------------------------------------------------------------------- /.storybook/.babelrc: -------------------------------------------------------------------------------- 1 | { "presets": ["@helpscout/zero/babel"] } 2 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-actions/register' 2 | import '@storybook/addon-links/register' 3 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import {configure} from '@storybook/react' 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context('../stories', true, /.stories.js$/) 5 | function loadStories() { 6 | req.keys().forEach(filename => req(filename)) 7 | } 8 | 9 | configure(loadStories, module) 10 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (baseConfig, env, config) => { 2 | // Typescript 3 | config.module.rules.push({ 4 | test: /\.(ts|tsx)$/, 5 | exclude: /__tests__/, 6 | use: { 7 | loader: require.resolve('babel-loader'), 8 | options: { 9 | presets: ['@helpscout/zero/babel'], 10 | }, 11 | }, 12 | }) 13 | config.resolve.extensions.push('.ts', '.tsx') 14 | return config 15 | } 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | 5 | cache: 6 | directories: 7 | - node_modules 8 | 9 | install: 10 | - npm install 11 | 12 | script: 13 | - npm run build 14 | 15 | branches: 16 | only: 17 | - master 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Help Scout 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 📊 Stats 2 | 3 | [![Build Status](https://travis-ci.org/helpscout/stats.svg?branch=master)](https://travis-ci.org/helpscout/stats) 4 | [![npm version](https://badge.fury.io/js/%40helpscout%2Fstats.svg)](https://badge.fury.io/js/%40helpscout%2Fstats) 5 | 6 | > Easy performance monitoring for JavaScript / React 7 | 8 | ![Stats](https://raw.githubusercontent.com/helpscout/stats/master/images/stats-demo.gif) 9 | 10 | ## Table of Contents 11 | 12 | 13 | 14 | 15 | - [Installation](#installation) 16 | - [Usage](#usage) 17 | - [JavaScript](#javascript) 18 | - [React](#react) 19 | - [Graphs](#graphs) 20 | - [Options](#options) 21 | - [Thanks](#thanks) 22 | 23 | 24 | 25 | ## Installation 26 | 27 | Add `stats` to your project via `npm install`: 28 | 29 | ``` 30 | npm install --save @helpscout/stats 31 | ``` 32 | 33 | ## Usage 34 | 35 | ### JavaScript 36 | 37 | To use Stats in your JavaScript project, simply import it and instantiate! 38 | 39 | ```js 40 | import createStats from '@helpscout/stats' 41 | 42 | const stats = createStats() 43 | // Stats will automatically mount to window.document 44 | 45 | // For clean up, execute the destroy() method 46 | stats.destroy() 47 | ``` 48 | 49 | ### React 50 | 51 | Stats comes with a handy `` component. To add it to your React project, import it and render it: 52 | 53 | ```jsx 54 | import React from 'react' 55 | import {StatsGraph} from '@helpscout/stats' 56 | 57 | class App extends React.Component { 58 | render() { 59 | return ( 60 |
61 | ... 62 | 63 | ... 64 |
65 | ) 66 | } 67 | } 68 | 69 | export default App 70 | ``` 71 | 72 | `StatsGraph` cleans up after itself if it unmounts. 73 | 74 | ## Graphs 75 | 76 | - **FPS** Frames rendered in the last second. The higher the number the better. 77 | - **MB** MBytes of allocated memory. (Run Chrome with --enable-precise-memory-info) 78 | - **NODES** Number of DOM nodes in `window.document` (including iFrame nodes). 79 | 80 | ## Options 81 | 82 | Stats accepts a handful of options to adjust it's position and UI. 83 | 84 | | Prop | Type | Default | Description | 85 | | -------- | ----------------- | -------- | --------------------------- | 86 | | top | `number`/`string` | 0 | (CSS) top position. | 87 | | right | `number`/`string` | 0 | (CSS) right position. | 88 | | bottom | `number`/`string` | 0 | (CSS) bottom position. | 89 | | left | `number`/`string` | 0 | (CSS) left position. | 90 | | opacity | `number` | 0.5 | Opacity for the Stats UI. | 91 | | position | `string` | fixed | Position for the Stats UI. | 92 | | zIndex | `string` | 99999999 | `z-index` for the Stats UI. | 93 | 94 | The React `StatsGraph` uses the same options for it's `defaultProps` 95 | 96 | ## Thanks 97 | 98 | Thanks for [mrdoob](https://github.com/mrdoob) for his [stats.js](https://github.com/mrdoob/stats.js) library, which inspired this one! 99 | -------------------------------------------------------------------------------- /images/stats-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/helpscout/stats/b3cc292f0f8fa2faf6015a8b070b3d6f39e5f68b/images/stats-demo.gif -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const jestConfig = require('@helpscout/zero/jest') 2 | 3 | const coverageList = [ 4 | 'src/**/*.{js,jsx,ts,tsx}', 5 | '!src/createBroadcast.{js,jsx,ts}', 6 | ] 7 | 8 | module.exports = Object.assign({}, jestConfig, { 9 | collectCoverageFrom: [] 10 | .concat(jestConfig.collectCoverageFrom) 11 | .concat(coverageList), 12 | // setupTestFrameworkScriptFile: '/scripts/setupTests.js', 13 | testEnvironment: 'jsdom', 14 | testURL: 'http://localhost', 15 | }) 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@helpscout/stats", 3 | "version": "0.0.5", 4 | "description": "Easy performance monitoring for JavaScript / React", 5 | "main": "dist/index.js", 6 | "private": false, 7 | "publishConfig": { 8 | "access": "public" 9 | }, 10 | "files": [ 11 | "dist", 12 | "README.md", 13 | "LICENSE" 14 | ], 15 | "scripts": { 16 | "add-contributor": "zero contributors add", 17 | "prestart": "zero prestart", 18 | "postbuild": "rm -rf dist/__tests__", 19 | "build": "zero build", 20 | "clean": "rm -rf dist", 21 | "lint": "zero lint", 22 | "dev": "zero test --watchAll", 23 | "test": "npm run build", 24 | "format": "zero format", 25 | "precommit": "zero pre-commit", 26 | "coverage": "nyc report --temp-directory=coverage --reporter=text-lcov | coveralls", 27 | "release": "zero release", 28 | "version": "npm run build", 29 | "start": "npm run storybook", 30 | "storybook": "start-storybook -p 5005", 31 | "build-storybook": "build-storybook", 32 | "pretty": "zero format" 33 | }, 34 | "author": "Jon Quach (https://jonquach.com)", 35 | "license": "MIT", 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/helpscout/stats.git" 39 | }, 40 | "bugs": { 41 | "url": "https://github.com/helpscout/stats/issues" 42 | }, 43 | "keywords": [ 44 | "stats", 45 | "performance", 46 | "fps", 47 | "react" 48 | ], 49 | "devDependencies": { 50 | "@helpscout/zero": "3.0.2", 51 | "@storybook/addon-actions": "4.1.12", 52 | "@storybook/addon-links": "4.1.12", 53 | "@storybook/addons": "4.1.12", 54 | "@storybook/react": "4.1.12", 55 | "@types/react": "16.8.7", 56 | "babel-loader": "^8.0.5", 57 | "react": "16.8.4", 58 | "react-dom": "16.8.4", 59 | "typescript": "3.3.3333" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Panel.ts: -------------------------------------------------------------------------------- 1 | export interface Panel { 2 | dom: HTMLCanvasElement 3 | update: (min: number, max: number) => void 4 | } 5 | 6 | /** 7 | * Generates the UI (Canvas) that renders the Stat graph 8 | * @param name {string} The name of the Panel. 9 | * @param fg {string} The foreground colour (hex). 10 | * @param bg {string} The background colour (hex). 11 | */ 12 | function Panel(name: string, fg: string, bg: string): Panel { 13 | let min = Infinity, 14 | max = 0, 15 | round = Math.round 16 | let PR = round(window.devicePixelRatio || 1) 17 | 18 | let WIDTH = 80 * PR, 19 | HEIGHT = 48 * PR, 20 | TEXT_X = 3 * PR, 21 | TEXT_Y = 2 * PR, 22 | GRAPH_X = 3 * PR, 23 | GRAPH_Y = 15 * PR, 24 | GRAPH_WIDTH = 74 * PR, 25 | GRAPH_HEIGHT = 30 * PR 26 | 27 | const canvas = document.createElement('canvas') 28 | canvas.width = WIDTH 29 | canvas.height = HEIGHT 30 | canvas.style.cssText = 'width:80px;height:48px' 31 | 32 | const context = canvas.getContext('2d') as CanvasRenderingContext2D 33 | context.font = 'bold ' + 9 * PR + 'px Helvetica,Arial,sans-serif' 34 | context.textBaseline = 'top' 35 | 36 | context.fillStyle = bg 37 | context.fillRect(0, 0, WIDTH, HEIGHT) 38 | 39 | context.fillStyle = fg 40 | context.fillText(name, TEXT_X, TEXT_Y) 41 | context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT) 42 | 43 | context.fillStyle = bg 44 | context.globalAlpha = 0.9 45 | context.fillRect(GRAPH_X, GRAPH_Y, GRAPH_WIDTH, GRAPH_HEIGHT) 46 | 47 | return { 48 | dom: canvas, 49 | 50 | update: function(value, maxValue) { 51 | min = Math.min(min, value) 52 | max = Math.max(max, value) 53 | 54 | context.fillStyle = bg 55 | context.globalAlpha = 1 56 | context.fillRect(0, 0, WIDTH, GRAPH_Y) 57 | context.fillStyle = fg 58 | context.fillText( 59 | round(value) + ' ' + name + ' (' + round(min) + '-' + round(max) + ')', 60 | TEXT_X, 61 | TEXT_Y 62 | ) 63 | 64 | context.drawImage( 65 | canvas, 66 | GRAPH_X + PR, 67 | GRAPH_Y, 68 | GRAPH_WIDTH - PR, 69 | GRAPH_HEIGHT, 70 | GRAPH_X, 71 | GRAPH_Y, 72 | GRAPH_WIDTH - PR, 73 | GRAPH_HEIGHT 74 | ) 75 | 76 | context.fillRect(GRAPH_X + GRAPH_WIDTH - PR, GRAPH_Y, PR, GRAPH_HEIGHT) 77 | 78 | context.fillStyle = bg 79 | context.globalAlpha = 0.9 80 | context.fillRect( 81 | GRAPH_X + GRAPH_WIDTH - PR, 82 | GRAPH_Y, 83 | PR, 84 | round((1 - value / maxValue) * GRAPH_HEIGHT) 85 | ) 86 | }, 87 | } 88 | } 89 | 90 | export default Panel 91 | -------------------------------------------------------------------------------- /src/Stats.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, getTotalNodeCount, toPx } from './utils' 2 | import Panel from './Panel' 3 | 4 | function Stats(options = defaultOptions) { 5 | const { bottom, right, opacity, position, top, left, zIndex } = { 6 | ...defaultOptions, 7 | ...options, 8 | } 9 | 10 | const container = document.createElement('div') 11 | container.style.cssText = ` 12 | position:${position}; 13 | top:${toPx(top)}; 14 | left:${toPx(left)}; 15 | bottom:${toPx(bottom)}; 16 | right:${toPx(right)}; 17 | opacity:${opacity}; 18 | z-index:${zIndex}; 19 | pointer-events: none; 20 | ` 21 | 22 | let beginTime = (performance || Date).now() 23 | let prevTime = beginTime 24 | let frames = 0 25 | let nodes = 0 26 | let maxNodes = 0 27 | 28 | function addPanel(panel) { 29 | container.appendChild(panel.dom) 30 | return panel 31 | } 32 | 33 | function mount() { 34 | window.document.body.appendChild(container) 35 | } 36 | 37 | function unmount() { 38 | if (container.parentNode) { 39 | container.parentNode.removeChild(container) 40 | } 41 | beginTime = (performance || Date).now() 42 | prevTime = beginTime 43 | frames = 0 44 | nodes = 0 45 | maxNodes = 0 46 | } 47 | 48 | // @ts-ignore 49 | const fpsPanel = addPanel(new Panel('FPS', '#0f0', '#020')) 50 | let memPanel 51 | 52 | // @ts-ignore 53 | if (window.performance && window.performance.memory) { 54 | // @ts-ignore 55 | memPanel = addPanel(new Panel('MB', '#0ff', '#002')) 56 | } 57 | 58 | // @ts-ignore 59 | const nodesPanel = addPanel(new Panel('NODES', '#f08', '#201')) 60 | 61 | mount() 62 | 63 | return { 64 | dom: container, 65 | 66 | mount, 67 | unmount, 68 | 69 | begin: function() { 70 | beginTime = (performance || Date).now() 71 | nodes = getTotalNodeCount() 72 | 73 | if (nodes > maxNodes) { 74 | maxNodes = nodes 75 | } 76 | }, 77 | 78 | end: function() { 79 | frames++ 80 | 81 | let time = (performance || Date).now() 82 | 83 | // msPanel.update(time - beginTime, 200) 84 | 85 | if (time >= prevTime + 1000) { 86 | fpsPanel.update((frames * 1000) / (time - prevTime), 100) 87 | 88 | prevTime = time 89 | frames = 0 90 | 91 | if (memPanel) { 92 | // @ts-ignore 93 | let memory = performance.memory 94 | memPanel.update( 95 | memory.usedJSHeapSize / 1048576, 96 | memory.jsHeapSizeLimit / 1048576 97 | ) 98 | } 99 | 100 | nodesPanel.update(nodes, maxNodes * 2) 101 | } 102 | 103 | return time 104 | }, 105 | 106 | update: function() { 107 | beginTime = this.end() 108 | }, 109 | } 110 | } 111 | 112 | export default Stats 113 | -------------------------------------------------------------------------------- /src/StatsGraph.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import createStats from './createStats' 3 | import { defaultOptions as defaultProps } from './utils' 4 | 5 | export class StatsGraph extends React.PureComponent { 6 | static defaultProps = defaultProps 7 | stats: any 8 | 9 | componentDidMount() { 10 | this.stats = createStats({ ...this.props }) 11 | } 12 | 13 | componentWillUnmount() { 14 | if (this.stats) { 15 | this.stats.destroy() 16 | } 17 | } 18 | 19 | render() { 20 | return null 21 | } 22 | } 23 | 24 | export default StatsGraph 25 | -------------------------------------------------------------------------------- /src/createStats.ts: -------------------------------------------------------------------------------- 1 | import Stats from './Stats' 2 | 3 | let __secretStats: any = null 4 | let __secretStatsRAF: any = null 5 | 6 | function destroy() { 7 | if (__secretStats && __secretStatsRAF) { 8 | cancelAnimationFrame(__secretStatsRAF) 9 | __secretStats.unmount() 10 | } 11 | } 12 | 13 | function createStats(options) { 14 | destroy() 15 | // @ts-ignore 16 | __secretStats = new Stats(options) 17 | __secretStats.destroy = destroy 18 | 19 | function tick() { 20 | __secretStats.begin() 21 | __secretStats.end() 22 | __secretStatsRAF = requestAnimationFrame(tick) 23 | } 24 | 25 | __secretStatsRAF = requestAnimationFrame(tick) 26 | 27 | return __secretStats 28 | } 29 | 30 | export default createStats 31 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import createStats from './createStats' 2 | export { default as StatsGraph } from './StatsGraph' 3 | 4 | export default createStats 5 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export const defaultOptions = { 2 | top: 0, 3 | left: 'initial', 4 | right: 0, 5 | bottom: 'initial', 6 | opacity: 0.5, 7 | position: 'fixed', 8 | zIndex: 99999999, 9 | } 10 | 11 | export const toPx = value => (typeof value === 'number' ? `${value}px` : value) 12 | 13 | export function getTotalNodeCountFromDocument( 14 | doc: Document | null = window.document 15 | ): number { 16 | if (!doc) return 0 17 | return doc.getElementsByTagName('*').length 18 | } 19 | 20 | export function getTotalNodeCountFromFrames(): number { 21 | let i = 0 22 | let total = 0 23 | const frameNodes = window.document.getElementsByTagName('iframe') 24 | const frames = Array.from(frameNodes) 25 | 26 | while (i < frames.length) { 27 | if (frames[i].contentDocument) { 28 | total = total + getTotalNodeCountFromDocument(frames[i].contentDocument) 29 | } 30 | i++ 31 | } 32 | 33 | return total 34 | } 35 | 36 | export function getTotalNodeCount(): number { 37 | const rootCount = getTotalNodeCountFromDocument(window.document) 38 | const frameCount = getTotalNodeCountFromFrames() 39 | 40 | return rootCount + frameCount 41 | } 42 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { linkTo } from '@storybook/addon-links' 4 | 5 | import { Welcome } from '@storybook/react/demo' 6 | import StatsGraph from '../src/StatsGraph' 7 | 8 | storiesOf('Welcome', module).add('to Storybook', () => ( 9 |
10 | 11 | 12 |
13 | )) 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowSyntheticDefaultImports": true, 5 | "baseUrl": "src", 6 | "declaration": true, 7 | "experimentalDecorators": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "jsx": "react", 10 | "lib": ["es5", "dom", "es2017"], 11 | "module": "commonjs", 12 | "moduleResolution": "node", 13 | "noImplicitAny": false, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": false, 16 | "noUnusedLocals": false, 17 | "outDir": "dist", 18 | "rootDirs": ["src", "stories"], 19 | "strictNullChecks": true, 20 | "suppressImplicitAnyIndexErrors": true, 21 | "target": "es5" 22 | }, 23 | "exclude": ["node_modules", "dist"] 24 | } 25 | --------------------------------------------------------------------------------