Files
prowler/ui/sentry/event-policy.ts
2026-07-20 16:34:24 +02:00

291 lines
7.3 KiB
TypeScript

const SENTRY_EVENT_LEVEL = {
WARNING: "warning",
} as const;
export const SENTRY_EVENT_SOURCE = {
CLIENT: "client",
EDGE: "edge",
SERVER: "server",
SERVER_ACTION: "server_action",
} as const;
const SENTRY_EVENT_KIND = {
API: "api",
RUNTIME: "runtime",
} as const;
const SENTRY_ACTIONABILITY = {
ACTIONABLE: "actionable",
} as const;
const EXPECTED_CONTROL_FLOW_MESSAGES = [
"NEXT_REDIRECT",
"NEXT_NOT_FOUND",
"AbortError",
"ResizeObserver",
] as const;
const HTTP_CONTEXT_MESSAGES = [
/\bapi\b/i,
/\bfetch\b/i,
/\bhttp\b/i,
/\brequest failed\b/i,
] as const;
const EXPECTED_HTTP_STATUS_CODES = new Set([401, 403, 404]);
const EXPECTED_HTTP_STATUS_PATTERN = /(^|\D)(401|403|404)(\D|$)/;
const REPORTED_ERROR_MARKER = Symbol.for("prowler.sentry.reported_error");
const SENTRY_CAPTURED_MARKER = "__sentry_captured__";
const reportedErrors = new WeakSet<object>();
type SentryEventSource =
(typeof SENTRY_EVENT_SOURCE)[keyof typeof SENTRY_EVENT_SOURCE];
type SentryPolicyTagValue = string | number | boolean | null | undefined;
export interface SentryEventPolicyOptions {
source?: SentryEventSource;
}
export interface SentryEventHint {
originalException?: unknown;
}
export interface SentryPolicyEvent {
tags?: Record<string, SentryPolicyTagValue>;
}
type ObjectRecord = Record<PropertyKey, unknown>;
type ReportedErrorRecord = Record<symbol, unknown>;
export function applySentryEventPolicy<TEvent extends object>(
event: TEvent,
hint?: SentryEventHint,
options: SentryEventPolicyOptions = {},
) {
if (shouldDropSentryEvent(event, hint)) {
return null;
}
tagActionableEvent(event, options);
return event;
}
export function markErrorAsReported(error: unknown) {
if (!isObjectLike(error)) {
return;
}
reportedErrors.add(error);
try {
Object.defineProperty(error, REPORTED_ERROR_MARKER, {
configurable: false,
enumerable: false,
value: true,
});
} catch {
// WeakSet fallback still suppresses duplicates for non-extensible objects.
}
}
export function isErrorAlreadyReported(error: unknown) {
if (!isObjectLike(error)) {
return false;
}
return (
reportedErrors.has(error) ||
(error as ReportedErrorRecord)[REPORTED_ERROR_MARKER] === true
);
}
export function isErrorCapturedBySentry(error: unknown) {
return (
isObjectLike(error) && getProperty(error, SENTRY_CAPTURED_MARKER) === true
);
}
function shouldDropSentryEvent(event: object, hint?: SentryEventHint) {
if (getStringProperty(event, "level") === SENTRY_EVENT_LEVEL.WARNING) {
return true;
}
if (isErrorAlreadyReported(hint?.originalException)) {
return true;
}
const messages = getEventMessages(event, hint);
if (hasExpectedControlFlowMessage(messages)) {
return true;
}
return hasExpectedHttpStatus(event, hint?.originalException, messages);
}
function tagActionableEvent(event: object, options: SentryEventPolicyOptions) {
const mutableEvent = event as SentryPolicyEvent;
mutableEvent.tags = {
...mutableEvent.tags,
actionability: SENTRY_ACTIONABILITY.ACTIONABLE,
kind: inferEventKind(event),
...(options.source ? { source: options.source } : {}),
};
}
function inferEventKind(event: object) {
const tags = getRecordProperty(event, "tags");
const errorType = getStringProperty(tags, "error_type");
const apiError = getProperty(tags, "api_error");
if (
errorType === "api_error" ||
apiError === true ||
getStatusFromTags(tags)
) {
return SENTRY_EVENT_KIND.API;
}
return SENTRY_EVENT_KIND.RUNTIME;
}
function hasExpectedControlFlowMessage(messages: string[]) {
return EXPECTED_CONTROL_FLOW_MESSAGES.some((expectedMessage) =>
messages.some((message) => message.includes(expectedMessage)),
);
}
function hasExpectedHttpStatus(
event: object,
originalException: unknown,
messages: string[],
) {
const tags = getRecordProperty(event, "tags");
const statusFromTags = getStatusFromTags(tags);
const statusFromError = getStatusFromError(originalException);
if (
(statusFromTags && EXPECTED_HTTP_STATUS_CODES.has(statusFromTags)) ||
(statusFromError && EXPECTED_HTTP_STATUS_CODES.has(statusFromError))
) {
return true;
}
return (
hasExpectedHttpStatusMessage(messages) &&
hasApiOrHttpContext(event, messages)
);
}
function hasExpectedHttpStatusMessage(messages: string[]) {
return messages.some((message) => EXPECTED_HTTP_STATUS_PATTERN.test(message));
}
function hasApiOrHttpContext(event: object, messages: string[]) {
const tags = getRecordProperty(event, "tags");
return hasApiTags(tags) || hasHttpContextMessage(messages);
}
function hasApiTags(tags: unknown) {
return (
getStringProperty(tags, "error_type") === "api_error" ||
getProperty(tags, "api_error") === true
);
}
function hasHttpContextMessage(messages: string[]) {
return messages.some((message) =>
HTTP_CONTEXT_MESSAGES.some((pattern) => pattern.test(message)),
);
}
function getStatusFromTags(tags: unknown) {
return (
normalizeStatusCode(getProperty(tags, "status_code")) ??
normalizeStatusCode(getProperty(tags, "status")) ??
normalizeStatusCode(getProperty(tags, "http.status_code"))
);
}
function getStatusFromError(error: unknown): number | undefined {
const response = getRecordProperty(error, "response");
return (
normalizeStatusCode(getProperty(error, "status")) ??
normalizeStatusCode(getProperty(error, "statusCode")) ??
normalizeStatusCode(getProperty(error, "status_code")) ??
normalizeStatusCode(getProperty(response, "status"))
);
}
function getEventMessages(event: object, hint?: SentryEventHint) {
return [
getStringProperty(event, "message"),
...getExceptionMessages(event),
getStringProperty(hint?.originalException, "name"),
getStringProperty(hint?.originalException, "message"),
typeof hint?.originalException === "string"
? hint.originalException
: undefined,
].filter(isString);
}
function getExceptionMessages(event: object) {
const exception = getRecordProperty(event, "exception");
const values = getProperty(exception, "values");
if (!Array.isArray(values)) {
return [];
}
return values.flatMap((value) => [
getStringProperty(value, "type"),
getStringProperty(value, "value"),
]);
}
function normalizeStatusCode(value: unknown) {
if (typeof value === "number" && Number.isInteger(value)) {
return value;
}
if (typeof value === "string" && /^\d{3}$/.test(value)) {
return Number(value);
}
return undefined;
}
function getStringProperty(value: unknown, property: string) {
const propertyValue = getProperty(value, property);
return typeof propertyValue === "string" ? propertyValue : undefined;
}
function getRecordProperty(value: unknown, property: string) {
const propertyValue = getProperty(value, property);
return isRecord(propertyValue) ? propertyValue : undefined;
}
function getProperty(value: unknown, property: string) {
return isRecord(value) ? value[property] : undefined;
}
function isRecord(value: unknown): value is ObjectRecord {
return typeof value === "object" && value !== null;
}
function isObjectLike(value: unknown): value is object {
return (
(typeof value === "object" && value !== null) || typeof value === "function"
);
}
function isString(value: unknown): value is string {
return typeof value === "string";
}