From db33095ecb68f72d6bfdf2bc1b5a890deac46306 Mon Sep 17 00:00:00 2001 From: butschster Date: Tue, 12 May 2026 18:35:03 +0400 Subject: [PATCH 1/2] fix: Sentry event page display bugs from issue #321 Trace context no longer renders a 'View full trace' link when no transaction was captured (which 404'd against /api/sentry/traces/{id}). Stack-frame chevrons that animate with no source context now render the frame's vars as a fallback list instead of an empty body. Empty Runtime/OS/SDK context boxes and blank tag pills are hidden rather than shown as empty placeholders. The event page header now formats the Sentry timestamp as epoch seconds (was rendering year 1970). The sidebar dot indicator no longer depends on the event-count visibility setting. --- .../sentry-exception-frame.stories.ts | 35 ++++++++++ .../sentry-exception-frame.vue | 55 ++++++++++++++-- .../sentry-page-tags.stories.ts | 27 ++++++++ .../ui/sentry-page-tags/sentry-page-tags.vue | 66 +++++++++++-------- .../ui/sentry-page/sentry-page.stories.ts | 52 +++++++++++++++ .../sentry/ui/sentry-page/sentry-page.vue | 7 +- .../trace-context-block.stories.ts | 15 +++++ .../trace-context-block.vue | 14 +++- .../ui/layout-sidebar/layout-sidebar.vue | 5 +- 9 files changed, 242 insertions(+), 34 deletions(-) diff --git a/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.stories.ts b/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.stories.ts index c4611c9a..955ba930 100644 --- a/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.stories.ts +++ b/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.stories.ts @@ -17,3 +17,38 @@ export const Frame: StoryObj = { frame: normalizeSentryEvent(sentrySpiralMock).payload?.exception?.values?.[0]?.stacktrace?.frames?.[1], } }; + +// A frame that has variables but no source context (no context_line / +// pre_context / post_context). Before the fix, expanding such a frame +// produced an empty body — the vars are now rendered as a fallback list. +export const VarsOnlyNoSource: StoryObj = { + args: { + isOpen: true, + frame: { + filename: 'vendor/lib/internal.php', + function: 'callUserFunction', + lineno: 42, + in_app: false, + vars: { + userId: 17, + attempts: 3, + config: { retries: 5, timeout: 1000 }, + token: null, + }, + } as never, + }, +}; + +// A frame with no body and no vars must still render a single line with no +// expandable body (chevron hidden). +export const Bare: StoryObj = { + args: { + isOpen: false, + frame: { + filename: '[internal]', + function: 'spl_autoload_call', + lineno: 0, + in_app: false, + } as never, + }, +}; diff --git a/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.vue b/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.vue index d5c8c5c9..343be22d 100644 --- a/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.vue +++ b/src/entities/sentry/ui/sentry-exception/sentry-exception-frame.vue @@ -21,12 +21,26 @@ const { buildLink } = useIdeLink() const ideLink = computed(() => buildLink(props.frame.filename ?? 'unknown', props.frame.lineno)) -const hasBody = computed(() => - Boolean(props.frame.context_line || props.frame.post_context || props.frame.pre_context) -) +const hasBody = computed(() => { + const f = props.frame + return Boolean( + f.context_line || + (Array.isArray(f.post_context) && f.post_context.length > 0) || + (Array.isArray(f.pre_context) && f.pre_context.length > 0) + ) +}) const hasVars = computed(() => props.frame.vars && Object.keys(props.frame.vars).length > 0) +const varEntries = computed(() => + props.frame.vars + ? Object.entries(props.frame.vars).map(([name, value]) => ({ + name, + value: formatVarValue(value) + })) + : [] +) + // Floating tooltip state const tooltip = reactive({ visible: false, @@ -198,7 +212,7 @@ const toggleOpen = () => {
+ + +
+ +
@@ -356,6 +389,20 @@ const toggleOpen = () => { @apply bg-amber-500/20; } } + +/* Vars fallback list (shown when frame has no source context) */ +.frame__vars { + @apply grid gap-x-3 gap-y-1 p-2 text-gray-200; + grid-template-columns: max-content 1fr; +} + +.frame__var-name { + @apply text-amber-300 font-mono text-2xs self-start whitespace-nowrap; +} + +.frame__var-value pre { + @apply text-2xs whitespace-pre-wrap break-words; +} diff --git a/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.stories.ts b/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.stories.ts index 22303133..98091370 100644 --- a/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.stories.ts +++ b/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.stories.ts @@ -21,3 +21,30 @@ export const Spiral: StoryObj = { payload: normalizeSentryEvent(sentrySpiralMock).payload, } }; + +// A payload that omits runtime / os / sdk and logger / server_name. The +// section should hide its empty context boxes and empty tag pills entirely +// rather than render placeholder rows with blank values. +export const MinimalNoContexts: StoryObj = { + args: { + payload: { + event_id: 'mini-1', + environment: 'production', + platform: 'php', + // No logger, server_name, contexts, sdk — everything else stripped. + } as never, + }, +}; + +// A payload with only logger + env populated. Verifies that those tags appear +// without the runtime/os/server pills. +export const EnvAndLoggerOnly: StoryObj = { + args: { + payload: { + event_id: 'env-only-1', + environment: 'staging', + logger: 'app.errors', + platform: 'php', + } as never, + }, +}; diff --git a/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.vue b/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.vue index f3375e8a..a0a01040 100644 --- a/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.vue +++ b/src/entities/sentry/ui/sentry-page-tags/sentry-page-tags.vue @@ -21,37 +21,46 @@ const contextsOS = computed(() => { return { name, version } }) -const boxes = computed(() => [ - { - title: 'Runtime', - name: contextsRuntime.value.name, - version: contextsRuntime.value.version - }, - { - title: 'OS', - name: contextsOS.value.name, - version: contextsOS.value.version - }, - { - title: 'SDK', - name: props.payload.sdk?.name, - version: props.payload.sdk?.version - } -]) - -const tags = computed(() => [ - { name: 'env', value: props.payload.environment }, - { name: 'logger', value: props.payload.logger }, - { name: 'os', value: `${contextsOS.value.name} ${contextsOS.value.version}` }, - { name: 'runtime', value: `${contextsRuntime.value.name} ${contextsRuntime.value.version}` }, - { name: 'server', value: props.payload.server_name } -]) +const boxes = computed(() => + [ + { + title: 'Runtime', + name: contextsRuntime.value.name, + version: contextsRuntime.value.version + }, + { + title: 'OS', + name: contextsOS.value.name, + version: contextsOS.value.version + }, + { + title: 'SDK', + name: props.payload.sdk?.name ?? '', + version: props.payload.sdk?.version ?? '' + } + ].filter((b) => b.name || b.version) +) + +const tags = computed(() => { + const os = `${contextsOS.value.name} ${contextsOS.value.version}`.trim() + const runtime = `${contextsRuntime.value.name} ${contextsRuntime.value.version}`.trim() + return [ + { name: 'env', value: props.payload.environment }, + { name: 'logger', value: props.payload.logger }, + { name: 'os', value: os }, + { name: 'runtime', value: runtime }, + { name: 'server', value: props.payload.server_name } + ].filter((t) => t.value) +})