Skip to main content

How to send actionable alerts

· 6 min read
Samuel Rossille
Samuel Rossille
CTO

If you've ever been on duty at a company with a lot of customers and 24/7 activity, chances are that you already found out the hard way what makes an alert actionable.

An alert is actionable when the first person who sees it can immediately answer three questions:

  • What broke? – a clear, human‑readable message, making it obvious what the problem is, business wise.
  • Where did it break? – for which specific entity(ies) and in which situation the problem happened.
  • Why did it break? – whenever possible, the precise error condition that leads to the unexpected situation.

If any of those bullets are missing, the engineer's next best move is usually to add logs, redeploy, and hope the error happens again. That is wasted time.

Scope of this article

This article focuses on application-level alerts – errors thrown by your code when business logic fails, and not on infrastructure alerts (CPU usage, disk space, network issues).

So how do you make sure your alerts are actionable?

Let's start with a counter-example and improve it step by step.

The worst approach

const certificate = await this.certificateGenerator
.generateCertificate(contractId)

assert(certificate,
'Certificate should be available at this stage')

This is not actionable:

  • It's not clear why we should have a certificate, or what certificate we talk about, so we need to reverse engineer the feature to understand what's going on.
  • We have no idea which contract triggered the assertion, so the search space is the entire database.
  • We have no idea why we have no certificate, so the search space is the entire code of the certificate generation feature.

Let's improve the message first

When we type the error message, we usually have all the context in mind, which is not the case for the person who will see the alert. So let's put ourselves in their shoes and show a little empathy. They know nothing about the feature we are developing, so we should avoid considering that anything is obvious or implicit.

assert(certificate,
'Unable to generate the insurance certificate right after signing the contract')

Now let's add more context

In addition to a precise message, it's useful to know the specific entities that caused the problem.

So instead of throwing a generic error, we can throw a more specific error, with the context of the entities that caused the problem.

const certificate = 
await this.certificateGenerator.generateCertificate(contractId)

if (!certificate) {
throw new TechnicalError(
'Unable to generate the insurance certificate right after signing the contract',
{ context: { contractId } }
);
}

✅ Better: we now know which contract triggered the error. It's good because we can identify the customers that were involved, and we can reason against the specifics of a single contract.

❌ But we still don't know why the certificate is not available, so we have to read most of the code of the certificate generation.

Providing the root cause helps a lot

The core of the issue in the previous example is that we don't know WHY the certificate is not available, and this is related to API design. While customers spend most of their time in the nominal case, support teams and developers spend most of their time in the edge cases.

So it's paramount to have the right tooling for these cases too.

So instead of returning null or throwing a generic error, the API responsible for generating the certificate could inform us about WHY the certificate could not be generated, with a lot more details about the problem.

Result/Failure Pattern

The Result pattern (borrowed from Rust) replaces exceptions for expected failures (network issues, validation errors, external API failures). Instead of throwing, functions return:

  • Success<OUTPUT> for successful operations: { type: 'success', output: OUTPUT }
  • Failure<PROBLEM> for expected errors: { type: 'failure', problem: PROBLEM }

This forces explicit error handling at compile time and keeps detailed error context without generating alerts for inevitable failures.

We have a dedicated utility for that at Orus, but that will be the topic of another blog post. For now, the important thing is that we can write something like this:

const certificateGenerationResult = 
await this.certificateGenerator.generateCertificate(contractId)

if (isFailure(certificateGenerationResult)) {
throw new TechnicalError(
'Unable to generate the insurance certificate right after signing the contract',
{
context: {
contractId,
unexpectedGenerationFailure: certificateGenerationResult.problem,
},
}
);
}

✅ Great. Now we also know why the certificate is missing, turning debugging into a one‑pass exercise.


info

Like what you read? We're based in Paris and we're hiring Software Engineers! Check out our 🌟 job offer!


How much detail is enough?

It depends. But it's often:

  • a very low overhead to add more context where you are typing "throw ..."
  • a much higher overhead to make a PR to add the missing context later
SituationRecommended context depth
"Should never happen" errorsLow – Pass what you have in the context.
Anything you are not 100% sure aboutHigh – work harder to add important context objects and root cause of the issue.

Automate context enrichment

To avoid cumbersome and repetitive code to pass common contexts, like requestId, userId, sessionId, etc., you can use a middleware to enrich the context with the right information. Most bug trackers have a way to pass context to any error report that would be sent in a specific section of the code.

For example, with Sentry, you can add a middleware in your router that does the following:

Sentry.setUser({ id: user.id });
Sentry.setContext('request', { method, path, query });

So when a crash happens inside a route, the alert already carries:

  • User id
  • HTTP method & path
  • Query parameters

...and whatever else we decide to add later

Conclusion

Alerts are the first line of defence in production. By making them actionable, we transform them from noisy reminders into precise calls to action that can be handled quickly.

Next time you throw an error, pause and ask yourself: Can someone on my team immediately answer the three critical questions?

  • What broke? Is the business impact clear from the message?
  • Where did it break? Are the relevant entity IDs included, and relevant context variables?
  • Why did it break? Is the root cause or enough context provided?

If any answer is "no," add the missing context. It takes only marginally more time than a generic error message, but transforms a potential hours-long debugging session into a quick, targeted fix.

And if you are the maintainer of the framework, add as much context as possible automatically, so that it's easier for the developers to send actionable alerts.