Files
2024-02-15 17:01:50 +00:00

529 lines
17 KiB
TypeScript

import {SymbolicatorStatus} from 'sentry/components/events/interfaces/types';
import ConfigStore from 'sentry/stores/configStore';
import type {
BaseGroup,
EntryException,
EntryRequest,
EntryThreads,
EventMetadata,
Group,
GroupActivityAssigned,
GroupTombstoneHelper,
TreeLabelPart,
} from 'sentry/types';
import {
EventOrGroupType,
GroupActivityType,
IssueCategory,
IssueType,
} from 'sentry/types';
import type {Event, ExceptionValue, Frame, Thread} from 'sentry/types/event';
import {EntryType} from 'sentry/types/event';
import {defined} from 'sentry/utils';
import type {BaseEventAnalyticsParams} from 'sentry/utils/analytics/workflowAnalyticsEvents';
import {uniq} from 'sentry/utils/array/uniq';
import {getDaysSinceDatePrecise} from 'sentry/utils/getDaysSinceDate';
import {isMobilePlatform, isNativePlatform} from 'sentry/utils/platform';
import {getReplayIdFromEvent} from 'sentry/utils/replays/getReplayIdFromEvent';
export function isTombstone(
maybe: BaseGroup | Event | GroupTombstoneHelper
): maybe is GroupTombstoneHelper {
return 'isTombstone' in maybe && maybe.isTombstone;
}
/**
* Extract the display message from an event.
*/
export function getMessage(
event: Event | BaseGroup | GroupTombstoneHelper
): string | undefined {
if (isTombstone(event)) {
return event.culprit || '';
}
const {metadata, type, culprit} = event;
switch (type) {
case EventOrGroupType.ERROR:
case EventOrGroupType.TRANSACTION:
return metadata.value;
case EventOrGroupType.CSP:
return metadata.message;
case EventOrGroupType.EXPECTCT:
case EventOrGroupType.EXPECTSTAPLE:
case EventOrGroupType.HPKP:
return '';
case EventOrGroupType.GENERIC:
return metadata.value;
default:
return culprit || '';
}
}
/**
* Get the location from an event.
*/
export function getLocation(event: Event | BaseGroup | GroupTombstoneHelper) {
if (isTombstone(event)) {
return undefined;
}
if (event.type === EventOrGroupType.ERROR && isNativePlatform(event.platform)) {
return event.metadata.filename || undefined;
}
return undefined;
}
export function getTreeLabelPartDetails(part: TreeLabelPart) {
// Note: This function also exists in Python in eventtypes/base.py, to make
// porting efforts simpler it's recommended to keep both variants
// structurally similar.
if (typeof part === 'string') {
return part;
}
const label = part?.function || part?.package || part?.filebase || part?.type;
const classbase = part?.classbase;
if (classbase) {
return label ? `${classbase}.${label}` : classbase;
}
return label || '<unknown>';
}
function computeTitleWithTreeLabel(metadata: EventMetadata) {
const {type, current_tree_label, finest_tree_label} = metadata;
const treeLabel = current_tree_label || finest_tree_label;
const formattedTreeLabel = treeLabel
? treeLabel.map(labelPart => getTreeLabelPartDetails(labelPart)).join(' | ')
: undefined;
if (!type) {
return {
title: formattedTreeLabel || metadata.function || '<unknown>',
treeLabel,
};
}
if (!formattedTreeLabel) {
return {title: type, treeLabel: undefined};
}
return {
title: `${type} | ${formattedTreeLabel}`,
treeLabel: [{type}, ...(treeLabel ?? [])],
};
}
export function getTitle(
event: Event | BaseGroup | GroupTombstoneHelper,
features: string[] = [],
grouping = false
) {
const {metadata, type, culprit, title} = event;
const customTitle = metadata?.title;
switch (type) {
case EventOrGroupType.ERROR: {
if (customTitle) {
return {
title: customTitle,
subtitle: culprit,
treeLabel: undefined,
};
}
const displayTitleWithTreeLabel =
!isTombstone(event) &&
features.includes('grouping-title-ui') &&
(grouping ||
isNativePlatform(event.platform) ||
isMobilePlatform(event.platform));
if (displayTitleWithTreeLabel) {
return {
subtitle: culprit,
...computeTitleWithTreeLabel(metadata),
};
}
return {
subtitle: culprit,
title: metadata.type || metadata.function || '<unknown>',
treeLabel: undefined,
};
}
case EventOrGroupType.CSP:
case EventOrGroupType.NEL:
return {
title: customTitle ?? metadata.directive ?? '',
subtitle: metadata.uri ?? '',
treeLabel: undefined,
};
case EventOrGroupType.EXPECTCT:
case EventOrGroupType.EXPECTSTAPLE:
case EventOrGroupType.HPKP:
// Due to a regression some reports did not have message persisted
// (https://github.com/getsentry/sentry/pull/19794) so we need to fall
// back to the computed title for these.
return {
title: customTitle ?? (metadata.message || title),
subtitle: metadata.origin ?? '',
treeLabel: undefined,
};
case EventOrGroupType.DEFAULT:
return {
title: customTitle ?? metadata.title ?? '',
subtitle: '',
treeLabel: undefined,
};
case EventOrGroupType.TRANSACTION:
case EventOrGroupType.GENERIC:
const isIssue = !isTombstone(event) && defined(event.issueCategory);
return {
title: customTitle ?? title,
subtitle: isIssue ? culprit : '',
treeLabel: undefined,
};
default:
return {
title: customTitle ?? title,
subtitle: '',
treeLabel: undefined,
};
}
}
/**
* Returns a short eventId with only 8 characters
*/
export function getShortEventId(eventId: string) {
return eventId.substring(0, 8);
}
/**
* Returns a comma delineated list of errors
*/
function getEventErrorString(event: Event) {
return uniq(event.errors?.map(error => error.type)).join(',');
}
function hasTrace(event: Event) {
if (event.type !== 'error') {
return false;
}
return !!event.contexts?.trace;
}
function hasProfile(event: Event) {
return defined(event.contexts?.profile);
}
/**
* Function to determine if an event has source maps
* by ensuring that every inApp frame has a valid sourcemap
*/
export function eventHasSourceMaps(event: Event) {
const inAppFrames = getExceptionFrames(event, true);
// the map field tells us if it's sourcemapped
return inAppFrames.every(frame => !!frame.map);
}
/**
* Function to determine if an event has been symbolicated. If the event
* goes through symbolicator and has in-app frames, it looks for at least one in-app frame
* to be successfully symbolicated. Otherwise falls back to checking for `rawStacktrace` field presence.
*/
export function eventIsSymbolicated(event: Event) {
const frames = getAllFrames(event, false);
const fromSymbolicator = frames.some(frame => defined(frame.symbolicatorStatus));
if (fromSymbolicator) {
// if the event goes through symbolicator and have in-app frames, we say it's symbolicated if
// at least one in-app frame is successfully symbolicated
const inAppFrames = frames.filter(frame => frame.inApp);
if (inAppFrames.length > 0) {
return inAppFrames.some(
frame => frame.symbolicatorStatus === SymbolicatorStatus.SYMBOLICATED
);
}
// if there's no in-app frames, we say it's symbolicated if at least
// one system frame is successfully symbolicated
return frames.some(
frame => frame.symbolicatorStatus === SymbolicatorStatus.SYMBOLICATED
);
}
// if none of the frames have symbolicatorStatus defined, most likely the event does not
// go through symbolicator and it's Java/Android/Javascript or something alike, so we fallback
// to the rawStacktrace presence
return event.entries?.some(entry => {
return (
(entry.type === EntryType.EXCEPTION || entry.type === EntryType.THREADS) &&
entry.data.values?.some(
(value: Thread | ExceptionValue) => !!value.rawStacktrace && !!value.stacktrace
)
);
});
}
/**
* Function to determine if an event has source context
*/
export function eventHasSourceContext(event: Event) {
const frames = getAllFrames(event, false);
return frames.some(frame => defined(frame.context) && !!frame.context.length);
}
/**
* Function to get status about how many frames have source maps
*/
export function getFrameBreakdownOfSourcemaps(event?: Event | null) {
if (!event) {
// return undefined if there is no event
return {};
}
const inAppFrames = getExceptionFrames(event, true);
if (!inAppFrames.length) {
return {};
}
return {
framesWithSourcemapsPercent:
(inAppFrames.filter(frame => !!frame.map).length * 100) / inAppFrames.length,
framesWithoutSourceMapsPercent:
(inAppFrames.filter(frame => !frame.map).length * 100) / inAppFrames.length,
};
}
/**
* Returns all stack frames of type 'exception' of this event
*/
function getExceptionFrames(event: Event, inAppOnly: boolean) {
const exceptions = getExceptionEntries(event);
const frames = exceptions
.flatMap(exception => exception.data.values || [])
.flatMap(exceptionValue => exceptionValue?.stacktrace?.frames || []);
return inAppOnly ? frames.filter(frame => frame.inApp) : frames;
}
/**
* Returns all entries of type 'exception' of this event
*/
function getExceptionEntries(event: Event) {
return (event.entries?.filter(entry => entry.type === EntryType.EXCEPTION) ||
[]) as EntryException[];
}
/**
* Returns all stack frames of type 'exception' or 'threads' of this event
*/
function getAllFrames(event: Event, inAppOnly: boolean): Frame[] {
const exceptions: EntryException[] | EntryThreads[] = getEntriesWithFrames(event);
const frames: Frame[] = exceptions
.flatMap(withStacktrace => withStacktrace.data.values ?? [])
.flatMap(
(withStacktrace: ExceptionValue | Thread) =>
withStacktrace?.stacktrace?.frames ?? []
);
return inAppOnly ? frames.filter(frame => frame.inApp) : frames;
}
/**
* Returns all entries that can have stack frames, currently of 'exception' and 'threads' type
*/
function getEntriesWithFrames(event: Event) {
return (event.entries?.filter(
entry => entry.type === EntryType.EXCEPTION || entry.type === EntryType.THREADS
) || []) as EntryException[] | EntryThreads[];
}
function getNumberOfStackFrames(event: Event) {
const entries = getExceptionEntries(event);
// for each entry, go through each frame and get the max
const frameLengths =
entries?.map(entry =>
(entry.data.values || []).reduce((best, exception) => {
// find the max number of frames in this entry
const frameCount = exception.stacktrace?.frames?.length || 0;
return Math.max(best, frameCount);
}, 0)
) || [];
if (!frameLengths.length) {
return 0;
}
return Math.max(...frameLengths);
}
function getNumberOfInAppStackFrames(event: Event) {
const entries = getExceptionEntries(event);
// for each entry, go through each frame
const frameLengths =
entries?.map(entry =>
(entry.data.values || []).reduce((best, exception) => {
// find the max number of frames in this entry
const frames = exception.stacktrace?.frames?.filter(f => f.inApp) || [];
return Math.max(best, frames.length);
}, 0)
) || [];
if (!frameLengths.length) {
return 0;
}
return Math.max(...frameLengths);
}
function getNumberOfThreadsWithNames(event: Event) {
const threadLengths =
(
(event.entries?.filter(entry => entry.type === 'threads') || []) as EntryThreads[]
).map(entry => entry.data?.values?.filter(thread => !!thread.name).length || 0) || [];
if (!threadLengths.length) {
return 0;
}
return Math.max(...threadLengths);
}
export function eventHasExceptionGroup(event: Event) {
const exceptionEntries = getExceptionEntries(event);
return exceptionEntries.some(entry =>
entry.data.values?.some(({mechanism}) => mechanism?.is_exception_group)
);
}
export function eventHasGraphQlRequest(event: Event) {
const requestEntry = event.entries?.find(entry => entry.type === EntryType.REQUEST) as
| EntryRequest
| undefined;
return (
typeof requestEntry?.data?.apiTarget === 'string' &&
requestEntry.data.apiTarget.toLowerCase() === 'graphql'
);
}
/**
* Return the integration type for the first assignment via integration
*/
function getAssignmentIntegration(group: Group) {
if (!group.activity) {
return '';
}
const assignmentAcitivies = group.activity.filter(
activity => activity.type === GroupActivityType.ASSIGNED
) as GroupActivityAssigned[];
const integrationAssignments = assignmentAcitivies.find(
activity => !!activity.data.integration
);
return integrationAssignments?.data.integration || '';
}
export function getAnalyticsDataForEvent(event?: Event | null): BaseEventAnalyticsParams {
const {framesWithSourcemapsPercent, framesWithoutSourceMapsPercent} =
getFrameBreakdownOfSourcemaps(event);
return {
event_id: event?.eventID || '-1',
num_commits: event?.release?.commitCount || 0,
num_event_tags: event?.tags?.length ?? 0,
num_stack_frames: event ? getNumberOfStackFrames(event) : 0,
num_in_app_stack_frames: event ? getNumberOfInAppStackFrames(event) : 0,
num_threads_with_names: event ? getNumberOfThreadsWithNames(event) : 0,
event_platform: event?.platform,
event_runtime: event?.tags?.find(tag => tag.key === 'runtime')?.value,
event_type: event?.type,
has_release: !!event?.release,
has_exception_group: event ? eventHasExceptionGroup(event) : false,
has_graphql_request: event ? eventHasGraphQlRequest(event) : false,
has_profile: event ? hasProfile(event) : false,
has_source_context: event ? eventHasSourceContext(event) : false,
has_source_maps: event ? eventHasSourceMaps(event) : false,
has_trace: event ? hasTrace(event) : false,
has_commit: !!event?.release?.lastCommit,
has_next_event: event ? defined(event.nextEventID) : false,
has_previous_event: event ? defined(event.previousEventID) : false,
is_symbolicated: event ? eventIsSymbolicated(event) : false,
event_errors: event ? getEventErrorString(event) : '',
frames_with_sourcemaps_percent: framesWithSourcemapsPercent,
frames_without_source_maps_percent: framesWithoutSourceMapsPercent,
sdk_name: event?.sdk?.name,
sdk_version: event?.sdk?.version,
release_user_agent: event?.release?.userAgent,
resolved_with: event?.resolvedWith ?? [],
error_has_replay: Boolean(getReplayIdFromEvent(event)),
error_has_user_feedback: defined(event?.userReport),
has_otel: event?.contexts?.otel !== undefined,
event_mechanism:
event?.tags?.find(tag => tag.key === 'mechanism')?.value || undefined,
};
}
export type CommonGroupAnalyticsData = {
days_since_last_seen: number;
error_count: number;
group_id: number;
group_num_user_feedback: number;
has_external_issue: boolean;
has_owner: boolean;
integration_assignment_source: string;
issue_age: number;
issue_category: IssueCategory;
issue_id: number;
issue_type: IssueType;
num_comments: number;
num_participants: number;
num_viewers: number;
is_assigned?: boolean;
issue_level?: string;
issue_status?: string;
issue_substatus?: string | null;
priority?: string;
};
export function getAnalyticsDataForGroup(group?: Group | null): CommonGroupAnalyticsData {
const groupId = group ? parseInt(group.id, 10) : -1;
const activeUser = ConfigStore.get('user');
return {
group_id: groupId,
// overload group_id with the issue_id
issue_id: groupId,
issue_category: group?.issueCategory ?? IssueCategory.ERROR,
issue_type: group?.issueType ?? IssueType.ERROR,
issue_status: group?.status,
issue_substatus: group?.substatus,
issue_age: group?.firstSeen ? getDaysSinceDatePrecise(group.firstSeen) : -1,
days_since_last_seen: group?.lastSeen ? getDaysSinceDatePrecise(group.lastSeen) : -1,
issue_level: group?.level,
is_assigned: !!group?.assignedTo,
error_count: Number(group?.count || -1),
num_comments: group ? group.numComments : -1,
has_external_issue: group?.annotations ? group?.annotations.length > 0 : false,
has_owner: group?.owners ? group?.owners.length > 0 : false,
integration_assignment_source: group ? getAssignmentIntegration(group) : '',
num_participants: group?.participants?.length ?? 0,
num_viewers: group?.seenBy?.filter(user => user.id !== activeUser?.id).length ?? 0,
group_num_user_feedback: group?.userReportCount ?? 0,
priority: group?.priority,
};
}
export function eventIsProfilingIssue(event: BaseGroup | Event | GroupTombstoneHelper) {
if (isTombstone(event) || isGroup(event)) {
return false;
}
if (event.issueCategory === IssueCategory.PROFILE) {
return true;
}
const evidenceData = event.occurrence?.evidenceData ?? {};
return evidenceData.templateName === 'profile';
}
function isGroup(event: BaseGroup | Event): event is BaseGroup {
return (event as BaseGroup).status !== undefined;
}