Skip to content

Commit b45c785

Browse files
Add configurable prometheus metrics prefix
1 parent d2ba464 commit b45c785

6 files changed

Lines changed: 163 additions & 64 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ Ember connects to the Caddy admin API and auto-detects FrankenPHP if present. In
8787
--json JSON output mode (streaming JSONL)
8888
--expose addr Expose Prometheus metrics (e.g. --expose=:9191)
8989
--daemon Headless mode (requires --expose)
90+
--metrics-prefix str Prefix for exported Prometheus metric names
9091
--no-color Disable colors
9192
```
9293

internal/app/daemon.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error {
2828
var state model.State
2929

3030
mux := http.NewServeMux()
31-
mux.HandleFunc("/metrics", exporter.Handler(holder))
31+
mux.HandleFunc("/metrics", exporter.Handler(holder, cfg.metricsPrefix))
3232
srv := &http.Server{Addr: cfg.expose, Handler: mux}
3333

3434
go func() {

internal/app/run.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ type config struct {
2121
pid int
2222
expose string
2323
daemon bool
24+
metricsPrefix string
2425
}
2526

2627
func newRootCmd(version string) *cobra.Command {
@@ -102,6 +103,7 @@ Keybindings:
102103
f.IntVar(&cfg.pid, "pid", 0, "FrankenPHP PID (auto-detected if not set)")
103104
f.StringVar(&cfg.expose, "expose", "", "Expose Prometheus metrics (e.g. :9191)")
104105
f.BoolVar(&cfg.daemon, "daemon", false, "Headless mode (requires --expose)")
106+
f.StringVar(&cfg.metricsPrefix, "metrics-prefix", "", "Prefix for exported Prometheus metric names")
105107

106108
cmd.SetVersionTemplate("ember {{.Version}}\n")
107109

internal/app/tui.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func runTUI(f fetcher.Fetcher, cfg *config, hasFrankenPHP bool, version string)
3131
}
3232

3333
mux := http.NewServeMux()
34-
mux.HandleFunc("/metrics", exporter.Handler(holder))
34+
mux.HandleFunc("/metrics", exporter.Handler(holder, cfg.metricsPrefix))
3535
srv = &http.Server{Addr: cfg.expose, Handler: mux}
3636

3737
listenErr := make(chan error, 1)

internal/exporter/exporter.go

Lines changed: 87 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ func (h *StateHolder) Load() model.State {
2929

3030
const prometheusContentType = "text/plain; version=0.0.4; charset=utf-8"
3131

32-
func Handler(holder *StateHolder) http.HandlerFunc {
32+
func Handler(holder *StateHolder, prefix ...string) http.HandlerFunc {
33+
p := ""
34+
if len(prefix) > 0 {
35+
p = prefix[0]
36+
}
3337
return func(w http.ResponseWriter, r *http.Request) {
3438
s := holder.Load()
3539
if s.Current == nil {
@@ -39,30 +43,38 @@ func Handler(holder *StateHolder) http.HandlerFunc {
3943

4044
w.Header().Set("Content-Type", prometheusContentType)
4145

42-
writeThreadMetrics(w, &s)
43-
writeThreadMemory(w, &s)
44-
writeWorkerMetrics(w, &s)
45-
writeHostMetrics(w, &s)
46-
writePercentiles(w, &s)
47-
writeProcessMetrics(w, &s)
46+
writeThreadMetrics(w, &s, p)
47+
writeThreadMemory(w, &s, p)
48+
writeWorkerMetrics(w, &s, p)
49+
writeHostMetrics(w, &s, p)
50+
writePercentiles(w, &s, p)
51+
writeProcessMetrics(w, &s, p)
52+
}
53+
}
54+
55+
func prefixed(prefix, name string) string {
56+
if prefix == "" {
57+
return name
4858
}
59+
return prefix + "_" + name
4960
}
5061

51-
func writeThreadMetrics(w http.ResponseWriter, s *model.State) {
62+
func writeThreadMetrics(w http.ResponseWriter, s *model.State, prefix string) {
5263
total := len(s.Current.Threads.ThreadDebugStates)
5364
other := total - s.Derived.TotalBusy - s.Derived.TotalIdle
5465
if other < 0 {
5566
other = 0
5667
}
5768

58-
fmt.Fprintln(w, "# HELP frankenphp_threads_total Number of FrankenPHP threads by state")
59-
fmt.Fprintln(w, "# TYPE frankenphp_threads_total gauge")
60-
fmt.Fprintf(w, "frankenphp_threads_total{state=\"busy\"} %d\n", s.Derived.TotalBusy)
61-
fmt.Fprintf(w, "frankenphp_threads_total{state=\"idle\"} %d\n", s.Derived.TotalIdle)
62-
fmt.Fprintf(w, "frankenphp_threads_total{state=\"other\"} %d\n", other)
69+
name := prefixed(prefix, "frankenphp_threads_total")
70+
fmt.Fprintf(w, "# HELP %s Number of FrankenPHP threads by state\n", name)
71+
fmt.Fprintf(w, "# TYPE %s gauge\n", name)
72+
fmt.Fprintf(w, "%s{state=\"busy\"} %d\n", name, s.Derived.TotalBusy)
73+
fmt.Fprintf(w, "%s{state=\"idle\"} %d\n", name, s.Derived.TotalIdle)
74+
fmt.Fprintf(w, "%s{state=\"other\"} %d\n", name, other)
6375
}
6476

65-
func writeThreadMemory(w http.ResponseWriter, s *model.State) {
77+
func writeThreadMemory(w http.ResponseWriter, s *model.State, prefix string) {
6678
hasMemory := false
6779
for _, t := range s.Current.Threads.ThreadDebugStates {
6880
if t.MemoryUsage > 0 {
@@ -74,68 +86,75 @@ func writeThreadMemory(w http.ResponseWriter, s *model.State) {
7486
return
7587
}
7688

77-
fmt.Fprintln(w, "# HELP frankenphp_thread_memory_bytes Memory usage per FrankenPHP thread")
78-
fmt.Fprintln(w, "# TYPE frankenphp_thread_memory_bytes gauge")
89+
name := prefixed(prefix, "frankenphp_thread_memory_bytes")
90+
fmt.Fprintf(w, "# HELP %s Memory usage per FrankenPHP thread\n", name)
91+
fmt.Fprintf(w, "# TYPE %s gauge\n", name)
7992
for _, t := range s.Current.Threads.ThreadDebugStates {
8093
if t.MemoryUsage > 0 {
81-
fmt.Fprintf(w, "frankenphp_thread_memory_bytes{index=\"%d\"} %d\n", t.Index, t.MemoryUsage)
94+
fmt.Fprintf(w, "%s{index=\"%d\"} %d\n", name, t.Index, t.MemoryUsage)
8295
}
8396
}
8497
}
8598

86-
func writeWorkerMetrics(out http.ResponseWriter, s *model.State) {
99+
func writeWorkerMetrics(out http.ResponseWriter, s *model.State, prefix string) {
87100
if len(s.Current.Metrics.Workers) == 0 {
88101
return
89102
}
90103

91104
names := sortedWorkerNames(s.Current.Metrics.Workers)
92105

93-
fmt.Fprintln(out, "# HELP frankenphp_worker_crashes_total Total worker crashes")
94-
fmt.Fprintln(out, "# TYPE frankenphp_worker_crashes_total counter")
106+
crashes := prefixed(prefix, "frankenphp_worker_crashes_total")
107+
fmt.Fprintf(out, "# HELP %s Total worker crashes\n", crashes)
108+
fmt.Fprintf(out, "# TYPE %s counter\n", crashes)
95109
for _, name := range names {
96110
wm := s.Current.Metrics.Workers[name]
97-
fmt.Fprintf(out, "frankenphp_worker_crashes_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.Crashes)
111+
fmt.Fprintf(out, "%s{worker=\"%s\"} %g\n", crashes, escapeLabelValue(name), wm.Crashes)
98112
}
99113

100-
fmt.Fprintln(out, "# HELP frankenphp_worker_restarts_total Total worker restarts")
101-
fmt.Fprintln(out, "# TYPE frankenphp_worker_restarts_total counter")
114+
restarts := prefixed(prefix, "frankenphp_worker_restarts_total")
115+
fmt.Fprintf(out, "# HELP %s Total worker restarts\n", restarts)
116+
fmt.Fprintf(out, "# TYPE %s counter\n", restarts)
102117
for _, name := range names {
103118
wm := s.Current.Metrics.Workers[name]
104-
fmt.Fprintf(out, "frankenphp_worker_restarts_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.Restarts)
119+
fmt.Fprintf(out, "%s{worker=\"%s\"} %g\n", restarts, escapeLabelValue(name), wm.Restarts)
105120
}
106121

107-
fmt.Fprintln(out, "# HELP frankenphp_worker_queue_depth Requests in queue per worker")
108-
fmt.Fprintln(out, "# TYPE frankenphp_worker_queue_depth gauge")
122+
queue := prefixed(prefix, "frankenphp_worker_queue_depth")
123+
fmt.Fprintf(out, "# HELP %s Requests in queue per worker\n", queue)
124+
fmt.Fprintf(out, "# TYPE %s gauge\n", queue)
109125
for _, name := range names {
110126
wm := s.Current.Metrics.Workers[name]
111-
fmt.Fprintf(out, "frankenphp_worker_queue_depth{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.QueueDepth)
127+
fmt.Fprintf(out, "%s{worker=\"%s\"} %g\n", queue, escapeLabelValue(name), wm.QueueDepth)
112128
}
113129

114-
fmt.Fprintln(out, "# HELP frankenphp_worker_requests_total Total requests processed per worker")
115-
fmt.Fprintln(out, "# TYPE frankenphp_worker_requests_total counter")
130+
reqs := prefixed(prefix, "frankenphp_worker_requests_total")
131+
fmt.Fprintf(out, "# HELP %s Total requests processed per worker\n", reqs)
132+
fmt.Fprintf(out, "# TYPE %s counter\n", reqs)
116133
for _, name := range names {
117134
wm := s.Current.Metrics.Workers[name]
118-
fmt.Fprintf(out, "frankenphp_worker_requests_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.RequestCount)
135+
fmt.Fprintf(out, "%s{worker=\"%s\"} %g\n", reqs, escapeLabelValue(name), wm.RequestCount)
119136
}
120137
}
121138

122-
func writeHostMetrics(w http.ResponseWriter, s *model.State) {
139+
func writeHostMetrics(w http.ResponseWriter, s *model.State, prefix string) {
123140
if len(s.HostDerived) == 0 {
124141
return
125142
}
126143

127144
hosts := sortedHostNames(s.HostDerived)
128145

129-
fmt.Fprintln(w, "# HELP ember_host_rps Requests per second by host")
130-
fmt.Fprintln(w, "# TYPE ember_host_rps gauge")
146+
rps := prefixed(prefix, "ember_host_rps")
147+
fmt.Fprintf(w, "# HELP %s Requests per second by host\n", rps)
148+
fmt.Fprintf(w, "# TYPE %s gauge\n", rps)
131149
for _, hd := range hosts {
132-
fmt.Fprintf(w, "ember_host_rps{host=\"%s\"} %.2f\n", escapeLabelValue(hd.Host), hd.RPS)
150+
fmt.Fprintf(w, "%s{host=\"%s\"} %.2f\n", rps, escapeLabelValue(hd.Host), hd.RPS)
133151
}
134152

135-
fmt.Fprintln(w, "# HELP ember_host_latency_avg_milliseconds Average response time by host")
136-
fmt.Fprintln(w, "# TYPE ember_host_latency_avg_milliseconds gauge")
153+
avg := prefixed(prefix, "ember_host_latency_avg_milliseconds")
154+
fmt.Fprintf(w, "# HELP %s Average response time by host\n", avg)
155+
fmt.Fprintf(w, "# TYPE %s gauge\n", avg)
137156
for _, hd := range hosts {
138-
fmt.Fprintf(w, "ember_host_latency_avg_milliseconds{host=\"%s\"} %.2f\n", escapeLabelValue(hd.Host), hd.AvgTime)
157+
fmt.Fprintf(w, "%s{host=\"%s\"} %.2f\n", avg, escapeLabelValue(hd.Host), hd.AvgTime)
139158
}
140159

141160
hasPercentiles := false
@@ -146,24 +165,26 @@ func writeHostMetrics(w http.ResponseWriter, s *model.State) {
146165
}
147166
}
148167
if hasPercentiles {
149-
fmt.Fprintln(w, "# HELP ember_host_latency_milliseconds Response time percentiles by host")
150-
fmt.Fprintln(w, "# TYPE ember_host_latency_milliseconds gauge")
168+
lat := prefixed(prefix, "ember_host_latency_milliseconds")
169+
fmt.Fprintf(w, "# HELP %s Response time percentiles by host\n", lat)
170+
fmt.Fprintf(w, "# TYPE %s gauge\n", lat)
151171
for _, hd := range hosts {
152172
if !hd.HasPercentiles {
153173
continue
154174
}
155175
h := escapeLabelValue(hd.Host)
156-
fmt.Fprintf(w, "ember_host_latency_milliseconds{host=\"%s\",quantile=\"0.5\"} %.2f\n", h, hd.P50)
157-
fmt.Fprintf(w, "ember_host_latency_milliseconds{host=\"%s\",quantile=\"0.9\"} %.2f\n", h, hd.P90)
158-
fmt.Fprintf(w, "ember_host_latency_milliseconds{host=\"%s\",quantile=\"0.95\"} %.2f\n", h, hd.P95)
159-
fmt.Fprintf(w, "ember_host_latency_milliseconds{host=\"%s\",quantile=\"0.99\"} %.2f\n", h, hd.P99)
176+
fmt.Fprintf(w, "%s{host=\"%s\",quantile=\"0.5\"} %.2f\n", lat, h, hd.P50)
177+
fmt.Fprintf(w, "%s{host=\"%s\",quantile=\"0.9\"} %.2f\n", lat, h, hd.P90)
178+
fmt.Fprintf(w, "%s{host=\"%s\",quantile=\"0.95\"} %.2f\n", lat, h, hd.P95)
179+
fmt.Fprintf(w, "%s{host=\"%s\",quantile=\"0.99\"} %.2f\n", lat, h, hd.P99)
160180
}
161181
}
162182

163-
fmt.Fprintln(w, "# HELP ember_host_inflight In-flight requests by host")
164-
fmt.Fprintln(w, "# TYPE ember_host_inflight gauge")
183+
infl := prefixed(prefix, "ember_host_inflight")
184+
fmt.Fprintf(w, "# HELP %s In-flight requests by host\n", infl)
185+
fmt.Fprintf(w, "# TYPE %s gauge\n", infl)
165186
for _, hd := range hosts {
166-
fmt.Fprintf(w, "ember_host_inflight{host=\"%s\"} %.0f\n", escapeLabelValue(hd.Host), hd.InFlight)
187+
fmt.Fprintf(w, "%s{host=\"%s\"} %.0f\n", infl, escapeLabelValue(hd.Host), hd.InFlight)
167188
}
168189

169190
hasStatus := false
@@ -174,14 +195,15 @@ func writeHostMetrics(w http.ResponseWriter, s *model.State) {
174195
}
175196
}
176197
if hasStatus {
177-
fmt.Fprintln(w, "# HELP ember_host_status_rate Request rate by host and status class")
178-
fmt.Fprintln(w, "# TYPE ember_host_status_rate gauge")
198+
sr := prefixed(prefix, "ember_host_status_rate")
199+
fmt.Fprintf(w, "# HELP %s Request rate by host and status class\n", sr)
200+
fmt.Fprintf(w, "# TYPE %s gauge\n", sr)
179201
for _, hd := range hosts {
180202
classes := statusClassRates(hd.StatusCodes)
181203
h := escapeLabelValue(hd.Host)
182204
for _, c := range []string{"2xx", "3xx", "4xx", "5xx"} {
183205
if rate, ok := classes[c]; ok {
184-
fmt.Fprintf(w, "ember_host_status_rate{host=\"%s\",class=\"%s\"} %.2f\n", h, c, rate)
206+
fmt.Fprintf(w, "%s{host=\"%s\",class=\"%s\"} %.2f\n", sr, h, c, rate)
185207
}
186208
}
187209
}
@@ -217,26 +239,29 @@ func sortedHostNames(hosts []model.HostDerived) []model.HostDerived {
217239
return sorted
218240
}
219241

220-
func writePercentiles(w http.ResponseWriter, s *model.State) {
242+
func writePercentiles(w http.ResponseWriter, s *model.State, prefix string) {
221243
if !s.Derived.HasPercentiles {
222244
return
223245
}
224246

225-
fmt.Fprintln(w, "# HELP frankenphp_request_duration_milliseconds Request duration percentiles")
226-
fmt.Fprintln(w, "# TYPE frankenphp_request_duration_milliseconds gauge")
227-
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.5\"} %.2f\n", s.Derived.P50)
228-
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.95\"} %.2f\n", s.Derived.P95)
229-
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.99\"} %.2f\n", s.Derived.P99)
247+
name := prefixed(prefix, "frankenphp_request_duration_milliseconds")
248+
fmt.Fprintf(w, "# HELP %s Request duration percentiles\n", name)
249+
fmt.Fprintf(w, "# TYPE %s gauge\n", name)
250+
fmt.Fprintf(w, "%s{quantile=\"0.5\"} %.2f\n", name, s.Derived.P50)
251+
fmt.Fprintf(w, "%s{quantile=\"0.95\"} %.2f\n", name, s.Derived.P95)
252+
fmt.Fprintf(w, "%s{quantile=\"0.99\"} %.2f\n", name, s.Derived.P99)
230253
}
231254

232-
func writeProcessMetrics(w http.ResponseWriter, s *model.State) {
233-
fmt.Fprintln(w, "# HELP process_cpu_percent CPU usage of the monitored process")
234-
fmt.Fprintln(w, "# TYPE process_cpu_percent gauge")
235-
fmt.Fprintf(w, "process_cpu_percent %.2f\n", s.Current.Process.CPUPercent)
255+
func writeProcessMetrics(w http.ResponseWriter, s *model.State, prefix string) {
256+
cpu := prefixed(prefix, "process_cpu_percent")
257+
fmt.Fprintf(w, "# HELP %s CPU usage of the monitored process\n", cpu)
258+
fmt.Fprintf(w, "# TYPE %s gauge\n", cpu)
259+
fmt.Fprintf(w, "%s %.2f\n", cpu, s.Current.Process.CPUPercent)
236260

237-
fmt.Fprintln(w, "# HELP process_rss_bytes Resident set size of the monitored process")
238-
fmt.Fprintln(w, "# TYPE process_rss_bytes gauge")
239-
fmt.Fprintf(w, "process_rss_bytes %d\n", s.Current.Process.RSS)
261+
rss := prefixed(prefix, "process_rss_bytes")
262+
fmt.Fprintf(w, "# HELP %s Resident set size of the monitored process\n", rss)
263+
fmt.Fprintf(w, "# TYPE %s gauge\n", rss)
264+
fmt.Fprintf(w, "%s %d\n", rss, s.Current.Process.RSS)
240265
}
241266

242267
func escapeLabelValue(s string) string {

internal/exporter/exporter_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ func get(holder *StateHolder) *httptest.ResponseRecorder {
3434
return rec
3535
}
3636

37+
func getWithPrefix(holder *StateHolder, prefix string) *httptest.ResponseRecorder {
38+
rec := httptest.NewRecorder()
39+
Handler(holder, prefix)(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
40+
return rec
41+
}
42+
3743
func TestHandler_NoData_Returns503(t *testing.T) {
3844
holder := &StateHolder{}
3945
rec := get(holder)
@@ -362,3 +368,68 @@ func TestHandler_HostMetrics_ValidPrometheus(t *testing.T) {
362368
assert.Contains(t, families, "ember_host_inflight")
363369
assert.Contains(t, families, "ember_host_status_rate")
364370
}
371+
372+
func TestPrefixed(t *testing.T) {
373+
assert.Equal(t, "frankenphp_threads_total", prefixed("", "frankenphp_threads_total"))
374+
assert.Equal(t, "prod_frankenphp_threads_total", prefixed("prod", "frankenphp_threads_total"))
375+
assert.Equal(t, "myapp_ember_host_rps", prefixed("myapp", "ember_host_rps"))
376+
}
377+
378+
func TestHandler_WithPrefix_AllMetricsPrefixed(t *testing.T) {
379+
threads := []fetcher.ThreadDebugState{
380+
{Index: 0, IsBusy: true, MemoryUsage: 10 * 1024 * 1024},
381+
{Index: 1, IsWaiting: true},
382+
}
383+
workers := map[string]*fetcher.WorkerMetrics{
384+
"/app/worker.php": {Crashes: 2, Restarts: 5, QueueDepth: 1, RequestCount: 10000},
385+
}
386+
s := stateWithThreads(threads, workers)
387+
s.Derived.HasPercentiles = true
388+
s.Derived.P50 = 12.5
389+
s.Derived.P95 = 45.0
390+
s.Derived.P99 = 120.3
391+
s.HostDerived = []model.HostDerived{
392+
{Host: "test.com", RPS: 100, AvgTime: 25, InFlight: 3,
393+
HasPercentiles: true, P50: 10, P90: 30, P95: 50, P99: 120,
394+
StatusCodes: map[int]float64{200: 90, 500: 5}},
395+
}
396+
397+
holder := &StateHolder{}
398+
holder.Store(s)
399+
400+
rec := getWithPrefix(holder, "prod")
401+
require.Equal(t, http.StatusOK, rec.Code)
402+
403+
parser := expfmt.NewTextParser(prommodel.UTF8Validation)
404+
families, err := parser.TextToMetricFamilies(rec.Body)
405+
require.NoError(t, err, "output must be valid Prometheus text format")
406+
407+
assert.Contains(t, families, "prod_frankenphp_threads_total")
408+
assert.Contains(t, families, "prod_frankenphp_thread_memory_bytes")
409+
assert.Contains(t, families, "prod_frankenphp_worker_crashes_total")
410+
assert.Contains(t, families, "prod_frankenphp_request_duration_milliseconds")
411+
assert.Contains(t, families, "prod_process_cpu_percent")
412+
assert.Contains(t, families, "prod_process_rss_bytes")
413+
assert.Contains(t, families, "prod_ember_host_rps")
414+
assert.Contains(t, families, "prod_ember_host_latency_avg_milliseconds")
415+
assert.Contains(t, families, "prod_ember_host_latency_milliseconds")
416+
assert.Contains(t, families, "prod_ember_host_inflight")
417+
assert.Contains(t, families, "prod_ember_host_status_rate")
418+
419+
// Original names must NOT be present
420+
assert.NotContains(t, families, "frankenphp_threads_total")
421+
assert.NotContains(t, families, "ember_host_rps")
422+
assert.NotContains(t, families, "process_cpu_percent")
423+
}
424+
425+
func TestHandler_EmptyPrefix_DefaultNames(t *testing.T) {
426+
holder := &StateHolder{}
427+
holder.Store(stateWithThreads(nil, nil))
428+
429+
rec := getWithPrefix(holder, "")
430+
body := rec.Body.String()
431+
432+
assert.Contains(t, body, "frankenphp_threads_total")
433+
assert.Contains(t, body, "process_cpu_percent")
434+
assert.NotContains(t, body, "_frankenphp_threads_total")
435+
}

0 commit comments

Comments
 (0)