EventListener is Like Logging, But Good

In 2013 I opened OkHttp Issue #270, ‘Analytics API’. The request was to just add some debug logging:

We should add verbose logging for the response cache. Folks occasionally are puzzled when the cache doesn't work. Let's make it easy for them to figure out why.

We didn’t add logging because logging sucks! Nikita Sobolev makes that case clear:

I either want a quick notification about some critical error with everything inside, or I want nothing: a peaceful morning with tea and youtube videos. There’s nothing in between for logging.

Instead we created an abstract class with functions for each of our would-be log statements:

abstract class EventListener {
  open fun callStart(call: Call) {}
  open fun callEnd(call: Call) {}
  open fun callFailed(call: Call, ioe: IOException) {}
  open fun dnsStart(call: Call, domainName: String) {}
  open fun dnsEnd(call: Call, domainName: String, list: List<InetAddress>) {}

I’m really happy with how this has turned out in OkHttp. It’s flexible enough to satisfy a broad range of use-cases...

  • Off by default. If you don’t configure an EventListener in your OkHttpClient, this abstraction is extremely efficient. Calling a function that does nothing is fast, especially on Hotspot!
  • Logger of choice. If you’re into logging, you probably have a favorite: Timber, SLF4J, or maybe println(). Either hook up LoggingEventListener to get everything, or create your own listener to customize which events trigger logging, which information to include, and at what level.
  • Metrics of choice. To understand aggregate system behavior it’s even better to hook in a system like Chronosphere or Datadog. The start/end calls are a natural fit for tracing systems and for building flame graphs.
  • Interactive development & debugging. When I’m looking into a crash or a performance problem, I can set breakpoints in an EventListener to get a high-level understanding of what’s happening. I might see an unexpected call to requestHeadersStart to learn that retries are involved.

It’s nice to celebrate a API win in OkHttp. But I’ve started to employ this pattern elsewhere, and I’m equally happy with the results. For example, we added an event listener to Zipline to notify users of leaked services. And I love that Coil is observable via its EventListener.

EventListener Recipe

If you wanna do an event listener in your library or app, here’s what I’ve done:

  1. Create an abstract class EventListener. I prefer an abstract class over an interface here ’cause I want to introduce new functions in library upgrades without breaking compatibility.
  2. Create a value EventListener.NONE that is the no-op EventListener. This makes a nice default.
  3. Create a pair of functions for important long-running operations. For example, OkHttp has dnsStart() and dnsEnd(). Include relevant data as arguments! In OkHttp we were a bit too conservative in what we passed to these functions; given a do-over I would have added more information.
  4. Create functions for stuff that can fail, like callFailed(). In OkHttp we don’t always call the end callback if the operation fails.
  5. Create functions for other interesting things that happen in your library! In OkHttp we have events like cacheHit() and cacheMiss(), and metrics systems can use this to create line charts showing cache effectiveness.

To be fancier, create an abstract Event class and subtype it for every event. We didn’t do this in OkHttp to keep our code small. But there’s a lot to like about  subtypes, particularly with Kotlin sealed classes.

You may also optionally create a LoggingEventListener to make it foolishly easy to see a text log of your events. Ours take a log lambda so the user is ultimately in control of where the strings go.

EventListener Everywhere

Consider defining an event listener to make your systems observable. It’s a lot of power in a simple pattern.