From 7eb3bb0b06f8e4d0dd094040e0cdada02d026431 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 09:58:44 -0400 Subject: [PATCH 1/9] feat(seer): add the single error event embed components Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../embeds/components/event/event.tsx | 17 +++++ .../embeds/components/event/eventLink.tsx | 29 ++++++++ .../embeds/components/event/eventPathnames.ts | 47 ++++++++++++ .../embeds/components/event/eventTagView.tsx | 64 +++++++++++++++++ .../embeds/components/event/eventTagsView.tsx | 72 +++++++++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/event/event.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventLink.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventPathnames.ts create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventTagView.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx diff --git a/static/app/components/seer/markdown/embeds/components/event/event.tsx b/static/app/components/seer/markdown/embeds/components/event/event.tsx new file mode 100644 index 000000000000..17fe5853c83b --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/event.tsx @@ -0,0 +1,17 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {EventLink} from 'sentry/components/seer/markdown/embeds/components/event/eventLink'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +const LazySeerEventBlock = lazy(() => import('./eventBlock')); + +export const SeerEvent = defineSeerEmbed({ + name: 'event', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/event/eventLink.tsx b/static/app/components/seer/markdown/embeds/components/event/eventLink.tsx new file mode 100644 index 000000000000..d1008016adf4 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventLink.tsx @@ -0,0 +1,29 @@ +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconFire} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {getShortEventId} from 'sentry/utils/events'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {makeEventPathname} from './eventPathnames'; + +export function getEventLinkTitle({ + id, + shortId, +}: Pick, 'id' | 'shortId'>) { + const shortEventId = getShortEventId(id); + return shortId ? t('%s event %s', shortId, shortEventId) : t('Event %s', shortEventId); +} + +export function EventLink({id, issueId, shortId}: EmbedOutput<'event'>) { + const organization = useOrganization(); + const href = makeEventPathname({ + organizationSlug: organization.slug, + issueId, + eventId: id, + }); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/event/eventPathnames.ts b/static/app/components/seer/markdown/embeds/components/event/eventPathnames.ts new file mode 100644 index 000000000000..cdef1402d359 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventPathnames.ts @@ -0,0 +1,47 @@ +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {Tab, TabPaths} from 'sentry/views/issueDetails/types'; + +/** + * There is no shared helper for issue event pathnames, so the embed builds them + * here once and passes the results down. `tags/` is a legacy alias that + * redirects to `distributions/` -- link at the canonical path directly. + */ +export function makeEventPathname({ + organizationSlug, + issueId, + eventId, +}: { + eventId: string; + issueId: string; + organizationSlug: string; +}) { + return normalizeUrl( + `/organizations/${organizationSlug}/issues/${issueId}/events/${eventId}/` + ); +} + +export function makeIssueDistributionsPathname({ + organizationSlug, + issueId, +}: { + issueId: string; + organizationSlug: string; +}) { + return normalizeUrl( + `/organizations/${organizationSlug}/issues/${issueId}/${TabPaths[Tab.DISTRIBUTIONS]}` + ); +} + +export function makeIssueTagDistributionPathname({ + organizationSlug, + issueId, + tagKey, +}: { + issueId: string; + organizationSlug: string; + tagKey: string; +}) { + return normalizeUrl( + `/organizations/${organizationSlug}/issues/${issueId}/${TabPaths[Tab.DISTRIBUTIONS]}${encodeURIComponent(tagKey)}/` + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/event/eventTagView.tsx b/static/app/components/seer/markdown/embeds/components/event/eventTagView.tsx new file mode 100644 index 000000000000..6486c2b6f552 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventTagView.tsx @@ -0,0 +1,64 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {fetchIssueTagApiOptions} from 'sentry/actionCreators/group'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import {IconIssues} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {TagDistribution} from 'sentry/views/issueDetails/groupTags/tagDistribution'; +import type {GroupTag} from 'sentry/views/issueDetails/groupTags/useGroupTags'; + +interface EventTagViewProps { + issueId: string; + organization: Organization; + /** Link to this tag's breakdown page. Derived once by the block. */ + tagHref: string; + tagKey: string; +} + +/** + * How one tag is distributed across the whole issue the event belongs to. + * `TagDistribution` is pure, so nothing here can reach the host page's URL. + */ +export function EventTagView({ + issueId, + organization, + tagKey, + tagHref, +}: EventTagViewProps) { + const { + data: tag, + isPending, + isError, + } = useQuery( + fetchIssueTagApiOptions({organization, groupId: issueId, tagKey}) + ); + + return ( + + + + {t('Tag Distribution')} + + + + {isPending ? ( + + + + ) : isError || !tag ? ( + {t('Unable to load values for %s', tagKey)} + ) : ( + + )} + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx b/static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx new file mode 100644 index 000000000000..09bbee2b3501 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx @@ -0,0 +1,72 @@ +import {Fragment} from 'react'; + +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {EventTags} from 'sentry/components/events/eventTags'; +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import {IconIssues} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Event} from 'sentry/types/event'; + +interface EventTagsViewProps { + /** Link to the issue's tag distributions page. Derived once by the block. */ + distributionsHref: string; + event: Event; + /** From `event.projectSlug`; undefined when the events API omitted it. */ + projectSlug: string | undefined; +} + +/** + * Fallback for events served without a project slug -- `EventTags` needs one to + * load the detailed project it renders tag rows against, so show the raw pairs + * rather than an empty section. + */ +function PlainTagList({event}: {event: Event}) { + const tags = event.tags ?? []; + + if (tags.length === 0) { + return {t('This event has no tags.')}; + } + + return ( + + {tags.map(tag => ( + + + {tag.key} + + + {tag.value ?? ''} + + + ))} + + ); +} + +export function EventTagsView({ + event, + projectSlug, + distributionsHref, +}: EventTagsViewProps) { + return ( + + + + {t('Tags')} + + + + {projectSlug ? ( + + ) : ( + + )} + + ); +} From 60167dc68b5927cbb67548b61bbecfad78c6100b Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 09:59:33 -0400 Subject: [PATCH 2/9] feat(seer): add the event embed block and its tests Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../embeds/components/event/event.spec.tsx | 156 +++++++++++++++ .../embeds/components/event/eventBlock.tsx | 178 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/event/event.spec.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx diff --git a/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx new file mode 100644 index 000000000000..ee692a62c386 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx @@ -0,0 +1,156 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {TagsFixture} from 'sentry-fixture/tags'; + +import {screen} from 'sentry-test/reactTestingLibrary'; + +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const EVENT_ID = '8f2c1a9d7e6b4f30a1b2c3d4e5f60718'; +const ISSUE_ID = '5551212'; + +function mockEvent(params: Record = {}) { + const event = EventFixture({ + id: EVENT_ID, + eventID: EVENT_ID, + groupID: ISSUE_ID, + projectSlug: 'project-slug', + title: 'ReferenceError: totals is not defined', + metadata: {type: 'ReferenceError', value: 'totals is not defined'}, + culprit: 'app/checkout in renderTotals', + tags: [ + {key: 'level', value: 'error'}, + {key: 'browser', value: 'Chrome'}, + ], + contexts: {browser: {type: 'browser', name: 'Chrome', version: '120.0.0'}}, + ...params, + }); + + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/events/${EVENT_ID}/`, + body: event, + }); + + return event; +} + +function renderEventEmbed(data: Record = {}) { + return renderEmbed({ + name: 'event', + data: {id: EVENT_ID, issueId: ISSUE_ID, shortId: 'JAVASCRIPT-22SP', ...data}, + }); +} + +describe('Seer event embed', () => { + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: '/projects/org-slug/project-slug/', + body: ProjectFixture({slug: 'project-slug'}), + }); + }); + + it('links to the event inline', () => { + expect( + getEmbedLinkHref('event', 'JAVASCRIPT-22SP event 8f2c1a9d', { + id: EVENT_ID, + issueId: ISSUE_ID, + shortId: 'JAVASCRIPT-22SP', + }) + ).toBe(`/organizations/org-slug/issues/${ISSUE_ID}/events/${EVENT_ID}/`); + }); + + it('falls back to the short event id when there is no short id', () => { + expect( + getEmbedLinkHref('event', 'Event 8f2c1a9d', {id: EVENT_ID, issueId: ISSUE_ID}) + ).toBe(`/organizations/org-slug/issues/${ISSUE_ID}/events/${EVENT_ID}/`); + }); + + it('renders the event title, message and culprit in the block', async () => { + mockEvent(); + + renderEventEmbed(); + + expect(await screen.findByText('ReferenceError')).toBeInTheDocument(); + expect(screen.getByText('totals is not defined')).toBeInTheDocument(); + expect(screen.getByText('app/checkout in renderTotals')).toBeInTheDocument(); + // `HighlightsIconSummary` renders without a `group`, off `event.projectSlug`. + expect(screen.getByLabelText('Icon highlights')).toBeInTheDocument(); + expect(screen.getByText('Chrome')).toBeInTheDocument(); + expect(screen.getByText('120.0.0')).toBeInTheDocument(); + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + }); + + it('renders the full tag list for view "tags"', async () => { + mockEvent(); + + renderEventEmbed({view: 'tags'}); + + expect(await screen.findByText('Tags')).toBeInTheDocument(); + expect(await screen.findByText('Chrome')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'All tags for this issue'})).toHaveAttribute( + 'href', + `/organizations/org-slug/issues/${ISSUE_ID}/distributions/` + ); + }); + + it('renders a plain tag list when the event has no project slug', async () => { + mockEvent({ + projectSlug: undefined, + contexts: {}, + tags: [{key: 'server_name', value: 'web-01'}], + }); + + renderEventEmbed({view: 'tags'}); + + expect(await screen.findByText('Tags')).toBeInTheDocument(); + expect(await screen.findByText('server_name')).toBeInTheDocument(); + expect(screen.getByText('web-01')).toBeInTheDocument(); + }); + + it('renders the distribution of a single tag for view "tag"', async () => { + mockEvent(); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/browser/`, + body: TagsFixture()[0], + }); + + renderEventEmbed({view: 'tag', tagKey: 'browser'}); + + expect(await screen.findByText('Tag Distribution')).toBeInTheDocument(); + expect(await screen.findByText('Firefox')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'All browser values'})).toHaveAttribute( + 'href', + `/organizations/org-slug/issues/${ISSUE_ID}/distributions/browser/` + ); + }); + + it('falls back to the summary when view is "tag" without a tag key', async () => { + mockEvent(); + const tagRequest = MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/browser/`, + body: TagsFixture()[0], + }); + + renderEventEmbed({view: 'tag'}); + + expect(await screen.findByText('ReferenceError')).toBeInTheDocument(); + expect(screen.queryByText('Tag Distribution')).not.toBeInTheDocument(); + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + expect(tagRequest).not.toHaveBeenCalled(); + }); + + it('shows an error when the event cannot be loaded', async () => { + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/events/${EVENT_ID}/`, + statusCode: 500, + }); + + renderEventEmbed(); + + expect(await screen.findByText('Unable to load event details')).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx new file mode 100644 index 000000000000..3590f508fa6d --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx @@ -0,0 +1,178 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {EventMessage} from 'sentry/components/events/eventMessage'; +import {HighlightsIconSummary} from 'sentry/components/events/highlights/highlightsIconSummary'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {EventTagsView} from 'sentry/components/seer/markdown/embeds/components/event/eventTagsView'; +import {EventTagView} from 'sentry/components/seer/markdown/embeds/components/event/eventTagView'; +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {TimeSince} from 'sentry/components/timeSince'; +import {IconFire} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Event, Level} from 'sentry/types/event'; +import type {Organization} from 'sentry/types/organization'; +import {getMessage, getTitle} from 'sentry/utils/events'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {groupEventApiOptions} from 'sentry/views/issueDetails/utils'; + +import {getEventLinkTitle} from './eventLink'; +import { + makeEventPathname, + makeIssueDistributionsPathname, + makeIssueTagDistributionPathname, +} from './eventPathnames'; + +type EventData = EmbedOutput<'event'>; + +function EventSummary({event}: {event: Event}) { + const {title, subtitle} = getTitle(event); + const level = event.tags?.find(tag => tag.key === 'level')?.value as Level | undefined; + const culprit = event.culprit || subtitle; + const date = event.dateCreated ?? event.dateReceived; + + return ( + + + {title} + + + + + {culprit ? ( + + {culprit} + + ) : null} + {date ? ( + + + + ) : null} + + + ); +} + +/** + * Renders whichever extra section `view` asked for underneath the summary. + * Adding a view is a new file plus a case here -- the conditions each view + * needs (hrefs, project slug) are derived once below and passed in as props. + */ +function EventBlockView({ + view, + tagKey, + event, + issueId, + organization, + distributionsHref, +}: { + distributionsHref: string; + event: Event; + issueId: string; + organization: Organization; + tagKey: string | undefined; + view: EventData['view']; +}) { + switch (view) { + case 'tags': + return ( + + ); + case 'tag': + // `tagKey` is required for this view; the caller already fell back to the + // summary when it is missing, so this is unreachable in practice. + return tagKey ? ( + + ) : null; + case 'summary': + default: + return null; + } +} + +export default function SeerEventBlock({id, issueId, shortId, view, tagKey}: EventData) { + const organization = useOrganization(); + // A `tag` view without a tag key has nothing to break down -- show the summary. + const resolvedView = view === 'tag' && !tagKey ? 'summary' : view; + const eventHref = makeEventPathname({ + organizationSlug: organization.slug, + issueId, + eventId: id, + }); + const distributionsHref = makeIssueDistributionsPathname({ + organizationSlug: organization.slug, + issueId, + }); + + const { + data: event, + isPending, + isError, + } = useQuery({ + ...groupEventApiOptions({ + orgSlug: organization.slug, + groupId: issueId, + eventId: id, + // Deliberately empty: the embed must not inherit the host page's filters. + environments: [], + }), + retry: false, + }); + + return ( + + + + + {isPending ? ( + + + + ) : isError || !event ? ( + {t('Unable to load event details')} + ) : ( + + + + + )} + + + ); +} From f430ff8d63e6102641ca19b7c0346021c71e8655 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:01:22 -0400 Subject: [PATCH 3/9] feat(seer): register the event embed and add its story Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- static/app/components/seer/markdown/embeds/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index ca31b55e1875..f9c360e03352 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -6,6 +6,7 @@ import {Dashboard} from './components/dashboard'; import {Docs} from './components/docs'; import {Dsn} from './components/dsn'; import {ErrorsQuery} from './components/errorsQuery'; +import {SeerEvent} from './components/event/event'; import {Issue, Issues} from './components/issue'; import {IssuesQuery} from './components/issuesQuery'; import {LogsQuery} from './components/logsQuery'; @@ -45,6 +46,7 @@ const embeds = [ ReplaysQuery, SavedIssueView, SavedQuery, + SeerEvent, SpansQuery, Timestamp, Trace, From 71bdc526443cc1864c641e6cf9afbf71851ea72f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:02:05 -0400 Subject: [PATCH 4/9] docs(seer): add the event embed to the markdown stories page Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- static/app/components/seer/markdown/seerMarkdown.mdx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 76226ee2ccf8..b60ad357d430 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -73,6 +73,10 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### event + + + ### replay From 0118080502157a94057618e837f889df0b325279 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:05:47 -0400 Subject: [PATCH 5/9] feat(seer): add the event embed schema Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/markdown/embeds/schemas.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 8f9f369d7ec3..2688bb5f6791 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -512,6 +512,77 @@ export const SEER_EMBED_SCHEMAS = { }, ], }, + event: { + description: + 'The ONLY way to reference a single error event inside a Sentry issue. ' + + '`id` is the 32-character event ID and `issueId` is the numeric group ID ' + + 'the event belongs to, both exactly as the events API returns them. ' + + 'Include the issue short ID as `shortId` when available. ' + + 'When referencing the issue as a whole rather than one of its events, use ' + + 'the `issue` embed instead. ' + + 'Inline: renders a compact link to the event. ' + + 'Block: renders the event with its title, message, culprit, and context — ' + + 'do NOT duplicate any of that as text. ' + + 'Set `view` to "tags" to also render the full tag list for the event, or ' + + 'to "tag" together with `tagKey` to render how that one tag is distributed ' + + 'across the issue. Leave `view` as "summary" unless the user asked about tags. ' + + 'Never use a markdown link for event references.', + level: ['inline', 'block'], + schema: z.object({ + id: z.string().min(1), + issueId: z.string().min(1), + shortId: z.string().min(1).optional(), + view: z.enum(['summary', 'tags', 'tag']).default('summary'), + tagKey: z + .string() + .min(1) + .optional() + .describe( + 'Required when view is "tag". The tag key to break down, e.g. "browser".' + ), + }), + examples: [ + { + label: 'Inline', + level: 'inline', + data: { + id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', + issueId: '5551212', + shortId: 'JAVASCRIPT-22SP', + }, + }, + { + label: 'Block', + level: 'block', + data: { + id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', + issueId: '5551212', + shortId: 'JAVASCRIPT-22SP', + }, + }, + { + label: 'All tags', + level: 'block', + data: { + id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', + issueId: '5551212', + shortId: 'JAVASCRIPT-22SP', + view: 'tags', + }, + }, + { + label: 'Single tag breakdown', + level: 'block', + data: { + id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', + issueId: '5551212', + shortId: 'JAVASCRIPT-22SP', + view: 'tag', + tagKey: 'browser', + }, + }, + ], + }, issuesQuery: { description: 'Link to the issue stream filtered by a search query. ' + From a3865534b78c20930cbd6545b24ae06ef637daba Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:11:13 -0400 Subject: [PATCH 6/9] feat(seer): regenerate embed widgets for the event embed Ran `pnpm gen:embed-widgets`. CI regenerates this file and fails if it is out of sync with schemas.ts. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/agent/embed_widgets.generated.json | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index c4706cfec7a3..b3d49133b12f 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -713,6 +713,78 @@ } ] }, + { + "name": "event", + "description": "The ONLY way to reference a single error event inside a Sentry issue. `id` is the 32-character event ID and `issueId` is the numeric group ID the event belongs to, both exactly as the events API returns them. Include the issue short ID as `shortId` when available. When referencing the issue as a whole rather than one of its events, use the `issue` embed instead. Inline: renders a compact link to the event. Block: renders the event with its title, message, culprit, and context — do NOT duplicate any of that as text. Set `view` to \"tags\" to also render the full tag list for the event, or to \"tag\" together with `tagKey` to render how that one tag is distributed across the issue. Leave `view` as \"summary\" unless the user asked about tags. Never use a markdown link for event references.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "issueId": { + "type": "string", + "minLength": 1 + }, + "shortId": { + "type": "string", + "minLength": 1 + }, + "view": { + "default": "summary", + "type": "string", + "enum": ["summary", "tags", "tag"] + }, + "tagKey": { + "description": "Required when view is \"tag\". The tag key to break down, e.g. \"browser\".", + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "issueId", "view"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Inline", + "data": { + "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", + "issueId": "5551212", + "shortId": "JAVASCRIPT-22SP" + } + }, + { + "label": "Block", + "data": { + "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", + "issueId": "5551212", + "shortId": "JAVASCRIPT-22SP" + } + }, + { + "label": "All tags", + "data": { + "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", + "issueId": "5551212", + "shortId": "JAVASCRIPT-22SP", + "view": "tags" + } + }, + { + "label": "Single tag breakdown", + "data": { + "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", + "issueId": "5551212", + "shortId": "JAVASCRIPT-22SP", + "view": "tag", + "tagKey": "browser" + } + } + ] + }, { "name": "issuesQuery", "description": "Link to the issue stream filtered by a search query. Use this when pointing the user at a SET of issues defined by a search rather than specific known issues — if you already have the short IDs, use the `issue` or `issues` embed instead. `query` uses issue search syntax, e.g. \"is:unresolved level:error\".", From 8bfe1e8d785da434ff9026e100fe082b808a39ce Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:40:01 -0400 Subject: [PATCH 7/9] feat(seer): Add a live-data storybook story for the event embed The event embed shipped with the generic ``, which renders the schema's own examples. Those hold a made-up event ID, so every block variant on the stories page rendered "Unable to load event details" -- the story showed the embed's error state and nothing else. Every embed that fetches by ID has the same problem, and the ones that matter already solve it the same way: query the viewer's own organization for a real resource and feed its ID into the tag. This does that for `event`, which needs two hops rather than one -- the issue list does not return an event ID, so the story resolves the issue's `latest` event to get one. The issue is picked by frequency so its tags have a distribution worth looking at, and the tag view breaks down a key whose values actually vary across the issue. A single event holds one value per tag, so `level` -- the first tag on most events -- would draw a single full-width bar; `browser` and friends are preferred, with the first tag as the fallback. `environments` is left empty on the event request, matching the embed itself: neither should inherit the host page's filters. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../__stories__/eventEmbedStory.spec.tsx | 75 +++++++++++++++ .../markdown/__stories__/eventEmbedStory.tsx | 94 +++++++++++++++++++ .../components/seer/markdown/seerMarkdown.mdx | 3 +- 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx create mode 100644 static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx diff --git a/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx new file mode 100644 index 000000000000..271d05fea2b3 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx @@ -0,0 +1,75 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {GroupFixture} from 'sentry-fixture/group'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {EventEmbedStory} from './eventEmbedStory'; + +jest.mock('sentry/components/seer/markdown', () => ({ + SeerMarkdown: ({raw}: {raw: string}) =>
{raw}
, +})); + +const EVENT_ID = '8f2c1a9d7e6b4f30a1b2c3d4e5f60718'; + +describe('EventEmbedStory', () => { + it('resolves the latest event of a recent issue and breaks down a varying tag', async () => { + const issue = GroupFixture({id: '5551212', shortId: 'JAVASCRIPT-22SP'}); + const issueRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/', + body: [issue], + match: [ + MockApiClient.matchQuery({ + project: '-1', + query: 'is:unresolved issue.category:error', + sort: 'freq', + statsPeriod: '14d', + limit: 1, + }), + ], + }); + const eventRequest = MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${issue.id}/events/latest/`, + body: EventFixture({ + id: EVENT_ID, + eventID: EVENT_ID, + groupID: issue.id, + tags: [ + {key: 'level', value: 'error'}, + {key: 'browser', value: 'Chrome'}, + ], + }), + }); + + render(); + + const variants = await screen.findAllByLabelText('Rendered markdown'); + expect(variants).toHaveLength(3); + + for (const variant of variants) { + expect(variant).toHaveTextContent(EVENT_ID); + expect(variant).toHaveTextContent(issue.id); + expect(variant).toHaveTextContent(issue.shortId); + } + + expect(variants[1]).toHaveTextContent('"view":"tags"'); + // `browser` is preferred over `level`, which is the same on every event. + expect(variants[2]).toHaveTextContent('"view":"tag","tagKey":"browser"'); + + expect(issueRequest).toHaveBeenCalled(); + expect(eventRequest).toHaveBeenCalled(); + }); + + it('falls back to a message when the organization has no error events', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/', + body: [], + }); + + render(); + + expect( + await screen.findByText('No error event is available for this organization.') + ).toBeInTheDocument(); + expect(screen.queryByLabelText('Rendered markdown')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx new file mode 100644 index 000000000000..3d6cf0a06cb3 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx @@ -0,0 +1,94 @@ +import {Fragment} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import type {Event} from 'sentry/types/event'; +import type {Group} from 'sentry/types/group'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {groupEventApiOptions} from 'sentry/views/issueDetails/utils'; + +import {EmbedStory, EmbedVariant} from './embedStory'; + +/** + * Tag keys worth breaking down in the `tag` view. An event holds one value per + * tag, so the story wants a key whose values actually vary across the issue -- + * `browser` reads better than `level`, which is the same on nearly every event. + */ +const STORY_TAG_KEYS = ['browser', 'os', 'device', 'release', 'url', 'environment']; + +function recentIssueApiOptions(organizationSlug: string) { + return apiOptions.as()('/organizations/$organizationIdOrSlug/issues/', { + path: {organizationIdOrSlug: organizationSlug}, + query: { + project: '-1', + statsPeriod: '14d', + query: 'is:unresolved issue.category:error', + // By frequency, so the issue picked has enough events for its tags to + // have a distribution worth rendering. + sort: 'freq', + limit: 1, + }, + staleTime: 0, + }); +} + +function getStoryTagKey(event: Event): string | undefined { + const tagKeys = new Set(event.tags?.map(tag => tag.key)); + return STORY_TAG_KEYS.find(key => tagKeys.has(key)) ?? event.tags?.[0]?.key; +} + +export function EventEmbedStory() { + const organization = useOrganization(); + const issueQuery = useQuery(recentIssueApiOptions(organization.slug)); + const issue = issueQuery.data?.[0]; + + // The embed takes an event ID, which the issue list does not return, so + // resolve the issue's latest event. `environments` is deliberately empty, + // matching the embed itself. + const eventQuery = useQuery({ + ...groupEventApiOptions({ + orgSlug: organization.slug, + groupId: issue?.id ?? '', + eventId: 'latest', + environments: [], + }), + enabled: Boolean(issue), + retry: false, + }); + const event = eventQuery.data; + + const isPending = issueQuery.isPending || (Boolean(issue) && eventQuery.isPending); + const isError = issueQuery.isError || eventQuery.isError; + const data = + issue && event + ? {id: event.id, issueId: issue.id, shortId: issue.shortId} + : undefined; + const tagKey = event ? getStoryTagKey(event) : undefined; + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load an event example. + ) : data ? ( + + + + {tagKey ? ( + + ) : null} + + ) : ( + No error event is available for this organization. + )} + + ); +} diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index b60ad357d430..bc48cfe654c4 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -12,6 +12,7 @@ import {BasicDemo, LinkifyDemo, StreamingEmbedExamples} from './__stories__/comp import {AlertEmbedStory} from './__stories__/alertEmbedStory'; import {DashboardEmbedStory} from './__stories__/dashboardEmbedStory'; import {EmbedStory} from './__stories__/embedStory'; +import {EventEmbedStory} from './__stories__/eventEmbedStory'; import {MonitorEmbedStory} from './__stories__/monitorEmbedStory'; import {ReleaseEmbedStory} from './__stories__/releaseEmbedStory'; import {ReplayEmbedStory} from './__stories__/replayEmbedStory'; @@ -75,7 +76,7 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a ### event - + ### replay From 7f022000406b344cd93f53933da038935b426baf Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:22:00 -0400 Subject: [PATCH 8/9] feat(seer): Break down several tags at once in the event embed `view: "tag"` took a single `tagKey`, so answering "how do browser and OS differ on this issue" meant emitting the whole event embed twice -- two cards, the same event summary repeated above each. `tagKeys` takes the list instead and draws one distribution per key in a grid. The array is deliberately uncapped in the schema. A Zod `.max()` would make an over-long list fail to parse, and an embed whose props fail to parse renders nothing at all -- the same reasoning the savedQuery schema records for widening its dataset enum. The cap lives in the view, which draws the first four, so a runaway list degrades to a few distributions rather than to an empty card. Each key fetches on its own rather than through one combined query, so a key the issue has never been tagged with shows its own error and cannot blank out the ones beside it. The grid pairs up on container width, not viewport width: the block already sets `containerType`, and an embed has no idea how wide the page around it is. The header link follows the same rule as before for one key -- straight to that tag's breakdown -- and falls back to the issue's distributions page for several, since no single tag page covers them all. Two conventions from the seer-embed skill, fixed while here: - The `tag` and `tags` views move into `event/eventViews/`, matching `alert/alertTypes/` and `monitor/monitorTypes/`. The skill asks for a sibling directory named for the axis the block switches on; `alertTypes/` holds two files, so two views is enough to earn one. - The first example dropped its `level: "inline"`, which was already the default. The marker never reached the LLM -- codegen strips it -- but it drove the shared stories fallback to relabel every example to the embed name, collapsing four into three that rendered under one duplicated React key. The dedicated story replaced that path; the schema now matches the convention too. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/agent/embed_widgets.generated.json | 29 ++-- .../__stories__/eventEmbedStory.spec.tsx | 32 ++++- .../markdown/__stories__/eventEmbedStory.tsx | 28 +++- .../embeds/components/event/event.spec.tsx | 57 +++++++- .../embeds/components/event/eventBlock.tsx | 36 ++--- .../embeds/components/event/eventTagView.tsx | 64 --------- .../components/event/eventViews/tag.tsx | 127 ++++++++++++++++++ .../tags.tsx} | 0 .../seer/markdown/embeds/schemas.ts | 31 +++-- 9 files changed, 279 insertions(+), 125 deletions(-) delete mode 100644 static/app/components/seer/markdown/embeds/components/event/eventTagView.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx rename static/app/components/seer/markdown/embeds/components/event/{eventTagsView.tsx => eventViews/tags.tsx} (100%) diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index b3d49133b12f..6305c62a2308 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -715,7 +715,7 @@ }, { "name": "event", - "description": "The ONLY way to reference a single error event inside a Sentry issue. `id` is the 32-character event ID and `issueId` is the numeric group ID the event belongs to, both exactly as the events API returns them. Include the issue short ID as `shortId` when available. When referencing the issue as a whole rather than one of its events, use the `issue` embed instead. Inline: renders a compact link to the event. Block: renders the event with its title, message, culprit, and context — do NOT duplicate any of that as text. Set `view` to \"tags\" to also render the full tag list for the event, or to \"tag\" together with `tagKey` to render how that one tag is distributed across the issue. Leave `view` as \"summary\" unless the user asked about tags. Never use a markdown link for event references.", + "description": "The ONLY way to reference a single error event inside a Sentry issue. `id` is the 32-character event ID and `issueId` is the numeric group ID the event belongs to, both exactly as the events API returns them. Include the issue short ID as `shortId` when available. When referencing the issue as a whole rather than one of its events, use the `issue` embed instead. Inline: renders a compact link to the event. Block: renders the event with its title, message, culprit, and context — do NOT duplicate any of that as text. Set `view` to \"tags\" to also render the full tag list for the event, or to \"tag\" together with `tagKeys` to render how those tags are distributed across the issue -- pass every key the user asked about in one embed rather than repeating the embed per key, and keep it to a handful. Leave `view` as \"summary\" unless the user asked about tags. Never use a markdown link for event references.", "level": ["inline", "block"], "body": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -738,10 +738,13 @@ "type": "string", "enum": ["summary", "tags", "tag"] }, - "tagKey": { - "description": "Required when view is \"tag\". The tag key to break down, e.g. \"browser\".", - "type": "string", - "minLength": 1 + "tagKeys": { + "description": "Required when view is \"tag\". The tag keys to break down, e.g. [\"browser\", \"os\"].", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "required": ["id", "issueId", "view"], @@ -749,7 +752,7 @@ }, "examples": [ { - "label": "Inline", + "label": "Event", "data": { "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", "issueId": "5551212", @@ -757,30 +760,32 @@ } }, { - "label": "Block", + "label": "All tags", "data": { "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", "issueId": "5551212", - "shortId": "JAVASCRIPT-22SP" + "shortId": "JAVASCRIPT-22SP", + "view": "tags" } }, { - "label": "All tags", + "label": "Single tag breakdown", "data": { "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", "issueId": "5551212", "shortId": "JAVASCRIPT-22SP", - "view": "tags" + "view": "tag", + "tagKeys": ["browser"] } }, { - "label": "Single tag breakdown", + "label": "Several tag breakdowns", "data": { "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", "issueId": "5551212", "shortId": "JAVASCRIPT-22SP", "view": "tag", - "tagKey": "browser" + "tagKeys": ["browser", "os", "release"] } } ] diff --git a/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx index 271d05fea2b3..de702d446494 100644 --- a/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx @@ -36,6 +36,7 @@ describe('EventEmbedStory', () => { tags: [ {key: 'level', value: 'error'}, {key: 'browser', value: 'Chrome'}, + {key: 'os', value: 'macOS'}, ], }), }); @@ -43,7 +44,7 @@ describe('EventEmbedStory', () => { render(); const variants = await screen.findAllByLabelText('Rendered markdown'); - expect(variants).toHaveLength(3); + expect(variants).toHaveLength(4); for (const variant of variants) { expect(variant).toHaveTextContent(EVENT_ID); @@ -52,13 +53,38 @@ describe('EventEmbedStory', () => { } expect(variants[1]).toHaveTextContent('"view":"tags"'); - // `browser` is preferred over `level`, which is the same on every event. - expect(variants[2]).toHaveTextContent('"view":"tag","tagKey":"browser"'); + // `browser` and `os` are preferred over `level`, which is the same on every + // event and would draw a single full-width bar. + expect(variants[2]).toHaveTextContent('"view":"tag","tagKeys":["browser"]'); + expect(variants[3]).toHaveTextContent('"view":"tag","tagKeys":["browser","os"]'); expect(issueRequest).toHaveBeenCalled(); expect(eventRequest).toHaveBeenCalled(); }); + it('omits the multi-tag variant when the event carries only one usable tag', async () => { + const issue = GroupFixture({id: '5551212', shortId: 'JAVASCRIPT-22SP'}); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/', + body: [issue], + }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${issue.id}/events/latest/`, + body: EventFixture({ + id: EVENT_ID, + eventID: EVENT_ID, + groupID: issue.id, + tags: [{key: 'browser', value: 'Chrome'}], + }), + }); + + render(); + + const variants = await screen.findAllByLabelText('Rendered markdown'); + expect(variants).toHaveLength(3); + expect(variants[2]).toHaveTextContent('"view":"tag","tagKeys":["browser"]'); + }); + it('falls back to a message when the organization has no error events', async () => { MockApiClient.addMockResponse({ url: '/organizations/org-slug/issues/', diff --git a/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx index 3d6cf0a06cb3..bd8f40a7e934 100644 --- a/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx @@ -14,11 +14,14 @@ import {EmbedStory, EmbedVariant} from './embedStory'; /** * Tag keys worth breaking down in the `tag` view. An event holds one value per - * tag, so the story wants a key whose values actually vary across the issue -- + * tag, so the story wants keys whose values actually vary across the issue -- * `browser` reads better than `level`, which is the same on nearly every event. */ const STORY_TAG_KEYS = ['browser', 'os', 'device', 'release', 'url', 'environment']; +/** How many keys the multi-tag variant asks for. */ +const STORY_TAG_KEY_COUNT = 3; + function recentIssueApiOptions(organizationSlug: string) { return apiOptions.as()('/organizations/$organizationIdOrSlug/issues/', { path: {organizationIdOrSlug: organizationSlug}, @@ -35,9 +38,13 @@ function recentIssueApiOptions(organizationSlug: string) { }); } -function getStoryTagKey(event: Event): string | undefined { +function getStoryTagKeys(event: Event): string[] { const tagKeys = new Set(event.tags?.map(tag => tag.key)); - return STORY_TAG_KEYS.find(key => tagKeys.has(key)) ?? event.tags?.[0]?.key; + const preferred = STORY_TAG_KEYS.filter(key => tagKeys.has(key)); + // Fall back to whatever the event does carry, so an event with no tag in the + // preferred list still demonstrates the view. + const keys = preferred.length ? preferred : (event.tags?.map(tag => tag.key) ?? []); + return keys.slice(0, STORY_TAG_KEY_COUNT); } export function EventEmbedStory() { @@ -66,7 +73,7 @@ export function EventEmbedStory() { issue && event ? {id: event.id, issueId: issue.id, shortId: issue.shortId} : undefined; - const tagKey = event ? getStoryTagKey(event) : undefined; + const tagKeys = event ? getStoryTagKeys(event) : []; return ( @@ -78,11 +85,18 @@ export function EventEmbedStory() { - {tagKey ? ( + {tagKeys.length ? ( + + ) : null} + {tagKeys.length > 1 ? ( ) : null} diff --git a/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx index ee692a62c386..fced81e1c12d 100644 --- a/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx @@ -2,7 +2,7 @@ import {EventFixture} from 'sentry-fixture/event'; import {ProjectFixture} from 'sentry-fixture/project'; import {TagsFixture} from 'sentry-fixture/tags'; -import {screen} from 'sentry-test/reactTestingLibrary'; +import {screen, waitFor} from 'sentry-test/reactTestingLibrary'; import { getEmbedLinkHref, @@ -118,7 +118,7 @@ describe('Seer event embed', () => { body: TagsFixture()[0], }); - renderEventEmbed({view: 'tag', tagKey: 'browser'}); + renderEventEmbed({view: 'tag', tagKeys: ['browser']}); expect(await screen.findByText('Tag Distribution')).toBeInTheDocument(); expect(await screen.findByText('Firefox')).toBeInTheDocument(); @@ -128,14 +128,63 @@ describe('Seer event embed', () => { ); }); - it('falls back to the summary when view is "tag" without a tag key', async () => { + it('renders one distribution per key for view "tag" with several keys', async () => { + mockEvent(); + const browserRequest = MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/browser/`, + body: TagsFixture()[0], + }); + const urlRequest = MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/url/`, + body: TagsFixture()[2], + }); + + renderEventEmbed({view: 'tag', tagKeys: ['browser', 'url']}); + + expect(await screen.findByText('Firefox')).toBeInTheDocument(); + expect(await screen.findByText('http://example.com/foo')).toBeInTheDocument(); + expect(browserRequest).toHaveBeenCalled(); + expect(urlRequest).toHaveBeenCalled(); + // No single tag page covers every requested key, so the header falls back to + // the issue's distributions page. + expect(screen.getByRole('link', {name: 'All tags for this issue'})).toHaveAttribute( + 'href', + `/organizations/org-slug/issues/${ISSUE_ID}/distributions/` + ); + }); + + it('renders only the first few distributions when given a long key list', async () => { + mockEvent(); + const tagKeys = ['browser', 'url', 'device', 'environment', 'user']; + const requests = Object.fromEntries( + tagKeys.map((tagKey, index) => [ + tagKey, + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/${tagKey}/`, + body: {...TagsFixture()[index], key: tagKey}, + }), + ]) + ); + + renderEventEmbed({view: 'tag', tagKeys}); + + expect(await screen.findByText('Firefox')).toBeInTheDocument(); + // The block caps at four, so the fifth key is never requested. + await waitFor(() => expect(requests.environment).toHaveBeenCalled()); + expect(requests.user).not.toHaveBeenCalled(); + }); + + it.each([ + ['without tag keys', {}], + ['with an empty tag key list', {tagKeys: []}], + ])('falls back to the summary when view is "tag" %s', async (_label, data) => { mockEvent(); const tagRequest = MockApiClient.addMockResponse({ url: `/organizations/org-slug/issues/${ISSUE_ID}/tags/browser/`, body: TagsFixture()[0], }); - renderEventEmbed({view: 'tag'}); + renderEventEmbed({view: 'tag', ...data}); expect(await screen.findByText('ReferenceError')).toBeInTheDocument(); expect(screen.queryByText('Tag Distribution')).not.toBeInTheDocument(); diff --git a/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx index 3590f508fa6d..d2397965845e 100644 --- a/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx @@ -6,8 +6,8 @@ import {Text} from '@sentry/scraps/text'; import {EventMessage} from 'sentry/components/events/eventMessage'; import {HighlightsIconSummary} from 'sentry/components/events/highlights/highlightsIconSummary'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; -import {EventTagsView} from 'sentry/components/seer/markdown/embeds/components/event/eventTagsView'; -import {EventTagView} from 'sentry/components/seer/markdown/embeds/components/event/eventTagView'; +import {EventTagView} from 'sentry/components/seer/markdown/embeds/components/event/eventViews/tag'; +import {EventTagsView} from 'sentry/components/seer/markdown/embeds/components/event/eventViews/tags'; import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; import {TimeSince} from 'sentry/components/timeSince'; @@ -20,11 +20,7 @@ import {useOrganization} from 'sentry/utils/useOrganization'; import {groupEventApiOptions} from 'sentry/views/issueDetails/utils'; import {getEventLinkTitle} from './eventLink'; -import { - makeEventPathname, - makeIssueDistributionsPathname, - makeIssueTagDistributionPathname, -} from './eventPathnames'; +import {makeEventPathname, makeIssueDistributionsPathname} from './eventPathnames'; type EventData = EmbedOutput<'event'>; @@ -64,7 +60,7 @@ function EventSummary({event}: {event: Event}) { */ function EventBlockView({ view, - tagKey, + tagKeys, event, issueId, organization, @@ -74,7 +70,7 @@ function EventBlockView({ event: Event; issueId: string; organization: Organization; - tagKey: string | undefined; + tagKeys: string[] | undefined; view: EventData['view']; }) { switch (view) { @@ -87,18 +83,14 @@ function EventBlockView({ /> ); case 'tag': - // `tagKey` is required for this view; the caller already fell back to the - // summary when it is missing, so this is unreachable in practice. - return tagKey ? ( + // `tagKeys` is required for this view; the caller already fell back to the + // summary when it is missing or empty, so this is unreachable in practice. + return tagKeys?.length ? ( ) : null; case 'summary': @@ -107,10 +99,10 @@ function EventBlockView({ } } -export default function SeerEventBlock({id, issueId, shortId, view, tagKey}: EventData) { +export default function SeerEventBlock({id, issueId, shortId, view, tagKeys}: EventData) { const organization = useOrganization(); - // A `tag` view without a tag key has nothing to break down -- show the summary. - const resolvedView = view === 'tag' && !tagKey ? 'summary' : view; + // A `tag` view without tag keys has nothing to break down -- show the summary. + const resolvedView = view === 'tag' && !tagKeys?.length ? 'summary' : view; const eventHref = makeEventPathname({ organizationSlug: organization.slug, issueId, @@ -164,7 +156,7 @@ export default function SeerEventBlock({id, issueId, shortId, view, tagKey}: Eve ({organization, groupId: issueId, tagKey}) - ); - - return ( - - - - {t('Tag Distribution')} - - - - {isPending ? ( - - - - ) : isError || !tag ? ( - {t('Unable to load values for %s', tagKey)} - ) : ( - - )} - - ); -} diff --git a/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx new file mode 100644 index 000000000000..075e17f35caa --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx @@ -0,0 +1,127 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {fetchIssueTagApiOptions} from 'sentry/actionCreators/group'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {makeIssueTagDistributionPathname} from 'sentry/components/seer/markdown/embeds/components/event/eventPathnames'; +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import {IconIssues} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {Organization} from 'sentry/types/organization'; +import {TagDistribution} from 'sentry/views/issueDetails/groupTags/tagDistribution'; +import type {GroupTag} from 'sentry/views/issueDetails/groupTags/useGroupTags'; + +/** + * The schema deliberately puts no `.max()` on `tagKeys` -- an over-long list + * would then fail to parse, and an embed whose props fail to parse renders + * nothing at all. The cap lives here instead, so a runaway list degrades to the + * first few distributions rather than to an empty card. + */ +const MAX_TAG_DISTRIBUTIONS = 4; + +interface EventTagViewProps { + /** Link to the issue's tag distributions page. Derived once by the block. */ + distributionsHref: string; + issueId: string; + organization: Organization; + tagKeys: string[]; +} + +/** + * One tag's distribution across the issue. Each key fetches on its own so a key + * the issue has never been tagged with cannot blank out the ones beside it. + */ +function TagDistributionCard({ + issueId, + organization, + tagKey, +}: { + issueId: string; + organization: Organization; + tagKey: string; +}) { + const { + data: tag, + isPending, + isError, + } = useQuery( + fetchIssueTagApiOptions({organization, groupId: issueId, tagKey}) + ); + + if (isPending) { + return ( + + + + ); + } + + if (isError || !tag) { + return {t('Unable to load values for %s', tagKey)}; + } + + return ; +} + +/** + * How the requested tags are distributed across the whole issue the event + * belongs to. `TagDistribution` is pure, so nothing here can reach the host + * page's URL. + */ +export function EventTagView({ + issueId, + organization, + tagKeys, + distributionsHref, +}: EventTagViewProps) { + const visibleTagKeys = tagKeys.slice(0, MAX_TAG_DISTRIBUTIONS); + // With one tag the header can point at that tag's own breakdown; with several + // the only page covering all of them is the issue's distributions page. + const singleTagKey = visibleTagKeys.length === 1 ? visibleTagKeys[0]! : undefined; + + return ( + + + + {t('Tag Distribution')} + + + + {/* + Bare keys are container queries, and the block sets `containerType`, so + this pairs up on the embed's own width rather than the viewport's -- + the embed has no idea how wide the page around it is. + */} + + {visibleTagKeys.map(tagKey => ( + + ))} + + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx similarity index 100% rename from static/app/components/seer/markdown/embeds/components/event/eventTagsView.tsx rename to static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 2688bb5f6791..13083e7db946 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -524,8 +524,10 @@ export const SEER_EMBED_SCHEMAS = { 'Block: renders the event with its title, message, culprit, and context — ' + 'do NOT duplicate any of that as text. ' + 'Set `view` to "tags" to also render the full tag list for the event, or ' + - 'to "tag" together with `tagKey` to render how that one tag is distributed ' + - 'across the issue. Leave `view` as "summary" unless the user asked about tags. ' + + 'to "tag" together with `tagKeys` to render how those tags are distributed ' + + 'across the issue -- pass every key the user asked about in one embed ' + + 'rather than repeating the embed per key, and keep it to a handful. ' + + 'Leave `view` as "summary" unless the user asked about tags. ' + 'Never use a markdown link for event references.', level: ['inline', 'block'], schema: z.object({ @@ -533,18 +535,19 @@ export const SEER_EMBED_SCHEMAS = { issueId: z.string().min(1), shortId: z.string().min(1).optional(), view: z.enum(['summary', 'tags', 'tag']).default('summary'), - tagKey: z - .string() - .min(1) + // Deliberately uncapped: a `.max()` would make an over-long list fail to + // parse, and an embed whose props fail to parse renders nothing at all. + // The block caps how many it draws instead. + tagKeys: z + .array(z.string().min(1)) .optional() .describe( - 'Required when view is "tag". The tag key to break down, e.g. "browser".' + 'Required when view is "tag". The tag keys to break down, e.g. ["browser", "os"].' ), }), examples: [ { - label: 'Inline', - level: 'inline', + label: 'Event', data: { id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', issueId: '5551212', @@ -552,33 +555,35 @@ export const SEER_EMBED_SCHEMAS = { }, }, { - label: 'Block', + label: 'All tags', level: 'block', data: { id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', issueId: '5551212', shortId: 'JAVASCRIPT-22SP', + view: 'tags', }, }, { - label: 'All tags', + label: 'Single tag breakdown', level: 'block', data: { id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', issueId: '5551212', shortId: 'JAVASCRIPT-22SP', - view: 'tags', + view: 'tag', + tagKeys: ['browser'], }, }, { - label: 'Single tag breakdown', + label: 'Several tag breakdowns', level: 'block', data: { id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', issueId: '5551212', shortId: 'JAVASCRIPT-22SP', view: 'tag', - tagKey: 'browser', + tagKeys: ['browser', 'os', 'release'], }, }, ], From fab34b7b240e75de89d2b0dc56617a5503e951d9 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 12:57:33 -0400 Subject: [PATCH 9/9] ref(seer): Keep the event embed's tag rows read-only The `tags` view rendered `EventTags` with its row action menu, which writes project highlight tags through `useUpdateProject` and builds its links out of the host page's `location.query` -- both of which the embed rules forbid. `EventTagsTreeRow` already takes a `config` with `disableActions`, but `EventTagsTree` and `EventTags` never forwarded one, so the hatch was unreachable from the embed. Thread `config` through both and pass `disableActions` from the embed. Also drops a redundant non-null assertion on `visibleTagKeys[0]`; the ternary around it already yields `string | undefined`. Claude-Session: https://claude.ai/code/session_019X26DmhoMrsQUAeUotE6Vp --- .../components/events/eventTags/eventTagsTree.tsx | 11 +++++++++-- .../events/eventTags/eventTagsTreeRow.tsx | 2 +- static/app/components/events/eventTags/index.tsx | 11 ++++++++++- .../markdown/embeds/components/event/event.spec.tsx | 13 +++++++++++++ .../embeds/components/event/eventViews/tag.tsx | 2 +- .../embeds/components/event/eventViews/tags.tsx | 13 ++++++++++++- 6 files changed, 46 insertions(+), 6 deletions(-) diff --git a/static/app/components/events/eventTags/eventTagsTree.tsx b/static/app/components/events/eventTags/eventTagsTree.tsx index 67501d0e5c5a..e7182b0d1edd 100644 --- a/static/app/components/events/eventTags/eventTagsTree.tsx +++ b/static/app/components/events/eventTags/eventTagsTree.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; import { + type EventTagTreeRowConfig, EventTagsTreeRow, type EventTagsTreeRowProps, } from 'sentry/components/events/eventTags/eventTagsTreeRow'; @@ -38,6 +39,8 @@ interface EventTagsTreeProps { event: Event; projectSlug: Project['slug']; tags: EventTagWithMeta[]; + /** Applied to every row; e.g. `disableActions` for read-only surfaces. */ + config?: EventTagTreeRowConfig; } function addToTagTree({ @@ -106,6 +109,7 @@ function getTagTreeRows({ event, project, isLast, + config, }: EventTagsTreeRowProps & {uniqueKey: string}): React.ReactNode[] { const subtreeEntries = Array.from(content.subtree.entries()); const subtreeRows = subtreeEntries.reduce( @@ -113,6 +117,7 @@ function getTagTreeRows({ const branchRows = getTagTreeRows({ event, project, + config, tagKey: tag, content: tagContent, spacerCount: spacerCount + 1, @@ -134,6 +139,7 @@ function getTagTreeRows({ event={event} project={project} isLast={isLast} + config={config} />, ...subtreeRows, ]; @@ -148,6 +154,7 @@ function TagTreeColumns({ columnCount, projectSlug, event, + config, }: EventTagsTreeProps & {columnCount: number}) { const organization = useOrganization(); const {data: project, isPending} = useDetailedProject({ @@ -171,7 +178,7 @@ function TagTreeColumns({ // root parent so that we do not split up roots/branches when forming columns const tagTreeRowGroups: React.ReactNode[][] = Array.from(tagTree.entries()).map( ([tagKey, content], i) => - getTagTreeRows({tagKey, content, uniqueKey: `${i}`, project, event}) + getTagTreeRows({tagKey, content, uniqueKey: `${i}`, project, event, config}) ); // Get the total number of TagTreeRow components to be rendered, and a goal size for each column const tagTreeRowTotal = tagTreeRowGroups.reduce( @@ -208,7 +215,7 @@ function TagTreeColumns({ {startIndex: 0, runningTotal: 0, columns: []} ); return data.columns; - }, [columnCount, isPending, project, event, tags]); + }, [columnCount, isPending, project, event, tags, config]); return {assembledColumns}; } diff --git a/static/app/components/events/eventTags/eventTagsTreeRow.tsx b/static/app/components/events/eventTags/eventTagsTreeRow.tsx index c360722a5bfb..248fb6622034 100644 --- a/static/app/components/events/eventTags/eventTagsTreeRow.tsx +++ b/static/app/components/events/eventTags/eventTagsTreeRow.tsx @@ -36,7 +36,7 @@ import { import {getTransactionSummaryBaseUrl} from 'sentry/views/performance/transactionSummary/utils'; import {getSizeBuildPath} from 'sentry/views/preprod/utils/buildLinkUtils'; -interface EventTagTreeRowConfig { +export interface EventTagTreeRowConfig { // Omits the dropdown of actions applicable to this tag disableActions?: boolean; // Omit error styling from being displayed, even if context is invalid diff --git a/static/app/components/events/eventTags/index.tsx b/static/app/components/events/eventTags/index.tsx index 12016be2f019..12ad3079c80f 100644 --- a/static/app/components/events/eventTags/index.tsx +++ b/static/app/components/events/eventTags/index.tsx @@ -3,6 +3,7 @@ import * as Sentry from '@sentry/react'; import {EventTagCustomBanner} from 'sentry/components/events/eventTags/eventTagCustomBanner'; import {EventTagsTree} from 'sentry/components/events/eventTags/eventTagsTree'; +import type {EventTagTreeRowConfig} from 'sentry/components/events/eventTags/eventTagsTreeRow'; import {associateTagsWithMeta, TagFilter} from 'sentry/components/events/eventTags/util'; import {AnnotatedText} from 'sentry/components/events/meta/annotatedText'; import type {Event, EventTagWithMeta} from 'sentry/types/event'; @@ -15,6 +16,8 @@ import {useOrganization} from 'sentry/utils/useOrganization'; type Props = { event: Event; projectSlug: Project['slug']; + /** Applied to every tag row; e.g. `disableActions` for read-only surfaces. */ + config?: EventTagTreeRowConfig; filteredTags?: EventTagWithMeta[]; tagFilter?: TagFilter; }; @@ -25,6 +28,7 @@ export function EventTags({ event, filteredTags, projectSlug, + config, tagFilter = TagFilter.ALL, }: Props) { const organization = useOrganization(); @@ -100,7 +104,12 @@ export function EventTags({ return ( - + {hasCustomTagsBanner && } ); diff --git a/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx index fced81e1c12d..c90cef95104e 100644 --- a/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx @@ -97,6 +97,19 @@ describe('Seer event embed', () => { ); }); + it('does not offer the tag row actions inside the embed', async () => { + mockEvent(); + + renderEventEmbed({view: 'tags'}); + + // Wait on the rows themselves -- the summary above renders the same tag values + // before the tree has loaded its project. + expect(await screen.findAllByTestId('tag-tree-row')).toHaveLength(2); + // The row menu writes project highlight tags and builds its links out of the + // host page's `location.query`, so the embed renders the rows without it. + expect(screen.queryAllByLabelText('Tag Actions Menu')).toHaveLength(0); + }); + it('renders a plain tag list when the event has no project slug', async () => { mockEvent({ projectSlug: undefined, diff --git a/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx index 075e17f35caa..1f776c9cd304 100644 --- a/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx +++ b/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx @@ -79,7 +79,7 @@ export function EventTagView({ const visibleTagKeys = tagKeys.slice(0, MAX_TAG_DISTRIBUTIONS); // With one tag the header can point at that tag's own breakdown; with several // the only page covering all of them is the issue's distributions page. - const singleTagKey = visibleTagKeys.length === 1 ? visibleTagKeys[0]! : undefined; + const singleTagKey = visibleTagKeys.length === 1 ? visibleTagKeys[0] : undefined; return ( diff --git a/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx index 09bbee2b3501..1c8ebf074a97 100644 --- a/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx +++ b/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx @@ -9,6 +9,13 @@ import {IconIssues} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Event} from 'sentry/types/event'; +/** + * The tag row menu writes project highlight tags and builds its links from the host + * page's `location.query`; an embed must reach neither. Hoisted so the reference stays + * stable -- `EventTagsTree` memoizes its columns against it. + */ +const READ_ONLY_ROW_CONFIG = {disableActions: true} as const; + interface EventTagsViewProps { /** Link to the issue's tag distributions page. Derived once by the block. */ distributionsHref: string; @@ -63,7 +70,11 @@ export function EventTagsView({ /> {projectSlug ? ( - + ) : ( )}