From 1486e688d6ee408d58e78b84746b6bf73f226f8d Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:13:01 -0400 Subject: [PATCH 01/14] feat(seer): add the log embed entry point, link and url helpers Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../markdown/embeds/components/log/log.tsx | 17 +++ .../embeds/components/log/logLink.tsx | 22 +++ .../embeds/components/log/logUtils.ts | 133 ++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/log/log.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/log/logLink.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/log/logUtils.ts 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/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..e0ec65f1b66d --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts @@ -0,0 +1,133 @@ +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'; + +/** + * 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. + */ +export 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}; +} From 4de6f697c8416a2e530118a7b6e9a9ec8f78b445 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:13:41 -0400 Subject: [PATCH 02/14] feat(seer): add the log attribute views Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../components/log/logAttributeView.tsx | 130 ++++++++++++++++++ .../components/log/logAttributesView.tsx | 116 ++++++++++++++++ 2 files changed, 246 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/log/logAttributesView.tsx 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..366732dcc307 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx @@ -0,0 +1,130 @@ +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()'; +const TOP_VALUE_COUNT = 5; + +/** + * 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: TOP_VALUE_COUNT, + project: selection.projects, + environment: selection.environments, + sampling: SAMPLING_MODE.NORMAL, + referrer: LOG_EMBED_REFERRER, + ...toDateQueryParams(selection), + }, + staleTime: 30_000, + } + ), + retry: false, + }); + + const rows = data?.data ?? []; + const total = rows.reduce((sum, row) => sum + Number(row[COUNT] ?? 0), 0); + + 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)} + + + + {share < 1 ? t('<1%') : `${Math.round(share)}%`} + + + + + ); + })} + + )} + + ); +} 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} + /> + + ); +} From ede401c8b6a7d1e15ccc7f1adc7591bacde5bbf7 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:14:31 -0400 Subject: [PATCH 03/14] feat(seer): add the log embed block Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../embeds/components/log/logBlock.tsx | 321 ++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/log/logBlock.tsx 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..9189bf5873ec --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -0,0 +1,321 @@ +import {useMemo} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {useQuery} from '@tanstack/react-query'; + +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 {unreachable} from 'sentry/utils/unreachable'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useProjectFromId} from 'sentry/utils/useProjectFromId'; +import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; +import type { + TraceItemDetailsResponse, + 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 {useExploreLogsTableRow} from 'sentry/views/explore/logs/useLogsQuery'; +import { + getLogRowTimestampMillis, + getLogSeverityLevel, + severityLevelToText, +} from 'sentry/views/explore/logs/utils'; + +import { + getLogPageFilters, + getLogTimestampMs, + 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) + ); +} + +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])); + const lookupTimestampMs = + getLogTimestampMs({id, projectId, timestamp}) ?? rowTimestampMillis(row); + + const detailsQuery = useExploreLogsTableRow({ + logId: id, + projectId: resolvedProjectId ?? '', + traceId: resolvedTraceId ?? '', + // The details endpoint takes unix seconds, not an ISO string. + timestamp: lookupTimestampMs === null ? undefined : lookupTimestampMs / 1000, + enabled: Boolean(resolvedProjectId && resolvedTraceId), + }); + const details = detailsQuery.data; + const project = useProjectFromId({project_id: resolvedProjectId}); + + const isResolving = needsResolution && rowQuery.isPending; + const canFetchDetails = Boolean(resolvedProjectId && resolvedTraceId); + const isPending = isResolving || (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, timestamp}), + [id, resolvedProjectId, timestamp] + ); + const datetime = useMemo( + () => getLogPageFilters(identity, LOG_LOOKUP_WINDOW_MS).datetime, + [identity] + ); + + return ( + + + + + {displayTimestampMs === null ? null : ( + + + + )} + + + {isPending ? ( + + + + ) : isError ? ( + {t('Unable to load log details')} + ) : ( + + + + {severityLevelToText(level)} + + + {String(message ?? '')} + + + + + )} + + + ); +} + +const SeverityTag = styled('span')<{logColors: ReturnType}>` + flex-shrink: 0; + border: 1px solid ${p => p.logColors.border}; + background: ${p => p.logColors.backgroundLight}; + color: ${p => p.logColors.color}; + border-radius: ${p => p.theme.radius.sm}; + padding: 0 ${p => p.theme.space.xs}; + font-size: ${p => p.theme.font.size.sm}; + font-weight: ${p => p.theme.font.weight.sans.medium}; + text-transform: uppercase; + white-space: nowrap; +`; From 6a94874ea77b383479b416143547e097b3f3e8e5 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:15:08 -0400 Subject: [PATCH 04/14] test(seer): cover the log embed Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../embeds/components/log/log.spec.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/log/log.spec.tsx 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..d677b97d6ae3 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -0,0 +1,205 @@ +import {ProjectFixture} from 'sentry-fixture/project'; + +import {screen, waitFor} from 'sentry-test/reactTestingLibrary'; + +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}, + }); + }); + + 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 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('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(); + }); +}); From 60afe647075544986045a5cecc4f5ada03177fd7 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:15:52 -0400 Subject: [PATCH 05/14] feat(seer): register the log embed and add its story Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- static/app/components/seer/markdown/embeds/index.ts | 2 ++ static/app/components/seer/markdown/seerMarkdown.mdx | 4 ++++ 2 files changed, 6 insertions(+) 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/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index e2fee6461d98..3f6a38de2454 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -135,6 +135,10 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a +### log + + + ### replaysQuery From 6f12c4adfc9ff20a6288b431213122f23074ee25 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:18:13 -0400 Subject: [PATCH 06/14] feat(seer): add the log embed schema Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../seer/markdown/embeds/schemas.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) 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. ' + From d88aad2d15571f2f079839499102ee46f48fe68f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:21:54 -0400 Subject: [PATCH 07/14] feat(seer): regenerate embed widgets for the log 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 | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) 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\".", From 696723947e36dda2d20723e3c69d695d065f4a96 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:14:29 -0400 Subject: [PATCH 08/14] feat(seer): add a live story for the log embed `` renders the schema's `examples` verbatim, and those ids are synthetic, so the stories page showed four dead cards. Every other embed keyed by an org-specific id -- replay, trace, monitor, release, savedQuery -- ships a story that fetches a real example from the current organization instead; do the same here. The story takes the most recent log that carries both a trace and a project, since the details lookup behind the block is addressed by those two, and renders the summary, both attribute views, and the id-only path that makes the block resolve trace and project for itself. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../__stories__/logEmbedStory.spec.tsx | 89 +++++++++++++++++ .../markdown/__stories__/logEmbedStory.tsx | 98 +++++++++++++++++++ .../components/seer/markdown/seerMarkdown.mdx | 3 +- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 static/app/components/seer/markdown/__stories__/logEmbedStory.spec.tsx create mode 100644 static/app/components/seer/markdown/__stories__/logEmbedStory.tsx 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/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 3f6a38de2454..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'; @@ -137,7 +138,7 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a ### log - + ### replaysQuery From 433fc48d728f41e4df1a94ae10f8daae358b456b Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:32:39 -0400 Subject: [PATCH 09/14] fix(seer): don't gate the log block on the host page's page filters The log block reached the details endpoint through `useExploreLogsTableRow`, which adds `enabled: props.enabled && usePageFilters().isReady` on top of whatever the caller asked for. The logs table needs that gate; an embed handed its own trace, project and timestamp does not. Nothing sets `isReady` but `PageFiltersContainer`, and that is mounted per view -- 36 of them -- not by the layout. Seer renders from the organization layout, so on every page without one the query stayed disabled, a disabled query reports `status: 'pending'`, and the block sat on a spinner forever. The stories page is one such page, which is how this surfaced. Call `useTraceItemDetails` directly instead, with the arguments the wrapper was passing anyway. The regression test drops page filters the way such a page has them -- `init()` alone, leaving `isReady` false -- and fails against the previous version. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../embeds/components/log/log.spec.tsx | 13 +++++++++++ .../embeds/components/log/logBlock.tsx | 22 ++++++++++++++----- .../embeds/components/log/logUtils.ts | 6 +++++ 3 files changed, 35 insertions(+), 6 deletions(-) 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 index d677b97d6ae3..fbcc1a08a113 100644 --- a/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -122,6 +122,19 @@ describe('Seer log embed', () => { ); }); + 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(); diff --git a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx index 9189bf5873ec..c466dfef2592 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -22,9 +22,10 @@ import {unreachable} from 'sentry/utils/unreachable'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjectFromId} from 'sentry/utils/useProjectFromId'; import {SAMPLING_MODE} from 'sentry/views/explore/hooks/useProgressiveQuery'; -import type { - TraceItemDetailsResponse, - TraceItemResponseAttribute, +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'; @@ -34,16 +35,17 @@ import { type EventsLogsResult, type OurLogsResponseItem, } from 'sentry/views/explore/logs/types'; -import {useExploreLogsTableRow} from 'sentry/views/explore/logs/useLogsQuery'; import { getLogRowTimestampMillis, getLogSeverityLevel, 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, @@ -216,10 +218,18 @@ export default function LogBlock(props: LogData) { const lookupTimestampMs = getLogTimestampMs({id, projectId, timestamp}) ?? rowTimestampMillis(row); - const detailsQuery = useExploreLogsTableRow({ - logId: id, + // 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: Boolean(resolvedProjectId && resolvedTraceId), diff --git a/static/app/components/seer/markdown/embeds/components/log/logUtils.ts b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts index e0ec65f1b66d..b8d160968b8a 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logUtils.ts +++ b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts @@ -9,6 +9,12 @@ 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 From 3108dc1e091ec1b01ce7725665b266eec764ca56 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:39:24 -0400 Subject: [PATCH 10/14] fix(seer): stop exporting LOG_FALLBACK_STATS_PERIOD Only `getLogPageFilters`, in the same module, ever reads it, so knip counts the export as dead and fails the frontend build. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../components/seer/markdown/embeds/components/log/logUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/seer/markdown/embeds/components/log/logUtils.ts b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts index b8d160968b8a..e25d16cf087f 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logUtils.ts +++ b/static/app/components/seer/markdown/embeds/components/log/logUtils.ts @@ -32,7 +32,7 @@ export const LOG_AGGREGATE_WINDOW_MS = 60 * 60 * 1000; /** * Used only when neither Seer nor the id tells us when the log happened. */ -export const LOG_FALLBACK_STATS_PERIOD = '14d'; +const LOG_FALLBACK_STATS_PERIOD = '14d'; /** * Seer may report a project id as a number, but everything downstream -- the From 054bb90a2981296cde88351a26fe135fc5574190 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 12:42:30 -0400 Subject: [PATCH 11/14] fix(seer): address review findings on the log block Two issues Bugbot raised, both real. The header link was built from the raw props while the rest of the card used the identity resolved from the row lookup. Given only an id, the link emitted no project and scoped Explore to My Projects, so it could miss the very row the card had just loaded. It now takes the resolved identity, which also carries the row's timestamp when neither Seer nor the id supplied one. `canFetchDetails` asked only for a trace and a project id, but `useTraceItemDetails` addresses the endpoint by the project's slug and stays disabled until `useProjectFromId` finds one. A disabled query reports `pending`, so a project the viewer cannot see never reached the error branch and spun forever -- the same trap as the page-filters gate, in the half of the condition that fix left open. The spinner now waits only while the store is filling, then falls through to "Unable to load log details". Both tests fail against the previous commit. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../embeds/components/log/log.spec.tsx | 27 +++++++++++++++ .../embeds/components/log/logBlock.tsx | 33 ++++++++++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) 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 index fbcc1a08a113..0f3393ef27a1 100644 --- a/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -215,4 +215,31 @@ describe('Seer log embed', () => { }); expect(details).toHaveBeenCalled(); }); + + 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(); + + renderLog(); + + expect(await screen.findByText('Unable to load log details')).toBeInTheDocument(); + expect(details).not.toHaveBeenCalled(); + }); }); diff --git a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx index c466dfef2592..fcbc06259741 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -21,6 +21,7 @@ import {DiscoverDatasets} from 'sentry/utils/discover/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, @@ -237,9 +238,17 @@ export default function LogBlock(props: LogData) { const details = detailsQuery.data; const project = useProjectFromId({project_id: resolvedProjectId}); + // `useTraceItemDetails` addresses the endpoint by the project's slug, so it + // disables itself until `useProjectFromId` finds one. A disabled query reports + // `pending`, so that condition has to gate the spinner here too -- otherwise a + // project this viewer cannot see spins forever instead of reaching the error + // branch. `fetching` covers the window where the store is still filling. + const {fetching: projectsFetching} = useProjects(); + const isResolving = needsResolution && rowQuery.isPending; - const canFetchDetails = Boolean(resolvedProjectId && resolvedTraceId); - const isPending = isResolving || (canFetchDetails && detailsQuery.isPending); + const canFetchDetails = Boolean(resolvedProjectId && resolvedTraceId && project); + const isPending = + isResolving || projectsFetching || (canFetchDetails && detailsQuery.isPending); const isError = !isPending && (!canFetchDetails || detailsQuery.isError || !details); const attributes = useMemo(() => (details ? toLogAttributes(details) : []), [details]); @@ -256,8 +265,19 @@ export default function LogBlock(props: LogData) { parseTimestampMillis(details?.timestamp) ?? lookupTimestampMs; const identity = useMemo( - () => ({id, projectId: resolvedProjectId, timestamp}), - [id, resolvedProjectId, timestamp] + () => ({ + id, + projectId: resolvedProjectId, + // Seer may have given neither a timestamp nor an id the decoder can read, + // in which case the row lookup is the only thing that knows when this log + // happened. Without it the link falls back to scanning all of retention. + timestamp: + timestamp ?? + (lookupTimestampMs === null + ? undefined + : new Date(lookupTimestampMs).toISOString()), + }), + [id, lookupTimestampMs, resolvedProjectId, timestamp] ); const datetime = useMemo( () => getLogPageFilters(identity, LOG_LOOKUP_WINDOW_MS).datetime, @@ -275,7 +295,10 @@ export default function LogBlock(props: LogData) { > - + {/* 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 : ( From 7c6c68ca2ff7c6965bdf94368444d1914f8c7d60 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 13:09:22 -0400 Subject: [PATCH 12/14] fix(seer): don't report an unreachable project as an app error `useTraceItemDetails` reports a project it cannot find to Sentry unless the caller disabled it too, so enabling the query on `resolvedProjectId && resolvedTraceId` -- an id, not a project we can actually reach -- captured an exception for a state the card already renders as "Unable to load log details". `canFetchDetails` was already the right condition and now moves above the hook to serve as its `enabled`, leaving one gate instead of two that have to agree. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../embeds/components/log/log.spec.tsx | 6 +++++ .../embeds/components/log/logBlock.tsx | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 10 deletions(-) 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 index 0f3393ef27a1..bb7045c746f0 100644 --- a/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/react'; import {ProjectFixture} from 'sentry-fixture/project'; import {screen, waitFor} from 'sentry-test/reactTestingLibrary'; @@ -236,10 +237,15 @@ describe('Seer log embed', () => { // 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/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx index fcbc06259741..2393f240394e 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -219,6 +219,17 @@ export default function LogBlock(props: LogData) { const lookupTimestampMs = getLogTimestampMs({id, projectId, timestamp}) ?? rowTimestampMillis(row); + 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 @@ -233,20 +244,12 @@ export default function LogBlock(props: LogData) { referrer: LOG_DETAILS_REFERRER, // The details endpoint takes unix seconds, not an ISO string. timestamp: lookupTimestampMs === null ? undefined : lookupTimestampMs / 1000, - enabled: Boolean(resolvedProjectId && resolvedTraceId), + enabled: canFetchDetails, }); const details = detailsQuery.data; - const project = useProjectFromId({project_id: resolvedProjectId}); - - // `useTraceItemDetails` addresses the endpoint by the project's slug, so it - // disables itself until `useProjectFromId` finds one. A disabled query reports - // `pending`, so that condition has to gate the spinner here too -- otherwise a - // project this viewer cannot see spins forever instead of reaching the error - // branch. `fetching` covers the window where the store is still filling. - const {fetching: projectsFetching} = useProjects(); const isResolving = needsResolution && rowQuery.isPending; - const canFetchDetails = Boolean(resolvedProjectId && resolvedTraceId && project); + // `projectsFetching` covers the window where the store is still filling. const isPending = isResolving || projectsFetching || (canFetchDetails && detailsQuery.isPending); const isError = !isPending && (!canFetchDetails || detailsQuery.isError || !details); From fdef2ee5c9e08a07ae3de6936e0e6ae31ccc6356 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 13:17:02 -0400 Subject: [PATCH 13/14] ref(seer): render the log severity with the core Tag Replaces the hand-rolled `SeverityTag` styled span, per review. `Tag` takes a semantic variant rather than the logs table's per-level colors, so TRACE and DEBUG share `muted` and FATAL joins ERROR on `danger`. Those finer shades earn their keep when scanned down a column of rows; a single embedded row has no column, and matching the design system is worth more here than the extra shades. The border goes with it. `getLogColors` stays -- the attribute tree still renders from it. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../embeds/components/log/logBlock.tsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx index 2393f240394e..3316cbbed533 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -1,8 +1,8 @@ import {useMemo} from 'react'; import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; 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'; @@ -18,6 +18,7 @@ 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'; @@ -39,6 +40,7 @@ import { import { getLogRowTimestampMillis, getLogSeverityLevel, + SeverityLevel, severityLevelToText, } from 'sentry/views/explore/logs/utils'; import {TraceItemDataset} from 'sentry/views/explore/types'; @@ -127,6 +129,31 @@ function toLogAttributes( ); } +/** + * `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; @@ -318,9 +345,7 @@ export default function LogBlock(props: LogData) { ) : ( - - {severityLevelToText(level)} - + {severityLevelToText(level)} {String(message ?? '')} @@ -342,16 +367,3 @@ export default function LogBlock(props: LogData) { ); } - -const SeverityTag = styled('span')<{logColors: ReturnType}>` - flex-shrink: 0; - border: 1px solid ${p => p.logColors.border}; - background: ${p => p.logColors.backgroundLight}; - color: ${p => p.logColors.color}; - border-radius: ${p => p.theme.radius.sm}; - padding: 0 ${p => p.theme.space.xs}; - font-size: ${p => p.theme.font.size.sm}; - font-weight: ${p => p.theme.font.weight.sans.medium}; - text-transform: uppercase; - white-space: nowrap; -`; From 8b70a03d4051acef61e755dd8e07c2cf827dab14 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 15:48:21 -0400 Subject: [PATCH 14/14] fix(seer): use the resolved row's timestamp, and size the breakdown honestly Two review findings. The id-only path threw away the timestamp it had just fetched: `??` preferred the UUIDv7 mint time, and the row lookup exists precisely because mint time drifts from ingest time -- hence the +/-5min window. Handing the mint time to a details endpoint that wants the real one could lose the row the lookup had already found. The row now wins, and the header link reads the same resolved instant instead of Seer's. The attribute breakdown drew five groups and called their sum 100%, so a sixth value inflated every share and went unmentioned. It now asks for fifty, draws five, and measures against all of them with the balance as "Other". Deliberately not the issue tag distribution's approach of a separate total query: the two are sampled independently, and on real data the total comes back smaller than the sum of the groups it is meant to contain -- 51,388,093 against 51,409,473 for one hour of one organization's severity -- which would render a negative remainder. One query keeps the parts consistent. The timestamp test has to move the clock: the decoder rejects ids minted after "now", jest pins now to 2017, and no realistic v7 id decodes there. Claude-Session: https://claude.ai/code/session_01MuArcDjh7FsQ3UgQKFPKst --- .../embeds/components/log/log.spec.tsx | 56 +++++++++++++++++++ .../components/log/logAttributeView.tsx | 42 ++++++++++++-- .../embeds/components/log/logBlock.tsx | 19 ++++--- 3 files changed, 105 insertions(+), 12 deletions(-) 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 index bb7045c746f0..e0e22a00792e 100644 --- a/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/log.spec.tsx @@ -2,6 +2,7 @@ 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 { @@ -86,6 +87,10 @@ describe('Seer log embed', () => { }); }); + afterEach(() => { + resetMockDate(); + }); + it('links to the single row in Explore, windowed around its timestamp', () => { const href = getEmbedLinkHref('log', 'Log 019bfe1c', { id: LOG_ID, @@ -181,6 +186,33 @@ describe('Seer log embed', () => { ); }); + 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({ @@ -217,6 +249,30 @@ describe('Seer log embed', () => { 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(); diff --git a/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx b/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx index 366732dcc307..a60fc8df6b82 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logAttributeView.tsx @@ -25,8 +25,26 @@ import { } 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. @@ -61,7 +79,7 @@ export function LogAttributeView({attribute, identity}: LogAttributeViewProps) { dataset: DiscoverDatasets.OURLOGS, field: [attribute, COUNT], orderby: `-${COUNT}`, - per_page: TOP_VALUE_COUNT, + per_page: AGGREGATE_ROW_LIMIT, project: selection.projects, environment: selection.environments, sampling: SAMPLING_MODE.NORMAL, @@ -74,8 +92,11 @@ export function LogAttributeView({attribute, identity}: LogAttributeViewProps) { retry: false, }); - const rows = data?.data ?? []; - const total = rows.reduce((sum, row) => sum + Number(row[COUNT] ?? 0), 0); + 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 ( @@ -116,13 +137,26 @@ export function LogAttributeView({attribute, identity}: LogAttributeViewProps) { - {share < 1 ? t('<1%') : `${Math.round(share)}%`} + {formatShare(share)} ); })} + {otherCount > 0 && ( + + + {t('Other')} + + + + {formatShare(percent(otherCount, total))} + + + + + )} )} diff --git a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx index 3316cbbed533..de9802072ba5 100644 --- a/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/log/logBlock.tsx @@ -243,8 +243,12 @@ export default function LogBlock(props: LogData) { 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 = - getLogTimestampMs({id, projectId, timestamp}) ?? rowTimestampMillis(row); + rowTimestampMillis(row) ?? getLogTimestampMs({id, projectId, timestamp}); const project = useProjectFromId({project_id: resolvedProjectId}); const {fetching: projectsFetching} = useProjects(); @@ -298,16 +302,15 @@ export default function LogBlock(props: LogData) { () => ({ id, projectId: resolvedProjectId, - // Seer may have given neither a timestamp nor an id the decoder can read, - // in which case the row lookup is the only thing that knows when this log - // happened. Without it the link falls back to scanning all of retention. + // 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: - timestamp ?? - (lookupTimestampMs === null + lookupTimestampMs === null ? undefined - : new Date(lookupTimestampMs).toISOString()), + : new Date(lookupTimestampMs).toISOString(), }), - [id, lookupTimestampMs, resolvedProjectId, timestamp] + [id, lookupTimestampMs, resolvedProjectId] ); const datetime = useMemo( () => getLogPageFilters(identity, LOG_LOOKUP_WINDOW_MS).datetime,