├── .editorconfig ├── .eslintrc ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── index.js ├── package.json ├── screenshot.png └── src ├── app.js ├── monitor.js └── reporter.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb/base", 3 | "env": { 4 | "es6": true, 5 | "node": true 6 | }, 7 | "rules": { 8 | "indent": [2, 2], 9 | "linebreak-style": [2, "unix"], 10 | "no-shadow": 0, 11 | "quotes": [2, "single"], 12 | "semi": [2, "always"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | node_modules 3 | lib 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .gitignore 3 | src 4 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright © 2015 Leonard Kinday 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 | # pm2-php-fpm 2 | 3 | PHP-FPM module for [Keymetrics][keymetrics] 4 | 5 | ![pm2-php-fpm screenshot](https://raw.githubusercontent.com/pm2-hive/pm2-php-fpm/master/screenshot.png) 6 | 7 | ## Description 8 | 9 | This module monitors following PHP-FPM metrics: 10 | - uptime; 11 | - number of connections since start; 12 | - average request: 13 | - duration; 14 | - CPU usage; 15 | - memory consumption. 16 | - active and idle proccesses count; 17 | - connection queue size; 18 | - child processes limit hits; 19 | - slow processes count. 20 | 21 | 22 | ## Requirements 23 | 24 | This module reqires PHP-FPM and [PM2][pm2] to be installed. Also it may require you to edit PHP-FPM configs to enable status page. Make sure your user has privileges to access PHP-FPM socket. 25 | 26 | 27 | ## Installation 28 | 29 | ```bash 30 | pm2 install pm2-php-fpm 31 | ``` 32 | 33 | 34 | ## Configuration 35 | 36 | Default settings: 37 | - `fcgi_path` is `/var/run/php5-fpm.sock`; 38 | - `endpoint` is `/status`; 39 | - `interval` is `1000` milliseconds. 40 | 41 | To modify the config values you can use [Keymetrics][keymetrics] dashboard or the following commands: 42 | 43 | ```bash 44 | pm2 set pm2-php-fpm:fcgi_path /var/run/custom-php-socket.sock 45 | pm2 set pm2-php-fpm:endpoint /health 46 | pm2 set pm2-php-fpm:interval 5000 47 | ``` 48 | 49 | You may use TCP port instead of UNIX socket: 50 | 51 | ```bash 52 | pm2 set pm2-php-fpm:fcgi_host 127.0.0.1 53 | pm2 set pm2-php-fpm:fcgi_port 9000 54 | ``` 55 | 56 | 57 | ## Troubleshooting 58 | 59 | ### `Cannot connect to PHP-FPM` error 60 | 61 | Check module configuration. Make sure that path or host and port are the ones you have in PHP-FPM config. Also this error may appear when your PHP-FPM instance is overloaded or stopped unexpectedly. 62 | 63 | 64 | ### `PHP-FPM status endpoint not found` error 65 | 66 | You’ve entered wrong endpoint. Or changed its name in PHP-FPM configuration recently and didn’t update module settings. 67 | 68 | 69 | ### `Access to PHP-FPM denied` error 70 | 71 | User running PM2 daemon has no rights to access PHP-FPM socket. You need to add your user to group owning socket. Find out its name in PHP-FPM pool config or use `stat -c %G /path/to/php5-fpm.sock` 72 | 73 | Add your user (e.g. `myuser`) to target group (e.g. `www-data`): `sudo usermod -a -G www-data myuser` 74 | 75 | Reconnect to your server and restart PM2 daemon: `pm2 update` 76 | 77 | 78 | ### Other 79 | 80 | Look through [Issues][issues]. You may find someone with the same problem. If your problem is unique, feel free to create a [new issue][new-issue]. 81 | 82 | 83 | ## Uninstallation 84 | 85 | ```bash 86 | pm2 uninstall pm2-php-fpm 87 | ``` 88 | 89 | 90 | ## License 91 | 92 | MIT 93 | 94 | 95 | [issues]: https://github.com/pm2-hive/pm2-php-fpm/issues 96 | [keymetrics]: https://keymetrics.io/ 97 | [new-issue]: https://github.com/pm2-hive/pm2-php-fpm/issues/new 98 | [pm2]: https://github.com/Unitech/pm2 99 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Workaround for PM2 inability to traverse filesystem for package.json 2 | require('./lib/app'); 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pm2-php-fpm", 3 | "version": "0.0.3", 4 | "description": "Monitor PHP-FPM with Keymetrics", 5 | "author": "Leonard Kinday ", 6 | "scripts": { 7 | "dev": "npm run transpile -- --watch --source-maps inline", 8 | "prepublish": "npm run transpile", 9 | "postpublish": "git push --follow-tags", 10 | "prepush": "npm test", 11 | "test": "eslint src", 12 | "transpile": "babel src --out-dir lib" 13 | }, 14 | "dependencies": { 15 | "babel": "^5.8.23", 16 | "fastcgi-parser": "^0.1.5", 17 | "moment": "^2.10.6", 18 | "pm2": "latest", 19 | "pmx": "latest", 20 | "ramda": "^0.17.1" 21 | }, 22 | "devDependencies": { 23 | "babel-eslint": "^4.1.3", 24 | "eslint": "^1.6.0", 25 | "eslint-config-airbnb": "^0.1.0", 26 | "husky": "^0.10.1" 27 | }, 28 | "config": { 29 | "fcgi_path": "/var/run/php5-fpm.sock", 30 | "fcgi_host": "", 31 | "fcgi_port": "", 32 | "interval": 1000 33 | }, 34 | "apps": [ 35 | { 36 | "script": "index.js", 37 | "name": "pm2-php-fpm" 38 | } 39 | ], 40 | "license": "MIT" 41 | } 42 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pm2-hive/pm2-php-fpm/682b57492187380e3527339a44c990900b3b558b/screenshot.png -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import 'babel/polyfill'; 2 | import pmx from 'pmx'; 3 | import Monitor from './monitor'; 4 | import Reporter from './reporter'; 5 | 6 | pmx.initModule({ 7 | pid: pmx.resolvePidPaths(['/var/run/php5-fpm.pid']), 8 | widget: { 9 | logo: 'https://cloud.githubusercontent.com/assets/1751980/10219857/3915e4b4-684b-11e5-8f5f-43ba320b21b8.png', 10 | theme: ['#18212F', '#0E131C', '#6082BB', '#48618C'], 11 | el: { 12 | probes: true, 13 | actions: true, 14 | }, 15 | block: { 16 | actions: false, 17 | issues: true, 18 | meta: true, 19 | main_probes: [ 20 | 'Uptime', 21 | 'Avg Req Duration', 22 | 'Avg Req CPU', 23 | 'Avg Req Memory', 24 | 'Active Processes', 25 | 'Idle Processes', 26 | ], 27 | }, 28 | }, 29 | }, (err, conf) => { 30 | if (err) { 31 | pmx.notify(err); 32 | } 33 | 34 | const monitor = new Monitor(conf); 35 | const reporter = new Reporter(conf); 36 | 37 | // Init metrics refresh loop 38 | monitor.start((err, data) => { 39 | if (err) { 40 | pmx.notify(err); 41 | } 42 | 43 | reporter.refresh(data); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/monitor.js: -------------------------------------------------------------------------------- 1 | import { Socket } from 'net'; 2 | import fastcgi, { writer as Writer, parser as Parser } from 'fastcgi-parser'; 3 | import { merge } from 'ramda'; 4 | 5 | const FCGI_BEGIN = fastcgi.constants.record.FCGI_BEGIN; 6 | const FCGI_END = fastcgi.constants.record.FCGI_END; 7 | const FCGI_STDOUT = fastcgi.constants.record.FCGI_STDOUT; 8 | const FCGI_PARAMS = fastcgi.constants.record.FCGI_PARAMS; 9 | 10 | export default class Stats { 11 | constructor(options = {}) { 12 | 13 | if (options.fcgi_host) { 14 | const fcgi = { 15 | host: options.fcgi_host, 16 | port: options.fcgi_port 17 | } 18 | } else { 19 | const fcgi = { 20 | path: options.fcgi_path 21 | } 22 | } 23 | 24 | this.options = { 25 | fcgi: fcgi, 26 | endpoint: options.endpoint || '/status', 27 | interval: options.interval || 1000, 28 | }; 29 | this.getBody = this.getBody.bind(this); 30 | this.headerGenerator = this.headerGenerator.bind(this); 31 | this.start = this.start.bind(this); 32 | this.stop = this.stop.bind(this); 33 | 34 | return { 35 | start: this.start, 36 | stop: this.stop, 37 | }; 38 | } 39 | 40 | * headerGenerator(options) { 41 | const header = { 42 | version: fastcgi.constants.version, 43 | type: FCGI_BEGIN, 44 | recordId: 0, 45 | contentLength: 0, 46 | paddingLength: 0, 47 | }; 48 | 49 | yield merge(header, { 50 | type: FCGI_BEGIN, 51 | contentLength: 8, 52 | }); 53 | 54 | yield merge(header, { 55 | type: FCGI_PARAMS, 56 | contentLength: fastcgi.getParamLength(options.params), 57 | }); 58 | 59 | yield merge(header, { 60 | type: FCGI_STDOUT, 61 | }); 62 | } 63 | 64 | start(cb) { 65 | if (typeof cb !== 'function') { 66 | throw new Error('No callback given'); 67 | } 68 | 69 | const FCGI_RESPONDER = fastcgi.constants.role.FCGI_RESPONDER; 70 | 71 | const params = [ 72 | ['SCRIPT_NAME', this.options.endpoint], 73 | ['SCRIPT_FILENAME', this.options.endpoint], 74 | ['QUERY_STRING', 'json&full'], 75 | ['REQUEST_METHOD', 'GET'], 76 | ]; 77 | 78 | this.fpm = new Socket(); 79 | this.fpm.writer = new Writer(); 80 | this.fpm.parser = new Parser(); 81 | this.buffer = []; 82 | 83 | this.fpm.on('connect', () => { 84 | const header = this.headerGenerator({ params }); 85 | 86 | this.fpm.writer.writeHeader(header.next().value); 87 | this.fpm.writer.writeBegin({ role: FCGI_RESPONDER, flags: 0 }); 88 | this.fpm.write(this.fpm.writer.tobuffer()); 89 | 90 | this.fpm.writer.writeHeader(header.next().value); 91 | this.fpm.writer.writeParams(params); 92 | this.fpm.write(this.fpm.writer.tobuffer()); 93 | 94 | this.fpm.writer.writeHeader(header.next().value); 95 | this.fpm.end(this.fpm.writer.tobuffer()); 96 | }); 97 | 98 | this.fpm.on('data', data => { 99 | this.fpm.parser.execute(data); 100 | }); 101 | 102 | this.fpm.on('error', err => { 103 | if (err.code === 'ECONNREFUSED') { 104 | return cb(new Error('Cannot connect to PHP-FPM')); 105 | } 106 | if (err.code === 'EACCES') { 107 | return cb(new Error('Access to PHP-FPM denied')); 108 | } 109 | cb(err); 110 | }); 111 | 112 | this.fpm.parser.onRecord = record => { 113 | if (record.header.type === FCGI_STDOUT) { 114 | this.buffer.push(record.body); 115 | } 116 | 117 | if (record.header.type === FCGI_END) { 118 | const body = this.getBody(this.buffer.join('')); 119 | 120 | try { 121 | const output = JSON.parse(body); 122 | cb(null, output); 123 | } catch (err) { 124 | if (body && body.trim() === 'File not found.') { 125 | const err = new Error('PHP-FPM status endpoint not found'); 126 | return cb(err); 127 | } 128 | 129 | // Normally we don’t show the body as an error message 130 | // This would be some unknown and not user-friendly error 131 | // Feel free to report an issue: 132 | // https://github.com/pm2-hive/pm2-php-fpm/issues/new 133 | cb(body); 134 | } 135 | 136 | this.buffer = []; 137 | } 138 | }; 139 | 140 | this.intervalId = setInterval(() => 141 | this.fpm.connect(this.options.fcgi), this.options.interval); 142 | } 143 | 144 | stop() { 145 | if (!this.intervalId) { 146 | return; 147 | } 148 | clearInterval(this.intervalId); 149 | this.intervalId = null; 150 | } 151 | 152 | getBody(input) { 153 | const body = input.split('\r\n\r\n')[1]; 154 | try { 155 | JSON.parse(body); 156 | } catch (err) { 157 | return body; 158 | } 159 | // "some key name" → "someKeyName" 160 | return body.replace(/\s(.)/g, (match, group) => 161 | group.toUpperCase()); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/reporter.js: -------------------------------------------------------------------------------- 1 | import pmx from 'pmx'; 2 | import moment from 'moment'; 3 | 4 | const NOT_AVAILABLE = 'N/A'; 5 | 6 | export default class Reporter { 7 | constructor() { 8 | this.init = this.init.bind(this); 9 | this.refresh = this.refresh.bind(this); 10 | this.setMetric = this.setMetric.bind(this); 11 | 12 | this.metrics = {}; 13 | this.probe = pmx.probe(); 14 | 15 | this.init(); 16 | 17 | return { 18 | refresh: this.refresh, 19 | }; 20 | } 21 | 22 | init() { 23 | this.metrics.slowProcs = this.probe.metric({ 24 | name: 'Slow Processes', 25 | value: 'N/A', 26 | alert: { 27 | mode: 'threshold', 28 | value: 0, 29 | cmp: '=', 30 | msg: 'Non-zero number of slow processes. Check your logs for details.', 31 | }, 32 | }); 33 | 34 | this.metrics.maxChildren = this.probe.metric({ 35 | name: 'Children Limit', 36 | value: 'N/A', 37 | alert: { 38 | mode: 'threshold', 39 | value: 0, 40 | cmp: '=', 41 | msg: 'Non-zero number of child processes limit hits. Consider increasing number of processes FPM can spawn.', 42 | }, 43 | }); 44 | 45 | this.metrics.connQueue = this.probe.metric({ 46 | name: 'Conn Queue', 47 | value: 'N/A', 48 | alert: { 49 | mode: 'threshold', 50 | value: 0, 51 | cmp: '=', 52 | msg: 'Non-zero number of requests in the queue of pending connections. Consider increasing number of processes FPM can spawn.', 53 | }, 54 | }); 55 | 56 | this.metrics.requestMem = this.probe.metric({ 57 | name: 'Avg Req Memory', 58 | }); 59 | 60 | this.metrics.requestCpu = this.probe.metric({ 61 | name: 'Avg Req CPU', 62 | }); 63 | 64 | this.metrics.requestDuration = this.probe.metric({ 65 | name: 'Avg Req Duration', 66 | }); 67 | 68 | this.metrics.idleProcesses = this.probe.metric({ 69 | name: 'Idle Processes', 70 | value: 'N/A', 71 | }); 72 | 73 | this.metrics.activeProcesses = this.probe.metric({ 74 | name: 'Active Processes', 75 | value: 'N/A', 76 | }); 77 | 78 | this.metrics.acceptedConn = this.probe.metric({ 79 | name: 'Σ Connections', 80 | value: 'N/A', 81 | }); 82 | 83 | this.metrics.startSince = this.probe.metric({ 84 | name: 'Uptime', 85 | value: 'N/A', 86 | }); 87 | } 88 | 89 | refresh(data) { 90 | this.metrics.startSince.set((uptime => { 91 | const value = parseFloat(String(uptime)); 92 | if (Number.isNaN(value)) return NOT_AVAILABLE; 93 | return moment().second(value).toNow(true); 94 | })(data.startSince)); 95 | this.setMetric({ 96 | type: 'metric', 97 | name: 'acceptedConn', 98 | data: data.acceptedConn, 99 | }); 100 | this.setMetric({ 101 | type: 'metric', 102 | name: 'activeProcesses', 103 | data: data.activeProcesses, 104 | }); 105 | this.setMetric({ 106 | type: 'metric', 107 | name: 'idleProcesses', 108 | data: data.idleProcesses, 109 | }); 110 | this.setMetric({ 111 | type: 'metric', 112 | name: 'requestDuration', 113 | data: data.processes.reduce((sum, process) => { 114 | return sum + parseFloat(process.requestDuration); 115 | }, 0) / data.processes.length, 116 | }); 117 | this.setMetric({ 118 | type: 'metric', 119 | name: 'requestCpu', 120 | skipZero: true, 121 | data: (() => { 122 | const load = data.processes.reduce((sum, process) => 123 | sum + parseFloat(process.lastRequestCpu), 0); 124 | const processes = data.processes.reduce((sum, process) => 125 | sum + Number(Boolean(parseFloat(process.lastRequestCpu))), 0); 126 | return load / (processes || 1); 127 | })(), 128 | }); 129 | this.setMetric({ 130 | type: 'metric', 131 | name: 'requestMem', 132 | skipZero: true, 133 | data: (() => { 134 | const load = data.processes.reduce((sum, process) => 135 | sum + parseFloat(process.lastRequestMemory) / 1024, 0); 136 | const processes = data.processes.reduce((sum, process) => 137 | sum + Number(Boolean(parseFloat(process.lastRequestMemory))), 0); 138 | return load / (processes || 1); 139 | })(), 140 | }); 141 | this.setMetric({ 142 | type: 'metric', 143 | name: 'connQueue', 144 | data: data.listenQueue, 145 | }); 146 | this.setMetric({ 147 | type: 'metric', 148 | name: 'maxChildren', 149 | data: data.maxChildrenReached, 150 | }); 151 | this.setMetric({ 152 | type: 'metric', 153 | name: 'slowProcs', 154 | data: data.slowRequests, 155 | }); 156 | } 157 | 158 | setMetric({ type, name, unit, data, skipZero }) { 159 | const types = { 160 | histogram: 'update', 161 | metric: 'set', 162 | }; 163 | const value = (data) => { 164 | const value = parseInt(String(data), 10); 165 | if (Number.isNaN(value)) return NOT_AVAILABLE; 166 | return unit ? value + unit : value; 167 | }; 168 | if (skipZero && value === 0) return false; 169 | this.metrics[name][types[type]](value(data)); 170 | } 171 | } 172 | --------------------------------------------------------------------------------