Skip to content

Commit ba40b42

Browse files
Add daemon and Prometheus metrics exposition
1 parent b3839b4 commit ba40b42

6 files changed

Lines changed: 504 additions & 6 deletions

File tree

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ Monitor your Caddy server in real time: per-host traffic, latency percentiles, s
2424
- Detail panel with per-thread info and memory sparkline trend
2525
- Memory delta indicators (↑/↓) per thread between polls
2626

27+
### Cloud Ready with Prometheus Export & Daemon Mode
28+
29+
- `--expose=:9191` starts a `/metrics` endpoint exposing FrankenPHP metrics in Prometheus format
30+
- `--daemon` runs headless (no TUI)
31+
- Exposes thread state, per-thread memory, worker crashes/restarts/queue, request duration percentiles, and process CPU/RSS
32+
- Works alongside the TUI (`--expose` without `--daemon`) or standalone
33+
2734
### General
2835

2936
- Tab-based navigation between Caddy and FrankenPHP views
@@ -74,7 +81,9 @@ Ember connects to the Caddy admin API and auto-detects FrankenPHP if present. In
7481
--addr string Caddy admin API address (default "http://localhost:2019")
7582
--interval dur Polling interval (default 1s)
7683
--pid int FrankenPHP PID (auto-detected if not set)
77-
--json JSON output mode (for scripting)
84+
--json JSON output mode (streaming JSONL)
85+
--expose addr Expose Prometheus metrics (e.g. --expose=:9191)
86+
--daemon Headless mode (requires --expose)
7887
--no-color Disable colors
7988
```
8089

cmd/ember/main.go

Lines changed: 93 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import (
55
"encoding/json"
66
"flag"
77
"fmt"
8+
"net/http"
89
"os"
910
"os/signal"
1011
"syscall"
1112
"time"
1213

14+
"github.com/alexandredaubois/ember/internal/exporter"
1315
"github.com/alexandredaubois/ember/internal/fetcher"
1416
"github.com/alexandredaubois/ember/internal/model"
1517
"github.com/alexandredaubois/ember/internal/ui"
@@ -23,6 +25,8 @@ type Config struct {
2325
NoColor bool
2426
JSONMode bool
2527
PID int
28+
Expose string
29+
Daemon bool
2630
}
2731

2832
var version = "1.0.0-dev"
@@ -36,6 +40,8 @@ func main() {
3640
flag.BoolVar(&cfg.NoColor, "no-color", false, "disable colors")
3741
flag.BoolVar(&cfg.JSONMode, "json", false, "JSON output mode")
3842
flag.IntVar(&cfg.PID, "pid", 0, "FrankenPHP process PID (auto-detected if not set)")
43+
flag.StringVar(&cfg.Expose, "expose", "", "expose Prometheus metrics on address (e.g. :9191)")
44+
flag.BoolVar(&cfg.Daemon, "daemon", false, "run in daemon mode (no TUI, requires --expose)")
3945
showVersion := flag.Bool("version", false, "show version")
4046
flag.Parse()
4147

@@ -44,6 +50,11 @@ func main() {
4450
return
4551
}
4652

53+
if cfg.Daemon && cfg.Expose == "" {
54+
fmt.Fprintf(os.Stderr, "error: --daemon requires --expose\n")
55+
os.Exit(1)
56+
}
57+
4758
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
4859
defer cancel()
4960

@@ -52,7 +63,7 @@ func main() {
5263
detected, err := fetcher.FindFrankenPHPProcess(ctx)
5364
if err != nil {
5465
detected, err = fetcher.FindCaddyProcess(ctx)
55-
if err != nil && cfg.JSONMode {
66+
if err != nil && (cfg.JSONMode || cfg.Daemon) {
5667
fmt.Fprintf(os.Stderr, "warning: no frankenphp or caddy process found\n")
5768
}
5869
}
@@ -65,23 +76,100 @@ func main() {
6576
hasFrankenPHP := f.DetectFrankenPHP(ctx)
6677
f.FetchServerNames(ctx)
6778

68-
if cfg.JSONMode {
79+
switch {
80+
case cfg.JSONMode:
6981
runJSON(ctx, f, cfg.Interval)
70-
return
82+
case cfg.Daemon:
83+
runDaemon(ctx, f, cfg)
84+
default:
85+
runTUI(ctx, f, cfg, hasFrankenPHP)
7186
}
87+
}
7288

73-
app := ui.NewApp(f, ui.Config{
89+
func runTUI(ctx context.Context, f *fetcher.HTTPFetcher, cfg Config, hasFrankenPHP bool) {
90+
uiCfg := ui.Config{
7491
Interval: cfg.Interval,
7592
SlowThreshold: time.Duration(cfg.SlowThreshold) * time.Millisecond,
7693
NoColor: cfg.NoColor,
7794
Version: version,
7895
HasFrankenPHP: hasFrankenPHP,
79-
})
96+
}
97+
98+
var srv *http.Server
99+
if cfg.Expose != "" {
100+
holder := &exporter.StateHolder{}
101+
uiCfg.OnStateUpdate = func(s model.State) {
102+
holder.Store(s)
103+
}
104+
105+
mux := http.NewServeMux()
106+
mux.HandleFunc("/metrics", exporter.Handler(holder))
107+
srv = &http.Server{Addr: cfg.Expose, Handler: mux}
108+
109+
go func() {
110+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
111+
fmt.Fprintf(os.Stderr, "metrics server error: %v\n", err)
112+
}
113+
}()
114+
}
115+
116+
app := ui.NewApp(f, uiCfg)
80117
p := tea.NewProgram(app, tea.WithAltScreen())
81118
if _, err := p.Run(); err != nil {
82119
fmt.Fprintf(os.Stderr, "error: %v\n", err)
83120
os.Exit(1)
84121
}
122+
123+
if srv != nil {
124+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
125+
defer shutdownCancel()
126+
srv.Shutdown(shutdownCtx)
127+
}
128+
}
129+
130+
func runDaemon(ctx context.Context, f *fetcher.HTTPFetcher, cfg Config) {
131+
holder := &exporter.StateHolder{}
132+
var state model.State
133+
134+
mux := http.NewServeMux()
135+
mux.HandleFunc("/metrics", exporter.Handler(holder))
136+
srv := &http.Server{Addr: cfg.Expose, Handler: mux}
137+
138+
go func() {
139+
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
140+
fmt.Fprintf(os.Stderr, "metrics server error: %v\n", err)
141+
os.Exit(1)
142+
}
143+
}()
144+
145+
fmt.Fprintf(os.Stderr, "ember daemon: exposing metrics on %s/metrics\n", cfg.Expose)
146+
147+
poll := func() {
148+
snap, err := f.Fetch(ctx)
149+
if err != nil {
150+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
151+
return
152+
}
153+
state.Update(snap)
154+
holder.Store(state)
155+
}
156+
157+
poll()
158+
159+
ticker := time.NewTicker(cfg.Interval)
160+
defer ticker.Stop()
161+
162+
for {
163+
select {
164+
case <-ctx.Done():
165+
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
166+
defer shutdownCancel()
167+
srv.Shutdown(shutdownCtx)
168+
return
169+
case <-ticker.C:
170+
poll()
171+
}
172+
}
85173
}
86174

87175
type jsonOutput struct {

internal/exporter/exporter.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package exporter
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"sort"
7+
"strings"
8+
"sync"
9+
10+
"github.com/alexandredaubois/ember/internal/model"
11+
)
12+
13+
type StateHolder struct {
14+
mu sync.RWMutex
15+
state model.State
16+
}
17+
18+
func (h *StateHolder) Store(s model.State) {
19+
h.mu.Lock()
20+
h.state = s
21+
h.mu.Unlock()
22+
}
23+
24+
func (h *StateHolder) Load() model.State {
25+
h.mu.RLock()
26+
defer h.mu.RUnlock()
27+
return h.state
28+
}
29+
30+
const prometheusContentType = "text/plain; version=0.0.4; charset=utf-8"
31+
32+
func Handler(holder *StateHolder) http.HandlerFunc {
33+
return func(w http.ResponseWriter, r *http.Request) {
34+
s := holder.Load()
35+
if s.Current == nil {
36+
http.Error(w, "no data yet", http.StatusServiceUnavailable)
37+
return
38+
}
39+
40+
w.Header().Set("Content-Type", prometheusContentType)
41+
42+
writeThreadMetrics(w, &s)
43+
writeThreadMemory(w, &s)
44+
writeWorkerMetrics(w, &s)
45+
writePercentiles(w, &s)
46+
writeProcessMetrics(w, &s)
47+
}
48+
}
49+
50+
func writeThreadMetrics(w http.ResponseWriter, s *model.State) {
51+
total := len(s.Current.Threads.ThreadDebugStates)
52+
other := total - s.Derived.TotalBusy - s.Derived.TotalIdle
53+
if other < 0 {
54+
other = 0
55+
}
56+
57+
fmt.Fprintln(w, "# HELP frankenphp_threads_total Number of FrankenPHP threads by state")
58+
fmt.Fprintln(w, "# TYPE frankenphp_threads_total gauge")
59+
fmt.Fprintf(w, "frankenphp_threads_total{state=\"busy\"} %d\n", s.Derived.TotalBusy)
60+
fmt.Fprintf(w, "frankenphp_threads_total{state=\"idle\"} %d\n", s.Derived.TotalIdle)
61+
fmt.Fprintf(w, "frankenphp_threads_total{state=\"other\"} %d\n", other)
62+
}
63+
64+
func writeThreadMemory(w http.ResponseWriter, s *model.State) {
65+
hasMemory := false
66+
for _, t := range s.Current.Threads.ThreadDebugStates {
67+
if t.MemoryUsage > 0 {
68+
hasMemory = true
69+
break
70+
}
71+
}
72+
if !hasMemory {
73+
return
74+
}
75+
76+
fmt.Fprintln(w, "# HELP frankenphp_thread_memory_bytes Memory usage per FrankenPHP thread")
77+
fmt.Fprintln(w, "# TYPE frankenphp_thread_memory_bytes gauge")
78+
for _, t := range s.Current.Threads.ThreadDebugStates {
79+
if t.MemoryUsage > 0 {
80+
fmt.Fprintf(w, "frankenphp_thread_memory_bytes{index=\"%d\"} %d\n", t.Index, t.MemoryUsage)
81+
}
82+
}
83+
}
84+
85+
func writeWorkerMetrics(out http.ResponseWriter, s *model.State) {
86+
if len(s.Current.Metrics.Workers) == 0 {
87+
return
88+
}
89+
90+
names := sortedWorkerNames(s.Current.Metrics.Workers)
91+
92+
fmt.Fprintln(out, "# HELP frankenphp_worker_crashes_total Total worker crashes")
93+
fmt.Fprintln(out, "# TYPE frankenphp_worker_crashes_total counter")
94+
for _, name := range names {
95+
wm := s.Current.Metrics.Workers[name]
96+
fmt.Fprintf(out, "frankenphp_worker_crashes_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.Crashes)
97+
}
98+
99+
fmt.Fprintln(out, "# HELP frankenphp_worker_restarts_total Total worker restarts")
100+
fmt.Fprintln(out, "# TYPE frankenphp_worker_restarts_total counter")
101+
for _, name := range names {
102+
wm := s.Current.Metrics.Workers[name]
103+
fmt.Fprintf(out, "frankenphp_worker_restarts_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.Restarts)
104+
}
105+
106+
fmt.Fprintln(out, "# HELP frankenphp_worker_queue_depth Requests in queue per worker")
107+
fmt.Fprintln(out, "# TYPE frankenphp_worker_queue_depth gauge")
108+
for _, name := range names {
109+
wm := s.Current.Metrics.Workers[name]
110+
fmt.Fprintf(out, "frankenphp_worker_queue_depth{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.QueueDepth)
111+
}
112+
113+
fmt.Fprintln(out, "# HELP frankenphp_worker_requests_total Total requests processed per worker")
114+
fmt.Fprintln(out, "# TYPE frankenphp_worker_requests_total counter")
115+
for _, name := range names {
116+
wm := s.Current.Metrics.Workers[name]
117+
fmt.Fprintf(out, "frankenphp_worker_requests_total{worker=\"%s\"} %g\n", escapeLabelValue(name), wm.RequestCount)
118+
}
119+
}
120+
121+
func writePercentiles(w http.ResponseWriter, s *model.State) {
122+
if !s.Derived.HasPercentiles {
123+
return
124+
}
125+
126+
fmt.Fprintln(w, "# HELP frankenphp_request_duration_milliseconds Request duration percentiles")
127+
fmt.Fprintln(w, "# TYPE frankenphp_request_duration_milliseconds gauge")
128+
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.5\"} %.2f\n", s.Derived.P50)
129+
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.95\"} %.2f\n", s.Derived.P95)
130+
fmt.Fprintf(w, "frankenphp_request_duration_milliseconds{quantile=\"0.99\"} %.2f\n", s.Derived.P99)
131+
}
132+
133+
func writeProcessMetrics(w http.ResponseWriter, s *model.State) {
134+
fmt.Fprintln(w, "# HELP process_cpu_percent CPU usage of the monitored process")
135+
fmt.Fprintln(w, "# TYPE process_cpu_percent gauge")
136+
fmt.Fprintf(w, "process_cpu_percent %.2f\n", s.Current.Process.CPUPercent)
137+
138+
fmt.Fprintln(w, "# HELP process_rss_bytes Resident set size of the monitored process")
139+
fmt.Fprintln(w, "# TYPE process_rss_bytes gauge")
140+
fmt.Fprintf(w, "process_rss_bytes %d\n", s.Current.Process.RSS)
141+
}
142+
143+
func escapeLabelValue(s string) string {
144+
s = strings.ReplaceAll(s, `\`, `\\`)
145+
s = strings.ReplaceAll(s, `"`, `\"`)
146+
s = strings.ReplaceAll(s, "\n", `\n`)
147+
return s
148+
}
149+
150+
func sortedWorkerNames[V any](m map[string]V) []string {
151+
names := make([]string, 0, len(m))
152+
for name := range m {
153+
names = append(names, name)
154+
}
155+
sort.Strings(names)
156+
return names
157+
}

0 commit comments

Comments
 (0)