Repository: caddyserver/caddy (language: go)

Issue:
Feature Request: Post to webhook on failed health check
This might be outside the scope of Caddy, but it would be fantastically useful if Caddy health checks could be configured to post to an arbitrary URL on failure (or maybe even success too).

Something like this in the JSON config for /apps/http/servers/routes/handle/reverse_proxy:

```
{
  "health_checks" : {
    //passive or active configs,
    "actions" : {
      "failure" : {
        "webhook_url": "your.apiurl.com/your-url"
      }
    }
  }
}
```

Ideally it would post some json to that webhook URL that would let the receiver identify the route, the datetime it failed, etc.

I think this could be really valuable for a lot of use cases (basically free uptime monitoring) and it feels like it would fit in well with a lot of other features that really make Caddy stand out in terms of practical convenience.


Discussion / hints:
Not a bad idea, but I'm not sure that specific config example would be what we'd want to go for - I think it would be better to find a solution that's more generalizable instead of only supporting webhooks.

I think this might fit into the realm of "event handling", where the `reverse_proxy` handler could emit an event that could be handled by some `event.handler.*` module, all this configured under an `event` app maybe. There's demand for events already such as https://github.com/caddyserver/caddy/issues/2344, and having a way to handle various kinds of situations would be quite powerful.

Anyways, not quite the same, but you can use the `reverse_proxy`'s `handle_response` functionality right now, to handle `502` statuses (i.e. none of the upstreams are usable; unfortunately not if it's just a single one) with a `reverse_proxy` to your "webhook" endpoint; you can set headers with the context info. https://caddyserver.com/docs/json/apps/http/servers/routes/handle/reverse_proxy/#handle_response
> you can use the reverse_proxy's handle_response functionality

That sounds like it would cover my use case well enough actually, thanks for the tip.

As for event handling, I agree that would be a much better and more powerful means of accomplishing this idea and a number of others. The only reason I'd suggested something webhook specific was to try and keep the scope small with as much utility as possible. I'd much rather see an event handling system in the long run.
I also prefer the event handler module. That way any app can emit events and have it handled flexibily in a separate module dedicated to that.

Fix patch:
diff --git a/context.go b/context.go
index 2a6f514214a..a2506a75b2a 100644
--- a/context.go
+++ b/context.go
@@ -37,9 +37,10 @@ import (
 // not actually need to do this).
 type Context struct {
 	context.Context
-	moduleInstances map[string][]interface{}
+	moduleInstances map[string][]Module
 	cfg             *Config
 	cleanupFuncs    []func()
+	ancestry        []Module
 }
 
 // NewContext provides a new context derived from the given
@@ -51,7 +52,7 @@ type Context struct {
 // modules which are loaded will be properly unloaded.
 // See standard library context package's documentation.
 func NewContext(ctx Context) (Context, context.CancelFunc) {
-	newCtx := Context{moduleInstances: make(map[string][]interface{}), cfg: ctx.cfg}
+	newCtx := Context{moduleInstances: make(map[string][]Module), cfg: ctx.cfg}
 	c, cancel := context.WithCancel(ctx.Context)
 	wrappedCancel := func() {
 		cancel()
@@ -90,15 +91,15 @@ func (ctx *Context) OnCancel(f func()) {
 // ModuleMap may be used in place of map[string]json.RawMessage. The return value's
 // underlying type mirrors the input field's type:
 //
-//    json.RawMessage              => interface{}
-//    []json.RawMessage            => []interface{}
-//    [][]json.RawMessage          => [][]interface{}
-//    map[string]json.RawMessage   => map[string]interface{}
-//    []map[string]json.RawMessage => []map[string]interface{}
+//	json.RawMessage              => interface{}
+//	[]json.RawMessage            => []interface{}
+//	[][]json.RawMessage          => [][]interface{}
+//	map[string]json.RawMessage   => map[string]interface{}
+//	[]map[string]json.RawMessage => []map[string]interface{}
 //
 // The field must have a "caddy" struct tag in this format:
 //
-//    caddy:"key1=val1 key2=val2"
+//	caddy:"key1=val1 key2=val2"
 //
 // To load modules, a "namespace" key is required. For example, to load modules
 // in the "http.handlers" namespace, you'd put: `namespace=http.handlers` in the
@@ -115,7 +116,7 @@ func (ctx *Context) OnCancel(f func()) {
 // meaning the key containing the module's name that is defined inline with the module
 // itself. You must specify the inline key in a struct tag, along with the namespace:
 //
-//    caddy:"namespace=http.handlers inline_key=handler"
+//	caddy:"namespace=http.handlers inline_key=handler"
 //
 // This will look for a key/value pair like `"handler": "..."` in the json.RawMessage
 // in order to know the module name.
@@ -301,17 +302,17 @@ func (ctx Context) loadModuleMap(namespace string, val reflect.Value) (map[strin
 // like from embedded scripts, etc.
 func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{}, error) {
 	modulesMu.RLock()
-	mod, ok := modules[id]
+	modInfo, ok := modules[id]
 	modulesMu.RUnlock()
 	if !ok {
 		return nil, fmt.Errorf("unknown module: %s", id)
 	}
 
-	if mod.New == nil {
-		return nil, fmt.Errorf("module '%s' has no constructor", mod.ID)
+	if modInfo.New == nil {
+		return nil, fmt.Errorf("module '%s' has no constructor", modInfo.ID)
 	}
 
-	val := mod.New().(interface{})
+	val := modInfo.New()
 
 	// value must be a pointer for unmarshaling into concrete type, even if
 	// the module's concrete type is a slice or map; New() *should* return
@@ -327,7 +328,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 	if len(rawMsg) > 0 {
 		err := strictUnmarshalJSON(rawMsg, &val)
 		if err != nil {
-			return nil, fmt.Errorf("decoding module config: %s: %v", mod, err)
+			return nil, fmt.Errorf("decoding module config: %s: %v", modInfo, err)
 		}
 	}
 
@@ -340,6 +341,8 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 		return nil, fmt.Errorf("module value cannot be null")
 	}
 
+	ctx.ancestry = append(ctx.ancestry, val)
+
 	if prov, ok := val.(Provisioner); ok {
 		err := prov.Provision(ctx)
 		if err != nil {
@@ -351,7 +354,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 					err = fmt.Errorf("%v; additionally, cleanup: %v", err, err2)
 				}
 			}
-			return nil, fmt.Errorf("provision %s: %v", mod, err)
+			return nil, fmt.Errorf("provision %s: %v", modInfo, err)
 		}
 	}
 
@@ -365,7 +368,7 @@ func (ctx Context) LoadModuleByID(id string, rawMsg json.RawMessage) (interface{
 					err = fmt.Errorf("%v; additionally, cleanup: %v", err, err2)
 				}
 			}
-			return nil, fmt.Errorf("%s: invalid configuration: %v", mod, err)
+			return nil, fmt.Errorf("%s: invalid configuration: %v", modInfo, err)
 		}
 	}
 
@@ -439,8 +442,10 @@ func (ctx Context) Storage() certmagic.Storage {
 	return ctx.cfg.storage
 }
 
+// TODO: aw man, can I please change this?
 // Logger returns a logger that can be used by mod.
 func (ctx Context) Logger(mod Module) *zap.Logger {
+	// TODO: if mod is nil, use ctx.Module() instead...
 	if ctx.cfg == nil {
 		// often the case in tests; just use a dev logger
 		l, err := zap.NewDevelopment()
@@ -451,3 +456,34 @@ func (ctx Context) Logger(mod Module) *zap.Logger {
 	}
 	return ctx.cfg.Logging.Logger(mod)
 }
+
+// TODO: use this
+// // Logger returns a logger that can be used by the current module.
+// func (ctx Context) Log() *zap.Logger {
+// 	if ctx.cfg == nil {
+// 		// often the case in tests; just use a dev logger
+// 		l, err := zap.NewDevelopment()
+// 		if err != nil {
+// 			panic("config missing, unable to create dev logger: " + err.Error())
+// 		}
+// 		return l
+// 	}
+// 	return ctx.cfg.Logging.Logger(ctx.Module())
+// }
+
+// Modules returns the lineage of modules that this context provisioned,
+// with the most recent/current module being last in the list.
+func (ctx Context) Modules() []Module {
+	mods := make([]Module, len(ctx.ancestry))
+	copy(mods, ctx.ancestry)
+	return mods
+}
+
+// Module returns the current module, or the most recent one
+// provisioned by the context.
+func (ctx Context) Module() Module {
+	if len(ctx.ancestry) == 0 {
+		return nil
+	}
+	return ctx.ancestry[len(ctx.ancestry)-1]
+}
diff --git a/modules/caddyevent/app.go b/modules/caddyevent/app.go
deleted file mode 100644
index 59e4c18f251..00000000000
--- a/modules/caddyevent/app.go
+++ /dev/null
@@ -1,202 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"encoding/json"
-	"fmt"
-	"sort"
-
-	"github.com/caddyserver/caddy/v2"
-	"go.uber.org/zap"
-)
-
-func init() {
-	caddy.RegisterModule(EventApp{})
-}
-
-// EventApp is a global event system.
-type EventApp struct {
-	// Registers each of these event subscribers
-	SubscribersRaw []json.RawMessage `json:"subscribers,omitempty" caddy:"namespace=event.subscribers inline_key=subscriber"`
-
-	listeners map[caddy.ModuleID]map[Priority][]ListenerFunc
-	optimized map[caddy.ModuleID][]ListenerFunc
-	ready     bool
-	logger    *zap.Logger
-}
-
-// CaddyModule returns the Caddy module information.
-func (EventApp) CaddyModule() caddy.ModuleInfo {
-	return caddy.ModuleInfo{
-		ID:  "event",
-		New: func() caddy.Module { return new(EventApp) },
-	}
-}
-
-// Provision sets up the app.
-func (app *EventApp) Provision(ctx caddy.Context) error {
-	app.listeners = make(map[caddy.ModuleID]map[Priority][]ListenerFunc)
-	app.logger = ctx.Logger(app)
-
-	// register all the configured subscribers
-	if app.SubscribersRaw != nil {
-		subscribersIface, err := ctx.LoadModule(app, "SubscribersRaw")
-		if err != nil {
-			return fmt.Errorf("loading event subscriber modules: %v", err)
-		}
-		for _, subscriber := range subscribersIface.([]Subscriber) {
-			app.RegisterSubscriber(subscriber)
-		}
-	}
-
-	return nil
-}
-
-// Validate ensures the app's configuration is valid.
-func (app *EventApp) Validate() error {
-	return nil
-}
-
-// Start runs the app.
-func (app *EventApp) Start() error {
-	// optimize the event listeners to order them by priority.
-	app.optimizeListeners()
-
-	// stop new listeners from being added,
-	// and allow events to be dispatched.
-	app.ready = true
-
-	return nil
-}
-
-// Stop gracefully shuts down the app.
-func (app *EventApp) Stop() error {
-	return nil
-}
-
-// RegisterSubscriber registers all the listeners from a subscriber.
-// Modules may register themselves as subscribers during their Provision
-// phase. Subscribers cannot be registered after the config is running.
-func (app *EventApp) RegisterSubscriber(subscriber Subscriber) {
-	for eventID, entry := range subscriber.SubscribedEvents() {
-		app.RegisterListener(eventID, entry)
-	}
-}
-
-// RegisterListener registers a single event listener.
-// Modules may register listeners during their Provision phase.
-// Listeners cannot be registered after the config is running.
-func (app *EventApp) RegisterListener(eventID caddy.ModuleID, entry ListenerEntry) {
-	// if the app is already running, we don't allow adding new listeners.
-	if app.ready {
-		// TODO: Panic or something?
-		return
-	}
-
-	if app.listeners[eventID] == nil {
-		app.listeners[eventID] = make(map[Priority][]ListenerFunc)
-	}
-
-	// There may be more than one listener with the same priority,
-	// for the same event, so we have an array of listeners for
-	// each priority level. Listeners at the same priority level
-	// will be in the order they are registered, which will not
-	// have a guaranteed order because Caddy modules may be loaded
-	// in an arbitrary order.
-	app.listeners[eventID][entry.Priority] = append(
-		app.listeners[eventID][entry.Priority],
-		entry.Listener,
-	)
-
-	app.logger.Debug("registered listener",
-		zap.String("event", eventID.Name()),
-		zap.Int("priority", int(entry.Priority)),
-	)
-}
-
-// Dispatch passes the event through the configured listeners synchronously.
-func (app *EventApp) Dispatch(event Event) error {
-	// if the app is not running, we don't allow dispatching events.
-	if !app.ready {
-		return fmt.Errorf("Cannot dispatch events until after the app is running")
-	}
-
-	// find the listeners for this event
-	listeners, ok := app.optimized[event.ID()]
-	if !ok {
-		return nil
-	}
-
-	app.logger.Debug("dispatching", zap.String("event", event.ID().Name()))
-
-	for _, listener := range listeners {
-		// listeners may mark the event to stop subsequent
-		// listeners from running on this event.
-		if event.IsPropagationStopped() {
-			app.logger.Debug("propagation stopped", zap.String("event", event.ID().Name()))
-			break
-		}
-
-		// run the listener.
-		err := listener(event)
-		if err != nil {
-			app.logger.Error("listener error",
-				zap.String("event", event.ID().Name()),
-				zap.Error(err),
-			)
-			return err
-		}
-	}
-
-	return nil
-}
-
-// AsyncDispatch passes the event through the configured listeners asynchronously.
-func (app *EventApp) AsyncDispatch(event Event) {
-	go func(event Event) {
-		_ = app.Dispatch(event)
-	}(event)
-}
-
-// optimizeListeners orders the listeners by priority.
-func (app *EventApp) optimizeListeners() {
-	app.optimized = make(map[caddy.ModuleID][]ListenerFunc)
-	for eventID, priorities := range app.listeners {
-		app.optimized[eventID] = []ListenerFunc{}
-
-		// sort the priorities (highest priority value first)
-		keys := make([]int, 0)
-		for k := range priorities {
-			keys = append(keys, int(k))
-		}
-		sort.Sort(sort.Reverse(sort.IntSlice(keys)))
-
-		// add all the listeners in order of priority
-		for _, k := range keys {
-			app.optimized[eventID] = append(app.optimized[eventID], priorities[Priority(k)]...)
-		}
-	}
-
-	// we no longer need this map once we're running
-	app.listeners = nil
-}
-
-// Interface guards
-var (
-	_ caddy.App         = (*EventApp)(nil)
-	_ caddy.Provisioner = (*EventApp)(nil)
-	_ caddy.Validator   = (*EventApp)(nil)
-)
diff --git a/modules/caddyevent/event.go b/modules/caddyevent/event.go
deleted file mode 100644
index 6255e5e6b66..00000000000
--- a/modules/caddyevent/event.go
+++ /dev/null
@@ -1,140 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"github.com/caddyserver/caddy/v2"
-)
-
-// DataMap is a key-value pair map.
-type DataMap map[string]interface{}
-
-// Event interface
-type Event interface {
-	// Get the module ID of the event.
-	ID() caddy.ModuleID
-
-	// Get an item from the data map by key.
-	Get(key string) interface{}
-
-	// Set an item in the data map by key.
-	Set(key string, val interface{})
-
-	// Get the entire data map.
-	Data() DataMap
-
-	// Replace the entire data map.
-	SetData(DataMap)
-
-	// StopPropagation sets the event to no longer propagate
-	// to subsequent event listeners.
-	StopPropagation(bool)
-
-	// IsPropagationStopped returns whether the event is set
-	// to no longer propagate. In other words, this is
-	// whether the event dispatcher should stop handling
-	// this event and skip passing it to any subsequent
-	// event listeners.
-	IsPropagationStopped() bool
-}
-
-// GenericEvent a generic event, which can be used
-// with composition to provide baseline functionality
-// for custom events.
-type GenericEvent struct {
-	// The module ID of the event.
-	id caddy.ModuleID
-
-	// Key-value data pairs of contextual information.
-	data DataMap
-
-	// Whether the event handling should be aborted,
-	// and no event handlers should be called after
-	// this point. Allows for "middleware chain" type
-	// of functionality.
-	propagationStopped bool
-}
-
-// NewGeneric creates a generic event instance.
-func NewGeneric(id caddy.ModuleID, data DataMap) *GenericEvent {
-	if data == nil {
-		data = make(DataMap)
-	}
-
-	return &GenericEvent{
-		id:   id,
-		data: data,
-	}
-}
-
-// SetID sets the module ID of the event.
-func (e *GenericEvent) SetID(id caddy.ModuleID) {
-	e.id = id
-}
-
-// Get the module ID of the event.
-func (e *GenericEvent) ID() caddy.ModuleID {
-	return e.id
-}
-
-// Get an item from the data map by key.
-func (e *GenericEvent) Get(key string) interface{} {
-	if v, ok := e.data[key]; ok {
-		return v
-	}
-
-	return nil
-}
-
-// Set an item in the data map by key.
-func (e *GenericEvent) Set(key string, val interface{}) {
-	if e.data == nil {
-		e.data = make(DataMap)
-	}
-
-	e.data[key] = val
-}
-
-// Data returns the entire data map.
-func (e *GenericEvent) Data() DataMap {
-	return e.data
-}
-
-// SetData overwrites the entire data map.
-func (e *GenericEvent) SetData(data DataMap) {
-	if data != nil {
-		e.data = data
-	}
-}
-
-// StopPropagation sets the event to no longer propagate
-// to subsequent event listeners.
-func (e *GenericEvent) StopPropagation(abort bool) {
-	e.propagationStopped = abort
-}
-
-// IsPropagationStopped returns whether the event is set
-// to no longer propagate. In other words, this is
-// whether the event dispatcher should stop handling
-// this event and skip passing it to any subsequent
-// event listeners.
-func (e *GenericEvent) IsPropagationStopped() bool {
-	return e.propagationStopped
-}
-
-// Interface guards
-var (
-	_ Event = (*GenericEvent)(nil)
-)
diff --git a/modules/caddyevent/listener.go b/modules/caddyevent/listener.go
deleted file mode 100644
index 8b29e897608..00000000000
--- a/modules/caddyevent/listener.go
+++ /dev/null
@@ -1,46 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddyevent
-
-import (
-	"github.com/caddyserver/caddy/v2"
-)
-
-// ListenerFunc is a function that can handle a dispatched event.
-type ListenerFunc func(e Event) error
-
-// Handle runs the event listener on the given event.
-func (fn ListenerFunc) Handle(e Event) error {
-	return fn(e)
-}
-
-// Priority is a factor by which event listeners can be sorted.
-type Priority int
-
-// ListenerEntry is a wrapper to allow associating a listener function
-// to a priority factor when registering event subscribers and listeners.
-type ListenerEntry struct {
-	Listener ListenerFunc
-	Priority Priority
-}
-
-// Subscriber defines an interface for modules that wish
-// to subscribe to dispatched events.
-type Subscriber interface {
-	// SubscribedEvents returns a map of event IDs that
-	// this subscriber can handle, to the function that
-	// handles the event.
-	SubscribedEvents() map[caddy.ModuleID]ListenerEntry
-}
diff --git a/modules/caddyevents/app.go b/modules/caddyevents/app.go
new file mode 100644
index 00000000000..761ea221894
--- /dev/null
+++ b/modules/caddyevents/app.go
@@ -0,0 +1,358 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyevents
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/caddyserver/caddy/v2"
+	"github.com/google/uuid"
+	"go.uber.org/zap"
+)
+
+func init() {
+	caddy.RegisterModule(App{})
+}
+
+// App implements a global eventing system within Caddy.
+// Modules can emit and subscribe to events, providing
+// hooks into deep parts of the code base that aren't
+// otherwise accessible. Events provide information about
+// what and when things are happening, and this facility
+// allows handlers to take action when events occur,
+// add information to the event's metadata, and even
+// control program flow in some cases.
+//
+// Events are propagated in a DOM-like fashion. An event
+// event emitted from module `a.b.c` (the "origin") will
+// first invoke handlers listening to `a.b.c`, then `a.b`,
+// then `a`, then those listening regardless of origin.
+// If a handler returns the special error Aborted, then
+// propagation immediately stops and the event is marked
+// as aborted. Emitters may optionally choose to adjust
+// program flow based on an abort.
+//
+// Modules can subscribe to events by origin and/or name.
+// A handler is invoked only if it is subscribed to the
+// event by name and origin. Subscriptions should be
+// registered during the provisioning phase, before apps
+// are started.
+//
+// Event handlers are fired synchronously as part of the
+// regular flow of the program. This allows event handlers
+// to control the flow of the program if the origin permits
+// it and also allows handlers to convey new information
+// back into the origin module before it continues.
+// In essence, event handlers are similar to HTTP
+// middleware handlers.
+//
+// Event bindings/subscribers are unordered; i.e.
+// event handlers are invoked in an arbitrary order.
+// Event handlers should not rely on the logic of other
+// handlers to succeed.
+type App struct {
+	// Subscriptions bind handlers to one or more events
+	// either globally or scoped to specific modules or module
+	// namespaces.
+	Subscriptions []*Subscription `json:"subscriptions,omitempty"`
+
+	// Map of event name to map of module ID/namespace to handlers
+	subscriptions map[string]map[caddy.ModuleID][]Handler
+
+	logger  *zap.Logger
+	started bool
+}
+
+// Subscription represents binding of one or more handlers to
+// one or more events.
+type Subscription struct {
+	// The name(s) of the event(s) to bind to. Default: all events.
+	Events []string `json:"events,omitempty"`
+
+	// The ID or namespace of the module(s) from which events
+	// originate to listen to for events. Default: all modules.
+	// Events propagate up, so events emitted by module "a.b.c"
+	// will also trigger the event for "a.b" and "a". Thus, to
+	// receive all events from "a.b.c" and "a.b.d", for example,
+	// one can subscribe to either "a.b" or all of "a" entirely.
+	Modules []caddy.ModuleID `json:"modules,omitempty"`
+
+	// The event handler modules. These implement the actual
+	// behavior to invoke when an event occurs. At least one
+	// handler is required.
+	HandlersRaw []json.RawMessage `json:"handlers,omitempty" caddy:"namespace=events.handlers inline_key=handler"`
+
+	// The decoded handlers; Go code that is subscribing to
+	// an event should set this field directly; HandlersRaw
+	// is meant for JSON configuration to fill out this field.
+	Handlers []Handler `json:"-"`
+}
+
+// CaddyModule returns the Caddy module information.
+func (App) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "events",
+		New: func() caddy.Module { return new(App) },
+	}
+}
+
+// Provision sets up the app.
+func (app *App) Provision(ctx caddy.Context) error {
+	app.logger = ctx.Logger(app)
+	app.subscriptions = make(map[string]map[caddy.ModuleID][]Handler)
+
+	for _, sub := range app.Subscriptions {
+		if sub.HandlersRaw != nil {
+			handlersIface, err := ctx.LoadModule(sub, "HandlersRaw")
+			if err != nil {
+				return fmt.Errorf("loading event subscriber modules: %v", err)
+			}
+			for _, h := range handlersIface.([]interface{}) {
+				sub.Handlers = append(sub.Handlers, h.(Handler))
+			}
+			if len(sub.Handlers) == 0 {
+				// pointless to bind without any handlers
+				return fmt.Errorf("no handlers defined")
+			}
+		}
+	}
+
+	return nil
+}
+
+// Start runs the app.
+func (app *App) Start() error {
+	for _, sub := range app.Subscriptions {
+		if err := app.Subscribe(sub); err != nil {
+			return err
+		}
+	}
+
+	app.started = true
+
+	return nil
+}
+
+// Stop gracefully shuts down the app.
+func (app *App) Stop() error {
+	return nil
+}
+
+// Subscribe binds one or more event handlers to one or more events
+// according to the subscription s. For now, subscriptions can only
+// be created during the provision phase; new bindings cannot be
+// created after the events app has started.
+func (app *App) Subscribe(s *Subscription) error {
+	if app.started {
+		return fmt.Errorf("events already started; new subscriptions closed")
+	}
+
+	// handle special case of catch-alls (omission of event name or module space implies all)
+	if len(s.Events) == 0 {
+		s.Events = []string{""}
+	}
+	if len(s.Modules) == 0 {
+		s.Modules = []caddy.ModuleID{""}
+	}
+
+	for _, eventName := range s.Events {
+		if app.subscriptions[eventName] == nil {
+			app.subscriptions[eventName] = make(map[caddy.ModuleID][]Handler)
+		}
+		for _, originModule := range s.Modules {
+			app.subscriptions[eventName][originModule] = append(app.subscriptions[eventName][originModule], s.Handlers...)
+		}
+	}
+
+	return nil
+}
+
+// On is syntactic sugar for Subscribe() that binds a single handler
+// to a single event from any module. If the eventName is empty string,
+// it counts for all events.
+func (app *App) On(eventName string, handler Handler) error {
+	return app.Subscribe(&Subscription{
+		Events:   []string{eventName},
+		Handlers: []Handler{handler},
+	})
+}
+
+// Emit creates and dispatches an event named eventName to all relevant handlers with
+// the metadata data. Events are emitted and propagated synchronously. The returned Event
+// value will have any additional information from the invoked handlers.
+func (app *App) Emit(ctx caddy.Context, eventName string, data map[string]interface{}) Event {
+	id, err := uuid.NewRandom()
+	if err != nil {
+		app.logger.Error("failed generating new event ID", zap.Error(err))
+	}
+
+	eventName = strings.ToLower(eventName)
+
+	// TODO: make pointer?
+	e := Event{
+		id:     id,
+		ts:     time.Now(),
+		name:   eventName,
+		origin: ctx.Module(),
+		data:   data,
+	}
+
+	// add event info to replacer, make sure it's in the context
+	repl, ok := ctx.Context.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
+	if !ok {
+		repl = caddy.NewReplacer()
+		ctx.Context = context.WithValue(ctx.Context, caddy.ReplacerCtxKey, repl)
+	}
+	repl.Set("event", e)
+	repl.Set("event.id", e.id)
+	repl.Set("event.name", e.name)
+	repl.Set("event.module", e.origin.CaddyModule().ID)
+	repl.Set("event.data", e.data)
+	repl.Map(func(key string) (interface{}, bool) {
+		switch key {
+		case "event.time":
+			return e.ts, true
+		case "event.time_unix":
+			return e.ts.UnixMilli(), true
+		}
+
+		if strings.HasPrefix(key, "event.data.") {
+			key = strings.TrimPrefix(key, "event.data.")
+			if val, ok := data[key]; ok {
+				return val, true
+			}
+		}
+
+		return nil, false
+	})
+
+	app.logger.Debug("event",
+		zap.String("name", e.name),
+		zap.String("id", e.id.String()),
+		zap.String("origin", e.origin.CaddyModule().String()),
+		zap.Any("data", e.data),
+	)
+
+	// invoke handlers bound to the event by name and also all events; this for loop
+	// iterates twice at most: once for the event name, once for "" (all events)
+	for {
+		moduleID := e.origin.CaddyModule().ID
+
+		// implement propagation up the module tree (i.e. start with "a.b.c" then "a.b" then "a" then "")
+		for {
+			if app.subscriptions[eventName] == nil {
+				break // shortcut if event not bound at all
+			}
+
+			for _, handler := range app.subscriptions[eventName][moduleID] {
+				// TODO: maybe log handler invocations instead of all event emissions?
+				err := handler.Handle(ctx, e)
+				if errors.Is(err, ErrAborted) {
+					// TODO: Figure out proper way to implement this
+					e.Aborted = err
+					return e
+				}
+				if err != nil {
+					app.logger.Error("handler error", zap.Error(err))
+				}
+			}
+
+			if moduleID == "" {
+				break
+			}
+			lastDot := strings.LastIndex(string(moduleID), ".")
+			if lastDot < 0 {
+				moduleID = "" // include handlers bound to events regardless of module
+			} else {
+				moduleID = moduleID[:lastDot]
+			}
+		}
+
+		// include handlers listening to all events
+		if eventName == "" {
+			break
+		}
+		eventName = ""
+	}
+
+	return e
+}
+
+// Event represents something that has happened or is happening.
+type Event struct {
+	id     uuid.UUID
+	ts     time.Time
+	name   string
+	origin caddy.Module
+	data   map[string]interface{}
+
+	// If non-nil, the event has been aborted, meaning
+	// propagation has stopped to other handlers and
+	// the code should stop what it was doing. Emitters
+	// may choose to use this as a signal to adjust their
+	// code path appropriately.
+	Aborted error
+}
+
+// CloudEvent exports event e as a structure that, when
+// serialized as JSON, is compatible with the
+// CloudEvents spec.
+func (e Event) CloudEvent() CloudEvent {
+	dataJSON, _ := json.Marshal(e.data)
+	return CloudEvent{
+		ID:              e.id.String(),
+		Source:          e.origin.CaddyModule().String(),
+		SpecVersion:     "1.0",
+		Type:            e.name,
+		Time:            e.ts,
+		DataContentType: "application/json",
+		Data:            dataJSON,
+	}
+}
+
+// CloudEvent is a JSON-serializable structure that
+// is compatible with the CloudEvents specification.
+// See https://cloudevents.io.
+type CloudEvent struct {
+	ID              string          `json:"id"`
+	Source          string          `json:"source"`
+	SpecVersion     string          `json:"specversion"`
+	Type            string          `json:"type"`
+	Time            time.Time       `json:"time"`
+	DataContentType string          `json:"datacontenttype,omitempty"`
+	Data            json.RawMessage `json:"data,omitempty"`
+}
+
+// ErrAborted cancels an event.
+var ErrAborted = errors.New("event aborted")
+
+// Handler is a type that can handle events.
+type Handler interface {
+	Handle(context.Context, Event) error
+}
+
+// // HandlerFunc
+// type HandlerFunc func(context.Context, Event) error
+
+// Interface guards
+var (
+	_ caddy.App         = (*App)(nil)
+	_ caddy.Provisioner = (*App)(nil)
+)
diff --git a/modules/caddyevents/eventsconfig/caddyfile.go b/modules/caddyevents/eventsconfig/caddyfile.go
new file mode 100644
index 00000000000..6b1347d6cba
--- /dev/null
+++ b/modules/caddyevents/eventsconfig/caddyfile.go
@@ -0,0 +1,88 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package eventsconfig is for configuring caddyevents.App with the
+// Caddyfile. This code can't be in the caddyevents package because
+// the httpcaddyfile package imports caddyhttp, which imports
+// caddyevents: hence, it creates an import cycle.
+package eventsconfig
+
+import (
+	"encoding/json"
+
+	"github.com/caddyserver/caddy/v2/caddyconfig"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+	"github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
+)
+
+func init() {
+	httpcaddyfile.RegisterGlobalOption("events", parseApp)
+}
+
+// parseApp configures the "events" global option from Caddyfile to set up the events app.
+// Syntax:
+//
+//	events {
+//		on <event> <handler_module...>
+//	}
+//
+// If <event> is *, then it will bind to all events.
+func parseApp(d *caddyfile.Dispenser, _ interface{}) (interface{}, error) {
+	app := new(caddyevents.App)
+
+	// consume the option name
+	if !d.Next() {
+		return nil, d.ArgErr()
+	}
+
+	// handle the block
+	for d.NextBlock(0) {
+		switch d.Val() {
+		case "on":
+			if !d.NextArg() {
+				return nil, d.ArgErr()
+			}
+			eventName := d.Val()
+			if eventName == "*" {
+				eventName = ""
+			}
+
+			if !d.NextArg() {
+				return nil, d.ArgErr()
+			}
+			handlerName := d.Val()
+			modID := "events.handlers." + handlerName
+			unm, err := caddyfile.UnmarshalModule(d, modID)
+			if err != nil {
+				return nil, err
+			}
+
+			app.Subscriptions = append(app.Subscriptions, &caddyevents.Subscription{
+				Events: []string{eventName},
+				HandlersRaw: []json.RawMessage{
+					caddyconfig.JSONModuleObject(unm, "handler", handlerName, nil),
+				},
+			})
+
+		default:
+			return nil, d.ArgErr()
+		}
+	}
+
+	return httpcaddyfile.App{
+		Name:  "events",
+		Value: caddyconfig.JSON(app, nil),
+	}, nil
+}
diff --git a/modules/caddyevents/exec.go b/modules/caddyevents/exec.go
new file mode 100644
index 00000000000..d5962e73d5b
--- /dev/null
+++ b/modules/caddyevents/exec.go
@@ -0,0 +1,88 @@
+// Copyright 2015 Matthew Holt and The Caddy Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package caddyevents
+
+import (
+	"context"
+	"os"
+	"os/exec"
+
+	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+	"go.uber.org/zap"
+)
+
+func init() {
+	caddy.RegisterModule(ExecHandler{})
+}
+
+// ExecHandler implements an event handler that runs a command/program.
+type ExecHandler struct {
+	Command string         `json:"command,omitempty"`
+	Args    []string       `json:"args,omitempty"`
+	Dir     string         `json:"dir,omitempty"`
+	Timeout caddy.Duration `json:"timeout,omitempty"`
+
+	logger *zap.Logger
+}
+
+// CaddyModule returns the Caddy module information.
+func (ExecHandler) CaddyModule() caddy.ModuleInfo {
+	return caddy.ModuleInfo{
+		ID:  "events.handlers.exec",
+		New: func() caddy.Module { return new(ExecHandler) },
+	}
+}
+
+// Provision sets up the module.
+func (eh *ExecHandler) Provision(ctx caddy.Context) error {
+	eh.logger = ctx.Logger(eh)
+	return nil
+}
+
+func (eh *ExecHandler) Handle(ctx context.Context, e Event) error {
+	repl := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
+
+	// expand placeholders in command args;
+	// notably, WE DO NOT EXPAND PLACEHOLDERS
+	// IN THE COMMAND ITSELF for safety reasons
+	expandedArgs := make([]string, len(eh.Args))
+	for i := range eh.Args {
+		expandedArgs[i] = repl.ReplaceAll(eh.Args[i], "")
+	}
+
+	cmd := exec.Command(eh.Command, expandedArgs...)
+	cmd.Dir = eh.Dir
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	return cmd.Run()
+}
+
+func (eh *ExecHandler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+	for d.Next() {
+		if !d.NextArg() {
+			return d.ArgErr()
+		}
+		eh.Command = d.Val()
+		eh.Args = d.RemainingArgs()
+	}
+	return nil
+}
+
+// Interface guards
+var (
+	_ caddyfile.Unmarshaler = (*ExecHandler)(nil)
+	_ caddy.Provisioner     = (*ExecHandler)(nil)
+)
diff --git a/modules/caddyhttp/app.go b/modules/caddyhttp/app.go
index 1894a975ce4..603108aa62f 100644
--- a/modules/caddyhttp/app.go
+++ b/modules/caddyhttp/app.go
@@ -23,6 +23,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddytls"
 	"github.com/lucas-clemente/quic-go/http3"
 	"go.uber.org/zap"
@@ -146,6 +147,11 @@ func (app *App) Provision(ctx caddy.Context) error {
 	app.ctx = ctx
 	app.logger = ctx.Logger(app)
 
+	eventsAppIface, err := ctx.App("events")
+	if err != nil {
+		return fmt.Errorf("getting events app: %v", err)
+	}
+
 	repl := caddy.NewReplacer()
 
 	// this provisions the matchers for each route,
@@ -160,6 +166,8 @@ func (app *App) Provision(ctx caddy.Context) error {
 	for srvName, srv := range app.Servers {
 		srv.name = srvName
 		srv.tlsApp = app.tlsApp
+		srv.events = eventsAppIface.(*caddyevents.App)
+		srv.ctx = ctx
 		srv.logger = app.logger.Named("log")
 		srv.errorLogger = app.logger.Named("log.error")
 
diff --git a/modules/caddyhttp/reverseproxy/event.go b/modules/caddyhttp/reverseproxy/event.go
deleted file mode 100644
index 2ab39fdb308..00000000000
--- a/modules/caddyhttp/reverseproxy/event.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package reverseproxy
-
-import (
-	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
-)
-
-// ActiveUnhealthyEvent is dispatched when an upstream
-// became unhealthy via active health checks, when it was
-// previously healthy.
-type ActiveUnhealthyEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewActiveUnhealthyEvent(hostAddr string) *ActiveUnhealthyEvent {
-	event := new(ActiveUnhealthyEvent)
-	event.SetID(caddy.ModuleID("http.handlers.reverse_proxy.event.active_unhealthy"))
-	event.Set("host", hostAddr)
-	return event
-}
-
-func (e ActiveUnhealthyEvent) GetHost() string {
-	return e.Get("host").(string)
-}
-
-// ActiveUnhealthyEvent is dispatched when an upstream
-// became healthy via active health checks, when it was
-// previously unhealthy.
-type ActiveHealthyEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewActiveHealthyEvent(hostAddr string) *ActiveHealthyEvent {
-	event := new(ActiveHealthyEvent)
-	event.SetID(caddy.ModuleID("http.handlers.reverse_proxy.event.active_healthy"))
-	event.Set("host", hostAddr)
-	return event
-}
-
-func (e ActiveHealthyEvent) GetHost() string {
-	return e.Get("host").(string)
-}
diff --git a/modules/caddyhttp/reverseproxy/healthchecks.go b/modules/caddyhttp/reverseproxy/healthchecks.go
index c47b5bd6552..bbd81ccacda 100644
--- a/modules/caddyhttp/reverseproxy/healthchecks.go
+++ b/modules/caddyhttp/reverseproxy/healthchecks.go
@@ -287,7 +287,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, upstre
 	markUnhealthy := func() {
 		// dispatch an event that the host newly became unhealthy
 		if upstream.setHealthy(false) {
-			h.event.AsyncDispatch(NewActiveUnhealthyEvent(hostAddr))
+			h.events.Emit(h.ctx, "unhealthy", map[string]interface{}{"host": hostAddr})
 		}
 	}
 
@@ -353,7 +353,7 @@ func (h *Handler) doActiveHealthCheck(dialInfo DialInfo, hostAddr string, upstre
 	// passed health check parameters, so mark as healthy
 	if upstream.setHealthy(true) {
 		h.HealthChecks.Active.logger.Info("host is up", zap.String("host", hostAddr))
-		h.event.AsyncDispatch(NewActiveHealthyEvent(hostAddr))
+		h.events.Emit(h.ctx, "healthy", map[string]interface{}{"host": hostAddr})
 	}
 
 	return nil
diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go
index 07eea31507e..91d2fdd352d 100644
--- a/modules/caddyhttp/reverseproxy/reverseproxy.go
+++ b/modules/caddyhttp/reverseproxy/reverseproxy.go
@@ -33,7 +33,7 @@ import (
 
 	"github.com/caddyserver/caddy/v2"
 	"github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/headers"
 	"github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite"
@@ -180,7 +180,7 @@ type Handler struct {
 
 	ctx    caddy.Context
 	logger *zap.Logger
-	event  *caddyevent.EventApp
+	events *caddyevents.App
 }
 
 // CaddyModule returns the Caddy module information.
@@ -193,14 +193,13 @@ func (Handler) CaddyModule() caddy.ModuleInfo {
 
 // Provision ensures that h is set up properly before use.
 func (h *Handler) Provision(ctx caddy.Context) error {
-	h.ctx = ctx
-	h.logger = ctx.Logger(h)
-
-	eventAppIface, err := ctx.App("event")
+	eventAppIface, err := ctx.App("events")
 	if err != nil {
-		return fmt.Errorf("getting event app: %v", err)
+		return fmt.Errorf("getting events app: %v", err)
 	}
-	h.event = eventAppIface.(*caddyevent.EventApp)
+	h.events = eventAppIface.(*caddyevents.App)
+	h.ctx = ctx
+	h.logger = ctx.Logger(h)
 
 	// verify SRV compatibility - TODO: LookupSRV deprecated; will be removed
 	for i, v := range h.Upstreams {
diff --git a/modules/caddyhttp/server.go b/modules/caddyhttp/server.go
index a4a976f7ef7..61d9a4061a4 100644
--- a/modules/caddyhttp/server.go
+++ b/modules/caddyhttp/server.go
@@ -27,6 +27,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/caddy/v2/modules/caddytls"
 	"github.com/caddyserver/certmagic"
 	"github.com/lucas-clemente/quic-go/http3"
@@ -134,9 +135,11 @@ type Server struct {
 	listenerWrappers    []caddy.ListenerWrapper
 
 	tlsApp       *caddytls.TLS
+	events       *caddyevents.App
 	logger       *zap.Logger
 	accessLogger *zap.Logger
 	errorLogger  *zap.Logger
+	ctx          caddy.Context
 
 	h3server *http3.Server
 }
diff --git a/modules/caddytls/event.go b/modules/caddytls/event.go
deleted file mode 100644
index 7503cd25bcc..00000000000
--- a/modules/caddytls/event.go
+++ /dev/null
@@ -1,93 +0,0 @@
-// Copyright 2015 Matthew Holt and The Caddy Authors
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//     http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package caddytls
-
-import (
-	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
-	"github.com/caddyserver/certmagic"
-)
-
-// onEvent translates certmagic events into caddy events
-// then dispatches them asynchronously.
-func (t *TLS) onEvent(event string, data interface{}) {
-	switch event {
-	// case "cached_managed_cert", "cached_unmanaged_cert":
-	// 	subjectNames, ok := data.([]string)
-	// 	if !ok {
-	// 		return
-	// 	}
-	// 	t.event.AsyncDispatch(caddyevent.NewGeneric(
-	// 		caddy.ModuleID("tls.event."+event),
-	// 		caddyevent.DataMap{"subjectNames": subjectNames},
-	// 	))
-
-	// case "tls_handshake_started", "tls_handshake_completed":
-	// 	clientHello, ok := data.(*tls.ClientHelloInfo)
-	// 	if !ok {
-	// 		return
-	// 	}
-	// 	t.event.AsyncDispatch(NewHandshakeEvent(event, clientHello))
-
-	case "cert_obtained", "cert_renewed", "cert_revoked":
-		eventData, ok := data.(certmagic.CertificateEventData)
-		if !ok {
-			return
-		}
-		t.event.AsyncDispatch(NewCertEvent(event, eventData))
-	}
-}
-
-// CertEvent is dispatched when a certificate is obtained, renewed, or revoked.
-type CertEvent struct {
-	caddyevent.GenericEvent
-}
-
-func NewCertEvent(event string, data certmagic.CertificateEventData) *CertEvent {
-	certEvent := new(CertEvent)
-	certEvent.SetID(caddy.ModuleID("tls.event." + event))
-	certEvent.Set("name", data.Name)
-	certEvent.Set("issuerKey", data.IssuerKey)
-	certEvent.Set("storageKey", data.StorageKey)
-	return certEvent
-}
-
-func (e CertEvent) GetName() string {
-	return e.Get("name").(string)
-}
-
-func (e CertEvent) GetIssuerKey() string {
-	return e.Get("issuerKey").(string)
-}
-
-func (e CertEvent) GetStorageKey() string {
-	return e.Get("storageKey").(string)
-}
-
-// HandshakeEvent is dispatched when a TLS handshake is started, or is completed.
-// type HandshakeEvent struct {
-// 	caddyevent.GenericEvent
-// }
-
-// func NewHandshakeEvent(event string, clientHello *tls.ClientHelloInfo) *HandshakeEvent {
-// 	handshakeEvent := new(HandshakeEvent)
-// 	handshakeEvent.SetID(caddy.ModuleID("tls.event." + event))
-// 	handshakeEvent.Set("clientHello", clientHello)
-// 	return handshakeEvent
-// }
-
-// func (e CertEvent) GetClientHello() *tls.ClientHelloInfo {
-// 	return e.Get("clientHello").(*tls.ClientHelloInfo)
-// }
diff --git a/modules/caddytls/tls.go b/modules/caddytls/tls.go
index a310d194c5d..b66a9857746 100644
--- a/modules/caddytls/tls.go
+++ b/modules/caddytls/tls.go
@@ -25,7 +25,7 @@ import (
 	"time"
 
 	"github.com/caddyserver/caddy/v2"
-	"github.com/caddyserver/caddy/v2/modules/caddyevent"
+	"github.com/caddyserver/caddy/v2/modules/caddyevents"
 	"github.com/caddyserver/certmagic"
 	"go.uber.org/zap"
 )
@@ -74,7 +74,7 @@ type TLS struct {
 	storageCleanTicker *time.Ticker
 	storageCleanStop   chan struct{}
 	logger             *zap.Logger
-	event              *caddyevent.EventApp
+	events             *caddyevents.App
 }
 
 // CaddyModule returns the Caddy module information.
@@ -87,16 +87,15 @@ func (TLS) CaddyModule() caddy.ModuleInfo {
 
 // Provision sets up the configuration for the TLS app.
 func (t *TLS) Provision(ctx caddy.Context) error {
+	eventsAppIface, err := ctx.App("events")
+	if err != nil {
+		return fmt.Errorf("getting events app: %v", err)
+	}
+	t.events = eventsAppIface.(*caddyevents.App)
 	t.ctx = ctx
 	t.logger = ctx.Logger(t)
 	repl := caddy.NewReplacer()
 
-	eventAppIface, err := ctx.App("event")
-	if err != nil {
-		return fmt.Errorf("getting event app: %v", err)
-	}
-	t.event = eventAppIface.(*caddyevent.EventApp)
-
 	// set up a new certificate cache; this (re)loads all certificates
 	cacheOpts := certmagic.CacheOptions{
 		GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
@@ -523,6 +522,23 @@ func (t *TLS) storageCleanInterval() time.Duration {
 	return defaultStorageCleanInterval
 }
 
+// onEvent translates CertMagic events into Caddy events then dispatches them.
+// TODO: enhance CertMagic's event features to better accommodate our needs
+func (t *TLS) onEvent(eventName string, data interface{}) {
+	evtData := make(map[string]interface{})
+	switch d := data.(type) {
+	case certmagic.CertificateEventData:
+		evtData["name"] = d.Name
+		evtData["issuer_key"] = d.IssuerKey
+		evtData["storage_key"] = d.StorageKey
+	case *tls.ClientHelloInfo:
+		evtData["client_hello"] = d
+	case []string:
+		evtData["subject_names"] = d
+	}
+	t.events.Emit(t.ctx, eventName, evtData)
+}
+
 // CertificateLoader is a type that can load certificates.
 // Certificates can optionally be associated with tags.
 type CertificateLoader interface {
diff --git a/modules/standard/imports.go b/modules/standard/imports.go
index 010742b597a..a9d0b396825 100644
--- a/modules/standard/imports.go
+++ b/modules/standard/imports.go
@@ -3,7 +3,8 @@ package standard
 import (
 	// standard Caddy modules
 	_ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
-	_ "github.com/caddyserver/caddy/v2/modules/caddyevent"
+	_ "github.com/caddyserver/caddy/v2/modules/caddyevents"
+	_ "github.com/caddyserver/caddy/v2/modules/caddyevents/eventsconfig"
 	_ "github.com/caddyserver/caddy/v2/modules/caddyhttp/standard"
 	_ "github.com/caddyserver/caddy/v2/modules/caddypki"
 	_ "github.com/caddyserver/caddy/v2/modules/caddypki/acmeserver"


Test patch: