Skip to content

Commit 38d7336

Browse files
Add state dump functionality via SIGUSR1 signal
1 parent 04d2812 commit 38d7336

5 files changed

Lines changed: 151 additions & 2 deletions

File tree

docs/prometheus-export.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,22 @@ The `--daemon` flag disables the TUI and requires `--expose`. Ember polls the Ca
2525
ember --expose :9191 --daemon --addr http://caddy:2019
2626
```
2727

28-
Logs are written to stderr:
28+
Logs are written to stderr. Use `--log-format json` for structured JSON logs suitable for log aggregation:
2929

30+
```bash
31+
ember --expose :9191 --daemon --log-format json
3032
```
31-
ember daemon: exposing metrics on http://localhost:9191/metrics
33+
34+
### State Dump via SIGUSR1
35+
36+
Send `SIGUSR1` to a running daemon to dump the full state snapshot to stderr as JSON. This is useful for debugging without interrupting the process:
37+
38+
```bash
39+
kill -USR1 $(pgrep ember)
3240
```
3341

42+
The dump includes threads, metrics, process info, and derived metrics. Not available on Windows.
43+
3444
## Exported Metrics
3545

3646
### FrankenPHP Thread Metrics

internal/app/daemon.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error {
5454
ticker := time.NewTicker(cfg.interval)
5555
defer ticker.Stop()
5656

57+
dumpCh := dumpSignal()
58+
5759
for {
5860
select {
5961
case <-ctx.Done():
@@ -66,6 +68,8 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error {
6668
return nil
6769
case <-ticker.C:
6870
poll()
71+
case <-dumpCh:
72+
dumpState(&state, log)
6973
}
7074
}
7175
}

internal/app/signal_unix.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//go:build !windows
2+
3+
package app
4+
5+
import (
6+
"encoding/json"
7+
"log/slog"
8+
"math"
9+
"os"
10+
"os/signal"
11+
"syscall"
12+
13+
"github.com/alexandre-daubois/ember/internal/fetcher"
14+
"github.com/alexandre-daubois/ember/internal/model"
15+
)
16+
17+
// dumpSignal returns a channel that receives a value each time SIGUSR1 is sent to the process.
18+
func dumpSignal() <-chan os.Signal {
19+
ch := make(chan os.Signal, 1)
20+
signal.Notify(ch, syscall.SIGUSR1)
21+
return ch
22+
}
23+
24+
func dumpState(state *model.State, log *slog.Logger) {
25+
if state.Current == nil {
26+
log.Info("dump requested but no data available yet")
27+
return
28+
}
29+
30+
out := buildJSONOutput(state.Current, state)
31+
sanitizeForJSON(&out)
32+
b, err := json.Marshal(out)
33+
if err != nil {
34+
log.Error("dump failed", "err", err)
35+
return
36+
}
37+
38+
log.Info("state dump (SIGUSR1)", "snapshot", string(b))
39+
}
40+
41+
// sanitizeForJSON removes +Inf and NaN values that encoding/json cannot serialize.
42+
func sanitizeForJSON(out *jsonOutput) {
43+
out.Metrics.DurationBuckets = sanitizeBuckets(out.Metrics.DurationBuckets)
44+
for _, h := range out.Metrics.Hosts {
45+
h.DurationBuckets = sanitizeBuckets(h.DurationBuckets)
46+
h.TTFBBuckets = sanitizeBuckets(h.TTFBBuckets)
47+
}
48+
}
49+
50+
func sanitizeBuckets(buckets []fetcher.HistogramBucket) []fetcher.HistogramBucket {
51+
clean := make([]fetcher.HistogramBucket, 0, len(buckets))
52+
for _, b := range buckets {
53+
if math.IsInf(b.UpperBound, 0) || math.IsNaN(b.UpperBound) {
54+
continue
55+
}
56+
clean = append(clean, b)
57+
}
58+
return clean
59+
}

internal/app/signal_unix_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
//go:build !windows
2+
3+
package app
4+
5+
import (
6+
"bytes"
7+
"log/slog"
8+
"math"
9+
"testing"
10+
"time"
11+
12+
"github.com/alexandre-daubois/ember/internal/fetcher"
13+
"github.com/alexandre-daubois/ember/internal/model"
14+
"github.com/stretchr/testify/assert"
15+
)
16+
17+
func TestDumpState_WithData(t *testing.T) {
18+
snap := &fetcher.Snapshot{
19+
Metrics: fetcher.MetricsSnapshot{
20+
Workers: map[string]*fetcher.WorkerMetrics{},
21+
HasHTTPMetrics: true,
22+
DurationBuckets: []fetcher.HistogramBucket{
23+
{UpperBound: 0.005, CumulativeCount: 50},
24+
{UpperBound: math.Inf(1), CumulativeCount: 100},
25+
},
26+
},
27+
Process: fetcher.ProcessMetrics{CPUPercent: 5.0, RSS: 64 * 1024 * 1024},
28+
FetchedAt: time.Now(),
29+
}
30+
var state model.State
31+
state.Update(snap)
32+
33+
var buf bytes.Buffer
34+
log := slog.New(slog.NewTextHandler(&buf, nil))
35+
36+
dumpState(&state, log)
37+
38+
output := buf.String()
39+
assert.Contains(t, output, "state dump")
40+
assert.Contains(t, output, "cpuPercent")
41+
}
42+
43+
func TestDumpState_NoData(t *testing.T) {
44+
var state model.State
45+
46+
var buf bytes.Buffer
47+
log := slog.New(slog.NewTextHandler(&buf, nil))
48+
49+
dumpState(&state, log)
50+
51+
assert.Contains(t, buf.String(), "no data")
52+
}
53+
54+
func TestDumpSignal_ReturnsChannel(t *testing.T) {
55+
ch := dumpSignal()
56+
assert.NotNil(t, ch)
57+
}

internal/app/signal_windows.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//go:build windows
2+
3+
package app
4+
5+
import (
6+
"log/slog"
7+
"os"
8+
9+
"github.com/alexandre-daubois/ember/internal/model"
10+
)
11+
12+
// dumpSignal returns a channel that never receives on Windows (no SIGUSR1 support).
13+
func dumpSignal() <-chan os.Signal {
14+
return make(chan os.Signal)
15+
}
16+
17+
func dumpState(_ *model.State, log *slog.Logger) {
18+
log.Warn("state dump not supported on Windows")
19+
}

0 commit comments

Comments
 (0)