src/app/core/logging/logging.service.ts

Index

Properties

Properties

event
event: PointerEvent
Type : PointerEvent
Optional

For breadcrumbs created from browser events, the Sentry SDK often supplies the event to the breadcrumb as a hint. This can be used to extract data from the target DOM element into a breadcrumb, for example.

input
input: string[]
Type : string[]
Optional
level
level: string
Type : string

e.g. console output level (warn / log / ...)

request
request: any
Type : any
Optional
response
response: Response
Type : Response
Optional
xhr
xhr: XMLHttpRequest
Type : XMLHttpRequest
Optional
import { LogLevel } from "./log-level";
import * as Sentry from "@sentry/angular";
import { environment } from "../../../environments/environment";
import {
  ErrorHandler,
  Provider,
  inject,
  provideAppInitializer,
  EnvironmentProviders,
} from "@angular/core";
import { HttpStatusCode } from "@angular/common/http";
import { Router } from "@angular/router";
import { TraceService } from "@sentry/angular";
import {
  CONNECTIVITY_ERROR_NAMES,
  CONNECTIVITY_ERROR_STATUS,
  isConnectivityErrorMessage,
} from "#src/app/utils/connectivity-error";
import { isExpiredSessionError } from "#src/app/utils/expired-session-error";
import {
  RegistryDuplicateError,
  RegistryLookupError,
} from "#src/app/core/config/registry/dynamic-registry";

/**
 * Centrally managed logging to allow log messages to be filtered by level and even sent to a remote logging service
 * that allows developers to monitor and analyse problems.
 *
 * Logging to the remote monitoring server is set only for warnings and errors.
 *
 * To allow remote logging, call Sentry.init during bootstrap in your AppModule or somewhere early on during startup.
 *
 * Import the constant `Logging` to use this from anywhere (without Angular DI).
 */
export class LoggingService {
  /**
   * Initialize the remote logging module with the given options.
   * If set up this will be used to send errors to a remote endpoint for analysis.
   * @param options
   */
  initRemoteLogging(options: Sentry.BrowserOptions) {
    if (!options.dsn) {
      // abort if no target url is set
      return;
    }

    const defaultOptions: Sentry.BrowserOptions = {
      release: "ndb-core@" + environment.appVersion,
      transport: Sentry.makeBrowserOfflineTransport(Sentry.makeFetchTransport),
      beforeBreadcrumb: enhanceSentryBreadcrumb,
      beforeSend: processSentryEvent,
    };
    Sentry.init(Object.assign(defaultOptions, options));
  }

  /**
   * Get the Angular providers to set up additional logging and tracing,
   * that should be added to the providers array of the AppModule.
   */
  getAngularTracingProviders(): (Provider | EnvironmentProviders)[] {
    return [
      /* Sentry setup */
      {
        provide: ErrorHandler,
        useValue: Sentry.createErrorHandler(),
      },
      {
        provide: Sentry.TraceService,
        deps: [Router],
      },
      provideAppInitializer(() => {
        inject(TraceService);
      }),
    ];
  }

  /**
   * Update a piece of context information that will be attached to all log messages for easier debugging,
   * especially in remote logging.
   * @param key Identifier of the key-value pair
   * @param value Value of the key-value pair
   * @param asTag If this should be added as indexed tag rather than simple context (see https://docs.sentry.io/platforms/javascript/enriching-events/tags/)
   */
  addContext(key: string, value: any, asTag: boolean = false) {
    if (asTag) {
      Sentry.setTag(key, value);
    } else {
      if (typeof value !== "object") {
        value = { value: value };
      }
      Sentry.getCurrentScope().setContext(key, value);
    }
  }

  // Deliberately no equivalent of Sentry's `setUser`: reported events carry no
  // user identity, as data minimization under the GDPR (see README.md). Adding
  // one back would put a personal identifier into every event of every issue,
  // so it is a decision to take before it is a line of code to write.

  /**
   * Log the message with "debug" level - for very detailed, non-essential information.
   * @param message
   * @param context Additional context for debugging
   */
  public debug(message: any, ...context: any[]) {
    this.log(message, LogLevel.DEBUG, ...context);
  }

  /**
   * Log the message with "info" level - for relevant information that occurs during regular functioning of the app.
   * @param message
   */
  public info(message: any) {
    this.log(message, LogLevel.INFO);
  }

  /**
   * Log the message with "warning" level - for unexpected events that the app can still handle gracefully.
   * @param message
   * @param context
   */
  public warn(message: any, ...context: any[]) {
    this.log(message, LogLevel.WARN, ...context);
  }

  /**
   * Log the message with "error" level - for unexpected critical events that cannot be handled and will affect functions.
   * @param message
   * @param context
   */
  public error(message: any, ...context: any[]) {
    this.log(message, LogLevel.ERROR, ...context);
  }

  /**
   * Generic logging of a message.
   * @param message Message to be logged
   * @param logLevel Optional log level - default is "info"
   * @param context Additional context for debugging
   */
  public log(
    message: any,
    logLevel: LogLevel = LogLevel.INFO,
    ...context: any[]
  ) {
    this.logToConsole(message, logLevel, ...context);

    if (logLevel !== LogLevel.DEBUG && logLevel !== LogLevel.INFO) {
      this.logToRemoteMonitoring(message, logLevel, ...context);
    }
  }

  private logToConsole(message: any, logLevel: LogLevel, ...context: any[]) {
    switch (+logLevel) {
      case LogLevel.DEBUG:
        console.debug(message, ...context);
        break;
      case LogLevel.INFO:
        console.info(message, ...context);
        break;
      case LogLevel.WARN:
        console.warn(message, ...context);
        break;
      case LogLevel.ERROR:
        console.error(message, ...context);
        break;
      default:
        console.log(message, ...context);
        break;
    }
  }

  private logToRemoteMonitoring(
    message: any,
    logLevel: LogLevel,
    ...context: any[]
  ) {
    const extra: Record<string, unknown> = {};
    if (context.length > 0) {
      extra.context = context;
    }
    if (typeof message !== "string" && !(message instanceof Error)) {
      // an object logged as the "message" only contributes its text to the
      // report, so keep the whole of it available for debugging
      extra.message = message;
    }
    const scope = {
      extra: Object.keys(extra).length > 0 ? extra : undefined,
    };

    if (logLevel === LogLevel.ERROR) {
      Sentry.captureException(toReportedError(message, context), scope);
    } else {
      Sentry.captureMessage(messageText(message), {
        ...scope,
        level: this.translateLogLevel(logLevel),
      });
    }
  }

  private translateLogLevel(logLevel: LogLevel): Sentry.SeverityLevel {
    switch (+logLevel) {
      case LogLevel.DEBUG:
        return "debug";
      case LogLevel.INFO:
        return "info";
      case LogLevel.WARN:
        return "warning";
      case LogLevel.ERROR:
        return "error";
      default:
        return "info";
    }
  }
}

/**
 * Add more human-readable descriptions to Sentry breadcrumbs for debugging.
 *
 * see https://docs.sentry.io/platforms/javascript/enriching-events/breadcrumbs/
 */
function enhanceSentryBreadcrumb(
  breadcrumb: Sentry.Breadcrumb,
  hint: SentryBreadcrumbHint,
) {
  if (breadcrumb.category === "ui.click") {
    const event = hint.event;
    const elementText = event.target?.["innerText"] ?? "";
    breadcrumb.message = elementText + " | " + breadcrumb.message;
  }
  return breadcrumb;
}

/**
 * https://docs.sentry.io/platforms/javascript/configuration/filtering/#hints-for-breadcrumbs
 */
interface SentryBreadcrumbHint {
  /**
   * For breadcrumbs created from browser events, the Sentry SDK often supplies the event to the breadcrumb as a hint.
   * This can be used to extract data from the target DOM element into a breadcrumb, for example.
   */
  event?: PointerEvent;

  input?: string[];

  /**
   * e.g. console output level (warn / log / ...)
   */
  level: string;

  response?: Response;
  request?: any;
  xhr?: XMLHttpRequest;
}

export const Logging = new LoggingService();

/**
 * Wrapper that {@link LoggingService.error} creates when an error is logged
 * together with a descriptive message (`Logging.error("msg", err)`), so that the
 * message - not the underlying error's own, often generic message - becomes the
 * issue title and grouping key in remote monitoring.
 *
 * The original error is kept as `cause`, which Sentry links into the reported
 * exception chain, so its stack trace is not lost.
 */
export class LoggedError extends Error {
  constructor(message: string, cause?: unknown) {
    super(message, { cause });
    // Sentry reports `error.name` as the exception type. Keep the plain "Error"
    // so titles read "Error: <our message>" rather than a class name that is
    // minified in production builds anyway; the beforeSend hook recognizes this
    // wrapper through `hint.originalException instanceof LoggedError` instead.
    this.name = "Error";
  }
}

/** The text of anything that was passed to the logger as its "message". */
function messageText(message: any): string {
  return typeof message === "string"
    ? message
    : String(message?.message ?? message?.error ?? message);
}

/**
 * The Error to report for a log call: an error that was logged directly is
 * reported as it is, anything else is wrapped in a {@link LoggedError}.
 */
export function toReportedError(message: any, context: any[]): Error {
  if (message instanceof Error) {
    return message;
  }
  return new LoggedError(
    messageText(message),
    context.find((c) => c instanceof Error),
  );
}

/**
 * Maximum number of times an identical event is sent to remote logging
 * within one app session (page load).
 * Guards against error loops (e.g. an error thrown on every change detection
 * cycle) flooding remote monitoring with thousands of duplicate events.
 */
export const MAX_REPEATED_SENTRY_EVENTS = 5;

const sentryEventCounts = new Map<string, number>();

/**
 * Clear the repeat budget that {@link isExcessiveRepeat} keeps per app session.
 *
 * Exported for tests: nothing else resets those counters, so without this every
 * spec in a file shares one budget and whichever runs last sees its events
 * dropped instead of processed - which is easy to mistake for the behaviour
 * under test, as the budget is shared by all events of one issue.
 */
export function resetSentryEventCounts() {
  sentryEventCounts.clear();
}

/**
 * Sentry `beforeSend` hook: drops network failures of offline devices
 * and excessive repeats of an identical event,
 * and enriches the remaining events with structured extra data
 * and a stable grouping fingerprint.
 *
 * Grouping runs before the repeat cap because the cap is applied per issue,
 * which is what the fingerprint defines (see {@link isExcessiveRepeat}).
 */
export function processSentryEvent(
  event: Sentry.ErrorEvent,
  hint: Sentry.EventHint,
): Sentry.ErrorEvent | null {
  if (
    isOfflineNetworkError(event) ||
    isDocumentUpdateConflict(event, hint) ||
    isCausedByExpiredSession(hint)
  ) {
    return null;
  }

  const grouped = groupSentryEvent(enrichSentryEvent(event, hint), hint);
  return isExcessiveRepeat(grouped) ? null : grouped;
}

/**
 * Whether the reported error is a rejected write whose revision was out of date.
 *
 * Two users editing one record is normal operation for an offline-first app, not
 * a fault: the save is rejected, the user is told so by the form that attempted
 * it, and the occurrence is counted in usage analytics instead (see
 * `PouchDatabase.reportConflict`). Reporting it here on top of that produced a
 * steady stream of issues nobody could act on - at *error* level, so louder than
 * the failures that do need attention.
 *
 * The trade-off is deliberate: how often conflicts happen is now only visible in
 * usage analytics, not in error monitoring.
 */
function isDocumentUpdateConflict(
  event: Sentry.ErrorEvent,
  hint: Sentry.EventHint,
): boolean {
  const thrown = hint?.originalException as { status?: unknown } | undefined;
  if (
    thrown &&
    typeof thrown === "object" &&
    thrown.status === HttpStatusCode.Conflict
  ) {
    return true;
  }

  // the status is not always preserved (e.g. an error rebuilt from a
  // `_bulk_docs` result), so fall back to the message - normalized, because
  // PouchDB words it both with and without a trailing period
  return [
    event.message,
    ...(event.exception?.values?.map((v) => v.value) ?? []),
  ].some((msg) => msg && fingerprintKey(msg).includes(CONFLICT_MESSAGE));
}

/** How PouchDB words a rejected write, after {@link fingerprintKey} normalization. */
const CONFLICT_MESSAGE = "document update conflict";

/** How many `cause` levels {@link isCausedByExpiredSession} follows. */
const MAX_CAUSE_DEPTH = 5;

/**
 * Whether the reported error is (or was caused by) the server rejecting an
 * expired access token that could not be renewed.
 *
 * `RemotePouchDatabase` renews the session and repeats such a request, so this
 * only surfaces while the login server cannot be reached - a transient state
 * the user is told about there, which says nothing about the code that
 * happened to be waiting for the data. Filtering it here spares every consumer
 * of the database from having to recognize it (whether it logs the failure,
 * wraps it or lets it go unhandled).
 *
 * The trade-off is deliberate: a consumer that mishandles a failed load is not
 * reported while the failure is an expired session (only through other causes,
 * e.g. connectivity). This also applies to errors that merely wrap it, like a
 * `ConfigLoadError` - which tells the user and reloads the app itself. Other `401` reasons (e.g. an invalid token signature)
 * are still reported, see {@link isExpiredSessionError}.
 *
 * The original error is inspected rather than the serialized event, as that
 * loses the `status` and `reason` of a wrapped `DatabaseException`.
 */
function isCausedByExpiredSession(hint: Sentry.EventHint): boolean {
  let error: unknown = hint?.originalException;
  for (let depth = 0; error && depth <= MAX_CAUSE_DEPTH; depth++) {
    if (isExpiredSessionError(error)) {
      return true;
    }
    error = (error as { cause?: unknown }).cause;
  }
  return false;
}

/**
 * Whether the event is a network-layer fetch failure that occurred while the
 * device was offline. In an offline-first app this is a normal state without
 * diagnostic value (server outages still surface through online users).
 */
function isOfflineNetworkError(event: Sentry.ErrorEvent): boolean {
  if (navigator.onLine) {
    return false;
  }

  const messages = [
    event.message,
    ...(event.exception?.values?.map((v) => v.value) ?? []),
  ];
  return messages.some((msg) => msg && isConnectivityErrorMessage(msg));
}

/**
 * Count occurrences of an event and check whether it exceeded the session cap.
 *
 * The budget is one per issue (see {@link repeatKey}), so that a device running
 * into several problems at once still reports each of them. Keying it more
 * coarsely - by the root cause the issues have in common - starves them instead:
 * in an offline-first app a single connectivity failure is the root cause of the
 * config load, the permission rules, the sync and every file download alike, so
 * whichever of them failed first would silence all the others for that session.
 *
 * Note that the cap therefore also limits how much an issue's event count says
 * about the volume of a problem - it shows that it occurs, not how often.
 */
function isExcessiveRepeat(event: Sentry.ErrorEvent): boolean {
  const key = repeatKey(event);

  const count = (sentryEventCounts.get(key) ?? 0) + 1;
  sentryEventCounts.set(key, count);

  if (count > MAX_REPEATED_SENTRY_EVENTS) {
    Logging.debug("Skipping repeated event for remote logging", {
      event: key,
      occurrence: count,
    });
    return true;
  }
  return false;
}

/**
 * The issue an event will end up in, as far as it is known here: the
 * fingerprint is exactly what separates issues, and for the events that keep
 * Sentry's stack-based grouping the thrown error is the closest stand-in
 * (deliberately without any stack information, so that an error loop is capped
 * even where the frames differ between occurrences).
 */
function repeatKey(event: Sentry.ErrorEvent): string {
  // serialized as a tagged tuple so that neither the elements of a fingerprint
  // nor the three kinds of key can collide with one another
  if (event.fingerprint?.length) {
    return JSON.stringify(["fingerprint", ...event.fingerprint]);
  }

  const values = event.exception?.values;
  const thrownError = values?.[values.length - 1];
  return thrownError
    ? JSON.stringify([
        "exception",
        thrownError.type,
        fingerprintKey(thrownError.value),
      ])
    : JSON.stringify([
        "message",
        fingerprintKey(String(event.message ?? "unknown")),
      ]);
}

/**
 * Our own error classes that describe *what* failed precisely enough that all
 * their occurrences belong into a single issue in remote monitoring,
 * no matter which component, route or async call site ran into them.
 *
 * For these, Sentry's default grouping by stack trace actively hurts: they are
 * thrown from one central place, while the stack differs per caller and even
 * per build (releases without source maps report minified frames). One problem
 * then scatters across a dozen issues that each have to be triaged separately,
 * and archiving one of them does not silence the others.
 *
 * Only add error types whose name and message already identify the problem on
 * their own, so that the stack adds nothing but noise. Generic errors (`Error`,
 * `TypeError`, ...) must keep the default grouping - for those the stack trace
 * is the only thing telling two unrelated bugs apart.
 */
const CAUSE_GROUPED_ERROR_TYPES = [
  "DatabaseException",
  "SyncStalledError",
  "ConfigLoadError",
  "PermissionRulesLoadError",
  "SiteSettingsLoadError",
  "RegistryLookupError",
  "RegistryDuplicateError",
];

/**
 * Data that varies between occurrences of the same problem and therefore has to
 * be masked before an error message can be used as a grouping key.
 * Order matters: the more specific patterns have to run before the plain number.
 */
const VOLATILE_VALUE_PATTERNS: [RegExp, string][] = [
  // a response body quoted into an error message (a proxy failure, an error
  // page served by the reverse proxy): its content varies per request and can
  // be a whole HTML document, which as an issue title hides every other row
  [/ with body ["'][\s\S]*$/i, ""],
  [/https?:\/\/\S+/gi, "<url>"],
  [
    /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
    "<uuid>",
  ],
  [/\b\d+-[0-9a-f]{32}\b/gi, "<rev>"],
  // legacy entity ids that embed a username (e.g. `User:some.person`); the
  // uuid-based ones are already masked above. Requires a non-space directly
  // after the colon, so that an error prefix like "TypeError: ..." is kept.
  [/\b[A-Z][A-Za-z]{2,}:[A-Za-z0-9._<>-]+/g, "<entityId>"],
  [/\d+/g, "<n>"],
];

/**
 * Mask volatile details (ids, urls, numbers) so that different occurrences of
 * the same problem produce the same string.
 *
 * Stays human-readable, as it is also used to describe the root cause in the
 * issue title (see {@link describeCause}).
 */
function normalizeErrorValue(value: string | undefined): string {
  if (!value) {
    return "<none>";
  }
  return VOLATILE_VALUE_PATTERNS.reduce(
    (normalized, [pattern, placeholder]) =>
      normalized.replace(pattern, placeholder),
    value,
  );
}

/**
 * Reduce an error message to a grouping key.
 *
 * On top of {@link normalizeErrorValue} this also removes differences that
 * carry no meaning but do split issues, as third-party libraries are not
 * consistent about them: PouchDB reports both "Unauthorized" and
 * "unauthorized", and both "Document update conflict" and "Document update
 * conflict." (with a period) for the same failure.
 */
function fingerprintKey(value: string | undefined): string {
  return normalizeErrorValue(value)
    .toLowerCase()
    .replace(/[.,;:!?]/g, "")
    .replace(/\s+/g, " ")
    .trim();
}

/**
 * Set an explicit grouping fingerprint where Sentry's default grouping splits
 * one problem across many issues.
 *
 * Sentry groups by stack trace whenever one is available. That is the right
 * default for a bug in application code, but not for a failure that is raised
 * from one central place and reached from many components, routes and async
 * call sites - the stack then differs per caller and even per build (releases
 * without source maps report minified frames), so one problem scatters across a
 * dozen issues that each have to be triaged separately, and archiving one of
 * them does not silence the others.
 *
 * The cases are checked most-specific first: an error we recognize by name says
 * more about what went wrong than the fact that a network request failed
 * underneath it, so those keep their own issues instead of being absorbed into
 * the shared network bucket.
 */
function groupSentryEvent(
  event: Sentry.ErrorEvent,
  hint: Sentry.EventHint,
): Sentry.ErrorEvent {
  const values = event.exception?.values;
  if (!values?.length) {
    // captureMessage events group by their message. Normalizing it enforces the
    // "keep message strings static" convention: a message that does interpolate
    // variable data still produces one issue instead of one per value.
    if (event.message) {
      event.fingerprint = [fingerprintKey(event.message)];
    }
    return event;
  }

  const thrownError = values[values.length - 1];
  const thrownType = thrownError.type ?? "";

  if (CAUSE_GROUPED_ERROR_TYPES.includes(thrownType)) {
    return groupByErrorChain(event, values, thrownError);
  }

  if (hint?.originalException instanceof LoggedError) {
    const unreachableServer = loggedUnreachableServer(values);
    if (unreachableServer) {
      event.tags = {
        ...event.tags,
        // Sentry rejects tag values longer than 200 characters
        logged_message: normalizeErrorValue(thrownError.value).slice(0, 200),
      };
      return groupByErrorChain(event, values, unreachableServer);
    }
    return groupByErrorChain(event, values, thrownError);
  }

  const rewrapped = rewrappedCauseGroupedError(values);
  if (rewrapped) {
    return groupByErrorChain(event, values, rewrapped);
  }

  if (values.some(isConnectivityException) || hasConnectivityStatus(event)) {
    return groupAsNetworkError(event, thrownError);
  }

  if (!values.some((v) => v.stacktrace?.frames?.length)) {
    // without a stack Sentry falls back to grouping by type and message, so an
    // id or url interpolated into the message opens a new issue every time
    event.fingerprint = [
      thrownType || "Error",
      fingerprintKey(thrownError.value),
    ];
    reportNormalized(event, thrownError);
  }

  return event;
}

/**
 * The error of a recognized type (see {@link CAUSE_GROUPED_ERROR_TYPES}) that a
 * framework re-threw as a generic one, if that is what this chain is.
 *
 * Angular does this for an error raised inside a `resource()` loader: it wraps
 * it in an error of its own that copies the original's message and whose type
 * is the plain "Error" a subclass inherits. The wrapper contributes nothing but
 * a different stack trace, so grouping by it opens a separate issue per call
 * site for exactly the problem that {@link groupByErrorChain} keeps as one.
 *
 * Only a wrapper that repeats the recognized error's message counts as such.
 * One that says what it was doing ("Failed to load configuration from the
 * database.") describes the failure better than its cause does, and stays the
 * error the event is grouped by.
 */
function rewrappedCauseGroupedError(
  values: Sentry.Exception[],
): Sentry.Exception | undefined {
  // fingerprintKey already lower-cased and stripped the colon, leaving at most
  // this leading "error " from the `Error: <cause>` shape `.toString()` gives
  const thrownValue = fingerprintKey(values[values.length - 1].value).replace(
    /^error /,
    "",
  );
  return values
    .slice(0, -1)
    .find(
      (cause) =>
        CAUSE_GROUPED_ERROR_TYPES.includes(cause.type ?? "") &&
        thrownValue === fingerprintKey(cause.value),
    );
}

/**
 * The failed request that a {@link LoggedError} directly wraps, if that is all
 * it has to report.
 *
 * The message an error is logged with says where it happened ("Failed to load
 * important notes"), which for most errors is what tells two problems apart.
 * For a request that never reached the server it is not: the connection
 * failed, and every component loading data at that moment logs it under a
 * message of its own. Grouped by those, a single connectivity drop opens one
 * issue per component - next to the issue the same failure already has where
 * it is reported without a message. Grouping them by the wrapped error puts
 * them into that one issue instead; the logged message is kept as a tag, and
 * the route is still the `transaction` tag.
 *
 * Only an error of a recognized type (see {@link CAUSE_GROUPED_ERROR_TYPES})
 * directly below the wrapper qualifies:
 * - a named error in between (a `ConfigLoadError` caused by a failed fetch)
 *   says more than the fact that a request failed, and keeps its own issue;
 * - any other failed request has no issue of its own to go to, only the shared
 *   network bucket - where a chunk that did not load during bootstrap, so that
 *   the app never started, would disappear among requests of no consequence.
 */
function loggedUnreachableServer(
  values: Sentry.Exception[],
): Sentry.Exception | undefined {
  const wrapped = values[values.length - 2];
  return wrapped &&
    CAUSE_GROUPED_ERROR_TYPES.includes(wrapped.type ?? "") &&
    isConnectivityException(wrapped)
    ? wrapped
    : undefined;
}

/**
 * Report an exception under the normalized message it is grouped by, keeping
 * the original one as extra data.
 *
 * Sentry titles an issue by the reported message, so the volatile details that
 * {@link fingerprintKey} masks out of the grouping key are still what the issue
 * list shows: one row per document id, and a response body quoted into an error
 * message (an nginx error page, say) pushing every other row off the screen.
 * Only used where the message is the grouping key anyway, so that the title of
 * an issue and the reason its events are in it cannot drift apart.
 */
function reportNormalized(
  event: Sentry.ErrorEvent,
  exception: Sentry.Exception,
) {
  if (!exception.value) {
    // nothing to read either way, and a placeholder would only pretend there is
    return;
  }
  const normalized = normalizeErrorValue(exception.value).slice(
    0,
    MAX_REPORTED_MESSAGE_LENGTH,
  );
  if (normalized === exception.value) {
    return;
  }
  event.extra = { ...event.extra, originalError: exception.value };
  exception.value = normalized;
}

/**
 * How much of an error message is kept as the reported one (see
 * {@link reportNormalized}). Generous enough for any message written to be
 * read, and a backstop against the ones that turn out to be a serialized
 * document or a web page.
 */
const MAX_REPORTED_MESSAGE_LENGTH = 300;

/**
 * Group by the error chain instead of the stack trace, so that one problem
 * shows up as one issue.
 *
 * `exception.values` is ordered innermost-first: `values[0]` is the deepest
 * `cause`, the originally thrown error is last. Both ends matter - the thrown
 * error says which operation failed, the root cause says why (e.g. a config
 * load failing because the device is offline is a different problem from the
 * same load failing because the user is unauthorized).
 *
 * The error the event is grouped by is usually the thrown one, but can be a
 * link further down the chain when the ones above it add nothing (see
 * {@link rewrappedCauseGroupedError} and {@link loggedUnreachableServer}).
 *
 * Because the root cause is part of the grouping key, it is also appended to
 * the reported message: otherwise several issues share one title (a dozen
 * "Failed to load configuration from the database." rows) and can only be told
 * apart by opening each of them.
 *
 * The route remains available as the `transaction` tag, so a failure can still
 * be filtered by where it happened without splitting it into separate issues.
 */
function groupByErrorChain(
  event: Sentry.ErrorEvent,
  values: Sentry.Exception[],
  groupedError: Sentry.Exception,
): Sentry.ErrorEvent {
  const groupedType = groupedError.type ?? "";
  const groupedValue = groupingValue(groupedError);
  const fingerprint = [groupedType, groupedValue];

  const rootCause = values[0];
  const rootType = rootCause.type ?? "";
  const rootValue = groupingValue(rootCause);
  // compared by content, not by identity: an error wrapping another error of
  // the same type and message describes one failure, not two, and has to match
  // the fingerprint of the same failure reported without the extra wrapper.
  // Two links that are both network failures are one such case - which of them
  // the chain happens to include says nothing about the problem.
  const isDistinctCause =
    !(groupedValue === NETWORK_FAILURE && rootValue === NETWORK_FAILURE) &&
    (rootType !== groupedType || rootValue !== groupedValue);

  if (isDistinctCause) {
    fingerprint.push(rootType, rootValue);
    groupedError.value = `${groupedError.value} ${describeCause(rootCause)}`;
  }

  if (groupedValue === NETWORK_FAILURE) {
    // the wordings collected here differ per browser, so use a stable title
    reportAsUnreachableServer(event, groupedError);
  }

  const thrownError = values[values.length - 1];
  if (thrownError !== groupedError) {
    // Sentry titles an event by its thrown error, so a title taken from the
    // links above the grouped one would differ from the title the same problem
    // has everywhere else in its issue
    thrownError.type = groupedError.type;
    thrownError.value = groupedError.value;
  }

  event.fingerprint = fingerprint;
  return event;
}

/**
 * Replace the reported message by one that does not depend on which browser
 * (or library) sent the event, keeping the original one available as extra data.
 */
function reportAsUnreachableServer(
  event: Sentry.ErrorEvent,
  exception: Sentry.Exception,
) {
  event.extra = { ...event.extra, originalError: exception.value };
  exception.value = "Failed to reach the server";
}

/**
 * Placeholder for a connectivity failure, which every browser words differently
 * ("Failed to fetch" / "Load failed" / ...) while describing one problem.
 *
 * Substituted wherever an error message is used as a grouping key or shown as
 * the cause of another error, so that a mix of browsers neither splits an issue
 * nor makes its title flip-flop between its events.
 */
const NETWORK_FAILURE = "network failure";

/** The part of an error message that identifies the problem. */
function groupingValue(exception: Sentry.Exception): string {
  return isConnectivityException(exception)
    ? NETWORK_FAILURE
    : fingerprintKey(exception.value);
}

/** Human-readable, but stable across occurrences of the same problem. */
function describeCause(rootCause: Sentry.Exception): string {
  const cause = isConnectivityException(rootCause)
    ? NETWORK_FAILURE
    : normalizeErrorValue(rootCause.value);
  return `(caused by ${rootCause.type ?? "Error"}: ${cause})`;
}

/**
 * Collect all failures that are really "the app could not reach the server"
 * into a single issue.
 *
 * These are raised by the browser (and by third-party libraries wrapping it) at
 * whatever point a request happened to be made, so grouping them by stack trace
 * produces an open-ended stream of near-identical issues that all have the same
 * (non-)answer. They still have to be reported rather than dropped, because a
 * server outage surfaces exactly this way - but as one issue whose event count
 * is the interesting signal.
 *
 * The browsers' differing wordings ("Failed to fetch" / "Load failed" / ...)
 * would make the title flip-flop between events of the merged issue, so it is
 * replaced by a stable one and kept as a searchable tag instead.
 */
function groupAsNetworkError(
  event: Sentry.ErrorEvent,
  thrownError: Sentry.Exception,
): Sentry.ErrorEvent {
  const original = `${thrownError.type ?? "Error"}: ${normalizeErrorValue(thrownError.value)}`;

  event.fingerprint = ["network-error"];
  event.tags = {
    ...event.tags,
    // Sentry rejects tag values longer than 200 characters
    network_error: original.slice(0, 200),
  };

  reportAsUnreachableServer(event, thrownError);
  thrownError.type = "NetworkError";

  return event;
}

/** Whether one link of a reported error chain is a connectivity failure. */
function isConnectivityException(exception: Sentry.Exception): boolean {
  return (
    CONNECTIVITY_ERROR_NAMES.includes(exception.type ?? "") ||
    isConnectivityErrorMessage(exception.value ?? "")
  );
}

/**
 * Whether the reported error carries the HTTP status of a request that never
 * reached the backend - a gateway error names the problem in its status rather
 * than in a message {@link isConnectivityException} could recognize.
 *
 * Event-level, because the status describes the error that was captured (it is
 * attached by {@link enrichSentryEvent}) rather than one link of its chain.
 */
function hasConnectivityStatus(event: Sentry.ErrorEvent): boolean {
  const status = event.extra?.status;
  return (
    typeof status === "number" && CONNECTIVITY_ERROR_STATUS.includes(status)
  );
}

/**
 * Placeholder type for an exception reported without one.
 *
 * Sentry builds an issue title from the reported exception's type and message,
 * and lists an issue whose exception carries no type as `<unknown>` - which
 * says nothing at all in a list of issues, and hides the message that would
 * have. The generic name is no loss: it is what an `Error` subclass reports
 * anyway unless it sets `name` explicitly (see {@link CAUSE_GROUPED_ERROR_TYPES}).
 */
const FALLBACK_EXCEPTION_TYPE = "Error";

/**
 * Which registration a registry error is about, if there is one in the chain.
 *
 * The key is not part of the error message, so that all keys of one registry
 * share one issue (see {@link RegistryLookupError}) - this is what still names
 * it in each report. The cause chain is searched as well, because Angular
 * re-throws an error raised in a `resource()` loader as an error of its own
 * (see {@link rewrappedCauseGroupedError}), which does not carry the key.
 */
function registryErrorContext(err: unknown): Record<string, string> {
  const seen = new Set<unknown>();
  let e = err;
  while (e && typeof e === "object" && !seen.has(e)) {
    if (
      e instanceof RegistryLookupError ||
      e instanceof RegistryDuplicateError
    ) {
      return { registry: e.registryName, registryKey: e.key };
    }
    seen.add(e);
    e = (e as { cause?: unknown }).cause;
  }
  return {};
}

/**
 * Enrich events with structured extra data
 * from custom Error properties (e.g. DatabaseException's entityId, status, reason).
 */
function enrichSentryEvent(
  event: Sentry.ErrorEvent,
  hint: Sentry.EventHint,
): Sentry.ErrorEvent {
  // Attach structured properties from custom Error subclasses (e.g. DatabaseException,
  // EntityPermissionError) so that details like entityId, status, reason are visible in
  // Sentry's "Additional Data" - even though (deliberately) not part of the message itself.
  const err = hint.originalException;
  if (err && typeof err === "object") {
    const extras: Record<string, unknown> = {};
    for (const key of [
      "entityId",
      "status",
      "reason",
      "name",
      "action",
      "entityType",
    ]) {
      if (key in err && (err as any)[key] !== undefined) {
        extras[key] = (err as any)[key];
      }
    }
    // a library may pass on the whole `fetch` Response rather than the status
    // (keycloak-js does), which otherwise leaves a report saying only that the
    // status was invalid and never which one it was
    if (extras.status === undefined) {
      const status = (err as { response?: { status?: unknown } }).response
        ?.status;
      if (typeof status === "number") {
        extras.status = status;
      }
    }

    Object.assign(extras, registryErrorContext(err));

    if (Object.keys(extras).length > 0) {
      event.extra = { ...event.extra, ...extras };
    }
  }

  for (const exception of event.exception?.values ?? []) {
    exception.type ||= FALLBACK_EXCEPTION_TYPE;
  }

  return event;
}

results matching ""

    No results matching ""