src/app/core/logging/logging.service.ts
Wrapper that 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.
Error
No results matching.
Error
constructor(message: string, cause?: unknown)
|
|||||||||
|
Defined in src/app/core/logging/logging.service.ts:267
|
|||||||||
|
Parameters :
|
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;
}