Skip to content

Commit 55d4e49

Browse files
Address comments about plugin system
1 parent 8a07116 commit 55d4e49

File tree

16 files changed

+1050
-584
lines changed

16 files changed

+1050
-584
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,19 @@ Ember polls the Caddy admin API and Prometheus metrics endpoint at a regular int
101101

102102
Ember supports a plugin system that lets third-party developers add custom tabs for visualizing metrics from additional Caddy modules (e.g., rate limiters, WAF modules, custom middleware). Plugins are compiled into the binary using Go's blank import pattern, the same approach used by Caddy itself.
103103

104+
Building a custom Ember binary with plugins is simple:
105+
106+
```go
107+
import (
108+
"github.com/alexandre-daubois/ember"
109+
_ "github.com/myorg/ember-myplugin"
110+
)
111+
112+
func main() { ember.Run() }
113+
```
114+
115+
Plugins can provide multiple tabs, subscribe to core metrics, conditionally hide their tabs, and reuse Ember's Prometheus parser via the `pkg/metrics` package.
116+
104117
See the [Plugin Development Guide](docs/plugins.md) for details on building and integrating plugins.
105118

106119
## Documentation

cmd/ember/main.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import (
44
"fmt"
55
"os"
66

7-
"github.com/alexandre-daubois/ember/internal/app"
7+
"github.com/alexandre-daubois/ember"
88
)
99

1010
var version = "1.0.0-dev"
1111

1212
func main() {
13-
if err := app.Run(os.Args[1:], version); err != nil {
13+
ember.Version = version
14+
if err := ember.Run(); err != nil {
1415
fmt.Fprintf(os.Stderr, "error: %v\n", err)
1516
os.Exit(1)
1617
}

docs/plugins.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,13 @@ import (
7676
"fmt"
7777
"os"
7878

79-
"github.com/alexandre-daubois/ember/internal/app"
79+
"github.com/alexandre-daubois/ember"
8080

8181
_ "github.com/example/ember-stats" // your plugin
8282
)
8383

8484
func main() {
85-
if err := app.Run(os.Args[1:], "custom"); err != nil {
85+
if err := ember.Run(); err != nil {
8686
fmt.Fprintln(os.Stderr, err)
8787
os.Exit(1)
8888
}
@@ -363,6 +363,62 @@ type Closer interface {
363363
}
364364
```
365365

366+
### MetricsSubscriber (optional)
367+
368+
```go
369+
type MetricsSubscriber interface {
370+
OnMetrics(snap *metrics.Snapshot)
371+
}
372+
```
373+
374+
Called synchronously after each successful core fetch, before plugin `Fetch` calls. The snapshot contains Caddy and FrankenPHP metrics already parsed by Ember. The snapshot must not be modified.
375+
376+
Import `"github.com/alexandre-daubois/ember/pkg/metrics"` to access the `Snapshot` and `MetricsSnapshot` types.
377+
378+
This avoids the need for plugins to make their own `/metrics` requests to Caddy when they need access to the same core metrics Ember already collects.
379+
380+
### MultiRenderer (optional)
381+
382+
```go
383+
type TabDescriptor struct {
384+
Key string
385+
Name string
386+
}
387+
388+
type MultiRenderer interface {
389+
Tabs() []TabDescriptor
390+
RendererForTab(key string) Renderer
391+
}
392+
```
393+
394+
Implement `MultiRenderer` instead of `Renderer` when your plugin needs multiple TUI tabs. Each tab gets its own `Renderer`, but all tabs share a single `Fetch` call. `Tabs()` is called once after `Init()` to determine the number and order of tabs. `RendererForTab` is called once per tab to create its initial `Renderer`.
395+
396+
If a plugin implements both `Renderer` and `MultiRenderer`, `MultiRenderer` takes priority.
397+
398+
### Availability (optional)
399+
400+
```go
401+
type Availability interface {
402+
Available() bool
403+
}
404+
```
405+
406+
Implement `Availability` when your plugin's tab(s) should be shown or hidden based on runtime conditions. For example, a plugin compiled into a custom build but talking to a Caddy instance that does not have the corresponding module enabled.
407+
408+
`Available()` is checked after each successful `Fetch`. When it returns `false`, the plugin's tab(s) are removed from the tab bar. When it returns `true`, they are re-added. If `Available()` panics, the tab stays visible (fail-open).
409+
410+
## Reusing Prometheus Parsing
411+
412+
The `pkg/metrics` package exposes the same Prometheus text parser that Ember uses internally:
413+
414+
```go
415+
import "github.com/alexandre-daubois/ember/pkg/metrics"
416+
417+
snap, err := metrics.ParsePrometheus(reader)
418+
```
419+
420+
This returns a `MetricsSnapshot` with all Caddy and FrankenPHP metrics parsed. The package also exposes all the data types (`Snapshot`, `MetricsSnapshot`, `HostMetrics`, `WorkerMetrics`, etc.) for plugins that need to work with core metrics.
421+
366422
## Reserved Keybindings
367423

368424
The following keys are handled by Ember core and **never** reach your plugin's `HandleKey`:

ember.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Package ember provides the public entry point for running Ember,
2+
// a real-time monitoring tool for Caddy and FrankenPHP.
3+
//
4+
// Plugin authors use this package to build custom Ember binaries:
5+
//
6+
// import (
7+
// "github.com/alexandre-daubois/ember"
8+
// _ "github.com/myorg/ember-myplugin"
9+
// )
10+
//
11+
// func main() {
12+
// ember.Run()
13+
// }
14+
package ember
15+
16+
import (
17+
"os"
18+
19+
"github.com/alexandre-daubois/ember/internal/app"
20+
)
21+
22+
// Version is set at build time via -ldflags.
23+
// When empty, it defaults to "dev".
24+
var Version = "dev"
25+
26+
// Run starts Ember with command-line arguments from os.Args.
27+
func Run() error {
28+
return app.Run(os.Args[1:], Version)
29+
}
30+
31+
// RunWithArgs starts Ember with the given arguments and version string.
32+
// This is useful for testing or embedding Ember with custom arguments.
33+
func RunWithArgs(args []string, version string) error {
34+
return app.Run(args, version)
35+
}

internal/app/daemon.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config, plugins []pl
104104
}
105105
errThrottle.recover(log)
106106
state.Update(snap)
107+
notifyDaemonSubscribers(dPlugins, snap)
107108
fetchDaemonPlugins(ctx, dPlugins, log)
108109
holder.StoreAll(state.CopyForExport(), daemonPluginExports(dPlugins))
109110
}
@@ -137,6 +138,7 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config, plugins []pl
137138
}
138139

139140
type daemonPlugin struct {
141+
p plugin.Plugin
140142
name string
141143
fetcher plugin.Fetcher
142144
exporter plugin.Exporter
@@ -146,7 +148,7 @@ type daemonPlugin struct {
146148
func newDaemonPlugins(plugins []plugin.Plugin) []daemonPlugin {
147149
var dps []daemonPlugin
148150
for _, p := range plugins {
149-
dp := daemonPlugin{name: p.Name()}
151+
dp := daemonPlugin{p: p, name: p.Name()}
150152
if f, ok := p.(plugin.Fetcher); ok {
151153
dp.fetcher = f
152154
}
@@ -184,6 +186,21 @@ func fetchDaemonPlugins(ctx context.Context, dps []daemonPlugin, log *slog.Logge
184186
wg.Wait()
185187
}
186188

189+
func notifyDaemonSubscribers(dps []daemonPlugin, snap *fetcher.Snapshot) {
190+
for _, dp := range dps {
191+
if sub, ok := dp.p.(plugin.MetricsSubscriber); ok {
192+
safeOnMetrics(sub, snap)
193+
}
194+
}
195+
}
196+
197+
func safeOnMetrics(sub plugin.MetricsSubscriber, snap *fetcher.Snapshot) {
198+
defer func() {
199+
recover() //nolint:errcheck // fire-and-forget: don't crash Ember if a subscriber panics
200+
}()
201+
sub.OnMetrics(snap)
202+
}
203+
187204
func daemonPluginExports(dps []daemonPlugin) []plugin.PluginExport {
188205
var exports []plugin.PluginExport
189206
for _, dp := range dps {

internal/fetcher/fetcher.go

Lines changed: 10 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,106 +2,25 @@ package fetcher
22

33
import (
44
"context"
5-
"time"
6-
)
7-
8-
type ThreadDebugState struct {
9-
Index int `json:"Index"`
10-
Name string `json:"Name"`
11-
State string `json:"State"`
12-
IsWaiting bool `json:"IsWaiting"`
13-
IsBusy bool `json:"IsBusy"`
14-
WaitingSinceMilliseconds int64 `json:"WaitingSinceMilliseconds"`
15-
16-
CurrentURI string `json:"CurrentURI,omitempty"`
17-
CurrentMethod string `json:"CurrentMethod,omitempty"`
18-
RequestStartedAt int64 `json:"RequestStartedAt,omitempty"`
19-
MemoryUsage int64 `json:"MemoryUsage,omitempty"`
20-
RequestCount int64 `json:"RequestCount,omitempty"`
21-
}
225

23-
type ThreadsResponse struct {
24-
ThreadDebugStates []ThreadDebugState `json:"ThreadDebugStates"`
25-
ReservedThreadCount int `json:"ReservedThreadCount"`
26-
}
27-
28-
type WorkerMetrics struct {
29-
Worker string `json:"worker"`
30-
Total float64 `json:"total"`
31-
Busy float64 `json:"busy"`
32-
Ready float64 `json:"ready"`
33-
RequestTime float64 `json:"requestTime"`
34-
RequestCount float64 `json:"requestCount"`
35-
Crashes float64 `json:"crashes"`
36-
Restarts float64 `json:"restarts"`
37-
QueueDepth float64 `json:"queueDepth"`
38-
}
6+
"github.com/alexandre-daubois/ember/pkg/metrics"
7+
)
398

40-
type HostMetrics struct {
41-
Host string `json:"host"`
42-
RequestsTotal float64 `json:"requestsTotal"`
43-
DurationSum float64 `json:"durationSum"`
44-
DurationCount float64 `json:"durationCount"`
45-
InFlight float64 `json:"inFlight"`
46-
DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"`
47-
StatusCodes map[int]float64 `json:"statusCodes,omitempty"`
48-
Methods map[string]float64 `json:"methods,omitempty"`
49-
ResponseSizeSum float64 `json:"responseSizeSum"`
50-
ResponseSizeCount float64 `json:"responseSizeCount"`
51-
RequestSizeSum float64 `json:"requestSizeSum"`
52-
RequestSizeCount float64 `json:"requestSizeCount"`
53-
ErrorsTotal float64 `json:"errorsTotal"`
54-
TTFBSum float64 `json:"ttfbSum"`
55-
TTFBCount float64 `json:"ttfbCount"`
56-
TTFBBuckets []HistogramBucket `json:"ttfbBuckets,omitempty"`
57-
}
9+
type ThreadDebugState = metrics.ThreadDebugState
5810

59-
type MetricsSnapshot struct {
60-
// FrankenPHP-specific (require frankenphp metrics)
61-
TotalThreads float64 `json:"totalThreads"`
62-
BusyThreads float64 `json:"busyThreads"`
63-
QueueDepth float64 `json:"queueDepth"`
64-
Workers map[string]*WorkerMetrics `json:"workers"`
11+
type ThreadsResponse = metrics.ThreadsResponse
6512

66-
// Caddy HTTP metrics (require `metrics` directive in Caddyfile)
67-
HTTPRequestErrorsTotal float64 `json:"httpRequestErrorsTotal"`
68-
HTTPRequestsTotal float64 `json:"httpRequestsTotal"`
69-
HTTPRequestDurationSum float64 `json:"httpRequestDurationSum"`
70-
HTTPRequestDurationCount float64 `json:"httpRequestDurationCount"`
71-
HTTPRequestsInFlight float64 `json:"httpRequestsInFlight"`
72-
DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"`
73-
HasHTTPMetrics bool `json:"hasHttpMetrics"`
13+
type WorkerMetrics = metrics.WorkerMetrics
7414

75-
// Per-host Caddy HTTP metrics
76-
Hosts map[string]*HostMetrics `json:"hosts,omitempty"`
15+
type HostMetrics = metrics.HostMetrics
7716

78-
// Go runtime process metrics (from standard Prometheus collector)
79-
ProcessCPUSecondsTotal float64 `json:"processCpuSecondsTotal,omitempty"`
80-
ProcessRSSBytes float64 `json:"processRssBytes,omitempty"`
81-
ProcessStartTimeSeconds float64 `json:"processStartTimeSeconds,omitempty"`
82-
}
17+
type MetricsSnapshot = metrics.MetricsSnapshot
8318

84-
type HistogramBucket struct {
85-
UpperBound float64 `json:"upperBound"`
86-
CumulativeCount float64 `json:"cumulativeCount"`
87-
}
19+
type HistogramBucket = metrics.HistogramBucket
8820

89-
type ProcessMetrics struct {
90-
PID int32 `json:"pid"`
91-
CPUPercent float64 `json:"cpuPercent"`
92-
RSS uint64 `json:"rss"`
93-
CreateTime int64 `json:"createTime"`
94-
Uptime time.Duration `json:"uptime"`
95-
}
21+
type ProcessMetrics = metrics.ProcessMetrics
9622

97-
type Snapshot struct {
98-
Threads ThreadsResponse `json:"threads"`
99-
Metrics MetricsSnapshot `json:"metrics"`
100-
Process ProcessMetrics `json:"process"`
101-
FetchedAt time.Time `json:"fetchedAt"`
102-
Errors []string `json:"errors,omitempty"`
103-
HasFrankenPHP bool `json:"hasFrankenPHP"`
104-
}
23+
type Snapshot = metrics.Snapshot
10524

10625
type Fetcher interface {
10726
Fetch(ctx context.Context) (*Snapshot, error)

0 commit comments

Comments
 (0)