Getting metrics by logging
Cloud computing platforms let you emit metrics both by a straightforward path where you directly send the metric to the metrics service:
import boto3
cloudwatch = boto3.client("cloudwatch")
cloudwatch.put_metric_data(...)
...but also by configuring the metrics service to look for appropriately structured logs, and then emitting those logs:
import logging
logging.info(appropriately_structured_json)
Try not to forget that this exists. AWS1 documents this, but it documents a lot of things! AWS simply has a ton of features, and everyone's knowledge has gaps.
I've found two main reasons to use this: performance and testability. The performance considerations here are pretty simple: it's often faster to emit a log than to call into CloudWatch. The testability angle is more interesting: if the function emitting the metric doesn't depend on2 the service that communicates with CloudWatch, you don't have to mock it out or otherwise handle it in a test. This lets you write cleaner, more intelligible, and faster tests.3
Now: how else might we have solved the testability problem (which was really a dependency problem)? This should feel like an instance of a more general problem, one with an answer better than "we can remove an import if we emit logs with the standard library."
One way to characterize the problem is: for the sake of observability, we took a function that didn't close over anything and made it close over a metric-emitting function. How can we address this?
- The EMF solution above. Keep the closure, but make it a standard-library function that won't require special work to be done when you test the function.
- Don't emit metrics in the function. This is not as crazy as it sounds: sometimes the metrics should be emitted by the caller or not at all.
- By making the dependency explicit. Instead of this:
def helper(my_hat):
raw_size = my_hat.get('size')
if raw_size is None:
emit_metric('Falsy or missing size', 1)
return None
try:
return float(raw_size)
except ValueError:
emit_metric('Size parsing error', 1)
return None
...make emit_metric a parameter:
def helper(my_hat, metric_emitter):
raw_size = my_hat.get('size')
if raw_size is None:
metric_emitter('Falsy or missing size', 1)
return None
try:
return float(raw_size)
except ValueError:
metric_emitter('Size parsing error', 1)
return None
And if you don't want to disturb your callers, you can provide a default:
def helper(my_hat, metric_emitter=emit_metric):
raw_size = my_hat.get('size')
if raw_size is None:
metric_emitter('Falsy or missing size', 1)
return None
try:
return float(raw_size)
except ValueError:
metric_emitter('Size parsing error', 1)
return None
Now you can test helper very easily, passing in a mock or trivial function.
This refactoring technique, by the way, is right out of Martin Fowler's Refactoring, which is still one of the very best and most relevant programming books.4
By the way, this illustrates an important meta-skill for software engineers. When you find a solution to an annoying problem, it's tempting to simply be grateful the solution exists, implement it, and move on to something else. It's usually better to take a minute to figure out what more general problem you were facing, and why the solution worked. The moment when you are most grateful not to have to think about something is often the moment when thinking about it is most valuable.
I'll talk about AWS, and use AWS notation in the code, because it's so popular and I'm familiar with it. Google Cloud (and, I'd guess, any other major platform) has analogous functionality.↩
It's a subtle question whether this actually eliminates a dependency; to answer it, I'd start by getting clear about what level of code we're talking about.↩
...and not just because of the clutter and noise from the mocks themselves. Many programmers find it irresistible, once they've mocked out the call to the metrics service, to test that it gets called. It is not quite impossible for such a test to be useful, but I've certainly never seen it pay off.↩
Warning: ChatGPT(-5.2 Auto) straight-up hallucinated a nonexistent Fowlerism when I asked which refactoring technique I was using.↩