diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index c4706cfec7a3..6305c62a2308 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -713,6 +713,83 @@ } ] }, + { + "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 `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", + "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"] + }, + "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"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Event", + "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", + "tagKeys": ["browser"] + } + }, + { + "label": "Several tag breakdowns", + "data": { + "id": "8f2c1a9d7e6b4f30a1b2c3d4e5f60718", + "issueId": "5551212", + "shortId": "JAVASCRIPT-22SP", + "view": "tag", + "tagKeys": ["browser", "os", "release"] + } + } + ] + }, { "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\".", 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/__stories__/eventEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx new file mode 100644 index 000000000000..de702d446494 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.spec.tsx @@ -0,0 +1,101 @@ +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'}, + {key: 'os', value: 'macOS'}, + ], + }), + }); + + render(); + + const variants = await screen.findAllByLabelText('Rendered markdown'); + expect(variants).toHaveLength(4); + + 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` 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/', + 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..bd8f40a7e934 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/eventEmbedStory.tsx @@ -0,0 +1,108 @@ +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 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}, + 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 getStoryTagKeys(event: Event): string[] { + const tagKeys = new Set(event.tags?.map(tag => tag.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() { + 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 tagKeys = event ? getStoryTagKeys(event) : []; + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load an event example. + ) : data ? ( + + + + {tagKeys.length ? ( + + ) : null} + {tagKeys.length > 1 ? ( + + ) : null} + + ) : ( + No error event is available for this organization. + )} + + ); +} 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..c90cef95104e --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/event.spec.tsx @@ -0,0 +1,218 @@ +import {EventFixture} from 'sentry-fixture/event'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {TagsFixture} from 'sentry-fixture/tags'; + +import {screen, waitFor} 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('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, + 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', tagKeys: ['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('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', ...data}); + + 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/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/eventBlock.tsx b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx new file mode 100644 index 000000000000..d2397965845e --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventBlock.tsx @@ -0,0 +1,170 @@ +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 {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'; +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} 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, + tagKeys, + event, + issueId, + organization, + distributionsHref, +}: { + distributionsHref: string; + event: Event; + issueId: string; + organization: Organization; + tagKeys: string[] | undefined; + view: EventData['view']; +}) { + switch (view) { + case 'tags': + return ( + + ); + case 'tag': + // `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': + default: + return null; + } +} + +export default function SeerEventBlock({id, issueId, shortId, view, tagKeys}: EventData) { + const organization = useOrganization(); + // 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, + 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')} + ) : ( + + + + + )} + + + ); +} 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/eventViews/tag.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tag.tsx new file mode 100644 index 000000000000..1f776c9cd304 --- /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/eventViews/tags.tsx b/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx new file mode 100644 index 000000000000..1c8ebf074a97 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/event/eventViews/tags.tsx @@ -0,0 +1,83 @@ +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'; + +/** + * 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; + 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 ? ( + + ) : ( + + )} + + ); +} 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, diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 8f9f369d7ec3..13083e7db946 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -512,6 +512,82 @@ 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 `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({ + id: z.string().min(1), + issueId: z.string().min(1), + shortId: z.string().min(1).optional(), + view: z.enum(['summary', 'tags', 'tag']).default('summary'), + // 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 keys to break down, e.g. ["browser", "os"].' + ), + }), + examples: [ + { + label: 'Event', + 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', + tagKeys: ['browser'], + }, + }, + { + label: 'Several tag breakdowns', + level: 'block', + data: { + id: '8f2c1a9d7e6b4f30a1b2c3d4e5f60718', + issueId: '5551212', + shortId: 'JAVASCRIPT-22SP', + view: 'tag', + tagKeys: ['browser', 'os', 'release'], + }, + }, + ], + }, issuesQuery: { description: 'Link to the issue stream filtered by a search query. ' + diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 76226ee2ccf8..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'; @@ -73,6 +74,10 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### event + + + ### replay