avatarZack Jackson

Summary

The context discusses implementing distributed logging across applications with Sentry and Webpack's Module Federation to improve observability in distributed software.

Abstract

The context explains the challenges of distributed logging in federated applications, focusing on Javascript logging and the use of Sentry. It introduces the concept of Component Level Ownership, where teams own components or operate as both product teams and library authors. The context then delves into the integration of Sentry with Module Federation, addressing the issue of registering multiple clients into the SentryHub and performing service discovery in the frontend. It also discusses the use of webpack globals and the ErrorBoundary component to handle errors and log them to the correct Sentry account. The context concludes with potential improvements and the possibility of using Sentry and federation for self-healing and adaptive software.

Bullet points

  • Distributed logging in federated applications is challenging due to the need to know all possible Sentry clients upfront and distribute them via an npm package.
  • Component Level Ownership is a term used to describe the granular use of Module Federation, where teams own components or operate as both product teams and library authors.
  • The integration of Sentry with Module Federation involves registering a Sentry client into the hub and assigning it to the global scope map.
  • The use of webpack globals and the ErrorBoundary component helps handle errors and log them to the correct Sentry account.
  • There is potential for using Sentry and federation for self-healing and adaptive software, with the possibility of restoring application functionality thanks to Sentry and Medusa integration.

Distributed Logging in Federated Applications, with Sentry

When it comes to distributed applications, Javascript logging gets tricky. What happens when you have multiple teams owning multiple parts of a single page or component? How can you use multiple DSNs in Sentry to send logs to the owning team, not the consuming team where the error occurred?

These are frustrating questions and up until now, I’ve seen no real examples to indicate a solution could work at scale. In this post, I attempt to answer these questions by sharing how to implement distributed logging across applications with Sentry and Webpack’s Module Federation and shed some light on how it could decrease rollback deployment to under 2 seconds.

Observability is key in distributed software leveraging Webpack’s Module Federation.

Setting the stage

I have 3 Next.js applications, they have Federated Routing (no page reloads between zones), the shop app exposes Header, BrokenComponent — then checkout, home apps consume and SSR these federated modules.

Header works, by design. But we built a knowingly broken component to see how one would handle an error thrown due to a bug in another apps exposed module.

In cases like this, the consuming team has little control out of the box, so If it starts erroring — typical sentry logging would just throw the errors to checkout,home because those are the host apps that consume these modules. Errors go to the wrong code owners, the team who owns it shop would never know about these errors without manually checking another team’s project, or through manually being told after the consuming team debugs it

It's pretty hard to offer at-runtime, distributed, modular architecture when there's uhhhhh…. no visibility or a good way to alert teams.

This presents a risk to business since observability and notifying the team who can resolve the problem wouldn’t happen by default with a global sentry handler. A critical failure could occur without the right people knowing that it’s happening at all. Opening a business up to revenue impacting failures.

Component Level Ownership

Component Level Ownership is a term used to describe the granular use of Module Federation. Teams owning components or operating as both a product team and a library author.

A good example of this would be the checkout team, they own everything around cart, payment, and quantities. A different team owns the shop experience, but that app needs an “add to cart” modal. Before federation, this meant old school npm packages for the shared part or literally having to PR some component you own in someone else's app.

With Federation, import CartModal from "checkoutTeam/components/cart-modal" — and you get it at runtime.

At this level, logging gets real important — because let's face it — you know your E2E tests probably are not where they need to be to properly test federated applications. Even so, observability is important! Checkout needs to get paged, I need to alert the owner — they are the “vendor” and are the ones who would know what changed in the last release.

Sentry, meet Module Federation

We face a few scale problems, so the first thing we have to do is figure out how can I register multiple clients into the SentryHub

Since these are distributed applications, I can't rely on some global package to register every possible sentry client that an application may or may not have. I need to be able to perform some kind of service discovery in the frontend and find what various remotes are being used and somehow get their sentry configurations, like dsn

This is a hard problem, till federated architecture came along. What makes it so challenging is that we would usually need to know all the possible sentry clients upfront and would need to distribute them via an npm package. That makes it tricky to guarantee that any possible micro-frontend has its sentry client registered in the hub since a micro-frontend could appear without notice or without updating some sentry hub package

Inside my entry point, each application imports this file, which hold their sentry configuration

// the home app
// _app.js
import "../sentry"
export default (props)=><App {...props}/>
// ../sentry
import { federatedSentry } from "federation-tools/sentry";
import { Integrations } from "@sentry/tracing";

federatedSentry({
  name: 'home',
  dsn: "https://[email protected]/0",
  integrations: [new Integrations.BrowserTracing()],
  tracesSampleRate: 1.0
});

Inside federatedSentry — I register a sentry client into the hub, which is assigned to global scope map. This lets me find the right client to report an error back to.

const Sentry = require("@sentry/browser");
if (typeof window !== "undefined") {
  window.sentryHub = __SENTRY_HUB__;
} else {
  global.sentryHub = __SENTRY_HUB__;
}

let hub = sentryHub;

const federatedSentry = (options) => {
  const name = options.name;
  delete options.name;
  if (typeof window !== "undefined") {
    options.beforeSend = (event) => {
      const request = { url: window.location.href, headers: { "User-Agent": navigator.userAgent } };
      if (event.request) {
        Object.assign(event.request, request);
      } else {
        event.request = request;
      }
      console.log("Sending event to app origin", event);
      return event;
    };
    if (!hub[name]) {
      const client = new Sentry.BrowserClient(options);
      hub[name] = new Sentry.Hub(client);
    }
  }
};
const captureException = (exception, host = __CURRENT_HOST__) => {
  hub[host].captureException(exception);
};
module.exports = { federatedSentry, captureException };

The trick here is I also introduce some webpack globals.

config.plugins.push(
  new webpack.DefinePlugin({
    __CURRENT_HOST__: JSON.stringify('home'),
    __SENTRY_HUB__: "{}"
  }),
)

When building the app, it'll replace those variables with identifying information or default information. So any time I use captureException inside a specific application, it always knows who to log an error against (itself) because it's the current host. This ensures normal logging still works, and then some…

Since the host application imports ./sentry which registers and sets up an application logging client. We can federate it, and since __CURRENT_HOST__ was statically set to that remote's name.

I use SSR I have an abstraction on top of my federated imports. I use the low-level API almost exclusively. federatedComponent(remoteName,moduleName)

const componentPromise = Promise.resolve(
  __webpack_init_sharing__(shareScope)
)
  .catch(() => null)
  .then(() => {
    if (!window[remoteName]) {
      throw new Error(`Remote ${remote} is not avaliable on window.`);
    }
    return Promise.resolve(
      window[remoteName].init(__webpack_share_scopes__[shareScope])
    ).catch(() => null);
  })
  .then(()=>{
    try {
      // attempt to get sentry remote module
      return window[remoteName].get('./sentry').then(factory=>factory())
    } catch (e) {

    }
    return null
  })
  .then(() => window[remoteName].get(moduleName))
  .then((factory) => factory())
  .then((mod) => {
    context.components[id] = mod.default || mod;
  })
  .catch((err) => {
    console.error(err);
    context.components[id] = () => null;
  });

Since I’m using the low-level API to load the requested module, I can also have the function import the exposed sentry module at the same time. While webpack is importing the desired federated module, it's also setting up the sentry client hub on the global scope.

Before we return the Federated Module, we will wrap it in an ErrorBoundary component. That way if something does break, it will not wipe out the entire react tree. I still want to preserve micro frontend level reliability, even when sharing a react tree as a polylith.

function windowFederatedComponent(remote, module, shareScope) {
  return (props) => {
    const context = React.useContext(federatedComponentsContext);
    const id = createId({ module, remote });

    const Component = context.components[id];

    const [mounted, setMounted] = React.useState();
    React.useEffect(() => {
      context
        .loadFederatedComponent(id, remote, module, props, shareScope)
        .then(() => setMounted(true));
    }, []);

    if (!mounted || !Component) {
      return React.createElement("div", {
        style: { display: "contents" },
        dangerouslySetInnerHTML: { __html: "" },
        suppressHydrationWarning: true,
      });
    }
    return React.createElement(
      ErrorBoundary, { remote, module }, 
      React.createElement(Component, props)
    );
  };
}

Inside the ErrorBoundary component — we add this lifecycle

_createClass(ErrorBoundary, [{
  key: "componentDidCatch",
  value: function componentDidCatch(error, errorInfo) {
    if(typeof window !== "undefined") {
      if(this.props.remote && sentryHub && sentryHub[this.props.remote]) {
        sentryHub[this.props.remote].withScope(scope =>{
          scope.setExtra("remote", this.props.remote);
          scope.setExtra("module", this.props.module);
          scope.setExtra("host", __CURRENT_HOST__);
          sentryHub[this.props.remote].captureException(error)
        });
      }
    }
  }
}

Lastly, inside the component that actually throws errors

import { useEffect } from "react";
import {captureException} from "federation-tools/sentry";

const ComponentThatErrors= ()=>{
  useEffect(()=>{
    // captureException already knows who __CURRENT_HOST__ is,  its statically set to 'shop'
    // any errors that cascade down are  always  logged to 
    // the sentry  account this component  belongs to 
    fetch('broken url').catch(captureException)
  },[])
  useEffect(()=>{
      throw new Error('remote module error')
  },[])

  return null
}

export default ComponentThatErrors

Lets Recap

Each apps entry point imports its own sentry file and uses our abstracted captureException. Each app also exposes it via module federation

Whenever that file gets loaded, it sets a sentry client on window.sentryHub[remoteName]

When importing a federated module, we also import that remotes sentry file, when it runs it sets up that federated modules own client on window.sentryHub

If the component catches on an error, we already know the remote and the module, as well as know what app is currently running since __CURRENT_HOST__ is named in each application. I can then access window.sentryHub['checkout'].captureException(someError) and properly notify the owning team as well as pass some metadata like the URL, and the name of the host it failed in.

With a little more work, it would be nice to be able to cascade the parent component name as well. So I could report an error listing what app and specifically which parent component it failed in. Perhaps import.meta.url could be useful here?

Room for improvement

The only possible problem I can see is what might happen when a global error is thrown, since I'm registering everything in Sentry hub, I'm not sure if there is still a global handler, like you'd get with Sentry.init

I'm not a huge fan of putting it on a window or having to load a sentry file when you're loading the first component from a remote.

I did briefly try to use setTag or setExtra as a way to keep Sentry.init and mess around with the beforeSend of an applications global handler. Then if it had extra metadata like remote I could use module federation to fetch that remotes sentry file, set it up in the hub, and send the exception to the right client. But the event seems to mess up how the error gets reported. It works but the titles are always <unknown> Unknown with the error stack passed as “additional data” instead of actually being parsed as it should. This would also make pages a little faster since I wouldn't blindly pull down the sentry file, instead only importing the federated remotes sentry client in the event of an error that's caught by the host applications global sentry handler.

My hub solution works, but it would be nice if there were a way to just use captureException, without abstractions, and manage to forward the error to the right client inside of some global integration.

Fast-forward a few months

There are some powerful integration capabilities that Sentry and federation open up.

If we continued to work on this t for a few months here’s what would be possible: Medusa, Webhooks, Module Federation, Sentry, and Remote Module Management.

Medusa lets us control a webpack runtime, at runtime. We can alter the dependency chain inside Webpack through a GUI — we don't have to redeploy anything to alter how or what webpack imports.

I can set up component level ownership, distributed logging, and alerting on each sentry account which can trigger a webhook event. If something fails, Sentry could fire an alert to Medusa.

Medusa could then attempt to automatically roll back the version of a remote a specific host is erroring on. The next time someone reloads the page, application functionality can be restored thanks to sentry and medusa are integrated.

This would pave the way for self-healing and adaptive software. Because of how webpack is designed, it's also faster than any rollback deployment, taking under 2 seconds to alter any aspect of the webpack dependency tree — this is something that usually would take 10+ min to rebuild or at least several minutes to point your infrastructure ingress at another origin and purge your cache

Observability is key

Distributed Systems
Logging
JavaScript
Webpack
Programming
Recommended from ReadMedium