Skip to content

handshake: skip building event data nobody will see - #406

Merged
francislavoie merged 2 commits into
caddyserver:masterfrom
u5surf:events-skip-unobserved
Sep 10, 2026
Merged

handshake: skip building event data nobody will see#406
francislavoie merged 2 commits into
caddyserver:masterfrom
u5surf:events-skip-unobserved

Conversation

@u5surf

@u5surf u5surf commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #405.

Problem

GetCertificateWithContext emits tls_get_certificate as its first statement:

cfg.emit(ctx, "tls_get_certificate", map[string]any{"client_hello": clientHelloWithoutConn(clientHello)})

emit returns immediately when OnEvent is nil, but Go evaluates arguments first, so the map and the serializableClientHello copy are built on every handshake whether or not anything can receive them.

Change

An optional Config.ShouldEmitFunc lets an embedder say whether an event is worth emitting. It is consulted through a small helper:

func (cfg *Config) shouldEmit(eventName string) bool {
	if cfg.OnEvent == nil {
		return false
	}
	if cfg.ShouldEmitFunc == nil {
		return true // no way to ask; assume it is
	}
	return cfg.ShouldEmitFunc(eventName)
}

Four things worth calling out:

Leaving the field unset keeps today's behavior exactly. The middle branch is the one every existing user takes: with no way to ask, every event is emitted. Nothing changes for anyone who does not opt in.

The predicate only comes along with the handler it was written for. newWithCache inherits ShouldEmitFunc from Default only when it also inherited OnEvent; a caller that brings its own handler and no predicate keeps a nil one, rather than picking up an unrelated predicate that knows nothing about it. TestShouldEmitFuncInheritedWithOnEvent covers the three combinations.

The check has to be at the call site. emit already returns early on a nil OnEvent, but by the time it is entered its arguments exist. That is the whole problem, and it is why the guard cannot live inside emit. The helper's doc comment says so, to keep it from being "simplified" away later.

Only the handshake is guarded. The other twelve emit calls happen per certificate — caching, issuance, renewal, OCSP — where the data costs less than deciding whether to build it. Sprinkling the guard there would be noise.

Benchmarks

GetCertificate against a cached certificate, with an OnEvent handler installed in both cases (Apple M2):

ns/op B/op allocs/op
GetCertificateEventSubscribed 1000 2848 24
GetCertificateEventUnsubscribed 777 2176 17

The 672 bytes and 7 allocations are exactly what building the event data costs; measured on its own it is 169 ns/op, 672 B/op, 7 allocs/op. This is not a full handshake — no TLS crypto, no I/O — so it isolates the lookup step.

Tests

TestShouldEmit covers the five states of the two hooks, including that a nil OnEvent outweighs a subscriber claim. TestGetCertificateSkipsUnobservedEvent checks the behavior through GetCertificate for subscribed, unsubscribed, and no-way-to-ask. TestGetCertificateHonorsEventAbort pins down that a subscribed handler can still abort a handshake, which is the thing it would be worst to break silently.

On the Caddy side

Caddy always installs an OnEvent hook, so the nil case alone would not help it. Its events app already keeps subscriptions per event name — caddyserver/caddy#7997 uses exactly that to shortcut dispatch — so ShouldEmitFunc would be a couple of lines there. I'll send that once this lands and Caddy picks up a release.


Assistance disclosure: I investigated this with Claude Code (Claude Opus 5), which read the code paths, wrote the patch, the benchmarks and the tests, and ran the test suite. I directed the investigation, reviewed the change, and vetted it for correctness.

GetCertificateWithContext emits tls_get_certificate as its first
statement, so its data is assembled on every handshake. emit() returns
right away when OnEvent is nil, but Go evaluates arguments first, so the
map and the ClientHello copy are built either way.

Add an optional HasEventSubscribersFunc to Config so an embedder can say
whether anything is subscribed to an event, and consult it through
eventHasSubscriber() before building the data. Leaving the field unset
keeps today's behavior: with no way to ask, every event is assumed to be
observed. A nil OnEvent falls out of the same check.

Only the handshake needs this. The other emit calls happen per
certificate, where the data costs less than tracking whether it is
wanted.

Benchmarks are included; measurements are in the pull request.
Comment thread config.go Outdated
Comment thread config.go
Rename HasEventSubscribersFunc to ShouldEmitFunc and document the
contract it actually carries: returning false skips the OnEvent call
entirely, so it has to account for everything OnEvent does, not just
subscribed handlers. Caddy writes a debug line for every event whether
or not anything is subscribed, and a predicate that answered only about
subscribers would silence it.

Inherit the predicate from Default only alongside the handler it was
written for. A caller that supplies its own OnEvent and leaves the
predicate nil would otherwise pick up an unrelated predicate from
Default and have its events suppressed. Covered by a regression test.

@steadytao steadytao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you. It does lgtm but ill let Matt or Francis touch seeing as I am not too familiar with this codebase -- @mholt @francislavoie

@francislavoie
francislavoie merged commit b7fe848 into caddyserver:master Sep 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Don't build event data on every handshake when nothing can receive it

3 participants