bugsnag-js icon indicating copy to clipboard operation
bugsnag-js copied to clipboard

Customising network breadcrumbs, or hooking Relay

Open ticky opened this issue 4 years ago ā€¢ 5 comments

Hi there, we have an application which is using Relay and GraphQL, there are cases where an HTTP request with a 200 response can contain an error message in addition to the data fetched by the response. The default network breadcrumb plugin only returns whether the response was a 200 or not.

Is there a good way we should hook this to get more information, or would a custom fork of the network breadcrumb plugin be the best approach?

Thanks!

ticky avatar Jul 23 '20 01:07 ticky

I have ended up hooking into Relay elsewhere to produce more relevant breadcrumbs, but Iā€™d love to see Bugsnag get better first-party support for it! šŸ‘šŸ¼

ticky avatar Jul 25 '20 01:07 ticky

Hi @ticky - thanks for the report! Have you looked into customising these automatic breadcrumbs via the use of an onBreadcrumb callback? https://docs.bugsnag.com/platforms/javascript/customizing-breadcrumbs/#discarding-and-amending-breadcrumbs

abigailbramble avatar Jul 27 '20 16:07 abigailbramble

Hi @phillipsam, I did look into that, but the onBreadcrumb callback is not given any more information about the request/response than Bugsnag itself already sees; it only gets passed the couple of bits of metadata that the fetch/XMLHttpRequest shims extract into the fields defined, i.e. it gets passed { message: "fetch() succeeded", metadata: { status: 200, request: "GET /api" }, timestamp: Date.now(), type: "request" }.

In order to add more useful information to it, I would need access to the underlying response object, which either means adding another monkey patch atop your monkey patch, or forking the network breadcrumb plugin.

ticky avatar Jul 27 '20 16:07 ticky

Hi @ticky - thanks, we will look into how we can improve this to provide raw data relating to the breadcrumbs in the callback.

abigailbramble avatar Jul 28 '20 14:07 abigailbramble

I wanted to add more info to existing network breadcrumbs based on the response information, and I was surprised that the onBreadcrumb method did not receive any kind of event object alongside the breadcrumb, from which, based on the event type, we could have gathered more data.

Also, the fact that the network plugin is internal limits a lot how it can be configured or extended.

That being said, I went ahead and created some kind of (convoluted) plugin which:

  1. caches network breadcrumbs
  2. prevents them from being sent
  3. adds the data from its own fetch wrapper
  4. sends a new modified breadcrumb

Here it is, use at your own risks:

import type {Breadcrumb, Client} from '@bugsnag/core';
import {debounce} from 'lodash-es';

const identity = <T>(value: T) => value;

export const BREADCRUMB_TYPE: Breadcrumb['type'] = 'request';
const BREADCRUMB_MESSAGE_FILTER: ReadonlyArray<string> = [
  'fetch() failed',
  'fetch() succeeded',
];

const FALLBACK_DELAY = 1000;

export type OnResponseCallback = (
  breadcrumb: Breadcrumb,
  response: Response,
) => Breadcrumb;

/**
 * Enables adding any metadata to a request breadcrumb based on the response.
 *
 * @see https://github.com/bugsnag/bugsnag-js/blob/ef3835776c0f95f0cdef0a200891e59367298147/packages/plugin-network-breadcrumbs/network-breadcrumbs.js
 */
export const createRequestBreadcrumbPlugin = ({
  globalWindow = window,
  onResponse = identity,
}: {
  globalWindow?: Window;
  onResponse?: OnResponseCallback;
}) => ({
  load: (client: Client) => {
    if (!('fetch' in globalWindow)) return;

    const sendBreadcrumb = ({message, metadata, type}: Breadcrumb) => {
      // A caveat of the bugsnag API is that we can't send an existing
      // breadcrumb instance, which means we may get a slightly different
      // timestamp when sending the updated breadcrumb.
      client.leaveBreadcrumb(message, metadata, type);
    };

    const breadcrumbMap = new Map<string, Breadcrumb>();

    /**
     * As a safety net, if, for some reason, we never catch the response related
     * to a request breadcrumb, we still want to make sure every breadcrumb
     * makes it to the server.
     */
    const sendUnhandledBreadcrumbs = debounce(() => {
      for (const [_key, breadcrumb] of breadcrumbMap) {
        sendBreadcrumb(breadcrumb);
      }
    }, FALLBACK_DELAY);

    /**
     * Intercepts requests breadcrumbs that are missing metadata.
     */
    client.addOnBreadcrumb((breadcrumb) => {
      const {message, metadata, type} = breadcrumb;
      // Only handle completed fetch requests since incomplete requests do not
      // have a request id.
      if (
        type !== BREADCRUMB_TYPE ||
        !BREADCRUMB_MESSAGE_FILTER.includes(message)
      ) {
        return;
      }

      const [_method, url]: string | undefined[] =
        metadata.request?.split(' ') || [];

      // If this breadcrumb has a different `request` metadata format, we'll
      // just ignore it and let it through as-is.
      if (!url) return;

      // Some breadcrumbs have a relative URL, so we need to convert it to an
      // absolute one which includes the origin in order to compare it with the
      // request URL.
      const urlKey =
        url.at(0) === '/' ? `${window.location.origin}${url}` : url;

      // We already processed this breadcrumb and it was re-sent with updated
      // metadata, so we can remove it and let it through.
      const existingBreadcrumb = breadcrumbMap.get(urlKey);
      if (existingBreadcrumb) {
        // Reassign the initial timestamp to the new breadcrumb to avoid messing
        // with the breadcrumb order, which bypasses the caveat mentioned in `sendBreadcrumb`.
        breadcrumb.timestamp = existingBreadcrumb.timestamp;
        breadcrumbMap.delete(urlKey);
        return;
      }

      // We need to wait for the response to get the request id.
      breadcrumbMap.set(urlKey, breadcrumb);
      // trigger the debounced fallback just in case we never get the response
      sendUnhandledBreadcrumbs();

      // Prevent the breadcrumb from being sent to the server.
      // This also logs an info message to the console unfortunately.
      return false;
    });

    const handleFetchSuccess = (response: Response) => {
      const url = response.url;

      const breadcrumb = breadcrumbMap.get(url);
      if (!breadcrumb) return;

      sendBreadcrumb(onResponse(breadcrumb, response));
    };

    // Hook ourselves into the fetch API, just like bugsnag does.
    const oldFetch = globalWindow.fetch;
    globalWindow.fetch = function fetch(...args) {
      return oldFetch(...args).then((response) => {
        handleFetchSuccess(response);
        return response;
      });
    };
  },
});

We use it like this:

const onResponse: OnResponseCallback = (breadcrumb, response) => {
  const requestId = response.headers.get(REQUEST_ID_HEADER);

  if (requestId) {
    breadcrumb.metadata = {
      ...breadcrumb.metadata,
      requestId,
    };
  }

  return breadcrumb;
};

const plugin = createRequestBreadcrumbPlugin({onResponse});

It has some limitations which were not addressed since our needs were limited:

  • only accounts for fetch requests, completely ignoring XMLHttpRequest
  • only offers an onResponse callback right now, though it would be easy to add an onRequest one
  • only calls onResponse for successful requests, fetch failures are ignored
  • it parses the breadcrumb metadata request string, which is unreliable on the long term, if the Bugsnag API ever changes, it could break this.

Other than that, it works really well for our use-case since the Bugsnag JS API doesn't offer any other way right now.

emileber avatar Nov 04 '22 21:11 emileber