Every TLS handshake dispatches an event even when no events app is configured
Issue Details
How I ran into this: I was looking at whether TLS/certificate metrics (#1683) could reasonably be built as an events app plugin rather than as native instrumentation, since the events system is the existing hook for "a certificate was obtained / failed". Before writing anything I measured what an event dispatch costs, and found that a dispatch happens per TLS handshake on installations that never configured the events app at all.
The chain
CertMagic gives its embedder a way to opt out of events for free — a nil hook:
// certmagic/config.go
func (cfg *Config) emit(ctx context.Context, eventName string, data map[string]any) error {
if cfg.OnEvent == nil {
return nil
}
return cfg.OnEvent(ctx, eventName, data)
}
Caddy always fills that hook in (modules/caddytls/tls.go, modules/caddytls/automation.go — both set OnEvent: t.onEvent unconditionally), so the opt-out never applies.
It is unconditional because the reference is always available: TLS.Provision uses ctx.App("events"), and App() instantiates an app that is not in the config rather than reporting its absence. AppIfConfigured() is the method that reports absence, and its doc comment describes exactly this case ("when the app is optional and you don't want to instantiate a new one that hasn't been explicitly configured"), but it isn't used here.
The event that matters is tls_get_certificate: CertMagic emits it as the first statement of GetCertificateWithContext, which modules/caddytls/connpolicy.go calls for every TLS handshake.
App.Emit does have a fast exit for "nothing is subscribed":
if app.subscriptions[eventName] == nil {
break // shortcut if event not bound at all
}
but it sits after a uuid.NewRandom(), three zap logger derivations (including an e.ID().String() that formats the UUID even when debug logging is off), and a replacer callback registration.
Measured on this machine, that dispatch is roughly two thirds of the per-handshake certificate lookup, and 28 of its 41 allocations. Figures are in the PR below.
A second-order problem
Switching a single call site to AppIfConfigured is not enough, and I think this is worth a look on its own: App() caches the app it instantiates in cfg.apps, and AppIfConfigured() consults cfg.apps before it looks at the config, so an app that was auto-instantiated by one module then looks "configured" to every module provisioned afterwards. Apps are provisioned in map iteration order (for appName := range newCfg.AppsRaw), so with three modules calling ctx.App("events") — caddytls, caddyhttp, reverseproxy — the result differs from run to run. Arguably AppIfConfigured should distinguish "present in the config" from "instantiated on demand".
(Incidentally, Server.events in modules/caddyhttp/server.go is assigned but never read, so one of those three lookups exists only to fill a dead field.)
Not covered by the above: CertMagic builds the event's data map and copies the ClientHello at the call site, so those allocations happen whether or not emit returns early. Removing them needs something on the CertMagic side, e.g. a way to ask whether anything subscribes.
I have a change ready for the Caddy side (switching the three call sites and letting OnEvent stay nil) with benchmarks and tests, and will open a PR referencing this issue. Happy to take a different approach if you would rather fix AppIfConfigured itself.
Assistance Disclosure
AI used
If AI was used, describe the extent to which it was used.
Claude Code (Claude Opus 5) traced the code paths, wrote the benchmarks, and drafted this report.
Source: caddyserver/caddy