Implement MultiInstancePlugin for Ember plugins
mainplugin.MultiInstancePlugin interface.repository·main·Indexed 18 days ago
https://github.com/alexandre-daubois/emberA real-time monitoring tool for Caddy servers providing a zero-config terminal dashboard for traffic, latency, and status codes. Ember includes deep introspection for FrankenPHP thread and worker management, a TUI for visualizing per-host HTTP metrics, and a headless daemon mode for Prometheus export. It supports remote Caddy instances via the admin API, mTLS, and an experimental plugin system for custom metric visualization.
plugin.MultiInstancePlugin interface.If you are running a FrankenPHP server (version 1.12.2 or later), the By Route view can display Avg Mem and Max Mem columns. These columns aggregate per-thread PHP memory usage keyed by (method, pattern).
Requirements and Behavior:
—.Count, Avg, and Max continue to update from the live log stream.The Ember plugin lifecycle follows these stages:
plugin.Register() is called during init() at import time.Provision(ctx, cfg) is called before the TUI or daemon starts. If this returns an error, the plugin is logged as a warning and disabled, but Ember continues running.Fetch is called on every tick. Update, View, HandleKey, StatusCount, and HelpBindings are called from the event loop.--daemon): Only Fetch and WriteMetrics are called.Close() is called on plugins implementing the Closer interface, in reverse registration order.When you provide multiple --addr flags, Ember polls every instance and emits one JSONL line per instance. Each line is prefixed with an instance field containing the alias provided in the --addr flag.
Behavioral notes:
--addr is provided, the instance field is omitted, and the output is identical to standard single-instance JSONL.--once, exactly one line per instance is produced in alphabetical order before exiting.ember --json \
--addr web1=https://web1.fr \
--addr web2=https://web2.frEmber can monitor multiple Caddy instances simultaneously and aggregate their metrics behind a single Prometheus endpoint. This is achieved using --daemon or --json modes.
--addr multiple times.
ember --daemon --expose :9191 --addr web1=https://a --addr web2=https://bEMBER_ADDR.
EMBER_ADDR='web1=https://a;web2=https://b' ember --daemon --expose :9191[[endpoints]] in a TOML file.ember_build_info) includes an ember_instance="<name>" label. If no alias is provided, the host is slugified (e.g., web1.fr becomes web1_fr).--daemon or --json for multi-instance needs.,ca=PATH: CA certificate,cert=PATH: Client certificate,key=PATH: Client private key,insecure: Skip TLS verification,interval=DURATION: Custom polling interval (minimum 100ms)# Example: Monitoring multiple instances with explicit aliases and per-instance TLS
ember --daemon --expose :9191 \
--addr web1=https://a,ca=/etc/ca1.pem \
--addr web2=https://b,ca=/etc/ca2.pemBy default, plugins are disabled in multi-instance mode (when Ember is launched with multiple --addr flags). To opt-in, implement the MultiInstancePlugin marker interface.
Key changes for multi-instance plugins:
PluginConfig.Instances is populated with all monitored instances.plugin.InstanceFromContext(ctx) to identify which instance is being polled.ember_instance="<name>" into every line.Fetch instead.type myPlugin struct{}
func (p *myPlugin) EmberMultiInstance() {} // marker only, never called
// In Fetch:
func (p *myPlugin) Fetch(ctx context.Context) (any, error) {
inst, _ := plugin.InstanceFromContext(ctx)
return queryMyBackend(inst.Addr)
}
// In WriteMetrics:
func (p *myPlugin) WriteMetrics(w io.Writer, data any, prefix string) {
fmt.Fprintln(w, "# HELP my_plugin_requests_total ...")
fmt.Fprintln(w, "# TYPE my_plugin_requests_total counter")
fmt.Fprintf(w, "my_plugin_requests_total %d\n", count)
}Ember can act as an aggregator for multiple Caddy instances. You can define multiple targets using the EMBER_ADDR environment variable with a semicolon-separated list of name=address pairs.
When using this mode, every emitted Prometheus metric (except ember_build_info) will include an ember_instance label corresponding to the name provided (e.g., ember_instance="blue").
For large fleets, instead of using EMBER_ADDR, use a configuration file and point the EMBER_CONFIG environment variable to its path.
services:
caddy-blue:
image: caddy:latest
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
caddy-green:
image: caddy:latest
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
ember:
image: alexandredaubois/ember
environment:
EMBER_ADDR: blue=http://caddy-blue:2019;green=http://caddy-green:2019
ports:
- "9191:9191"
depends_on:
- caddy-blue
- caddy-greenFor plugins with complex state, you can follow the Elm architecture by returning a separate Renderer from the Update method.
When using this pattern:
View, HandleKey, etc., on the plugin struct itself until the first Update returns a new Renderer.Renderer is returned, all subsequent calls to Update, View, HandleKey, etc., are directed to that returned Renderer.This allows the main plugin struct to handle the initial "no data yet" state in its own View method while the Renderer handles the actual data visualization.
func (p *statsPlugin) Update(data any, _, _ int) plugin.Renderer {
return &statsRenderer{count: data.(int64), ts: time.Now()}
}
// Initial View, before the first Fetch completes
func (p *statsPlugin) View(_, _ int) string { return " Waiting for data..." }
type statsRenderer struct {
count int64
ts time.Time
}
func (r *statsRenderer) Update(data any, _, _ int) plugin.Renderer {
return &statsRenderer{count: data.(int64), ts: time.Now()}
}
func (r *statsRenderer) View(_, _ int) string {
return fmt.Sprintf("\n Tick counter: %d\n Last update: %s\n",
r.count, r.ts.Format("15:04:05"))
}
func (r *statsRenderer) HandleKey(msg tea.KeyMsg) bool {
return msg.String() == "x"
}
func (r *statsRenderer) StatusCount() string {
return fmt.Sprintf("%d ticks", r.count)
}
func (r *statsRenderer) HelpBindings() []plugin.HelpBinding {
return []plugin.HelpBinding{{Key: "x", Desc: "Example action"}}
}Ember can scrape multiple Caddy instances simultaneously and aggregate their metrics into a single /metrics endpoint. This is useful for monitoring a fleet of small Caddy services through a single Prometheus target.
Every emitted metric (except ember_build_info) includes an ember_instance="<name>" label, allowing you to split or aggregate data per instance using PromQL.
Note: If you are running Ember as a sidecar next to a single Caddy instance, you do not need multi-instance mode; use a single --addr instead.
ember --daemon --expose :9191 --addr blue=http://10.0.0.10:2019 --addr green=http://10.0.0.11:2019When developing a plugin for Ember, the name you provide during registration must follow specific rules. If these rules are violated, the Register() function will panic at startup.
Naming Requirements:
my-plugin and myplugin both map to the same environment variable prefix (EMBER_PLUGIN_MYPLUGIN_) and therefore cannot coexist.The log table operates in two modes:
● PAUSED indicator appears in the header, showing how many new lines have been captured in the background.Switching Modes:
↑, ↓, PgUp, PgDn, End) automatically enters Frozen mode.p to toggle between Frozen and Following.f or Home to resume live following.Selecting By Route in the sidepanel provides an aggregated view of every request seen during the session. Unlike the standard log table, these counts are independent of the 10,000-entry access ring buffer and continue to climb.
Each row represents a (host, method, pattern) bucket. For example, GET /users/:id on api.localhost is a distinct row from GET /users/:id on app.localhost.
Key Features:
api.localhost /users/:id).s or S to cycle through sort fields: Count -> Pattern -> Avg -> Max -> Avg Mem -> Max Mem.