chore(err): introduce error properties logic (#31769)

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Hugues Pouillot
2025-05-05 12:56:38 +02:00
committed by GitHub
parent a408a71922
commit 295da0f0f2
34 changed files with 741 additions and 765 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -149,6 +149,7 @@ export function StacktracelessSafariScriptError(): JSX.Element {
eventProperties={errorProperties({
$exception_list: [{ type: 'ScriptError', value: 'Script error.', mechanism: { synthetic: true } }],
})}
eventId="error"
/>
)
}
@@ -165,6 +166,7 @@ export function StacktracelessImportModuleError(): JSX.Element {
},
],
})}
eventId="error"
/>
)
}
@@ -192,6 +194,7 @@ export function AnonymousErrorWithStackTrace(): JSX.Element {
},
],
})}
eventId="error"
/>
)
}
@@ -246,6 +249,7 @@ export function ChainedErrorStack(): JSX.Element {
},
],
})}
eventId="error"
/>
)
}
@@ -275,6 +279,7 @@ export function StackTraceWithLineContext(): JSX.Element {
},
],
})}
eventId="error"
/>
)
}
@@ -291,10 +296,15 @@ export function WithCymbalErrors(): JSX.Element {
],
$cymbal_errors: ['This is an ingestion error', 'This is a second one'],
})}
eventId="error"
/>
)
}
export function SentryStackTrace(): JSX.Element {
return <ErrorDisplay eventProperties={errorProperties({ $exception_list: [] })} eventId="error" />
}
export function LegacyEventProperties(): JSX.Element {
return (
<ErrorDisplay
@@ -304,6 +314,7 @@ export function LegacyEventProperties(): JSX.Element {
$exception_personURL: 'https://app.posthog.com/person/the-person-id',
$exception_synthetic: true,
})}
eventId="error"
/>
)
}

View File

@@ -1,29 +1,37 @@
import { LemonBanner } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { BindLogic, useValues } from 'kea'
import { TitledSnack } from 'lib/components/TitledSnack'
import { LemonDivider } from 'lib/lemon-ui/LemonDivider'
import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch'
import { Link } from 'lib/lemon-ui/Link'
import { getExceptionAttributes } from 'scenes/error-tracking/utils'
import { useState } from 'react'
import { EventType } from '~/types'
import { FingerprintRecordPart, stackFrameLogic } from './stackFrameLogic'
import { errorPropertiesLogic } from './errorPropertiesLogic'
import { ChainedStackTraces } from './StackTraces'
import { ErrorTrackingException } from './types'
import { concatValues, hasInAppFrames, hasStacktrace } from './utils'
import { ErrorEventId, ErrorEventProperties } from './types'
import { concatValues } from './utils'
export function ErrorDisplay({ eventProperties }: { eventProperties: EventType['properties'] }): JSX.Element {
const exceptionAttributes = getExceptionAttributes(eventProperties)
const { type, value, sentryUrl, exceptionList, level, ingestionErrors, handled } = exceptionAttributes
const exceptionWithStack = hasStacktrace(exceptionList)
const fingerprintRecords: FingerprintRecordPart[] = eventProperties.$exception_fingerprint_record || []
export function ErrorDisplay({
eventProperties,
eventId,
}: {
eventProperties: ErrorEventProperties
eventId: ErrorEventId
}): JSX.Element {
return (
<BindLogic logic={errorPropertiesLogic} props={{ properties: eventProperties, id: eventId }}>
<ErrorDisplayContent />
</BindLogic>
)
}
export function ErrorDisplayContent(): JSX.Element {
const { exceptionAttributes, hasStacktrace } = useValues(errorPropertiesLogic)
const { type, value, sentryUrl, level, ingestionErrors, handled } = exceptionAttributes || {}
return (
<div className="flex flex-col deprecated-space-y-2 pb-2">
<h1 className="mb-0">{type || level}</h1>
{!exceptionWithStack && <div className="text-secondary italic">{value}</div>}
{!hasStacktrace && <div className="text-secondary italic">{value}</div>}
<div className="flex flex-row gap-2 flex-wrap">
<TitledSnack
type="success"
@@ -54,7 +62,7 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType['
<TitledSnack title="os" value={concatValues(exceptionAttributes, 'os', 'osVersion') ?? 'unknown'} />
</div>
{ingestionErrors || exceptionWithStack ? <LemonDivider dashed={true} /> : null}
{ingestionErrors || hasStacktrace ? <LemonDivider dashed={true} /> : null}
{ingestionErrors && (
<>
<LemonBanner type="error">
@@ -66,39 +74,24 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType['
</LemonBanner>
</>
)}
{exceptionWithStack && <StackTrace exceptionList={exceptionList} fingerprintRecords={fingerprintRecords} />}
{hasStacktrace && <StackTrace />}
</div>
)
}
const StackTrace = ({
exceptionList,
fingerprintRecords,
}: {
exceptionList: ErrorTrackingException[]
fingerprintRecords: FingerprintRecordPart[]
}): JSX.Element => {
const { showAllFrames } = useValues(stackFrameLogic)
const { setShowAllFrames } = useActions(stackFrameLogic)
const hasInApp = hasInAppFrames(exceptionList)
const StackTrace = (): JSX.Element => {
const [showAllFrames, setShowAllFrames] = useState(false)
return (
<>
<div className="flex gap-1 mt-6 justify-between items-center">
<h3 className="mb-0">Stack Trace</h3>
{hasInApp ? (
<LemonSwitch
checked={showAllFrames}
label="Show entire stack trace"
onChange={() => setShowAllFrames(!showAllFrames)}
/>
) : null}
<LemonSwitch
checked={showAllFrames}
label="Show entire stack trace"
onChange={() => setShowAllFrames(!showAllFrames)}
/>
</div>
<ChainedStackTraces
exceptionList={exceptionList}
showAllFrames={hasInApp && showAllFrames}
fingerprintRecords={fingerprintRecords}
/>
<ChainedStackTraces showAllFrames={showAllFrames} />
</>
)
}

View File

@@ -4,7 +4,7 @@ import { IconFingerprint } from 'lib/lemon-ui/icons'
import { cn } from 'lib/utils/css-classes'
import { useRef } from 'react'
import { FingerprintRecordPart } from './stackFrameLogic'
import { FingerprintRecordPart } from './types'
export function FingerprintRecordPartDisplay({
part,
@@ -16,7 +16,7 @@ export function FingerprintRecordPartDisplay({
const iconRef = useRef<HTMLDivElement>(null)
const isHovering = useIsHovering(iconRef)
return (
<Tooltip title={getPartPieces(part)} placement="right">
<Tooltip title={renderPartTooltip(part)} placement="right">
<span ref={iconRef} className="inline-flex items-center">
<IconFingerprint className={className} color={isHovering ? 'red' : 'gray'} fontSize="17px" />
</span>
@@ -24,7 +24,7 @@ export function FingerprintRecordPartDisplay({
)
}
function getPartPieces(component: FingerprintRecordPart): React.ReactNode {
function renderPartTooltip(component: FingerprintRecordPart): React.ReactNode {
if (component.type === 'manual') {
return null
}

View File

@@ -5,19 +5,21 @@ import { LemonCollapse, Tooltip } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { MouseEvent, useEffect, useMemo } from 'react'
import { MouseEvent, useEffect } from 'react'
import { cancelEvent } from 'scenes/error-tracking/utils'
import { match, P } from 'ts-pattern'
import { CodeLine, getLanguage, Language } from '../CodeSnippet/CodeSnippet'
import { CopyToClipboardInline } from '../CopyToClipboard'
import { errorPropertiesLogic } from './errorPropertiesLogic'
import { FingerprintRecordPartDisplay } from './FingerprintRecordPartDisplay'
import { FingerprintRecordPart, stackFrameLogic } from './stackFrameLogic'
import { stackFrameLogic } from './stackFrameLogic'
import {
ErrorTrackingException,
ErrorTrackingStackFrame,
ErrorTrackingStackFrameContext,
ErrorTrackingStackFrameContextLine,
FingerprintRecordPart,
} from './types'
import { stacktraceHasInAppFrames } from './utils'
@@ -46,22 +48,19 @@ function ExceptionHeader({ type, value, part }: ExceptionHeaderProps): JSX.Eleme
type FrameContextClickHandler = (ctx: ErrorTrackingStackFrameContext, e: MouseEvent) => void
export function ChainedStackTraces({
exceptionList,
showAllFrames,
renderExceptionHeader,
onFrameContextClick,
embedded = false,
fingerprintRecords = [],
}: {
renderExceptionHeader?: (props: ExceptionHeaderProps) => React.ReactNode
exceptionList: ErrorTrackingException[]
fingerprintRecords?: FingerprintRecordPart[]
showAllFrames: boolean
embedded?: boolean
onFrameContextClick?: FrameContextClickHandler
}): JSX.Element {
const { loadFromRawIds } = useActions(stackFrameLogic)
const getters = useFingerprintGetters(fingerprintRecords)
const { exceptionList, getExceptionFingerprint } = useValues(errorPropertiesLogic)
useEffect(() => {
const frames: ErrorTrackingStackFrame[] = exceptionList.flatMap((e) => {
@@ -78,7 +77,7 @@ export function ChainedStackTraces({
<div className="flex flex-col gap-y-2">
{exceptionList.map(({ stacktrace, value, type, id }, index) => {
const displayTrace = shouldDisplayTrace(stacktrace, showAllFrames)
const part = getters.getExceptionPart(id)
const part = getExceptionFingerprint(id)
const traceHeaderProps = { id, type, value, part, loading: false }
return (
<div
@@ -94,7 +93,6 @@ export function ChainedStackTraces({
frames={stacktrace?.frames || []}
showAllFrames={showAllFrames}
embedded={embedded}
getters={getters}
onFrameContextClick={onFrameContextClick}
/>
)}
@@ -123,13 +121,11 @@ function Trace({
frames,
showAllFrames,
embedded,
getters,
onFrameContextClick,
}: {
frames: ErrorTrackingStackFrame[]
showAllFrames: boolean
embedded: boolean
getters?: FingerprintGetters
onFrameContextClick?: FrameContextClickHandler
}): JSX.Element | null {
const { stackFrameRecords } = useValues(stackFrameLogic)
@@ -140,7 +136,7 @@ function Trace({
const record = stackFrameRecords[raw_id]
return {
key: idx,
header: <FrameHeaderDisplay frame={frame} getters={getters} />,
header: <FrameHeaderDisplay frame={frame} />,
content:
record && record.context ? (
<div onClick={(e) => onFrameContextClick?.(record.context!, e)}>
@@ -154,15 +150,10 @@ function Trace({
return <LemonCollapse embedded={embedded} multiple panels={panels} size="xsmall" />
}
export function FrameHeaderDisplay({
frame,
getters,
}: {
frame: ErrorTrackingStackFrame
getters?: FingerprintGetters
}): JSX.Element {
export function FrameHeaderDisplay({ frame }: { frame: ErrorTrackingStackFrame }): JSX.Element {
const { raw_id, source, line, column, resolved_name, resolved, resolve_failure, in_app } = frame
const part = getters?.getFramePart(raw_id)
const { getFrameFingerprint } = useValues(errorPropertiesLogic)
const part = getFrameFingerprint(raw_id)
return (
<div className="flex flex-1 justify-between items-center h-full">
<div className="flex flex-wrap gap-x-1">
@@ -210,24 +201,6 @@ export function FrameHeaderDisplay({
)
}
export type FingerprintGetters = {
getExceptionPart(excId: string): FingerprintRecordPart | undefined
getFramePart(frameId: string): FingerprintRecordPart | undefined
}
function useFingerprintGetters(fingerprintRecords: FingerprintRecordPart[]): FingerprintGetters {
return useMemo(() => {
return {
getExceptionPart(excId: string) {
return fingerprintRecords.find((record) => record.type === 'exception' && record.id === excId)
},
getFramePart(frameId: string) {
return fingerprintRecords.find((record) => record.type === 'frame' && record.raw_id === frameId)
},
}
}, [fingerprintRecords])
}
function FrameContext({
context,
language,

View File

@@ -1,4 +1,5 @@
import { ExceptionAttributes, getExceptionAttributes } from 'scenes/error-tracking/utils'
import { ExceptionAttributes } from './types'
import { getExceptionAttributes, getExceptionList } from './utils'
describe('Error Display', () => {
it('can read sentry stack trace when $exception_list is not present', () => {
@@ -47,47 +48,28 @@ describe('Error Display', () => {
$exception_personURL: 'https://app.posthog.com/person/f6kW3HXaha6dAvHZiOmgrcAXK09682P6nNPxvfjqM9c',
$exception_type: 'Error',
}
const result = getExceptionAttributes(eventProperties)
expect(result).toEqual({
browser: 'Chrome',
browserVersion: '92.0.4515',
value: 'There was an error creating the support ticket with zendesk.',
exceptionList: [
{
mechanism: {
handled: true,
type: 'generic',
},
stacktrace: {
frames: [
{
colno: 220,
filename: 'https://app-static-prod.posthog.com/static/chunk-UFQKIDIH.js',
function: 'submitZendeskTicket',
in_app: true,
lineno: 25,
},
],
},
type: 'Error',
value: 'There was an error creating the support ticket with zendesk.',
const result = getExceptionList(eventProperties)
expect(result).toEqual([
{
mechanism: {
handled: true,
type: 'generic',
},
],
synthetic: undefined,
handled: true,
type: 'Error',
lib: 'posthog-js',
libVersion: '1.0.0',
level: undefined,
os: 'Windows',
osVersion: '10',
ingestionErrors: undefined,
fingerprintRecords: [],
url: undefined,
runtime: 'web',
sentryUrl:
'https://sentry.io/organizations/posthog/issues/?project=1899813&query=40e442d79c22473391aeeeba54c82163',
})
stacktrace: {
frames: [
{
colno: 220,
filename: 'https://app-static-prod.posthog.com/static/chunk-UFQKIDIH.js',
function: 'submitZendeskTicket',
in_app: true,
lineno: 25,
},
],
},
type: 'Error',
value: 'There was an error creating the support ticket with zendesk.',
},
])
})
it('can read sentry message', () => {
@@ -119,12 +101,10 @@ describe('Error Display', () => {
browser: 'Chrome',
browserVersion: '92.0.4515',
value: 'the message sent into sentry captureMessage',
exceptionList: [],
ingestionErrors: undefined,
handled: false,
synthetic: undefined,
type: undefined,
fingerprintRecords: [],
url: undefined,
runtime: 'web',
lib: 'posthog-js',
@@ -183,32 +163,9 @@ describe('Error Display', () => {
level: undefined,
os: 'Windows',
osVersion: '10',
fingerprintRecords: [],
url: undefined,
runtime: 'web',
sentryUrl: undefined,
exceptionList: [
{
mechanism: {
handled: true,
type: 'generic',
synthetic: false,
},
stacktrace: {
frames: [
{
colno: 220,
filename: 'https://app-static-prod.posthog.com/static/chunk-UFQKIDIH.js',
function: 'submitZendeskTicket',
in_app: true,
lineno: 25,
},
],
},
type: 'Error',
value: 'There was an error creating the support ticket with zendesk2.',
},
],
ingestionErrors: undefined,
handled: true,
})

View File

@@ -0,0 +1,81 @@
import { connect, kea, key, path, props, selectors } from 'kea'
import {
ErrorEventId,
ErrorEventProperties,
ErrorTrackingException,
FingerprintRecordPart,
} from 'lib/components/Errors/types'
import {
getAdditionalProperties,
getExceptionAttributes,
getExceptionList,
getFingerprintRecords,
getSessionId,
hasStacktrace,
} from 'lib/components/Errors/utils'
import { dayjs } from 'lib/dayjs'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import type { errorPropertiesLogicType } from './errorPropertiesLogicType'
export interface ErrorPropertiesLogicProps {
properties?: ErrorEventProperties
id: ErrorEventId
}
export const errorPropertiesLogic = kea<errorPropertiesLogicType>([
path((key) => ['components', 'Errors', 'errorPropertiesLogic', key]),
props({} as ErrorPropertiesLogicProps),
key((props) => props.id),
connect(() => ({
values: [preflightLogic, ['isCloudOrDev']],
})),
selectors({
properties: [
() => [(_, props) => props.properties as ErrorEventProperties],
(properties: ErrorEventProperties) => properties,
],
exceptionAttributes: [
(s) => [s.properties],
(properties: ErrorEventProperties) => (properties ? getExceptionAttributes(properties) : null),
],
exceptionList: [
(s) => [s.properties],
(properties: ErrorEventProperties) => {
return properties ? getExceptionList(properties) : []
},
],
timestamp: [
(s) => [s.properties],
(properties: ErrorEventProperties) => {
return properties ? dayjs(properties.timestamp as string) : null
},
],
additionalProperties: [
(s) => [s.properties, s.isCloudOrDev],
(properties: ErrorEventProperties, isCloudOrDev: boolean | undefined) =>
properties ? getAdditionalProperties(properties, isCloudOrDev) : {},
],
fingerprintRecords: [
(s) => [s.properties],
(properties: ErrorEventProperties) => (properties ? getFingerprintRecords(properties) : []),
],
hasStacktrace: [(s) => [s.exceptionList], (excList: ErrorTrackingException[]) => hasStacktrace(excList)],
sessionId: [
(s) => [s.properties],
(properties: ErrorEventProperties) => (properties ? getSessionId(properties) : undefined),
],
getExceptionFingerprint: [
(s) => [s.fingerprintRecords],
(records: FingerprintRecordPart[]) => (excId: string) =>
records.find((record) => record.type === 'exception' && record.id === excId),
],
getFrameFingerprint: [
(s) => [s.fingerprintRecords],
(records: FingerprintRecordPart[]) => (frameRawId: string) =>
records.find((record) => record.type === 'frame' && record.raw_id === frameRawId),
],
}),
])

View File

@@ -1,4 +1,4 @@
import { actions, kea, path, reducers } from 'kea'
import { actions, kea, path } from 'kea'
import { loaders } from 'kea-loaders'
import api from 'lib/api'
@@ -14,51 +14,14 @@ function mapStackFrameRecords(
return newRecords.reduce((frames, record) => ({ ...frames, [record.raw_id]: record }), initialRecords)
}
interface FingerprintFrame {
type: 'frame'
raw_id: string
pieces: string[]
}
interface FingerprintException {
type: 'exception'
id: string // Exception ID
pieces: string[]
}
interface FingerprintManual {
type: 'manual'
}
export type FingerprintRecordPart = FingerprintManual | FingerprintFrame | FingerprintException
export const stackFrameLogic = kea<stackFrameLogicType>([
path(['components', 'Errors', 'stackFrameLogic']),
actions({
loadFromRawIds: (rawIds: ErrorTrackingStackFrame['raw_id'][]) => ({ rawIds }),
loadForSymbolSet: (symbolSetId: ErrorTrackingSymbolSet['id']) => ({ symbolSetId }),
setShowAllFrames: (showAllFrames: boolean) => ({ showAllFrames }),
setFrameOrderReversed: (reverseOrder: boolean) => ({ reverseOrder }),
}),
reducers(() => ({
showAllFrames: [
false,
{ persist: true },
{
setShowAllFrames: (_, { showAllFrames }) => showAllFrames,
},
],
frameOrderReversed: [
false,
{ persist: true },
{
setFrameOrderReversed: (_, { reverseOrder }) => reverseOrder,
},
],
})),
loaders(({ values }) => ({
stackFrameRecords: [
{} as KeyedStackFrameRecords,

View File

@@ -1,3 +1,5 @@
import { EventType } from '~/types'
export interface ErrorTrackingException {
stacktrace?: ErrorTrackingRawStackTrace | ErrorTrackingResolvedStackTrace
module: string
@@ -61,5 +63,43 @@ export interface ErrorTrackingSymbolSet {
failure_reason: string | null
}
interface FingerprintFrame {
type: 'frame'
raw_id: string
pieces: string[]
}
interface FingerprintException {
type: 'exception'
id: string // Exception ID
pieces: string[]
}
interface FingerprintManual {
type: 'manual'
}
export type FingerprintRecordPart = FingerprintManual | FingerprintFrame | FingerprintException
export interface ExceptionAttributes {
ingestionErrors?: string[]
runtime: ErrorTrackingRuntime
type?: string
value?: string
synthetic?: boolean
lib?: string
libVersion?: string
browser?: string
browserVersion?: string
os?: string
osVersion?: string
sentryUrl?: string
level?: string
url?: string
handled: boolean
}
export type SymbolSetStatus = 'valid' | 'invalid'
export type SymbolSetStatusFilter = SymbolSetStatus | 'all'
export type ErrorEventProperties = EventType['properties']
export type ErrorEventId = NonNullable<EventType['uuid']>

View File

@@ -1,6 +1,12 @@
import { ExceptionAttributes } from 'scenes/error-tracking/utils'
import { isPostHogProperty } from '~/taxonomy/taxonomy'
import { ErrorTrackingException, ErrorTrackingRuntime } from './types'
import {
ErrorEventProperties,
ErrorTrackingException,
ErrorTrackingRuntime,
ExceptionAttributes,
FingerprintRecordPart,
} from './types'
export function hasStacktrace(exceptionList: ErrorTrackingException[]): boolean {
return exceptionList.length > 0 && exceptionList.some((e) => !!e.stacktrace)
@@ -41,3 +47,86 @@ export function concatValues(
}
return definedKeys.map((key) => attrs[key]).join(' ')
}
export function getExceptionAttributes(properties: Record<string, any>): ExceptionAttributes {
const {
$lib: lib,
$lib_version: libVersion,
$browser: browser,
$browser_version: browserVersion,
$os: os,
$os_version: osVersion,
$sentry_url: sentryUrl,
$level: level,
$cymbal_errors: ingestionErrors,
} = properties
let type = properties.$exception_type
let value = properties.$exception_message
let synthetic: boolean | undefined = properties.$exception_synthetic
const url: string | undefined = properties.$current_url
const exceptionList: ErrorTrackingException[] | undefined = getExceptionList(properties)
if (!type) {
type = exceptionList?.[0]?.type
}
if (!value) {
value = exceptionList?.[0]?.value
}
if (synthetic == undefined) {
synthetic = exceptionList?.[0]?.mechanism?.synthetic
}
const handled = exceptionList?.[0]?.mechanism?.handled ?? false
const runtime: ErrorTrackingRuntime = getRuntimeFromLib(lib)
return {
type,
value,
synthetic,
runtime,
lib,
libVersion,
browser,
browserVersion,
os,
osVersion,
url,
sentryUrl,
handled,
level,
ingestionErrors,
}
}
export function getExceptionList(properties: ErrorEventProperties): ErrorTrackingException[] {
const { $sentry_exception } = properties
let exceptionList: ErrorTrackingException[] | undefined = properties.$exception_list
// exception autocapture sets $exception_list for all exceptions.
// If it's not present, then this is probably a sentry exception. Get this list from the sentry_exception
if (!exceptionList?.length && $sentry_exception) {
if (Array.isArray($sentry_exception.values)) {
exceptionList = $sentry_exception.values
}
}
return exceptionList || []
}
export function getFingerprintRecords(properties: ErrorEventProperties): FingerprintRecordPart[] {
const { $exception_fingerprint_record } = properties
return $exception_fingerprint_record || []
}
export function getAdditionalProperties(
properties: ErrorEventProperties,
isCloudOrDev: boolean | undefined
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(properties).filter(([key]) => {
return !isPostHogProperty(key, isCloudOrDev)
})
)
}
export function getSessionId(properties: ErrorEventProperties): string | undefined {
return properties['$session_id'] as string | undefined
}

View File

@@ -142,7 +142,11 @@ export function EventDetails({ event, tableProps }: EventDetailsProps): JSX.Elem
label: 'Exception',
content: (
<div className="mx-3">
<ErrorDisplay eventProperties={event.properties} />
<ErrorDisplay
eventProperties={event.properties}
// fallback on timestamp as uuid is optional
eventId={event.uuid ?? event.timestamp ?? 'error'}
/>
</div>
),
})

View File

@@ -10,7 +10,7 @@ import { ErrorTrackingIssue } from '~/queries/schema/schema-general'
import { AssigneeIconDisplay, AssigneeLabelDisplay } from './components/Assignee/AssigneeDisplay'
import { AssigneeSelect } from './components/Assignee/AssigneeSelect'
import { IssueCard } from './components/IssueCard'
import { ExceptionCard } from './components/ExceptionCard'
import { DateRangeFilter, FilterGroup, InternalAccountsFilter } from './ErrorTrackingFilters'
import { errorTrackingIssueSceneLogic } from './errorTrackingIssueSceneLogic'
import { ErrorTrackingSetupPrompt } from './ErrorTrackingSetupPrompt'
@@ -37,7 +37,7 @@ export const STATUS_LABEL: Record<ErrorTrackingIssue['status'], string> = {
}
export function ErrorTrackingIssueScene(): JSX.Element {
const { issue, issueLoading } = useValues(errorTrackingIssueSceneLogic)
const { issue, issueLoading, properties, propertiesLoading } = useValues(errorTrackingIssueSceneLogic)
const { loadIssue, updateStatus, updateAssignee } = useActions(errorTrackingIssueSceneLogic)
useEffect(() => {
@@ -77,7 +77,12 @@ export function ErrorTrackingIssueScene(): JSX.Element {
}
/>
<div className="ErrorTrackingIssue space-y-2">
<IssueCard />
<ExceptionCard
issue={issue ?? undefined}
issueLoading={issueLoading}
properties={properties ?? undefined}
propertiesLoading={propertiesLoading}
/>
<div className="flex items-center gap-2 p-0 bg-transparent">
<div className="h-full flex items-center justify-center w-full gap-2">
<DateRangeFilter />

View File

@@ -1,17 +0,0 @@
import javascript_empty from './javascript_empty.json'
import javascript_resolved from './javascript_resolved.json'
import javascript_unresolved from './javascript_unresolved.json'
import node_unresolved from './node_unresolved.json'
import python_multierror from './python_multierror.json'
import python_resolved from './python_resolved.json'
export const TEST_EVENTS = {
javascript_empty,
javascript_resolved,
javascript_unresolved,
node_unresolved,
python_resolved,
python_multierror,
}
export type TestEventNames = keyof typeof TEST_EVENTS

View File

@@ -0,0 +1,45 @@
import { BindLogic } from 'kea'
import { errorPropertiesLogic } from 'lib/components/Errors/errorPropertiesLogic'
import { ErrorEventProperties } from 'lib/components/Errors/types'
import { exceptionCardLogic } from 'scenes/error-tracking/components/ExceptionCard/exceptionCardLogic'
import javascript_empty from './javascript_empty.json'
import javascript_resolved from './javascript_resolved.json'
import javascript_unresolved from './javascript_unresolved.json'
import node_unresolved from './node_unresolved.json'
import python_multierror from './python_multierror.json'
import python_resolved from './python_resolved.json'
export const TEST_EVENTS = {
javascript_empty,
javascript_resolved,
javascript_unresolved,
node_unresolved,
python_resolved,
python_multierror,
}
export type TestEventName = keyof typeof TEST_EVENTS
export function getEventProperties(eventName: TestEventName): ErrorEventProperties {
return TEST_EVENTS[eventName].properties
}
export function ExceptionLogicWrapper({
eventName,
loading = false,
children,
}: {
eventName: TestEventName
loading?: boolean
children: JSX.Element
}): JSX.Element {
const properties = getEventProperties(eventName)
return (
<BindLogic logic={exceptionCardLogic} props={{ loading }}>
<BindLogic logic={errorPropertiesLogic} props={{ properties: properties, id: eventName }}>
{children}
</BindLogic>
</BindLogic>
)
}

View File

@@ -1,7 +1,7 @@
import { LemonCard } from '@posthog/lemon-ui'
import { Meta } from '@storybook/react'
import { TEST_EVENTS, TestEventNames } from 'scenes/error-tracking/__mocks__/events'
import { getAdditionalProperties, getExceptionAttributes } from 'scenes/error-tracking/utils'
import { getAdditionalProperties, getExceptionAttributes } from 'lib/components/Errors/utils'
import { TEST_EVENTS, TestEventName } from 'scenes/error-tracking/__mocks__/events'
import { ContextDisplay, ContextDisplayProps } from './ContextDisplay'
@@ -27,7 +27,7 @@ export default meta
///////////////////// Context Display
export function ContextDisplayEmpty(): JSX.Element {
return <ContextDisplay attributes={null} additionalProperties={{}} loading={false} />
return <ContextDisplay loading={false} />
}
export function ContextDisplayWithStacktrace(): JSX.Element {
@@ -36,7 +36,7 @@ export function ContextDisplayWithStacktrace(): JSX.Element {
//////////////////// Utils
function getProps(event_name: TestEventNames | null, overrideProps: Record<string, unknown> = {}): ContextDisplayProps {
function getProps(event_name: TestEventName, overrideProps: Record<string, unknown> = {}): ContextDisplayProps {
const properties = event_name ? TEST_EVENTS[event_name].properties : null
const attributes = properties ? getExceptionAttributes(properties) : null
const additionalProperties = properties ? getAdditionalProperties(properties, true) : {}
@@ -49,10 +49,10 @@ function getProps(event_name: TestEventNames | null, overrideProps: Record<strin
}
function ContextWrapperAllEvents({ children }: { children: (props: ContextDisplayProps) => JSX.Element }): JSX.Element {
const eventNames = Object.keys(TEST_EVENTS) as TestEventNames[]
const eventNames = Object.keys(TEST_EVENTS) as TestEventName[]
return (
<div className="space-y-4">
{eventNames.map((name: TestEventNames) => {
{eventNames.map((name: TestEventName) => {
const props = getProps(name)
return (
<div className="px-3 py-2" key={name}>

View File

@@ -1,27 +1,27 @@
import { IconCopy } from '@posthog/icons'
import { LemonButton, Spinner } from '@posthog/lemon-ui'
import { ExceptionAttributes } from 'lib/components/Errors/types'
import { concatValues } from 'lib/components/Errors/utils'
import useIsHovering from 'lib/hooks/useIsHovering'
import { identifierToHuman } from 'lib/utils'
import { copyToClipboard } from 'lib/utils/copyToClipboard'
import { cn } from 'lib/utils/css-classes'
import { Properties } from 'posthog-js'
import { useRef } from 'react'
import { match } from 'ts-pattern'
import { cancelEvent, ExceptionAttributes } from '../utils'
import { cancelEvent } from '../utils'
export interface ContextDisplayProps {
className?: string
attributes: ExceptionAttributes | null
additionalProperties: Properties
attributes?: ExceptionAttributes
additionalProperties?: Record<string, unknown>
loading: boolean
}
export function ContextDisplay({
className,
attributes,
additionalProperties,
additionalProperties = {},
loading,
}: ContextDisplayProps): JSX.Element {
return (

View File

@@ -1,45 +0,0 @@
import { IconBug } from '@posthog/icons'
import { LemonTag } from '@posthog/lemon-ui'
import { PropertyIcon } from 'lib/components/PropertyIcon/PropertyIcon'
import { Children } from 'react'
import { ExceptionAttributes } from 'scenes/error-tracking/utils'
export interface ExceptionAttributesIconListProps {
attributes: ExceptionAttributes
}
export function ExceptionAttributesIconList({ attributes }: ExceptionAttributesIconListProps): JSX.Element {
return (
<div className="flex items-center gap-2">
<PropertyWrapper title="Unhandled" visible={!attributes.handled}>
<IconBug className="text-sm text-secondary" />
</PropertyWrapper>
<PropertyWrapper title={attributes.browser} visible={!!attributes.browser}>
<PropertyIcon property="$browser" value={attributes.browser} className="text-sm text-secondary" />
</PropertyWrapper>
<PropertyWrapper title={attributes.os} visible={!!attributes.os}>
<PropertyIcon property="$os" value={attributes.os} className="text-sm text-secondary" />
</PropertyWrapper>
</div>
)
}
export function PropertyWrapper({
title,
visible = true,
children,
}: {
title?: string
visible?: boolean
children: JSX.Element
}): JSX.Element {
if (Children.count(children) == 0 || title === undefined || !visible) {
return <></>
}
return (
<LemonTag>
{children}
<span className="capitalize">{title}</span>
</LemonTag>
)
}

View File

@@ -0,0 +1,71 @@
import { IconBug } from '@posthog/icons'
import { LemonTag, Spinner } from '@posthog/lemon-ui'
import { ExceptionAttributes } from 'lib/components/Errors/types'
import { PropertyIcon } from 'lib/components/PropertyIcon/PropertyIcon'
import { Children } from 'react'
import { match } from 'ts-pattern'
export interface ExceptionAttributesPreviewProps {
attributes: ExceptionAttributes | null
loading: boolean
}
export function ExceptionAttributesPreview({ attributes, loading }: ExceptionAttributesPreviewProps): JSX.Element {
return (
<span className="flex items-center gap-1 text-muted group-hover:text-brand-red">
{match(loading)
.with(true, () => (
<span className="text-muted space-x-2 text-xs">
<Spinner />
<span>Loading details...</span>
</span>
))
.with(
false,
() =>
attributes && (
<div className="flex items-center gap-2">
<PropertyWrapper title="Unhandled" visible={!attributes.handled}>
<IconBug className="text-sm text-secondary" />
</PropertyWrapper>
<PropertyWrapper title={attributes.browser} visible={!!attributes.browser}>
<PropertyIcon
property="$browser"
value={attributes.browser}
className="text-sm text-secondary"
/>
</PropertyWrapper>
<PropertyWrapper title={attributes.os} visible={!!attributes.os}>
<PropertyIcon
property="$os"
value={attributes.os}
className="text-sm text-secondary"
/>
</PropertyWrapper>
</div>
)
)
.exhaustive()}
</span>
)
}
export function PropertyWrapper({
title,
visible = true,
children,
}: {
title?: string
visible?: boolean
children: JSX.Element
}): JSX.Element {
if (Children.count(children) == 0 || title === undefined || !visible) {
return <></>
}
return (
<LemonTag>
{children}
<span className="capitalize">{title}</span>
</LemonTag>
)
}

View File

@@ -0,0 +1 @@
export * from './ExceptionAttributesPreview'

View File

@@ -0,0 +1,163 @@
import { IconBox, IconDocument, IconList } from '@posthog/icons'
import { LemonCard } from '@posthog/lemon-ui'
import { BindLogic, useActions, useValues } from 'kea'
import { errorPropertiesLogic, ErrorPropertiesLogicProps } from 'lib/components/Errors/errorPropertiesLogic'
import { ErrorEventProperties } from 'lib/components/Errors/types'
import { TZLabel } from 'lib/components/TZLabel'
import ViewRecordingButton, { mightHaveRecording } from 'lib/components/ViewRecordingButton/ViewRecordingButton'
import { IconSubtitles, IconSubtitlesOff } from 'lib/lemon-ui/icons'
import { ButtonGroupPrimitive } from 'lib/ui/Button/ButtonPrimitives'
import { cn } from 'lib/utils/css-classes'
import { ErrorTrackingRelationalIssue } from '~/queries/schema/schema-general'
import { Collapsible } from '../Collapsible'
import { ContextDisplay } from '../ContextDisplay'
import { ExceptionAttributesPreview } from '../ExceptionAttributesPreview'
import { ToggleButtonPrimitive } from '../ToggleButton/ToggleButton'
import { exceptionCardLogic } from './exceptionCardLogic'
import { StacktraceBaseDisplayProps, StacktraceEmptyDisplay } from './Stacktrace/StacktraceBase'
import { StacktraceGenericDisplay } from './Stacktrace/StacktraceGenericDisplay'
import { StacktraceTextDisplay } from './Stacktrace/StacktraceTextDisplay'
interface ExceptionCardContentProps {
issue?: ErrorTrackingRelationalIssue
issueLoading: boolean
}
export interface ExceptionCardProps extends ExceptionCardContentProps {
properties?: ErrorEventProperties
propertiesLoading: boolean
}
export function ExceptionCard({ issue, issueLoading, properties, propertiesLoading }: ExceptionCardProps): JSX.Element {
return (
<BindLogic logic={exceptionCardLogic} props={{ loading: propertiesLoading }}>
<BindLogic
logic={errorPropertiesLogic}
props={{ properties, id: issue?.id ?? 'error' } as ErrorPropertiesLogicProps}
>
<ExceptionCardContent issue={issue} issueLoading={issueLoading} />
</BindLogic>
</BindLogic>
)
}
function ExceptionCardContent({ issue, issueLoading }: ExceptionCardContentProps): JSX.Element {
const { loading, showContext, isExpanded } = useValues(exceptionCardLogic)
const { properties, exceptionAttributes, additionalProperties, timestamp, sessionId } =
useValues(errorPropertiesLogic)
return (
<LemonCard hoverEffect={false} className="group py-2 px-3 relative overflow-hidden">
<Collapsible isExpanded={isExpanded} className="pb-1 flex w-full" minHeight="calc(var(--spacing) * 12)">
<StacktraceIssueDisplay
className={cn('flex-grow', showContext && isExpanded ? 'w-2/3' : 'w-full')}
truncateMessage={!isExpanded}
issue={issue ?? undefined}
issueLoading={issueLoading}
/>
<ContextDisplay
className={cn(showContext && isExpanded ? 'w-1/3 pl-2' : 'w-0')}
attributes={exceptionAttributes ?? undefined}
additionalProperties={additionalProperties}
loading={loading}
/>
</Collapsible>
<ExceptionCardActions className="absolute top-2 right-3 flex gap-2 items-center z-10">
<ExceptionCardToggles />
</ExceptionCardActions>
<div className="flex justify-between items-center pt-1">
<ExceptionAttributesPreview attributes={exceptionAttributes} loading={loading} />
<ExceptionCardActions>
{timestamp && <TZLabel className="text-muted text-xs" time={timestamp} />}
<ViewRecordingButton
sessionId={sessionId}
timestamp={timestamp ?? undefined}
loading={loading}
inModal={true}
size="xsmall"
type="secondary"
disabledReason={mightHaveRecording(properties || {}) ? undefined : 'No recording available'}
/>
</ExceptionCardActions>
</div>
</LemonCard>
)
}
function ExceptionCardToggles(): JSX.Element {
const { showDetails, showAllFrames, showContext, showAsText } = useValues(exceptionCardLogic)
const { setShowDetails, setShowAllFrames, setShowContext, setShowAsText } = useActions(exceptionCardLogic)
return (
<ButtonGroupPrimitive size="sm">
<ToggleButtonPrimitive className="px-2" checked={showDetails} onCheckedChange={setShowDetails}>
{showDetails ? (
<>
<IconSubtitlesOff />
Hide details
</>
) : (
<>
<IconSubtitles />
Show details
</>
)}
</ToggleButtonPrimitive>
<ToggleButtonPrimitive iconOnly checked={showAsText} onCheckedChange={setShowAsText} tooltip="Show as text">
<IconDocument />
</ToggleButtonPrimitive>
<ToggleButtonPrimitive
iconOnly
checked={showAllFrames}
onCheckedChange={setShowAllFrames}
tooltip="Show vendor frames"
>
<IconBox />
</ToggleButtonPrimitive>
<ToggleButtonPrimitive
iconOnly
checked={showContext}
onCheckedChange={setShowContext}
tooltip="Show context"
>
<IconList />
</ToggleButtonPrimitive>
</ButtonGroupPrimitive>
)
}
function ExceptionCardActions({ children, className }: { children: React.ReactNode; className?: string }): JSX.Element {
return (
<div
className={cn('flex justify-between items-center gap-1 bg-surface-primary', className)}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
)
}
function StacktraceIssueDisplay({
issue,
issueLoading,
...stacktraceDisplayProps
}: {
issue?: ErrorTrackingRelationalIssue
issueLoading: boolean
} & Omit<StacktraceBaseDisplayProps, 'renderLoading' | 'renderEmpty'>): JSX.Element {
const { showAsText } = useValues(exceptionCardLogic)
const Component = showAsText ? StacktraceTextDisplay : StacktraceGenericDisplay
return (
<Component
{...stacktraceDisplayProps}
renderLoading={(renderHeader) =>
renderHeader({
type: issue?.name ?? undefined,
value: issue?.description ?? undefined,
loading: issueLoading,
})
}
renderEmpty={() => <StacktraceEmptyDisplay />}
/>
)
}

View File

@@ -1,21 +1,13 @@
import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage'
import { FingerprintRecordPart } from 'lib/components/Errors/stackFrameLogic'
import { ExceptionHeaderProps } from 'lib/components/Errors/StackTraces'
import { ErrorTrackingRuntime } from 'lib/components/Errors/types'
import { ExceptionAttributes } from 'scenes/error-tracking/utils'
import { ErrorTrackingRuntime, FingerprintRecordPart } from 'lib/components/Errors/types'
export type HeaderRenderer = (props: ExceptionHeaderProps) => JSX.Element
export interface StacktraceBaseDisplayProps {
className?: string
truncateMessage: boolean
loading: boolean
renderLoading: (renderHeader: HeaderRenderer) => JSX.Element
renderEmpty: () => JSX.Element
attributes: ExceptionAttributes | null
showAllFrames: boolean
}
export interface StacktraceBaseExceptionHeaderProps {

View File

@@ -1,7 +1,6 @@
import { LemonCard } from '@posthog/lemon-ui'
import { Meta } from '@storybook/react'
import { TEST_EVENTS, TestEventNames } from 'scenes/error-tracking/__mocks__/events'
import { getExceptionAttributes } from 'scenes/error-tracking/utils'
import { ExceptionLogicWrapper, TEST_EVENTS, TestEventName } from 'scenes/error-tracking/__mocks__/events'
import { sceneLogic } from 'scenes/sceneLogic'
import { mswDecorator } from '~/mocks/browser'
@@ -28,7 +27,7 @@ const meta: Meta = {
},
mswDecorator({
post: {
'api/environments/:team_id/error_tracking/stack_frames/batch_get/': require('../../__mocks__/stack_frames/batch_get'),
'api/environments/:team_id/error_tracking/stack_frames/batch_get/': require('../../../__mocks__/stack_frames/batch_get'),
},
}),
],
@@ -39,57 +38,59 @@ export default meta
////////////////////// Generic stacktraces
export function GenericDisplayPropertiesLoading(): JSX.Element {
const props = defaultBaseProps(
'python_resolved',
{
loading: true,
},
false
const props = defaultBaseProps({}, false)
return (
<ExceptionLogicWrapper eventName="python_resolved" loading={true}>
<StacktraceGenericDisplay {...props} />
</ExceptionLogicWrapper>
)
return <StacktraceGenericDisplay {...props} />
}
export function GenericDisplayEmpty(): JSX.Element {
const props = defaultBaseProps(
'javascript_empty',
{
loading: false,
},
false
const props = defaultBaseProps({}, false)
return (
<ExceptionLogicWrapper eventName="javascript_empty">
<StacktraceGenericDisplay {...props} />
</ExceptionLogicWrapper>
)
return <StacktraceGenericDisplay {...props} />
}
export function GenericDisplayWithStacktrace(): JSX.Element {
return <StacktraceWrapperAllEvents>{(props) => <StacktraceGenericDisplay {...props} />}</StacktraceWrapperAllEvents>
const props = defaultBaseProps({}, false)
return (
<StacktraceWrapperAllEvents>
<StacktraceGenericDisplay {...props} />
</StacktraceWrapperAllEvents>
)
}
///////////////////// Text stacktraces
export function TextDisplayEmpty(): JSX.Element {
const props = defaultBaseProps(
'javascript_empty',
{
loading: false,
},
false
const props = defaultBaseProps({}, false)
return (
<ExceptionLogicWrapper eventName="javascript_empty">
<StacktraceTextDisplay {...props} />
</ExceptionLogicWrapper>
)
return <StacktraceTextDisplay {...props} />
}
export function TextDisplayPropertiesLoading(): JSX.Element {
const props = defaultBaseProps(
'javascript_resolved',
{
loading: true,
},
false
const props = defaultBaseProps({}, false)
return (
<ExceptionLogicWrapper eventName="javascript_resolved">
<StacktraceTextDisplay {...props} />
</ExceptionLogicWrapper>
)
return <StacktraceTextDisplay {...props} />
}
export function TextDisplayWithStacktrace(): JSX.Element {
return <StacktraceWrapperAllEvents>{(props) => <StacktraceTextDisplay {...props} />}</StacktraceWrapperAllEvents>
const props = defaultBaseProps({}, false)
return (
<StacktraceWrapperAllEvents>
<StacktraceTextDisplay {...props} />
</StacktraceWrapperAllEvents>
)
}
//////////////////// Utils
@@ -104,16 +105,11 @@ const issue = {
} as ErrorTrackingRelationalIssue
function defaultBaseProps(
event_name: TestEventNames | null,
overrideProps: Partial<StacktraceBaseDisplayProps> = {},
issueLoading: boolean = false
): StacktraceBaseDisplayProps {
const attributes = event_name ? getExceptionAttributes(TEST_EVENTS[event_name].properties) : null
return {
loading: false,
showAllFrames: true,
truncateMessage: true,
attributes,
renderLoading: (renderHeader: HeaderRenderer) =>
renderHeader({
type: issue?.name ?? undefined,
@@ -125,29 +121,17 @@ function defaultBaseProps(
} as StacktraceBaseDisplayProps
}
function StacktraceWrapperAllEvents({
children,
}: {
children: (props: StacktraceBaseDisplayProps) => JSX.Element
}): JSX.Element {
const eventNames = Object.keys(TEST_EVENTS) as TestEventNames[]
function getProps(eventName: TestEventNames): StacktraceBaseDisplayProps {
return defaultBaseProps(
eventName,
{
loading: false,
},
false
)
}
function StacktraceWrapperAllEvents({ children }: { children: JSX.Element }): JSX.Element {
const eventNames = Object.keys(TEST_EVENTS) as TestEventName[]
return (
<div className="space-y-4">
{eventNames.map((name: TestEventNames) => {
const props = getProps(name)
{eventNames.map((name: TestEventName) => {
return (
<LemonCard className="px-3 py-2" key={name}>
{children(props)}
</LemonCard>
<ExceptionLogicWrapper key={name} eventName={name}>
<LemonCard hoverEffect={false} className="px-3 py-2">
{children}
</LemonCard>
</ExceptionLogicWrapper>
)
})}
</div>

View File

@@ -1,24 +1,25 @@
import { LemonSkeleton } from '@posthog/lemon-ui'
import { useValues } from 'kea'
import { errorPropertiesLogic } from 'lib/components/Errors/errorPropertiesLogic'
import { FingerprintRecordPartDisplay } from 'lib/components/Errors/FingerprintRecordPartDisplay'
import { ChainedStackTraces, ExceptionHeaderProps } from 'lib/components/Errors/StackTraces'
import { hasStacktrace } from 'lib/components/Errors/utils'
import { cn } from 'lib/utils/css-classes'
import { useCallback } from 'react'
import { cancelEvent } from '../../utils'
import { RuntimeIcon } from '../RuntimeIcon'
import { cancelEvent } from '../../../utils'
import { RuntimeIcon } from '../../RuntimeIcon'
import { exceptionCardLogic } from '../exceptionCardLogic'
import { StacktraceBaseDisplayProps, StacktraceBaseExceptionHeaderProps } from './StacktraceBase'
export function StacktraceGenericDisplay({
className,
truncateMessage,
loading,
renderLoading,
renderEmpty,
showAllFrames,
attributes,
}: StacktraceBaseDisplayProps): JSX.Element {
const { runtime, exceptionList, fingerprintRecords } = attributes || {}
const { exceptionAttributes, hasStacktrace } = useValues(errorPropertiesLogic)
const { loading, showAllFrames } = useValues(exceptionCardLogic)
const { runtime } = exceptionAttributes || {}
const renderExceptionHeader = useCallback(
({ type, value, loading, part }: ExceptionHeaderProps): JSX.Element => {
return (
@@ -34,21 +35,18 @@ export function StacktraceGenericDisplay({
},
[runtime, truncateMessage]
)
const isEmpty = !hasStacktrace(exceptionList || [])
return (
<div className={className}>
{loading
? renderLoading(renderExceptionHeader)
: exceptionList && (
<ChainedStackTraces
showAllFrames={showAllFrames}
exceptionList={exceptionList}
renderExceptionHeader={renderExceptionHeader}
fingerprintRecords={fingerprintRecords}
onFrameContextClick={(_, e) => cancelEvent(e)}
/>
)}
{!loading && isEmpty && renderEmpty()}
{loading ? (
renderLoading(renderExceptionHeader)
) : (
<ChainedStackTraces
showAllFrames={showAllFrames}
renderExceptionHeader={renderExceptionHeader}
onFrameContextClick={(_, e) => cancelEvent(e)}
/>
)}
{!loading && !hasStacktrace && renderEmpty()}
</div>
)
}

View File

@@ -1,24 +1,23 @@
import { LemonSkeleton } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { errorPropertiesLogic } from 'lib/components/Errors/errorPropertiesLogic'
import { stackFrameLogic } from 'lib/components/Errors/stackFrameLogic'
import { ExceptionHeaderProps } from 'lib/components/Errors/StackTraces'
import { ErrorTrackingException, ErrorTrackingStackFrame } from 'lib/components/Errors/types'
import { hasStacktrace } from 'lib/components/Errors/utils'
import { cn } from 'lib/utils/css-classes'
import { useCallback, useEffect } from 'react'
import { exceptionCardLogic } from '../exceptionCardLogic'
import { StacktraceBaseDisplayProps, StacktraceBaseExceptionHeaderProps } from './StacktraceBase'
export function StacktraceTextDisplay({
className,
attributes,
renderLoading,
renderEmpty,
truncateMessage,
loading,
}: StacktraceBaseDisplayProps): JSX.Element {
const { exceptionList } = attributes || {}
const isEmpty = !hasStacktrace(exceptionList || [])
const { exceptionList, hasStacktrace } = useValues(errorPropertiesLogic)
const { loading } = useValues(exceptionCardLogic)
const renderExceptionHeader = useCallback(
({ type, value, loading, part }: ExceptionHeaderProps): JSX.Element => {
return (
@@ -37,9 +36,10 @@ export function StacktraceTextDisplay({
<div className={className}>
{loading
? renderLoading(renderExceptionHeader)
: exceptionList &&
exceptionList.map((exception) => <ExceptionTextDisplay key={exception.id} exception={exception} />)}
{!loading && isEmpty && renderEmpty()}
: exceptionList.map((exception: ErrorTrackingException) => (
<ExceptionTextDisplay key={exception.id} exception={exception} />
))}
{!loading && !hasStacktrace && renderEmpty()}
</div>
)
}
@@ -65,7 +65,7 @@ export function StacktraceTextExceptionHeader({
}
function ExceptionTextDisplay({ exception }: { exception: ErrorTrackingException }): JSX.Element {
const { showAllFrames } = useValues(stackFrameLogic)
const { showAllFrames } = useValues(exceptionCardLogic)
return (
<div>
<p className="font-mono mb-0 font-bold line-clamp-1">

View File

@@ -0,0 +1,65 @@
import { actions, kea, listeners, path, props, reducers, selectors } from 'kea'
import type { exceptionCardLogicType } from './exceptionCardLogicType'
export interface ExceptionCardLogicProps {
loading: boolean
}
export const exceptionCardLogic = kea<exceptionCardLogicType>([
path(() => ['scenes', 'error-tracking', 'exceptionCardLogic']),
props({} as ExceptionCardLogicProps),
actions({
setShowDetails: (showDetails: boolean) => ({ showDetails }),
setShowAsText: (showAsText: boolean) => ({ showAsText }),
setShowContext: (showContext: boolean) => ({ showContext }),
setShowAllFrames: (showAllFrames: boolean) => ({ showAllFrames }),
}),
reducers({
showDetails: [
false,
{ persist: true },
{
setShowDetails: (_, { showDetails }: { showDetails: boolean }) => showDetails,
},
],
showAsText: [
false,
{ persist: true },
{
setShowAsText: (_, { showAsText }: { showAsText: boolean }) => showAsText,
},
],
showAllFrames: [
false,
{ persist: true },
{
setShowAllFrames: (_, { showAllFrames }: { showAllFrames: boolean }) => showAllFrames,
},
],
showContext: [
true,
{ persist: true },
{
setShowContext: (_, { showContext }: { showContext: boolean }) => showContext,
},
],
}),
selectors({
loading: [() => [(_, props) => props.loading], (loading: boolean) => loading],
isExpanded: [
(s) => [s.showDetails, s.loading],
(showDetails: boolean, loading: boolean) => showDetails && !loading,
],
}),
listeners(({ actions }) => {
return {
setShowContext: () => actions.setShowDetails(true),
setShowAllFrames: () => actions.setShowDetails(true),
}
}),
])

View File

@@ -0,0 +1 @@
export * from './ExceptionCard'

View File

@@ -1,198 +0,0 @@
import { IconBox, IconDocument, IconList } from '@posthog/icons'
import { LemonCard, Spinner } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { TZLabel } from 'lib/components/TZLabel'
import ViewRecordingButton, { mightHaveRecording } from 'lib/components/ViewRecordingButton/ViewRecordingButton'
import { IconSubtitles, IconSubtitlesOff } from 'lib/lemon-ui/icons'
import { ButtonGroupPrimitive } from 'lib/ui/Button/ButtonPrimitives'
import { cn } from 'lib/utils/css-classes'
import { match } from 'ts-pattern'
import { ErrorTrackingRelationalIssue } from '~/queries/schema/schema-general'
import { errorTrackingIssueSceneLogic } from '../errorTrackingIssueSceneLogic'
import { Collapsible } from './Collapsible'
import { ContextDisplay } from './ContextDisplay'
import { ExceptionAttributesIconList } from './ExceptionAttributes/ExceptionAttributesIconList'
import { StacktraceBaseDisplayProps, StacktraceEmptyDisplay } from './Stacktrace/StacktraceBase'
import { StacktraceGenericDisplay } from './Stacktrace/StacktraceGenericDisplay'
import { StacktraceTextDisplay } from './Stacktrace/StacktraceTextDisplay'
import { ToggleButtonPrimitive } from './ToggleButton/ToggleButton'
export function IssueCard(): JSX.Element {
const {
propertiesLoading,
firstSeen,
issueLoading,
properties,
exceptionAttributes,
additionalProperties,
issue,
sessionId,
showStacktrace,
showAllFrames,
showContext,
showAsText,
} = useValues(errorTrackingIssueSceneLogic)
const { setShowStacktrace, setShowAllFrames, setShowContext, setShowAsText } =
useActions(errorTrackingIssueSceneLogic)
const stacktraceDisplayProps = {
className: cn('flex-grow', showContext ? 'w-2/3' : 'w-full'),
truncateMessage: !showStacktrace,
attributes: exceptionAttributes,
showAllFrames,
showAsText,
issue,
issueLoading,
loading: propertiesLoading,
}
const contextDisplayProps = {
className: cn(showContext && showStacktrace ? 'w-1/3 pl-2' : 'w-0'),
attributes: exceptionAttributes,
additionalProperties,
showContext,
loading: propertiesLoading,
}
return (
<LemonCard hoverEffect={false} className="p-0 group p-2 px-3 relative overflow-hidden">
<Collapsible
isExpanded={showStacktrace && !propertiesLoading}
className="pb-1 flex w-full"
minHeight="calc(var(--spacing) * 12)"
>
<StacktraceIssueDisplay {...stacktraceDisplayProps} />
<ContextDisplay {...contextDisplayProps} />
</Collapsible>
<IssueCardActions className="absolute top-2 right-3 flex gap-2 items-center z-10">
<ButtonGroupPrimitive size="sm">
<ToggleButtonPrimitive
className="px-2"
checked={showStacktrace}
onCheckedChange={() => setShowStacktrace(!showStacktrace)}
>
{match(showStacktrace)
.with(false, () => (
<>
<IconSubtitles />
Show details
</>
))
.with(true, () => (
<>
<IconSubtitlesOff />
Hide details
</>
))
.exhaustive()}
</ToggleButtonPrimitive>
<ToggleButtonPrimitive
iconOnly
checked={showAsText}
onCheckedChange={setShowAsText}
tooltip="Show as text"
>
<IconDocument />
</ToggleButtonPrimitive>
<ToggleButtonPrimitive
iconOnly
checked={showAllFrames}
onCheckedChange={setShowAllFrames}
tooltip="Show vendor frames"
>
<IconBox />
</ToggleButtonPrimitive>
<ToggleButtonPrimitive
iconOnly
checked={showContext}
onCheckedChange={setShowContext}
tooltip="Show context"
>
<IconList />
</ToggleButtonPrimitive>
</ButtonGroupPrimitive>
</IssueCardActions>
<div className="flex justify-between items-center pt-1">
<EventPropertiesPreview />
<IssueCardActions>
{
// We should timestamp from event properties here but for now only first seen event is accessible and data is not available
firstSeen && <TZLabel className="text-muted text-xs" time={firstSeen} />
}
<ViewRecordingButton
sessionId={sessionId}
timestamp={properties.timestamp}
loading={propertiesLoading}
inModal={true}
size="xsmall"
type="secondary"
disabledReason={mightHaveRecording(properties) ? undefined : 'No recording available'}
/>
</IssueCardActions>
</div>
</LemonCard>
)
}
function EventPropertiesPreview(): JSX.Element {
const { exceptionAttributes, propertiesLoading } = useValues(errorTrackingIssueSceneLogic)
return (
<span className="flex items-center gap-1 text-muted group-hover:text-brand-red">
{match(propertiesLoading)
.with(true, () => (
<span className="text-muted space-x-2 text-xs">
<Spinner />
<span>Loading details...</span>
</span>
))
.with(false, () => <ExceptionAttributesIconList attributes={exceptionAttributes!} />)
.exhaustive()}
</span>
)
}
function IssueCardActions({
children,
onlyOnHover = false,
className,
}: {
children: React.ReactNode
onlyOnHover?: boolean
className?: string
}): JSX.Element {
return (
<div
className={cn('flex justify-between items-center gap-1 bg-surface-primary', className, {
'opacity-0 group-hover:opacity-100 transition-opacity': onlyOnHover,
})}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
)
}
function StacktraceIssueDisplay({
showAsText,
issue,
issueLoading,
...stacktraceDisplayProps
}: {
showAsText: boolean
issue: ErrorTrackingRelationalIssue | null
issueLoading: boolean
} & Omit<StacktraceBaseDisplayProps, 'renderLoading' | 'renderEmpty'>): JSX.Element {
const Component = showAsText ? StacktraceTextDisplay : StacktraceGenericDisplay
return (
<Component
{...stacktraceDisplayProps}
renderLoading={(renderHeader) =>
renderHeader({
type: issue?.name ?? undefined,
value: issue?.description ?? undefined,
loading: issueLoading,
})
}
renderEmpty={() => <StacktraceEmptyDisplay />}
/>
)
}

View File

@@ -2,13 +2,10 @@ import { actions, connect, defaults, kea, key, listeners, path, props, reducers,
import { loaders } from 'kea-loaders'
import { actionToUrl, router } from 'kea-router'
import api from 'lib/api'
import { stackFrameLogic } from 'lib/components/Errors/stackFrameLogic'
import { ErrorTrackingException } from 'lib/components/Errors/types'
import { hasStacktrace } from 'lib/components/Errors/utils'
import { ErrorEventProperties } from 'lib/components/Errors/types'
import { Dayjs, dayjs } from 'lib/dayjs'
import { objectsEqual } from 'lib/utils'
import { posthog } from 'posthog-js'
import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic'
import { Params, Scene } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
@@ -25,14 +22,7 @@ import { ActivityScope, Breadcrumb } from '~/types'
import type { errorTrackingIssueSceneLogicType } from './errorTrackingIssueSceneLogicType'
import { errorTrackingLogic } from './errorTrackingLogic'
import { errorTrackingIssueEventsQuery, errorTrackingIssueQuery } from './queries'
import {
defaultSearchParams,
ExceptionAttributes,
getAdditionalProperties,
getExceptionAttributes,
getSessionId,
resolveDateRange,
} from './utils'
import { defaultSearchParams, resolveDateRange } from './utils'
export interface ErrorTrackingIssueSceneLogicProps {
id: ErrorTrackingIssue['id']
@@ -47,27 +37,8 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
key((props) => props.id),
connect(() => ({
values: [
errorTrackingLogic,
['dateRange', 'filterTestAccounts', 'filterGroup', 'searchQuery', 'showStacktrace', 'showContext'],
stackFrameLogic,
['frameOrderReversed', 'showAllFrames'],
preflightLogic,
['isCloudOrDev'],
],
actions: [
errorTrackingLogic,
[
'setDateRange',
'setFilterTestAccounts',
'setFilterGroup',
'setSearchQuery',
'setShowStacktrace',
'setShowContext',
],
stackFrameLogic,
['setFrameOrderReversed', 'setShowAllFrames'],
],
values: [errorTrackingLogic, ['dateRange', 'filterTestAccounts', 'filterGroup', 'searchQuery']],
actions: [errorTrackingLogic, ['setDateRange', 'setFilterTestAccounts', 'setFilterGroup', 'setSearchQuery']],
})),
actions({
@@ -78,16 +49,14 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
updateStatus: (status: ErrorTrackingIssueStatus) => ({ status }),
updateAssignee: (assignee: ErrorTrackingIssueAssignee | null) => ({ assignee }),
setLastSeen: (lastSeen: Dayjs) => ({ lastSeen }),
setShowAsText: (showAsText: boolean) => ({ showAsText }),
}),
defaults({
issue: null as ErrorTrackingRelationalIssue | null,
properties: {} as Record<string, string>,
summary: null as ErrorTrackingIssueSummary | null,
properties: null as ErrorEventProperties | null,
volumeResolution: 50,
lastSeen: null as Dayjs | null,
showAsText: false as boolean,
}),
reducers({
@@ -101,7 +70,6 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
},
},
summary: {},
properties: {},
volumeResolution: {
setVolumeResolution: (_, { volumeResolution }: { volumeResolution: number }) => volumeResolution,
},
@@ -113,9 +81,6 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
return prevLastSeen
},
},
showAsText: {
setShowAsText: (_, { showAsText }: { showAsText: boolean }) => showAsText,
},
}),
selectors({
@@ -165,33 +130,6 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
],
aggregations: [(s) => [s.summary], (summary: ErrorTrackingIssueSummary | null) => summary?.aggregations],
exceptionAttributes: [
(s) => [s.properties],
(properties: Record<string, string>) => (properties ? getExceptionAttributes(properties) : null),
],
additionalProperties: [
(s) => [s.properties, s.isCloudOrDev],
(properties: Record<string, string>, isCloudOrDev: boolean | undefined) =>
properties ? getAdditionalProperties(properties, isCloudOrDev) : {},
],
exceptionList: [
(s) => [s.exceptionAttributes, s.frameOrderReversed],
(attributes: ExceptionAttributes | null, orderReversed: boolean) => {
if (!attributes || !attributes.exceptionList) {
return []
}
return applyFrameOrder(attributes.exceptionList, orderReversed)
},
],
fingerprintRecords: [
(s) => [s.exceptionAttributes],
(attributes: ExceptionAttributes | null) => attributes?.fingerprintRecords,
],
hasStacktrace: [(s) => [s.exceptionList], (excList: ErrorTrackingException[]) => hasStacktrace(excList)],
sessionId: [
(s) => [s.properties],
(properties: Record<string, string> | null) => (properties ? getSessionId(properties) : undefined),
],
}),
loaders(({ values, actions, props }) => ({
@@ -264,8 +202,6 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
posthog.capture('error_tracking_issue_assigned', { issue_id: props.id })
await api.errorTracking.assignIssue(props.id, assignee)
},
setShowContext: () => actions.setShowStacktrace(true),
setShowAllFrames: () => actions.setShowStacktrace(true),
}
}),
@@ -309,32 +245,11 @@ export const errorTrackingIssueSceneLogic = kea<errorTrackingIssueSceneLogicType
function getPropertiesDateRange(issue: ErrorTrackingRelationalIssue): DateRange {
const firstSeen = dayjs(issue.first_seen)
return {
date_from: firstSeen.startOf('minute').toISOString(),
date_to: firstSeen.endOf('minute').toISOString(),
date_from: firstSeen.subtract(1, 'hour').toISOString(),
date_to: firstSeen.add(1, 'hour').toISOString(),
}
}
function applyFrameOrder(
exceptionList: ErrorTrackingException[],
frameOrderReversed: boolean
): ErrorTrackingException[] {
if (frameOrderReversed) {
return exceptionList
.map((exception) => {
const copiedException = { ...exception }
if (copiedException.stacktrace) {
copiedException.stacktrace = {
...copiedException.stacktrace,
frames: copiedException.stacktrace.frames.slice().reverse(),
}
}
return copiedException
})
.reverse()
}
return [...exceptionList]
}
export type ErrorTrackingIssueSummary = {
aggregations: ErrorTrackingIssueAggregations
}

View File

@@ -27,8 +27,6 @@ export const errorTrackingLogic = kea<errorTrackingLogicType>([
setSearchQuery: (searchQuery: string) => ({ searchQuery }),
setFilterGroup: (filterGroup: UniversalFiltersGroup) => ({ filterGroup }),
setFilterTestAccounts: (filterTestAccounts: boolean) => ({ filterTestAccounts }),
setShowStacktrace: (showStacktrace: boolean) => ({ showStacktrace }),
setShowContext: (showContext: boolean) => ({ showContext }),
}),
reducers({
dateRange: [
@@ -64,20 +62,6 @@ export const errorTrackingLogic = kea<errorTrackingLogicType>([
setSearchQuery: (_, { searchQuery }) => searchQuery,
},
],
showStacktrace: [
true,
{ persist: true },
{
setShowStacktrace: (_, { showStacktrace }: { showStacktrace: boolean }) => showStacktrace,
},
],
showContext: [
true,
{ persist: true },
{
setShowContext: (_, { showContext }: { showContext: boolean }) => showContext,
},
],
}),
loaders({
hasSentExceptionEvent: {

View File

@@ -1,14 +1,10 @@
import { FingerprintRecordPart } from 'lib/components/Errors/stackFrameLogic'
import { ErrorTrackingException, ErrorTrackingRuntime } from 'lib/components/Errors/types'
import { getRuntimeFromLib } from 'lib/components/Errors/utils'
import { ErrorTrackingException } from 'lib/components/Errors/types'
import { Dayjs, dayjs } from 'lib/dayjs'
import { componentsToDayJs, dateStringToComponents, isStringDateRegex, objectsEqual } from 'lib/utils'
import { Properties } from 'posthog-js'
import { MouseEvent } from 'react'
import { Params } from 'scenes/sceneTypes'
import { DateRange, ErrorTrackingIssue } from '~/queries/schema/schema-general'
import { isPostHogProperty } from '~/taxonomy/taxonomy'
import { DEFAULT_ERROR_TRACKING_DATE_RANGE, DEFAULT_ERROR_TRACKING_FILTER_GROUP } from './errorTrackingLogic'
@@ -72,101 +68,6 @@ export const mergeIssues = (
}
}
export type ExceptionAttributes = {
ingestionErrors?: string[]
exceptionList: ErrorTrackingException[]
fingerprintRecords: FingerprintRecordPart[]
runtime: ErrorTrackingRuntime
type?: string
value?: string
synthetic?: boolean
lib?: string
libVersion?: string
browser?: string
browserVersion?: string
os?: string
osVersion?: string
sentryUrl?: string
level?: string
url?: string
handled: boolean
}
export function getExceptionAttributes(properties: Record<string, any>): ExceptionAttributes {
const {
$lib: lib,
$lib_version: libVersion,
$browser: browser,
$browser_version: browserVersion,
$os: os,
$os_version: osVersion,
$sentry_url: sentryUrl,
$sentry_exception,
$level: level,
$cymbal_errors: ingestionErrors,
} = properties
let type = properties.$exception_type
let value = properties.$exception_message
let synthetic: boolean | undefined = properties.$exception_synthetic
const url: string | undefined = properties.$current_url
let exceptionList: ErrorTrackingException[] | undefined = properties.$exception_list
const fingerprintRecords: FingerprintRecordPart[] | undefined = properties.$exception_fingerprint_record
// exception autocapture sets $exception_list for all exceptions.
// If it's not present, then this is probably a sentry exception. Get this list from the sentry_exception
if (!exceptionList?.length && $sentry_exception) {
if (Array.isArray($sentry_exception.values)) {
exceptionList = $sentry_exception.values
}
}
if (!type) {
type = exceptionList?.[0]?.type
}
if (!value) {
value = exceptionList?.[0]?.value
}
if (synthetic == undefined) {
synthetic = exceptionList?.[0]?.mechanism?.synthetic
}
const handled = exceptionList?.[0]?.mechanism?.handled ?? false
const runtime: ErrorTrackingRuntime = getRuntimeFromLib(lib)
return {
type,
value,
synthetic,
runtime,
lib,
libVersion,
browser,
browserVersion,
os,
osVersion,
url,
sentryUrl,
exceptionList: exceptionList || [],
fingerprintRecords: fingerprintRecords || [],
handled,
level,
ingestionErrors,
}
}
export function getAdditionalProperties(properties: Properties, isCloudOrDev: boolean | undefined): Properties {
return Object.fromEntries(
Object.entries(properties).filter(([key]) => {
return !isPostHogProperty(key, isCloudOrDev)
})
)
}
export function getSessionId(properties: Record<string, any>): string | undefined {
return properties['$session_id']
}
export function isThirdPartyScriptError(value: ErrorTrackingException['value']): boolean {
return value === THIRD_PARTY_SCRIPT_ERROR
}

View File

@@ -183,7 +183,7 @@ export function ItemEventDetail({ item }: ItemEventProps): JSX.Element {
{item.data.fullyLoaded ? (
item.data.event === '$exception' ? (
<ErrorDisplay eventProperties={item.data.properties} />
<ErrorDisplay eventProperties={item.data.properties} eventId={item.data.id} />
) : (
<LemonTabs
size="small"