All files / server/src/utils retryingSentryTransport.ts

100% Statements 28/28
100% Branches 10/10
100% Functions 6/6
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89                                      6x       25x 25x 21x 15x           6x   4x                   6x     9x           9x   9x 5x 5x 1x 1x   4x 4x     9x     11x   25x 19x     11x 6x 6x     5x 5x 4x     1x        
import { makeNodeTransport } from "@sentry/node";
import { logger } from "server/utils/logger";
import {
  flattenErrorMessageChain,
  retryWithDelays,
  sentryRetryDelaysMs,
} from "server/utils/sentryRetry";
 
type NodeTransportOptions = Parameters<typeof makeNodeTransport>[0];
type NodeTransport = ReturnType<typeof makeNodeTransport>;
type TransportResponse = Awaited<ReturnType<NodeTransport["send"]>>;
 
type SendAttempt =
  | { delivered: true; response: TransportResponse }
  | { delivered: false; reason: string; response: TransportResponse }
  | { delivered: false; reason: string; rejection: unknown };
 
// 4xx responses count as delivered: they are final verdicts from Sentry, and
// 429 backoff stays with the SDK's rate limiter
const attemptSend = async (
  transport: NodeTransport,
  envelope: Parameters<NodeTransport["send"]>[0],
): Promise<SendAttempt> => {
  try {
    const response = await transport.send(envelope);
    if (response.statusCode !== undefined && response.statusCode >= 500) {
      return {
        delivered: false,
        reason: `upstream responded with ${response.statusCode}`,
        response,
      };
    }
    return { delivered: true, response };
  } catch (error) {
    return {
      delivered: false,
      reason: flattenErrorMessageChain(error),
      rejection: error,
    };
  }
};
 
// The default transport silently drops an envelope when the send fails or
// Sentry responds with 5xx, so retry each envelope with increasing delays
export const makeRetryingNodeTransport = (
  options: NodeTransportOptions,
): NodeTransport => {
  const transport = makeNodeTransport(options);
 
  // Tracks the failing state so each outage logs one Sentry-bound error when
  // it starts; repeats during the episode only warn to the console. Without
  // this a failed drop report would feed itself back into the failing
  // transport forever
  let sendFailing = false;
 
  const reportDrop = (reason: string): void => {
    const message = `Sentry event dropped: ${reason}`;
    if (sendFailing) {
      logger.warn(message);
      return;
    }
    sendFailing = true;
    logger.error(new Error(message));
  };
 
  return {
    ...transport,
    send: async (envelope) => {
      const attempt = await retryWithDelays(
        sentryRetryDelaysMs,
        async () => attemptSend(transport, envelope),
        (result) => !result.delivered,
      );
 
      if (attempt.delivered) {
        sendFailing = false;
        return attempt.response;
      }
 
      reportDrop(attempt.reason);
      if ("response" in attempt) {
        return attempt.response;
      }
      // eslint-disable-next-line no-restricted-syntax -- Transport contract propagates send failures to the SDK as rejections
      throw attempt.rejection;
    },
  };
};