diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index e2dbbe1d7874..dd30d6a5cac7 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -790,6 +790,93 @@ } ] }, + { + "name": "log", + "description": "The ONLY way to reference a single log line (Explore > Logs). `id` is the log item ID exactly as the logs API returns it. Provide `traceId`, `projectId`, and `timestamp` whenever the API gave them to you — without them the embed has to scan a wider window to find the row. When referencing a SET of logs defined by a search, use the `logsQuery` embed instead. Inline: renders a compact link that opens the log row in Explore. Block: renders the log row with its severity, message, and timestamp — do NOT duplicate any of that as text. Set `view` to \"attributes\" to also render the full attribute list for the log, or to \"attribute\" together with `attribute` to break that one attribute down across matching logs. Leave `view` as \"summary\" unless the user asked about attributes. Never use a markdown link for log references.", + "level": ["inline", "block"], + "body": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "traceId": { + "type": "string", + "minLength": 1 + }, + "projectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + "view": { + "default": "summary", + "type": "string", + "enum": ["summary", "attributes", "attribute"] + }, + "attribute": { + "description": "Required when view is \"attribute\". The attribute key to break down, e.g. \"severity\".", + "type": "string", + "minLength": 1 + } + }, + "required": ["id", "view"], + "additionalProperties": false + }, + "examples": [ + { + "label": "Inline", + "data": { + "id": "019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e", + "traceId": "a1b2c3d4e5f678901234567890abcdef", + "projectId": "1", + "timestamp": "2026-08-25T16:37:12Z" + } + }, + { + "label": "Block", + "data": { + "id": "019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e", + "traceId": "a1b2c3d4e5f678901234567890abcdef", + "projectId": "1", + "timestamp": "2026-08-25T16:37:12Z" + } + }, + { + "label": "All attributes", + "data": { + "id": "019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e", + "traceId": "a1b2c3d4e5f678901234567890abcdef", + "projectId": "1", + "timestamp": "2026-08-25T16:37:12Z", + "view": "attributes" + } + }, + { + "label": "Single attribute breakdown", + "data": { + "id": "019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e", + "traceId": "a1b2c3d4e5f678901234567890abcdef", + "projectId": "1", + "timestamp": "2026-08-25T16:37:12Z", + "view": "attribute", + "attribute": "severity" + } + } + ] + }, { "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/seer/markdown/__stories__/logEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/logEmbedStory.spec.tsx new file mode 100644 index 000000000000..722bfdcfddc3 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/logEmbedStory.spec.tsx @@ -0,0 +1,89 @@ +import {LogFixture} from 'sentry-fixture/log'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; + +import {LogEmbedStory} from './logEmbedStory'; + +jest.mock('sentry/components/seer/markdown', () => ({ + SeerMarkdown: ({raw}: {raw: string}) =>
{raw}
, +})); + +const TIMESTAMP_MS = Date.UTC(2026, 7, 28, 16, 37, 12); + +function createLog(id: string, overrides: Record = {}) { + return LogFixture({ + [OurLogKnownFieldKey.ID]: id, + [OurLogKnownFieldKey.PROJECT_ID]: '2', + [OurLogKnownFieldKey.ORGANIZATION_ID]: 3, + [OurLogKnownFieldKey.TRACE_ID]: 'a1b2c3d4e5f678901234567890abcdef', + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String( + BigInt(TIMESTAMP_MS) * 1_000_000n + ) as unknown as string, + ...overrides, + }); +} + +describe('LogEmbedStory', () => { + it('renders every view of a recent log that carries a trace and project', async () => { + const untraced = createLog('019bfe1c-0000-7e3d-9a2f-000000000000', { + [OurLogKnownFieldKey.TRACE_ID]: '', + }); + const log = createLog('019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e'); + const eventsRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: {data: [untraced, log], meta: {}}, + match: [ + MockApiClient.matchQuery({ + dataset: 'ourlogs', + sort: '-timestamp', + statsPeriod: '7d', + per_page: 25, + }), + ], + }); + + render(); + + const variants = await screen.findAllByLabelText('Rendered markdown'); + expect(variants).toHaveLength(4); + expect(eventsRequest).toHaveBeenCalled(); + + const [summary, attributes, attribute, idOnly] = variants as [ + HTMLElement, + HTMLElement, + HTMLElement, + HTMLElement, + ]; + + // The traceless row is skipped in favour of the one the details lookup can use. + for (const variant of variants) { + expect(variant).toHaveTextContent(log[OurLogKnownFieldKey.ID]); + expect(variant).not.toHaveTextContent(untraced[OurLogKnownFieldKey.ID]); + } + + expect(summary).toHaveTextContent(log[OurLogKnownFieldKey.TRACE_ID]); + expect(summary).toHaveTextContent(new Date(TIMESTAMP_MS).toISOString()); + expect(summary).not.toHaveTextContent('"view"'); + + expect(attributes).toHaveTextContent('"view":"attributes"'); + expect(attribute).toHaveTextContent('"view":"attribute","attribute":"severity"'); + + // The id-only variant exercises the embed resolving trace and project itself. + expect(idOnly).not.toHaveTextContent(log[OurLogKnownFieldKey.TRACE_ID]); + }); + + it('explains itself when the organization has no logs', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: {data: [], meta: {}}, + }); + + render(); + + expect( + await screen.findByText('No log is available for this organization.') + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/__stories__/logEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/logEmbedStory.tsx new file mode 100644 index 000000000000..91e225a18265 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/logEmbedStory.tsx @@ -0,0 +1,98 @@ +import {Fragment} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {AlwaysPresentLogFields} from 'sentry/views/explore/logs/constants'; +import { + OurLogKnownFieldKey, + type EventsLogsResult, + type OurLogsResponseItem, +} from 'sentry/views/explore/logs/types'; +import {getLogRowTimestampMillis} from 'sentry/views/explore/logs/utils'; + +import {EmbedStory, EmbedVariant} from './embedStory'; + +const STORY_REFERRER = 'seer-log-embed-story'; + +/** + * Every log carries a severity, so the breakdown variant always has something + * to group by whichever row this organization happens to supply. + */ +const BREAKDOWN_ATTRIBUTE = OurLogKnownFieldKey.SEVERITY; + +function toIdentity(log: OurLogsResponseItem) { + const timestampMs = getLogRowTimestampMillis(log); + + return { + id: String(log[OurLogKnownFieldKey.ID]), + traceId: String(log[OurLogKnownFieldKey.TRACE_ID]), + projectId: String(log[OurLogKnownFieldKey.PROJECT_ID]), + timestamp: Number.isFinite(timestampMs) + ? new Date(timestampMs).toISOString() + : String(log[OurLogKnownFieldKey.TIMESTAMP]), + }; +} + +export function LogEmbedStory() { + const organization = useOrganization(); + const {data, isError, isPending} = useQuery( + apiOptions.as()('/organizations/$organizationIdOrSlug/events/', { + path: {organizationIdOrSlug: organization.slug}, + query: { + dataset: DiscoverDatasets.OURLOGS, + field: AlwaysPresentLogFields, + project: [ALL_ACCESS_PROJECTS], + sort: `-${OurLogKnownFieldKey.TIMESTAMP}`, + statsPeriod: '7d', + per_page: 25, + referrer: STORY_REFERRER, + }, + // Keep every variant on the page pointed at the same row while it is read. + staleTime: Infinity, + }) + ); + + // The details lookup behind the block is addressed by trace and project, so + // prefer a row that already carries both over one the block has to resolve. + const log = data?.data.find( + row => row[OurLogKnownFieldKey.TRACE_ID] && row[OurLogKnownFieldKey.PROJECT_ID] + ); + const identity = log ? toIdentity(log) : null; + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load a log example. + ) : identity ? ( + + + + + + + ) : ( + No log is available for this organization. + )} + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx new file mode 100644 index 000000000000..e0e22a00792e --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -0,0 +1,307 @@ +import * as Sentry from '@sentry/react'; +import {ProjectFixture} from 'sentry-fixture/project'; + +import {screen, waitFor} from 'sentry-test/reactTestingLibrary'; +import {resetMockDate, setMockDate} from 'sentry-test/utils'; + +import {PageFiltersStore} from 'sentry/components/pageFilters/store'; +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; +import {ProjectsStore} from 'sentry/stores/projectsStore'; +import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; + +const LOG_ID = '019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e'; +const TRACE_ID = 'a1b2c3d4e5f678901234567890abcdef'; +const TIMESTAMP = '2026-08-25T16:37:12Z'; +const PROJECT_ID = '2'; +const PROJECT_SLUG = 'web'; + +const ATTRIBUTES: TraceItemResponseAttribute[] = [ + {name: OurLogKnownFieldKey.MESSAGE, type: 'str', value: 'Payment provider timed out'}, + {name: OurLogKnownFieldKey.SEVERITY, type: 'str', value: 'error'}, + {name: OurLogKnownFieldKey.SEVERITY_NUMBER, type: 'int', value: 17}, + {name: OurLogKnownFieldKey.TRACE_ID, type: 'str', value: TRACE_ID}, + {name: 'region', type: 'str', value: 'us-east-1'}, +]; + +function mockLogDetails(attributes = ATTRIBUTES) { + return MockApiClient.addMockResponse({ + url: `/projects/org-slug/${PROJECT_SLUG}/trace-items/${LOG_ID}/`, + body: { + itemId: LOG_ID, + links: null, + meta: {}, + timestamp: TIMESTAMP, + attributes, + }, + }); +} + +/** The row the id-only path has to find before it can ask for details. */ +function mockLogRowLookup() { + return MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: { + data: [ + { + [OurLogKnownFieldKey.ID]: LOG_ID, + [OurLogKnownFieldKey.PROJECT_ID]: PROJECT_ID, + [OurLogKnownFieldKey.TRACE_ID]: TRACE_ID, + [OurLogKnownFieldKey.SEVERITY]: 'error', + [OurLogKnownFieldKey.SEVERITY_NUMBER]: 17, + [OurLogKnownFieldKey.TIMESTAMP]: TIMESTAMP, + [OurLogKnownFieldKey.TIMESTAMP_PRECISE]: String( + BigInt(new Date(TIMESTAMP).getTime()) * 1_000_000n + ), + }, + ], + meta: {fields: {}, units: {}}, + }, + }); +} + +function renderLog(data: Record = {}) { + return renderEmbed({ + name: 'log', + data: { + id: LOG_ID, + traceId: TRACE_ID, + projectId: PROJECT_ID, + timestamp: TIMESTAMP, + ...data, + }, + }); +} + +describe('Seer log embed', () => { + beforeEach(() => { + ProjectsStore.loadInitialData([ProjectFixture({id: PROJECT_ID, slug: PROJECT_SLUG})]); + PageFiltersStore.init(); + PageFiltersStore.onInitializeUrlState({ + projects: [Number(PROJECT_ID)], + environments: [], + datetime: {period: '14d', start: null, end: null, utc: null}, + }); + }); + + afterEach(() => { + resetMockDate(); + }); + + it('links to the single row in Explore, windowed around its timestamp', () => { + const href = getEmbedLinkHref('log', 'Log 019bfe1c', { + id: LOG_ID, + traceId: TRACE_ID, + projectId: PROJECT_ID, + timestamp: TIMESTAMP, + }); + + expect(href).toContain('/organizations/org-slug/explore/logs/'); + expect(href).toContain(`logsQuery=id%3A${LOG_ID}`); + expect(href).toContain(`logsRowId=${LOG_ID}`); + expect(href).toContain('mode=samples'); + expect(href).toContain('project=2'); + expect(href).toContain('start=2026-08-25T16%3A32%3A12'); + expect(href).toContain('end=2026-08-25T16%3A42%3A12'); + }); + + it('renders the severity, message and timestamp of the log', async () => { + const details = mockLogDetails(); + + renderLog(); + + expect(await screen.findByText('Payment provider timed out')).toBeInTheDocument(); + expect(screen.getByText('error')).toBeInTheDocument(); + expect(screen.getByTestId('seer-log-embed')).toBeInTheDocument(); + expect(screen.queryByTestId('seer-log-attributes')).not.toBeInTheDocument(); + expect(details).toHaveBeenCalledWith( + `/projects/org-slug/${PROJECT_SLUG}/trace-items/${LOG_ID}/`, + expect.objectContaining({ + query: expect.objectContaining({ + trace_id: TRACE_ID, + timestamp: new Date(TIMESTAMP).getTime() / 1000, + }), + }) + ); + }); + + it('renders on a page that never initialized page filters', async () => { + // Seer renders from the organization layout, so it appears on plenty of + // pages that mount no PageFiltersContainer -- the stories page among them. + // `init()` leaves `isReady` false, which is all such a page ever has. + PageFiltersStore.init(); + const details = mockLogDetails(); + + renderLog(); + + expect(await screen.findByText('Payment provider timed out')).toBeInTheDocument(); + expect(details).toHaveBeenCalled(); + }); + + it('renders the attribute tree for view "attributes"', async () => { + mockLogDetails(); + + renderLog({view: 'attributes'}); + + expect(await screen.findByTestId('seer-log-attributes')).toBeInTheDocument(); + expect(screen.getByTestId('tree-key-region')).toHaveTextContent('region'); + expect(screen.getByText('us-east-1')).toBeInTheDocument(); + // The message is the summary line, not a tree row. + expect(screen.getAllByText('Payment provider timed out')).toHaveLength(1); + }); + + it('breaks a single attribute down for view "attribute"', async () => { + mockLogDetails(); + const aggregates = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: { + data: [ + {region: 'us-east-1', 'count()': 8}, + {region: 'eu-west-1', 'count()': 2}, + ], + }, + }); + + renderLog({view: 'attribute', attribute: 'region'}); + + expect(await screen.findByTestId('seer-log-attribute-breakdown')).toBeInTheDocument(); + expect(await screen.findByText('eu-west-1')).toBeInTheDocument(); + expect(screen.getByText('80%')).toBeInTheDocument(); + expect(screen.getByText('20%')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'Break down in Explore'})).toHaveAttribute( + 'href', + expect.stringContaining('mode=aggregate') + ); + expect(aggregates).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + field: ['region', 'count()'], + orderby: '-count()', + }), + }) + ); + }); + + it('measures the breakdown against every value, not just the drawn ones', async () => { + mockLogDetails(); + // Six values for five slots: the sixth is what the drawn shares are missing. + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: { + data: [ + {region: 'a', 'count()': 10}, + {region: 'b', 'count()': 10}, + {region: 'c', 'count()': 10}, + {region: 'd', 'count()': 10}, + {region: 'e', 'count()': 10}, + {region: 'f', 'count()': 50}, + ], + }, + }); + + renderLog({view: 'attribute', attribute: 'region'}); + + expect(await screen.findByTestId('seer-log-attribute-breakdown')).toBeInTheDocument(); + // 10 of 100, not 10 of the 50 that fit. + expect(await screen.findAllByText('10%')).toHaveLength(5); + expect(screen.queryByText('f')).not.toBeInTheDocument(); + expect(screen.getByText('Other')).toBeInTheDocument(); + expect(screen.getByText('50%')).toBeInTheDocument(); + }); + + it('falls back to the summary when view "attribute" has no key', async () => { + mockLogDetails(); + const aggregates = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: {data: []}, + }); + + renderLog({view: 'attribute'}); + + expect(await screen.findByText('Payment provider timed out')).toBeInTheDocument(); + expect(screen.queryByTestId('seer-log-attribute-breakdown')).not.toBeInTheDocument(); + expect(aggregates).not.toHaveBeenCalled(); + }); + + it('resolves the trace and project from the id alone before fetching details', async () => { + const lookup = mockLogRowLookup(); + const details = mockLogDetails(); + + renderEmbed({name: 'log', data: {id: LOG_ID, timestamp: TIMESTAMP}}); + + expect(await screen.findByText('Payment provider timed out')).toBeInTheDocument(); + await waitFor(() => { + expect(lookup).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({ + dataset: 'ourlogs', + query: `id:${LOG_ID}`, + project: [-1], + }), + }) + ); + }); + expect(details).toHaveBeenCalled(); + }); + + it('asks for details at the row timestamp, not the one minted into the id', async () => { + // The decoder refuses ids minted after "now", and jest pins now to 2017, so + // no realistic v7 id decodes at the default clock. Move it past this id's + // mint time (2026-01-27) or the mint time cannot compete with the row's. + setMockDate(new Date('2026-09-01T00:00:00Z')); + mockLogRowLookup(); + const details = mockLogDetails(); + + // With no timestamp from Seer the id is the only other clue, and it carries + // mint time -- the drift the lookup exists to correct. + renderEmbed({name: 'log', data: {id: LOG_ID}}); + + await waitFor(() => + expect(details).toHaveBeenCalledWith( + `/projects/org-slug/${PROJECT_SLUG}/trace-items/${LOG_ID}/`, + expect.objectContaining({ + query: expect.objectContaining({ + timestamp: new Date(TIMESTAMP).getTime() / 1000, + }), + }) + ) + ); + }); + + it('points the header link at the project it resolved from the id', async () => { + mockLogRowLookup(); + mockLogDetails(); + + renderEmbed({name: 'log', data: {id: LOG_ID, timestamp: TIMESTAMP}}); + + expect(await screen.findByText('Payment provider timed out')).toBeInTheDocument(); + // Without the resolved project the link scopes Explore to My Projects and + // can miss the row the card just loaded. + const href = screen + .getByRole('link', {name: `Log ${LOG_ID.slice(0, 8)}`}) + .getAttribute('href'); + expect(href).toContain(`project=${PROJECT_ID}`); + }); + + it('reports an error for a project this viewer cannot see', async () => { + // `useTraceItemDetails` needs the project in the store to build its URL, and + // disables itself without one -- a disabled query must not read as loading. + ProjectsStore.loadInitialData([ProjectFixture({id: '999', slug: 'other'})]); + const details = mockLogDetails(); + const captureException = jest + .spyOn(Sentry, 'captureException') + .mockImplementation(() => ''); + + renderLog(); + + expect(await screen.findByText('Unable to load log details')).toBeInTheDocument(); + expect(details).not.toHaveBeenCalled(); + // An inaccessible project is a state the card renders, not an app error. + expect(captureException).not.toHaveBeenCalled(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/log/log.tsx b/static/app/components/seer/markdown/embeds/components/log/log.tsx new file mode 100644 index 000000000000..1ff44d090e3e --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/log.tsx @@ -0,0 +1,17 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {LogLink} from 'sentry/components/seer/markdown/embeds/components/log/logLink'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +const LazyLogBlock = lazy(() => import('./logBlock')); + +export const Log = defineSeerEmbed({ + name: 'log', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx b/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx new file mode 100644 index 000000000000..a60fc8df6b82 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx @@ -0,0 +1,164 @@ +import {useMemo} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import {IconList} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {percent} from 'sentry/utils'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import {TagBar} from 'sentry/views/issueDetails/groupTags/tagDistribution'; + +import { + getLogAttributeUrl, + getLogPageFilters, + LOG_AGGREGATE_WINDOW_MS, + LOG_EMBED_REFERRER, + toDateQueryParams, + type LogEmbedIdentity, +} from './logUtils'; + +const COUNT = 'count()'; + +/** How many groups the card draws. */ +const TOP_VALUE_COUNT = 5; + +/** + * How many it asks for. The surplus rows never render -- they are what makes + * each share a portion of everything nearby rather than a portion of whatever + * happened to fit, and what gives the remainder below a real size. + * + * Deliberately not a second, ungrouped `count()` query the way the issue tag + * distribution gets its total: the two are sampled independently, so that total + * comes back disagreeing with the groups it is supposed to contain -- smaller + * than their sum often enough to render a negative remainder. + */ +const AGGREGATE_ROW_LIMIT = 50; + +function formatShare(share: number) { + return share < 1 ? t('<1%') : `${Math.round(share)}%`; +} + +/** + * The aggregates endpoint returns one row per group, keyed by the attribute + * that was grouped on, so the row shape isn't known until runtime. + */ +interface LogAttributeAggregates { + data: Array>; +} + +interface LogAttributeViewProps { + attribute: string; + identity: LogEmbedIdentity; +} + +/** + * Logs have no attribute-distribution endpoint of their own -- the trace item + * stats endpoint only serves spans and occurrences -- so break the attribute + * down with a plain aggregate logs query instead. + */ +export function LogAttributeView({attribute, identity}: LogAttributeViewProps) { + const organization = useOrganization(); + const selection = useMemo( + () => getLogPageFilters(identity, LOG_AGGREGATE_WINDOW_MS), + [identity] + ); + + const {data, isError, isPending} = useQuery({ + ...apiOptions.as()( + '/organizations/$organizationIdOrSlug/events/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + dataset: DiscoverDatasets.OURLOGS, + field: [attribute, COUNT], + orderby: `-${COUNT}`, + per_page: AGGREGATE_ROW_LIMIT, + project: selection.projects, + environment: selection.environments, + sampling: SAMPLING_MODE.NORMAL, + referrer: LOG_EMBED_REFERRER, + ...toDateQueryParams(selection), + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + const allRows = data?.data ?? []; + const rows = allRows.slice(0, TOP_VALUE_COUNT); + const total = allRows.reduce((sum, row) => sum + Number(row[COUNT] ?? 0), 0); + const shown = rows.reduce((sum, row) => sum + Number(row[COUNT] ?? 0), 0); + const otherCount = total - shown; + + return ( + + + + {attribute} + + + + {isPending ? ( + + + + ) : isError ? ( + {t('Unable to load attribute breakdown')} + ) : rows.length === 0 ? ( + {t('No logs with this attribute nearby')} + ) : ( + + {rows.map((row, index) => { + const value = row[attribute]; + const count = Number(row[COUNT] ?? 0); + const share = percent(count, total); + + return ( + + + {value === null || value === '' ? t('(empty)') : String(value)} + + + + {formatShare(share)} + + + + + ); + })} + {otherCount > 0 && ( + + + {t('Other')} + + + + {formatShare(percent(otherCount, total))} + + + + + )} + + )} + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/log/logAttributesView.tsx b/static/app/components/seer/markdown/embeds/components/log/logAttributesView.tsx new file mode 100644 index 000000000000..ba1c65408a0c --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logAttributesView.tsx @@ -0,0 +1,116 @@ +import {useMemo} from 'react'; +import {useTheme} from '@emotion/react'; +import type {Location} from 'history'; + +import {Container} from '@sentry/scraps/layout'; + +import type {PageFilterDatetime} from 'sentry/types/core'; +import type {ReactRouter3Navigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {AttributesTree} from 'sentry/views/explore/components/traceItemAttributes/attributesTree'; +import type {TraceItemResponseAttribute} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {HiddenLogDetailFields} from 'sentry/views/explore/logs/constants'; +import { + LogAttributesRendererMap, + type RendererExtra, +} from 'sentry/views/explore/logs/fieldRenderers'; +import {adjustAliases} from 'sentry/views/explore/logs/utils'; + +/** + * The attribute renderers take a router location and a navigate callback so the + * logs table can round-trip its own query params. An embed must not read or + * write the host page's URL, so they get an inert pair instead: the one + * renderer that reads the location (the trace link) then builds a clean target + * from the attributes alone, and nothing in the map ever navigates. + */ +const INERT_LOCATION: Location = { + pathname: '', + search: '', + hash: '', + query: {}, + state: null, + key: '', + action: 'POP', +}; + +const INERT_NAVIGATE: ReactRouter3Navigate = () => {}; + +interface LogAttributesViewProps { + attributeTypes: RendererExtra['attributeTypes']; + attributeValues: RendererExtra['attributes']; + /** + * Every attribute the log has, already assembled by the block. + */ + attributes: TraceItemResponseAttribute[]; + datetime: PageFilterDatetime; + logColors: RendererExtra['logColors']; + projectSlug?: string; +} + +export function LogAttributesView({ + attributes, + attributeTypes, + attributeValues, + datetime, + logColors, + projectSlug, +}: LogAttributesViewProps) { + const organization = useOrganization(); + const theme = useTheme(); + + const visibleAttributes = useMemo( + () => + attributes + .filter(attribute => !HiddenLogDetailFields.includes(attribute.name)) + .toSorted((a, b) => a.name.localeCompare(b.name)), + [attributes] + ); + + const rendererExtra = useMemo( + () => ({ + attributes: attributeValues, + attributeTypes, + caseSensitiveHighlighting: false, + datetime, + // Nothing in the embed is searching, so there is nothing to highlight. + highlightTerms: [], + logColors, + location: INERT_LOCATION, + navigate: INERT_NAVIGATE, + organization, + projectSlug, + theme, + // The attributes are already on screen, so nothing should wait to render. + disableLazyLoad: true, + }), + [ + attributeTypes, + attributeValues, + datetime, + logColors, + organization, + projectSlug, + theme, + ] + ); + + if (visibleAttributes.length === 0) { + return null; + } + + return ( + + + attributes={visibleAttributes} + // A single column keeps the tree readable at the width Seer renders in. + columnCount={1} + // The row actions filter the logs table the tree normally lives in, + // which an embed has no query params to write to. + config={{disableActions: true}} + getAdjustedAttributeKey={adjustAliases} + renderers={LogAttributesRendererMap} + rendererExtra={rendererExtra} + /> + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx new file mode 100644 index 000000000000..de9802072ba5 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -0,0 +1,372 @@ +import {useMemo} from 'react'; +import {useTheme} from '@emotion/react'; +import {useQuery} from '@tanstack/react-query'; + +import {Tag} from '@sentry/scraps/badge'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import {DateTime} from 'sentry/components/dateTime'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; +import {LogAttributesView} from 'sentry/components/seer/markdown/embeds/components/log/logAttributesView'; +import {LogAttributeView} from 'sentry/components/seer/markdown/embeds/components/log/logAttributeView'; +import {LogLink} from 'sentry/components/seer/markdown/embeds/components/log/logLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {t} from 'sentry/locale'; +import type {PageFilterDatetime} from 'sentry/types/core'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {toSplicedSorted} from 'sentry/utils/array/toSplicedSorted'; +import {DiscoverDatasets} from 'sentry/utils/discover/types'; +import type {TagVariant} from 'sentry/utils/theme/types'; +import {unreachable} from 'sentry/utils/unreachable'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useProjectFromId} from 'sentry/utils/useProjectFromId'; +import {useProjects} from 'sentry/utils/useProjects'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import { + useTraceItemDetails, + type TraceItemDetailsResponse, + type TraceItemResponseAttribute, +} from 'sentry/views/explore/hooks/useTraceItemDetails'; +import {AlwaysPresentLogFields} from 'sentry/views/explore/logs/constants'; +import type {RendererExtra} from 'sentry/views/explore/logs/fieldRenderers'; +import {getLogColors} from 'sentry/views/explore/logs/styles'; +import { + OurLogKnownFieldKey, + type EventsLogsResult, + type OurLogsResponseItem, +} from 'sentry/views/explore/logs/types'; +import { + getLogRowTimestampMillis, + getLogSeverityLevel, + SeverityLevel, + severityLevelToText, +} from 'sentry/views/explore/logs/utils'; +import {TraceItemDataset} from 'sentry/views/explore/types'; + +import { + getLogPageFilters, + getLogTimestampMs, + LOG_DETAILS_REFERRER, + LOG_EMBED_REFERRER, + LOG_LOOKUP_WINDOW_MS, + toDateQueryParams, + toProjectId, + type LogEmbedIdentity, +} from './logUtils'; + +type LogData = EmbedOutput<'log'>; + +/** + * The details endpoint is addressed by trace and project, but Seer only has to + * give us the log's id. When it withheld either one, find the row first: it + * carries both, plus the precise timestamp the details lookup wants. + */ +function useResolvedLogRow({ + enabled, + id, + projectId, + timestamp, +}: LogEmbedIdentity & {enabled: boolean}) { + const organization = useOrganization(); + const selection = useMemo( + () => getLogPageFilters({id, projectId, timestamp}, LOG_LOOKUP_WINDOW_MS), + [id, projectId, timestamp] + ); + + return useQuery({ + ...apiOptions.as()('/organizations/$organizationIdOrSlug/events/', { + path: {organizationIdOrSlug: organization.slug}, + query: { + dataset: DiscoverDatasets.OURLOGS, + field: AlwaysPresentLogFields, + query: `${OurLogKnownFieldKey.ID}:${id}`, + // Without a project id the row could be in any of them. + project: + selection.projects.length > 0 ? selection.projects : [ALL_ACCESS_PROJECTS], + // The row is a single needle, so never let sampling drop it. + sampling: SAMPLING_MODE.HIGH_ACCURACY, + per_page: 1, + referrer: LOG_EMBED_REFERRER, + ...toDateQueryParams(selection), + }, + // A log line never changes once written. + staleTime: Infinity, + }), + enabled, + retry: false, + }); +} + +function toAttributeValues(attributes: TraceItemResponseAttribute[]) { + return Object.fromEntries( + attributes.map(attribute => [attribute.name, attribute.value]) + ) as RendererExtra['attributes']; +} + +function toAttributeTypes(attributes: TraceItemResponseAttribute[]) { + return Object.fromEntries( + attributes.map(attribute => [attribute.name, attribute.type]) + ) as RendererExtra['attributeTypes']; +} + +/** + * The details response keeps the timestamp beside the attributes rather than + * among them, so splice it back in the way the logs table does. + */ +function toLogAttributes( + details: TraceItemDetailsResponse +): TraceItemResponseAttribute[] { + if (details.attributes.some(a => a.name === OurLogKnownFieldKey.TIMESTAMP)) { + return details.attributes; + } + + return toSplicedSorted( + details.attributes, + {name: OurLogKnownFieldKey.TIMESTAMP, type: 'str', value: details.timestamp}, + (a, b) => a.name.localeCompare(b.name) + ); +} + +/** + * `Tag` offers the semantic variants rather than the logs table's per-level + * colors. That is the right trade here: those finer shades exist to be scanned + * down a column of rows, and a single embedded row has no column. + */ +function severityTagVariant(level: SeverityLevel): TagVariant { + switch (level) { + case SeverityLevel.FATAL: + case SeverityLevel.ERROR: + return 'danger'; + case SeverityLevel.WARN: + return 'warning'; + case SeverityLevel.INFO: + return 'info'; + case SeverityLevel.TRACE: + case SeverityLevel.DEBUG: + case SeverityLevel.DEFAULT: + case SeverityLevel.UNKNOWN: + return 'muted'; + default: + unreachable(level); + return 'muted'; + } +} + +function rowTimestampMillis(row: OurLogsResponseItem | undefined): number | null { + if (!row) { + return null; + } + const millis = getLogRowTimestampMillis(row); + return Number.isFinite(millis) ? millis : null; +} + +function parseTimestampMillis(timestamp: string | undefined): number | null { + const millis = timestamp === undefined ? NaN : new Date(timestamp).getTime(); + return Number.isFinite(millis) ? millis : null; +} + +interface LogBlockContentProps { + attributeTypes: RendererExtra['attributeTypes']; + attributeValues: RendererExtra['attributes']; + attributes: TraceItemResponseAttribute[]; + datetime: PageFilterDatetime; + identity: LogEmbedIdentity; + logColors: ReturnType; + view: LogData['view']; + attribute?: string; + projectSlug?: string; +} + +/** + * Dispatches to the one component that knows how to render this view. Adding a + * view is a new file next to this one, plus a case here. + */ +function LogBlockContent({ + attribute, + attributes, + attributeTypes, + attributeValues, + datetime, + identity, + logColors, + projectSlug, + view, +}: LogBlockContentProps) { + switch (view) { + case 'summary': + // The severity, message and timestamp above are the whole summary. + return null; + case 'attributes': + return ( + + ); + case 'attribute': + // `view` is narrowed to 'summary' by the block when no key was given. + return attribute ? ( + + ) : null; + default: + unreachable(view); + return null; + } +} + +export default function LogBlock(props: LogData) { + const {id, traceId, timestamp, attribute} = props; + const projectId = toProjectId(props.projectId); + const theme = useTheme(); + + // A breakdown needs a key to break down; without one there is nothing to + // render beyond the log itself. + const view = props.view === 'attribute' && !attribute ? 'summary' : props.view; + + const needsResolution = !traceId || !projectId; + const rowQuery = useResolvedLogRow({ + enabled: needsResolution, + id, + projectId, + timestamp, + }); + const row = rowQuery.data?.data?.[0]; + + const resolvedTraceId = + traceId ?? (row?.[OurLogKnownFieldKey.TRACE_ID] as string | undefined); + const resolvedProjectId = + projectId ?? + (row === undefined ? undefined : String(row[OurLogKnownFieldKey.PROJECT_ID])); + // The row wins when the lookup found one: the id carries only the time the + // SDK minted it, and the +/-5min window exists precisely because that drifts + // from when the log was ingested. Handing the mint time to a details endpoint + // that wants the real one would lose the row the lookup just found. + const lookupTimestampMs = + rowTimestampMillis(row) ?? getLogTimestampMs({id, projectId, timestamp}); + + const project = useProjectFromId({project_id: resolvedProjectId}); + const {fetching: projectsFetching} = useProjects(); + + // `useTraceItemDetails` addresses the endpoint by the project's slug, so it + // disables itself until `useProjectFromId` finds one -- and reports the miss + // to Sentry unless the caller disabled it too. This is the one condition, and + // it gates the spinner as well: a disabled query reads as `pending`, so + // without it a project this viewer cannot see spins forever instead of + // reaching the error branch. + const canFetchDetails = Boolean(resolvedProjectId && resolvedTraceId && project); + + // Deliberately not `useExploreLogsTableRow`: that hook additionally waits on + // the host page's `usePageFilters().isReady`, which the logs table needs and + // an embed carrying its own trace, project and timestamp does not. Seer + // renders from the organization layout, so it appears on plenty of pages that + // mount no `PageFiltersContainer` -- there the gate never opens and the block + // spins forever. + const detailsQuery = useTraceItemDetails({ + traceItemId: id, + projectId: resolvedProjectId ?? '', + traceId: resolvedTraceId ?? '', + traceItemType: TraceItemDataset.LOGS, + referrer: LOG_DETAILS_REFERRER, + // The details endpoint takes unix seconds, not an ISO string. + timestamp: lookupTimestampMs === null ? undefined : lookupTimestampMs / 1000, + enabled: canFetchDetails, + }); + const details = detailsQuery.data; + + const isResolving = needsResolution && rowQuery.isPending; + // `projectsFetching` covers the window where the store is still filling. + const isPending = + isResolving || projectsFetching || (canFetchDetails && detailsQuery.isPending); + const isError = !isPending && (!canFetchDetails || detailsQuery.isError || !details); + + const attributes = useMemo(() => (details ? toLogAttributes(details) : []), [details]); + const attributeValues = useMemo(() => toAttributeValues(attributes), [attributes]); + const attributeTypes = useMemo(() => toAttributeTypes(attributes), [attributes]); + + const level = getLogSeverityLevel( + Number(attributeValues[OurLogKnownFieldKey.SEVERITY_NUMBER]) || null, + (attributeValues[OurLogKnownFieldKey.SEVERITY] as string | undefined) ?? null + ); + const logColors = getLogColors(level, theme); + const message = attributeValues[OurLogKnownFieldKey.MESSAGE]; + const displayTimestampMs = + parseTimestampMillis(details?.timestamp) ?? lookupTimestampMs; + + const identity = useMemo( + () => ({ + id, + projectId: resolvedProjectId, + // The same resolved instant the details lookup used, so the link's window + // and the breakdown's window agree with the row that was actually found. + // Falls back to Seer's own timestamp, then to the id, then to retention. + timestamp: + lookupTimestampMs === null + ? undefined + : new Date(lookupTimestampMs).toISOString(), + }), + [id, lookupTimestampMs, resolvedProjectId] + ); + const datetime = useMemo( + () => getLogPageFilters(identity, LOG_LOOKUP_WINDOW_MS).datetime, + [identity] + ); + + return ( + + + + {/* The resolved identity, not the raw props: when Seer gave only an + id, the link would otherwise scope Explore to My Projects and miss + the very row this card just loaded. */} + + {displayTimestampMs === null ? null : ( + + + + )} + + + {isPending ? ( + + + + ) : isError ? ( + {t('Unable to load log details')} + ) : ( + + + {severityLevelToText(level)} + + {String(message ?? '')} + + + + + )} + + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/log/logLink.tsx b/static/app/components/seer/markdown/embeds/components/log/logLink.tsx new file mode 100644 index 000000000000..12deb0407d80 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logLink.tsx @@ -0,0 +1,22 @@ +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconList} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {getShortEventId} from 'sentry/utils/events'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {getLogRowUrl, toProjectId} from './logUtils'; + +export function LogLink({id, projectId, timestamp}: EmbedOutput<'log'>) { + const organization = useOrganization(); + const href = getLogRowUrl({ + organization, + id, + projectId: toProjectId(projectId), + timestamp, + }); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/log/logUtils.ts b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts new file mode 100644 index 000000000000..e25d16cf087f --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts @@ -0,0 +1,139 @@ +import type {PageFilters} from 'sentry/types/core'; +import type {Organization} from 'sentry/types/organization'; +import {getUtcDateString} from 'sentry/utils/dates'; +import {LOGS_ROW_ID_KEY} from 'sentry/views/explore/contexts/logs/logsPageParams'; +import {Mode} from 'sentry/views/explore/contexts/pageParamsContext/mode'; +import {logItemIdToTimestamp} from 'sentry/views/explore/logs/pinning/logItemId'; +import {OurLogKnownFieldKey} from 'sentry/views/explore/logs/types'; +import {getLogsUrl} from 'sentry/views/explore/logs/utils'; + +export const LOG_EMBED_REFERRER = 'seer-log-embed'; + +/** + * The trace-item details endpoint keeps an allowlist of referrers, so the block + * keeps the one the logs table already registered rather than its own. + */ +export const LOG_DETAILS_REFERRER = 'api.explore.log-item-details'; + +/** + * Padding around the log's own timestamp when looking the single row up, to + * absorb clock skew between when the SDK minted the id and when the log was + * ingested. Matches the window the pinned-log lookup uses. + */ +export const LOG_LOOKUP_WINDOW_MS = 5 * 60 * 1000; + +/** + * A single log's neighbourhood is too thin to break an attribute down over, so + * the aggregate view widens to the hour around it. The Explore link uses the + * same window, so the numbers there match the ones rendered here. + */ +export const LOG_AGGREGATE_WINDOW_MS = 60 * 60 * 1000; + +/** + * Used only when neither Seer nor the id tells us when the log happened. + */ +const LOG_FALLBACK_STATS_PERIOD = '14d'; + +/** + * Seer may report a project id as a number, but everything downstream -- the + * details endpoint's project lookup included -- keys off the string form. + */ +export function toProjectId(projectId: string | number | undefined) { + return projectId === undefined ? undefined : String(projectId); +} + +export interface LogEmbedIdentity { + id: string; + projectId?: string; + timestamp?: string; +} + +/** + * Log ids are UUIDv7, so the id alone carries the creation time. Seer's + * `timestamp` wins when it supplied one; otherwise decode the id so we can + * still scan a tight window instead of the org's whole retention. The decoder + * only reads unseparated hex, so try the dashed form stripped as well. + */ +export function getLogTimestampMs({id, timestamp}: LogEmbedIdentity): number | null { + if (timestamp) { + const parsed = new Date(timestamp).getTime(); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return logItemIdToTimestamp(id) ?? logItemIdToTimestamp(id.replace(/-/g, '')); +} + +/** + * The embed carries no page filters of its own, so every query and link it + * builds is scoped to the log itself rather than to whatever the host page + * happens to have selected. + */ +export function getLogPageFilters( + identity: LogEmbedIdentity, + windowMs: number +): PageFilters { + const timestampMs = getLogTimestampMs(identity); + const projectId = Number(identity.projectId); + + return { + projects: Number.isInteger(projectId) ? [projectId] : [], + environments: [], + datetime: + timestampMs === null + ? {period: LOG_FALLBACK_STATS_PERIOD, start: null, end: null, utc: null} + : { + period: null, + start: getUtcDateString(timestampMs - windowMs), + end: getUtcDateString(timestampMs + windowMs), + utc: true, + }, + }; +} + +/** + * Filters Explore down to this one row. `logsRowId` additionally highlights and + * auto-expands it once the table loads. + */ +export function getLogRowUrl({ + organization, + ...identity +}: LogEmbedIdentity & {organization: Organization}): string { + const url = getLogsUrl({ + organization, + selection: getLogPageFilters(identity, LOG_LOOKUP_WINDOW_MS), + query: `${OurLogKnownFieldKey.ID}:${identity.id}`, + mode: Mode.SAMPLES, + referrer: LOG_EMBED_REFERRER, + }); + + return `${url}&${LOGS_ROW_ID_KEY}=${encodeURIComponent(identity.id)}`; +} + +/** + * Explore in aggregate mode, grouped by the attribute the block broke down. + */ +export function getLogAttributeUrl({ + organization, + attribute, + ...identity +}: LogEmbedIdentity & {attribute: string; organization: Organization}): string { + return getLogsUrl({ + organization, + selection: getLogPageFilters(identity, LOG_AGGREGATE_WINDOW_MS), + mode: Mode.AGGREGATE, + groupBy: [attribute], + aggregateFn: 'count', + aggregateParam: OurLogKnownFieldKey.MESSAGE, + referrer: LOG_EMBED_REFERRER, + }); +} + +/** + * `PageFilters` datetime as the events endpoint wants it. + */ +export function toDateQueryParams(selection: PageFilters) { + const {period, start, end} = selection.datetime; + return period ? {statsPeriod: period} : {start, end, utc: true}; +} diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index 2460ba2b8827..285f2606ecb7 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -11,6 +11,7 @@ import {ErrorsQuery} from './components/errorsQuery'; import {SeerEvent} from './components/event/event'; import {Issue, Issues} from './components/issue'; import {IssuesQuery} from './components/issuesQuery'; +import {Log} from './components/log/log'; import {LogsQuery} from './components/logsQuery'; import {MetricsQuery} from './components/metricsQuery'; import {Monitor} from './components/monitor/monitor'; @@ -41,6 +42,7 @@ const embeds = [ Issue, Issues, IssuesQuery, + Log, LogsQuery, MetricsQuery, Monitor, diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 172d5c0f8545..36880bbb1bc0 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -588,6 +588,83 @@ export const SEER_EMBED_SCHEMAS = { }, ], }, + log: { + description: + 'The ONLY way to reference a single log line (Explore > Logs). ' + + '`id` is the log item ID exactly as the logs API returns it. Provide ' + + '`traceId`, `projectId`, and `timestamp` whenever the API gave them to ' + + 'you — without them the embed has to scan a wider window to find the row. ' + + 'When referencing a SET of logs defined by a search, use the `logsQuery` ' + + 'embed instead. ' + + 'Inline: renders a compact link that opens the log row in Explore. ' + + 'Block: renders the log row with its severity, message, and timestamp — ' + + 'do NOT duplicate any of that as text. ' + + 'Set `view` to "attributes" to also render the full attribute list for the ' + + 'log, or to "attribute" together with `attribute` to break that one ' + + 'attribute down across matching logs. Leave `view` as "summary" unless the ' + + 'user asked about attributes. ' + + 'Never use a markdown link for log references.', + level: ['inline', 'block'], + schema: z.object({ + id: z.string().min(1), + traceId: z.string().min(1).optional(), + projectId: idString.optional(), + timestamp: isoTimestampSchema.optional(), + view: z.enum(['summary', 'attributes', 'attribute']).default('summary'), + attribute: z + .string() + .min(1) + .optional() + .describe( + 'Required when view is "attribute". The attribute key to break down, e.g. "severity".' + ), + }), + examples: [ + { + label: 'Inline', + level: 'inline', + data: { + id: '019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e', + traceId: 'a1b2c3d4e5f678901234567890abcdef', + projectId: '1', + timestamp: '2026-08-25T16:37:12Z', + }, + }, + { + label: 'Block', + level: 'block', + data: { + id: '019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e', + traceId: 'a1b2c3d4e5f678901234567890abcdef', + projectId: '1', + timestamp: '2026-08-25T16:37:12Z', + }, + }, + { + label: 'All attributes', + level: 'block', + data: { + id: '019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e', + traceId: 'a1b2c3d4e5f678901234567890abcdef', + projectId: '1', + timestamp: '2026-08-25T16:37:12Z', + view: 'attributes', + }, + }, + { + label: 'Single attribute breakdown', + level: 'block', + data: { + id: '019bfe1c-4c1f-7e3d-9a2f-3e6b1a2c3d4e', + traceId: 'a1b2c3d4e5f678901234567890abcdef', + projectId: '1', + timestamp: '2026-08-25T16:37:12Z', + view: 'attribute', + attribute: 'severity', + }, + }, + ], + }, 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 e2fee6461d98..ec353b41516f 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -14,6 +14,7 @@ import {ConversationEmbedStory} from './__stories__/conversationEmbedStory'; import {DashboardEmbedStory} from './__stories__/dashboardEmbedStory'; import {EmbedStory} from './__stories__/embedStory'; import {EventEmbedStory} from './__stories__/eventEmbedStory'; +import {LogEmbedStory} from './__stories__/logEmbedStory'; import {MonitorEmbedStory} from './__stories__/monitorEmbedStory'; import {ReleaseEmbedStory} from './__stories__/releaseEmbedStory'; import {ReplayEmbedStory} from './__stories__/replayEmbedStory'; @@ -135,6 +136,10 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### log + + + ### replaysQuery