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