Skip to content

Instantly share code, notes, and snippets.

@deanrad
Last active October 20, 2023 14:27
Show Gist options
  • Save deanrad/f66070f29aeba01c750851d2cd444cb6 to your computer and use it in GitHub Desktop.
Save deanrad/f66070f29aeba01c750851d2cd444cb6 to your computer and use it in GitHub Desktop.

Regarding Logger functions and Uncaught Exceptions from Destinations

If one of the log functions throws an uncaught exception, what is affected? Some possibilities - all of which you'd probably want to avoid - are:

  • Some of the other loggers wouldn't fire
  • The code that triggered the log() call would see the exception.
  • The entire app might shut down

Now, if log destinations are explicitly try/catch wrapped, this is not an issue. But we might forget to do so. An Event Bus could help here. The event bus style of a logger would be: rather than composing multiple functions into a single one, each log destination becomes a bus listener. Let's say we have a dedicated Bus for logging, here's how we can write a log() function that puts a message on the bus for listener destinations to pick up:

import { Bus } from '@rxfx/bus';

const logs = new Bus<LogData>();

function log(logData: LogData) {
   logs.trigger(logData);
}

function listener1 (logData: LogData) {
   console.log(logData)
}

function listener2 (logData: LogData) {
  somethingThatCouldThrow(logData)
}

const dest1 = logBus.listen(
  () => true, // invokes listener if this returns true
  listener1
);

const dest2 = logBus.listen(
  () => true,
  listener2
);

If we call log(), and one of our listeners has an uncaught exception, the bus behaves like a circuit breaker - the listener with the uncaught error will simply be shut down (unsubscribed) and handle no further events. This limits, by default, the scope of failures, and makes an app more robust.

With this approach, no other listener will be prevented from logging. Even more important, the code that calls trigger will never see the exception! Subscribers are isolated from each other, and the publisher is isolated from subscriber errors. This allows the number of subscribers to grow, without adding additional risk to publishers that a newly added publisher breaks them.

const dest3 = logBus.listen(
  () => true,
  errorProneListener,
  { error(e) {  /* An error-handling Observer, allowing the destination to keep handling despite its errors */ } }
);

The bus is built from RxJS - the return value of logBus.listen() is a Subscription. So you can stop listening with dest2.unsubscribe(). And the 3rd argument to logBus.listen is an Observer that allows callbacks to run on lifecycle events like complete, and next.

As a bonus, if a logging destination returns a Promise for its completion - such as an Adobe Analytics event sent via HTTP Post - any of the RxJS concurrency operators (mergeMap, switchMap, concatMap, and exhaustMap) can be employed to adjust the concurrency. In other words - to queue up analytics events being sent requires only a change to:

const dest4 = logBus.listen(
  () => true,
  sendToAdobe,
  { error: console.error },
+  concatMap
)

Or simply:

- const dest4 = logBus.listen(
+ const dest4 = logBus.listenQueueing(
  () => true,
  sendToAdobe,
  { error: console.error }
)

Information about listeners being shut down is available on bus.errors:

bus.errors.subscribe(console.error);

Note that if your subscriber to .errors throws, the app may shut down ungracefully.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment