1// Copyright © 2018 Enrico Stahn <enrico.stahn@gmail.com>
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//
6//     http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14// Package phpfpm provides convenient access to PHP-FPM pool data
15package phpfpm
16
17import (
18	"encoding/json"
19	"fmt"
20	"io/ioutil"
21	"net/url"
22	"regexp"
23	"strconv"
24	"strings"
25	"sync"
26	"time"
27
28	fcgiclient "github.com/tomasen/fcgi_client"
29)
30
31// PoolProcessRequestIdle defines a process that is idle.
32const PoolProcessRequestIdle string = "Idle"
33
34// PoolProcessRequestRunning defines a process that is running.
35const PoolProcessRequestRunning string = "Running"
36
37// PoolProcessRequestFinishing defines a process that is about to finish.
38const PoolProcessRequestFinishing string = "Finishing"
39
40// PoolProcessRequestReadingHeaders defines a process that is reading headers.
41const PoolProcessRequestReadingHeaders string = "Reading headers"
42
43// PoolProcessRequestInfo defines a process that is getting request information. Was changed in PHP 7.4 to PoolProcessRequestInfo74
44const PoolProcessRequestInfo string = "Getting request informations"
45const PoolProcessRequestInfo74 string = "Getting request information"
46
47// PoolProcessRequestEnding defines a process that is about to end.
48const PoolProcessRequestEnding string = "Ending"
49
50var log logger
51
52type logger interface {
53	Info(ar ...interface{})
54	Infof(string, ...interface{})
55	Debug(ar ...interface{})
56	Debugf(string, ...interface{})
57	Error(ar ...interface{})
58	Errorf(string, ...interface{})
59}
60
61// PoolManager manages all configured Pools
62type PoolManager struct {
63	Pools []Pool `json:"pools"`
64}
65
66// Pool describes a single PHP-FPM pool that can be reached via a Socket or TCP address
67type Pool struct {
68	// The address of the pool, e.g. tcp://127.0.0.1:9000 or unix:///tmp/php-fpm.sock
69	Address             string        `json:"-"`
70	ScrapeError         error         `json:"-"`
71	ScrapeFailures      int64         `json:"-"`
72	Name                string        `json:"pool"`
73	ProcessManager      string        `json:"process manager"`
74	StartTime           timestamp     `json:"start time"`
75	StartSince          int64         `json:"start since"`
76	AcceptedConnections int64         `json:"accepted conn"`
77	ListenQueue         int64         `json:"listen queue"`
78	MaxListenQueue      int64         `json:"max listen queue"`
79	ListenQueueLength   int64         `json:"listen queue len"`
80	IdleProcesses       int64         `json:"idle processes"`
81	ActiveProcesses     int64         `json:"active processes"`
82	TotalProcesses      int64         `json:"total processes"`
83	MaxActiveProcesses  int64         `json:"max active processes"`
84	MaxChildrenReached  int64         `json:"max children reached"`
85	SlowRequests        int64         `json:"slow requests"`
86	Processes           []PoolProcess `json:"processes"`
87}
88
89type requestDuration int64
90
91// PoolProcess describes a single PHP-FPM process. A pool can have multiple processes.
92type PoolProcess struct {
93	PID               int64           `json:"pid"`
94	State             string          `json:"state"`
95	StartTime         int64           `json:"start time"`
96	StartSince        int64           `json:"start since"`
97	Requests          int64           `json:"requests"`
98	RequestDuration   requestDuration `json:"request duration"`
99	RequestMethod     string          `json:"request method"`
100	RequestURI        string          `json:"request uri"`
101	ContentLength     int64           `json:"content length"`
102	User              string          `json:"user"`
103	Script            string          `json:"script"`
104	LastRequestCPU    float64         `json:"last request cpu"`
105	LastRequestMemory int64           `json:"last request memory"`
106}
107
108// PoolProcessStateCounter holds the calculated metrics for pool processes.
109type PoolProcessStateCounter struct {
110	Running        int64
111	Idle           int64
112	Finishing      int64
113	ReadingHeaders int64
114	Info           int64
115	Ending         int64
116}
117
118// Add will add a pool to the pool manager based on the given URI.
119func (pm *PoolManager) Add(uri string) Pool {
120	p := Pool{Address: uri}
121	pm.Pools = append(pm.Pools, p)
122	return p
123}
124
125// Update will run the pool.Update() method concurrently on all Pools.
126func (pm *PoolManager) Update() (err error) {
127	wg := &sync.WaitGroup{}
128
129	started := time.Now()
130
131	for idx := range pm.Pools {
132		wg.Add(1)
133		go func(p *Pool) {
134			defer wg.Done()
135			if err := p.Update(); err != nil {
136				log.Error(err)
137			}
138		}(&pm.Pools[idx])
139	}
140
141	wg.Wait()
142
143	ended := time.Now()
144
145	log.Debugf("Updated %v pool(s) in %v", len(pm.Pools), ended.Sub(started))
146
147	return nil
148}
149
150// Update will connect to PHP-FPM and retrieve the latest data for the pool.
151func (p *Pool) Update() (err error) {
152	p.ScrapeError = nil
153
154	scheme, address, path, err := parseURL(p.Address)
155	if err != nil {
156		return p.error(err)
157	}
158
159	fcgi, err := fcgiclient.DialTimeout(scheme, address, time.Duration(3)*time.Second)
160	if err != nil {
161		return p.error(err)
162	}
163
164	defer fcgi.Close()
165
166	env := map[string]string{
167		"SCRIPT_FILENAME": path,
168		"SCRIPT_NAME":     path,
169		"SERVER_SOFTWARE": "go / php-fpm_exporter",
170		"REMOTE_ADDR":     "127.0.0.1",
171		"QUERY_STRING":    "json&full",
172	}
173
174	resp, err := fcgi.Get(env)
175	if err != nil {
176		return p.error(err)
177	}
178
179	defer resp.Body.Close()
180
181	content, err := ioutil.ReadAll(resp.Body)
182	if err != nil {
183		return p.error(err)
184	}
185
186	content = JSONResponseFixer(content)
187
188	log.Debugf("Pool[%v]: %v", p.Address, string(content))
189
190	if err = json.Unmarshal(content, &p); err != nil {
191		log.Errorf("Pool[%v]: %v", p.Address, string(content))
192		return p.error(err)
193	}
194
195	return nil
196}
197
198func (p *Pool) error(err error) error {
199	p.ScrapeError = err
200	p.ScrapeFailures++
201	log.Error(err)
202	return err
203}
204
205// JSONResponseFixer resolves encoding issues with PHP-FPMs JSON response
206func JSONResponseFixer(content []byte) []byte {
207	c := string(content)
208	re := regexp.MustCompile(`(,"request uri":)"(.*?)"(,"content length":)`)
209	matches := re.FindAllStringSubmatch(c, -1)
210
211	for _, match := range matches {
212		requestURI, _ := json.Marshal(match[2])
213
214		sold := match[0]
215		snew := match[1] + string(requestURI) + match[3]
216
217		c = strings.ReplaceAll(c, sold, snew)
218	}
219
220	return []byte(c)
221}
222
223// CountProcessState return the calculated metrics based on the reported processes.
224func CountProcessState(processes []PoolProcess) (active int64, idle int64, total int64) {
225	for idx := range processes {
226		switch processes[idx].State {
227		case PoolProcessRequestRunning:
228			active++
229		case PoolProcessRequestIdle:
230			idle++
231		case PoolProcessRequestEnding:
232		case PoolProcessRequestFinishing:
233		case PoolProcessRequestInfo:
234		case PoolProcessRequestInfo74:
235		case PoolProcessRequestReadingHeaders:
236			active++
237		default:
238			log.Errorf("Unknown process state '%v'", processes[idx].State)
239		}
240	}
241
242	return active, idle, active + idle
243}
244
245// parseURL creates elements to be passed into fcgiclient.DialTimeout
246func parseURL(rawurl string) (scheme string, address string, path string, err error) {
247	uri, err := url.Parse(rawurl)
248	if err != nil {
249		return uri.Scheme, uri.Host, uri.Path, err
250	}
251
252	scheme = uri.Scheme
253
254	switch uri.Scheme {
255	case "unix":
256		result := strings.Split(uri.Path, ";")
257		address = result[0]
258		if len(result) > 1 {
259			path = result[1]
260		}
261	default:
262		address = uri.Host
263		path = uri.Path
264	}
265
266	return
267}
268
269type timestamp time.Time
270
271// MarshalJSON customise JSON for timestamp
272func (t *timestamp) MarshalJSON() ([]byte, error) {
273	ts := time.Time(*t).Unix()
274	stamp := fmt.Sprint(ts)
275	return []byte(stamp), nil
276}
277
278// UnmarshalJSON customise JSON for timestamp
279func (t *timestamp) UnmarshalJSON(b []byte) error {
280	ts, err := strconv.Atoi(string(b))
281	if err != nil {
282		return err
283	}
284	*t = timestamp(time.Unix(int64(ts), 0))
285	return nil
286}
287
288// This is because of bug in php-fpm that can return 'request duration' which can't
289// fit to int64. For details check links:
290// https://bugs.php.net/bug.php?id=62382
291// https://serverfault.com/questions/624977/huge-request-duration-value-for-a-particular-php-script
292func (rd *requestDuration) MarshalJSON() ([]byte, error) {
293	stamp := fmt.Sprint(rd)
294	return []byte(stamp), nil
295}
296
297func (rd *requestDuration) UnmarshalJSON(b []byte) error {
298	rdc, err := strconv.Atoi(string(b))
299	if err != nil {
300		*rd = 0
301	} else {
302		*rd = requestDuration(rdc)
303	}
304	return nil
305}
306
307// SetLogger configures the used logger
308func SetLogger(logger logger) {
309	log = logger
310}
311