From e0a0b4ca5240e3cf4d1c28661f0ccc6b228a681f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:10:34 -0400 Subject: [PATCH 1/2] feat(seer): Record when a markdown embed is rendered Answers "which embed types do people actually see", which nothing tracked. A render is emitted as a Sentry log, one per embed instance per page load. Counting embeds needs an identity, and until now an embed had none. The Tag component receives `{name, data, level, attrs, raw}` -- nothing positional -- so two embeds could only be told apart by their source text, which collapses byte-identical duplicates and cannot be sent as-is (an embed body is a live query, which may carry customer data). So `Markdown` now stamps each tag token with its position among all tags in the message, in document order, and passes it to the Tag component. It is assigned after lexing rather than in the tokenizer because marked defers inline tokenization to a second pass: a counter there would number an inline tag in the first paragraph after a block tag in the second. Counting tags rather than blocks is what distinguishes two inline embeds sharing a paragraph, which the existing top-level token index cannot. The index holds still while streaming. Content only ever grows by appending, so a newly closed tag can only appear after the existing ones, and a tag whose closing marker has not arrived is not a tag token at all -- it claims no index early and displaces nothing when it completes. That gives `conversation:message:index`, stable across viewers and reloads because every part is server-assigned or derived from the settled message. It is both the dedup key and the key the query counts distinct on, which makes client-side dedup an optimisation rather than a correctness requirement: a render missed by the Set -- a reopened conversation, a second tab -- collapses again at query time. Dedup is needed at all because seer markdown re-lexes on every streamed chunk and a paragraph holding an inline embed remounts each time text lands after it. Logs rather than metrics: the question is "how many distinct embeds", which needs count_unique over an identifier, an aggregate the logs dataset offers and a pre-aggregated counter cannot. Both composites are pre-composed as attributes since the query layer cannot concatenate them -- count_unique on message_key counts messages that showed a type, on embed_key counts embeds. The conversation uses the gen_ai.conversation.id convention name; the message id stays bespoke, since gen_ai.response.id means the provider's completion id, not a Seer block. Two deliberate limits: - Only the settled render is tracked. While a block is loading its id is the optimistic client-side one, which the server replaces on the next poll, so tracking both would count one embed twice. Nothing is lost -- the settled render fires immediately after. - A surface supplies the scope or its embeds go untracked. Stories, demos and previews stay silent, and a surface that cannot name both ids records nothing rather than rows that cannot be deduplicated. Claude-Session: https://claude.ai/code/session_01TEetW4KdYbgin31P9WWG5s --- .../core/markdown/markdown.spec.tsx | 85 ++++++++++ .../app/components/core/markdown/markdown.tsx | 65 +++++++- static/app/components/core/markdown/token.tsx | 1 + .../seer/markdown/embeds/registry.tsx | 5 + .../markdown/embeds/renderTracking.spec.tsx | 145 ++++++++++++++++++ .../seer/markdown/embeds/renderTracking.tsx | 99 ++++++++++++ .../components/seer/markdown/embeds/utils.tsx | 6 +- static/app/components/seer/markdown/index.tsx | 23 ++- static/app/utils/marked/extensions/tag.ts | 9 ++ .../components/chat/assistant.tsx | 16 +- 10 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 static/app/components/seer/markdown/embeds/renderTracking.spec.tsx create mode 100644 static/app/components/seer/markdown/embeds/renderTracking.tsx diff --git a/static/app/components/core/markdown/markdown.spec.tsx b/static/app/components/core/markdown/markdown.spec.tsx index 3187247ab7eb..93e165f98237 100644 --- a/static/app/components/core/markdown/markdown.spec.tsx +++ b/static/app/components/core/markdown/markdown.spec.tsx @@ -402,6 +402,91 @@ describe('Markdown', () => { }); }); + describe('tag index', () => { + function IndexProbe({name, index}: {name: string; index?: number}) { + return {`${name}=${index}`}; + } + + const indexes = () => screen.getAllByRole('log').map(el => el.textContent); + + it('numbers tags in document order across blocks', () => { + render( + + ); + expect(indexes()).toEqual(['a=0', 'b=1', 'c=2']); + }); + + it('numbers two inline tags in the same paragraph separately', () => { + render( + + ); + expect(indexes()).toEqual(['a=0', 'b=1']); + }); + + it('numbers identical tags separately', () => { + render( + + ); + expect(indexes()).toEqual(['a=0', 'a=1']); + }); + + it('numbers tags nested in lists', () => { + render( + + ); + expect(indexes()).toEqual(['a=0', 'b=1']); + }); + + it('numbers tags in table headers before table rows', () => { + render( + + ); + expect(indexes()).toEqual(['a=0', 'b=1']); + }); + + it('keeps existing indexes when content is appended', () => { + const {rerender} = render( + + ); + expect(indexes()).toEqual(['a=0']); + + rerender( + + ); + expect(indexes()).toEqual(['a=0', 'b=1']); + }); + + it('does not count a tag whose closing marker has not arrived', () => { + const {rerender} = render( + + ); + expect(indexes()).toEqual(['a=0']); + + rerender( + + ); + expect(indexes()).toEqual(['a=0', 'b=1']); + }); + }); + describe('token caching', () => { it('renders correctly when raw prop changes', () => { const {rerender} = render(); diff --git a/static/app/components/core/markdown/markdown.tsx b/static/app/components/core/markdown/markdown.tsx index 17127111a48d..3cedcd30853f 100644 --- a/static/app/components/core/markdown/markdown.tsx +++ b/static/app/components/core/markdown/markdown.tsx @@ -50,6 +50,11 @@ export type MarkdownComponents = Partial<{ name: string; /** Original `{% tag %}` source, including body and closing tag. */ raw: string; + /** + * Position of this tag among all tags in the message, in document order. + * Counts tags only, so two inline tags in one paragraph get 0 and 1. + */ + index?: number; }> >; TaskList: ComponentType>; @@ -64,19 +69,75 @@ export interface MarkdownProps { variant?: 'static' | 'streaming'; } +/** + * Stamps every tag token with its position among all tags in the message, in + * document order. + * + * Runs after lexing rather than inside the tokenizer because marked defers + * inline tokenization to a second pass: a tokenizer counter would number an + * inline tag in the first paragraph after a block tag in the second. + * + * The result is stable while streaming. Content only ever grows by appending, + * so a newly closed tag can only appear after the existing ones and never + * shifts their index -- and a tag whose closing marker has not arrived yet is + * not a tag token at all, so it claims no index early. + */ +function assignTagIndexes(tokens: ExtendedToken[]): void { + let nextIndex = 0; + + function visitAll(list: readonly ExtendedToken[]): void { + for (const token of list) { + visit(token); + } + } + + function visit(token: ExtendedToken): void { + if (token.type === 'tag') { + // A tag body is JSON, never markdown, so it has no child tokens. + token.index = nextIndex++; + return; + } + if ('tokens' in token && token.tokens) { + visitAll(token.tokens as ExtendedToken[]); + } + if ('items' in token && token.items) { + visitAll(token.items as ExtendedToken[]); + } + // Tables hold their cells outside `tokens`; header precedes rows on screen. + if ('header' in token && token.header) { + for (const cell of token.header) { + visitAll(cell.tokens as ExtendedToken[]); + } + } + if ('rows' in token && token.rows) { + for (const row of token.rows) { + for (const cell of row) { + visitAll(cell.tokens as ExtendedToken[]); + } + } + } + } + + visitAll(tokens); +} + export function Markdown({raw, components = {}, variant = 'static'}: MarkdownProps) { const containerRef = useRef(null); const prevTextLensRef = useRef(new Map()); const isStreaming = variant === 'streaming'; - const tokens = useMemo(() => MarkedLexer.lex(raw), [raw]); + const tokens = useMemo(() => { + const lexed = MarkedLexer.lex(raw) as ExtendedToken[]; + assignTagIndexes(lexed); + return lexed; + }, [raw]); const elements = useMemo( () => tokens.map((token, i) => ( )), diff --git a/static/app/components/core/markdown/token.tsx b/static/app/components/core/markdown/token.tsx index bf7c4b4dbabf..9c5d46e0f48c 100644 --- a/static/app/components/core/markdown/token.tsx +++ b/static/app/components/core/markdown/token.tsx @@ -295,6 +295,7 @@ export function Token({ attrs={token.attrs} data={token.data} raw={token.raw} + index={token.index} /> ); } diff --git a/static/app/components/seer/markdown/embeds/registry.tsx b/static/app/components/seer/markdown/embeds/registry.tsx index 544f394c7424..14c7c0017c80 100644 --- a/static/app/components/seer/markdown/embeds/registry.tsx +++ b/static/app/components/seer/markdown/embeds/registry.tsx @@ -8,6 +8,11 @@ export interface SeerEmbedProps { data: unknown; level: 'block' | 'inline'; name: string; + /** + * Position among all embeds in the message, in document order. Assigned by + * `Markdown` while lexing; see `renderTracking` for what it is used for. + */ + index?: number; } export type SeerEmbedComponent = (props: SeerEmbedProps) => ReactNode; diff --git a/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx b/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx new file mode 100644 index 000000000000..7eb0a13770cd --- /dev/null +++ b/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx @@ -0,0 +1,145 @@ +import {GEN_AI_CONVERSATION_ID} from '@sentry/conventions/attributes'; +import * as Sentry from '@sentry/react'; + +import {render} from 'sentry-test/reactTestingLibrary'; + +import {SeerMarkdown} from 'sentry/components/seer/markdown'; + +import type {SeerEmbedScope} from './renderTracking'; + +const timestamp = (value: string) => + `{% timestamp %}${JSON.stringify({value, format: 'absolute'})}{% /timestamp %}`; + +/** + * Renders already reported are suppressed for the life of the page, so every + * test needs a scope no earlier test has used. + */ +let nextConversation = 0; +function scope(overrides: Partial = {}): SeerEmbedScope { + nextConversation += 1; + return { + conversationId: `run-${nextConversation}`, + messageId: 'block-1', + surface: 'seer_explorer', + ...overrides, + }; +} + +describe('seer embed render tracking', () => { + let info!: jest.SpyInstance; + + beforeEach(() => { + info = jest.spyOn(Sentry.logger, 'info').mockImplementation(() => {}); + }); + + afterEach(() => { + info.mockRestore(); + }); + + const attributesOf = (call: unknown[]) => call[1] as Record; + + it('records a render with its conversation, message and index', () => { + const current = scope(); + render( + + ); + + expect(info).toHaveBeenCalledTimes(1); + expect(attributesOf(info.mock.calls[0]!)).toEqual( + expect.objectContaining({ + 'seer_embed.name': 'timestamp', + 'seer_embed.level': 'inline', + 'seer_embed.index': 0, + 'seer_embed.surface': 'seer_explorer', + [GEN_AI_CONVERSATION_ID]: current.conversationId, + 'seer_embed.message_id': 'block-1', + 'seer_embed.message_key': `${current.conversationId}:block-1`, + 'seer_embed.embed_key': `${current.conversationId}:block-1:0`, + }) + ); + }); + + it('records each embed in a message separately', () => { + render( + + ); + + expect(info).toHaveBeenCalledTimes(2); + expect(info.mock.calls.map(call => attributesOf(call)['seer_embed.index'])).toEqual([ + 0, 1, + ]); + }); + + it('records identical embeds in one message separately', () => { + const same = timestamp('2025-07-15T14:30:00Z'); + render(); + + expect(info).toHaveBeenCalledTimes(2); + expect(info.mock.calls.map(call => attributesOf(call)['seer_embed.index'])).toEqual([ + 0, 1, + ]); + }); + + it('records an embed once across re-renders of the same message', () => { + const current = scope(); + const raw = `at ${timestamp('2025-07-15T14:30:00Z')}`; + const {rerender} = render(); + expect(info).toHaveBeenCalledTimes(1); + + // Streaming remounts the paragraph holding an inline embed on every chunk. + rerender(); + rerender(); + + expect(info).toHaveBeenCalledTimes(1); + }); + + it('records the same embed in a different message', () => { + const raw = `at ${timestamp('2025-07-15T14:30:00Z')}`; + const conversationId = scope().conversationId; + + render( + + ); + render( + + ); + + expect(info).toHaveBeenCalledTimes(2); + expect( + info.mock.calls.map(call => attributesOf(call)['seer_embed.message_id']) + ).toEqual(['block-1', 'block-2']); + }); + + it('records nothing without a scope', () => { + render(); + expect(info).not.toHaveBeenCalled(); + }); + + it('records nothing for an embed whose props are invalid', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const captureException = jest + .spyOn(Sentry, 'captureException') + .mockImplementation(() => ''); + + render( + + ); + + expect(info).not.toHaveBeenCalled(); + + warn.mockRestore(); + captureException.mockRestore(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/renderTracking.tsx b/static/app/components/seer/markdown/embeds/renderTracking.tsx new file mode 100644 index 000000000000..a8e892df3800 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/renderTracking.tsx @@ -0,0 +1,99 @@ +import {createContext, useContext, useEffect} from 'react'; +import {GEN_AI_CONVERSATION_ID} from '@sentry/conventions/attributes'; +import * as Sentry from '@sentry/react'; + +/** + * Identifies where a Seer embed was rendered, so a render can be attributed to + * a conversation and a message rather than just a page load. + * + * Supplied by the surface rendering the markdown. A surface that cannot name + * both ids supplies nothing and its embeds go untracked -- better a missing + * surface than rows that cannot be deduplicated. + */ +export interface SeerEmbedScope { + /** Run the embed was rendered in. */ + conversationId: string; + /** + * Message within the run. Must be the server-assigned id: it is what makes a + * render deduplicable across viewers and reloads. Optimistic client-side ids + * change once the server responds and would double count. + */ + messageId: string; + /** Product surface, so one embed type can be compared across surfaces. */ + surface: string; +} + +export const SeerEmbedScopeContext = createContext(null); + +/** + * Embeds already reported this page load. + * + * Seer markdown re-lexes and re-renders on every streamed chunk, and a + * paragraph holding an inline embed remounts each time text is appended after + * it, so an embed would otherwise report once per chunk. Keyed on the same + * composite the query counts distinct on, which makes this an optimisation + * rather than a correctness requirement: a missed dedup (a reopened + * conversation, a second tab) collapses again at query time. + */ +const reportedEmbeds = new Set(); + +interface TrackEmbedRenderedOptions { + /** + * Position among all embeds in the message, in document order. Undefined when + * the markdown was not rendered through `Markdown` (which assigns it). + */ + index: number | undefined; + level: 'block' | 'inline'; + name: string; + /** + * False when the embed's props failed validation. Such an embed renders + * nothing, so counting it would overstate what users actually saw. + */ + rendered: boolean; +} + +/** + * Records that an embed was rendered, once per embed instance per page load. + * + * Emitted as a log rather than a metric because the question it answers is + * "how many distinct embeds", which needs `count_unique` over an identifier -- + * an aggregate the logs dataset offers and a pre-aggregated counter cannot. + */ +export function useTrackEmbedRendered({ + index, + level, + name, + rendered, +}: TrackEmbedRenderedOptions): void { + const scope = useContext(SeerEmbedScopeContext); + + useEffect(() => { + if (!rendered || !scope || index === undefined) { + return; + } + + const messageKey = `${scope.conversationId}:${scope.messageId}`; + const embedKey = `${messageKey}:${index}`; + if (reportedEmbeds.has(embedKey)) { + return; + } + reportedEmbeds.add(embedKey); + + Sentry.logger.info('Seer embed rendered', { + 'seer_embed.name': name, + 'seer_embed.level': level, + 'seer_embed.index': index, + 'seer_embed.surface': scope.surface, + // The conversation has a convention name; the rest of these concepts are + // Seer's own. `gen_ai.response.id` is deliberately not used for the + // message: it means the provider's completion id, not a Seer block id. + [GEN_AI_CONVERSATION_ID]: scope.conversationId, + 'seer_embed.message_id': scope.messageId, + // Pre-composed because the query layer cannot concatenate attributes: + // `count_unique(seer_embed.message_key)` counts messages that showed an + // embed of a type, `count_unique(seer_embed.embed_key)` counts embeds. + 'seer_embed.message_key': messageKey, + 'seer_embed.embed_key': embedKey, + }); + }, [index, level, name, rendered, scope]); +} diff --git a/static/app/components/seer/markdown/embeds/utils.tsx b/static/app/components/seer/markdown/embeds/utils.tsx index 0f78cc862dd7..786c1df58eb5 100644 --- a/static/app/components/seer/markdown/embeds/utils.tsx +++ b/static/app/components/seer/markdown/embeds/utils.tsx @@ -5,6 +5,7 @@ import type {z} from 'zod'; import {NODE_ENV} from 'sentry/constants/env'; import type {SeerEmbedProps} from './registry'; +import {useTrackEmbedRendered} from './renderTracking'; import {ALL_SEER_EMBED_SCHEMAS, type SeerEmbedName} from './schemas'; export type EmbedOutput = z.output< @@ -51,8 +52,11 @@ export function defineSeerEmbed({ }: DefineSeerEmbedOptions) { const {schema} = ALL_SEER_EMBED_SCHEMAS[name]; - function Embed({data, level}: SeerEmbedProps) { + function Embed({data, level, index}: SeerEmbedProps) { const parsed = schema.safeParse(data); + // Called before the early return so the hook stays unconditional; it + // no-ops for an embed that failed validation and renders nothing. + useTrackEmbedRendered({name, level, index, rendered: parsed.success}); if (!parsed.success) { reportInvalidEmbed(name, parsed.error.issues); return null; diff --git a/static/app/components/seer/markdown/index.tsx b/static/app/components/seer/markdown/index.tsx index d53993485f20..8e411f646e19 100644 --- a/static/app/components/seer/markdown/index.tsx +++ b/static/app/components/seer/markdown/index.tsx @@ -8,6 +8,7 @@ import {Link} from '@sentry/scraps/link'; import {Markdown, type MarkdownProps} from '@sentry/scraps/markdown'; import {Heading} from '@sentry/scraps/text'; +import {type SeerEmbedScope, SeerEmbedScopeContext} from './embeds/renderTracking'; import {STRUCTURED_SEER_EMBED_SCHEMAS} from './embeds/schemas'; import {SeerEmbedRegistry} from './embeds'; @@ -85,7 +86,7 @@ function reportUnhandledTag( } const SEER_EMBED_COMPONENTS: MarkdownProps['components'] = { - Tag: function SeerTag({name, data, level, attrs}) { + Tag: function SeerTag({name, data, level, attrs, index}) { const structuredContent = useContext(StructuredContentContext); const Embed = SeerEmbedRegistry.get(name); if (Embed) { @@ -95,7 +96,7 @@ const SEER_EMBED_COMPONENTS: MarkdownProps['components'] = { : data === undefined ? structuredContent?.[name] : data; - const embed = ; + const embed = ; if (level === 'inline') { return embed; } @@ -159,13 +160,25 @@ const SEER_EMBED_COMPONENTS: MarkdownProps['components'] = { export function SeerMarkdown({ components, structuredContent = null, + scope = null, ...props }: MarkdownProps & { + /** + * Conversation and message this markdown belongs to. Supply it to record + * embed renders; omit it (stories, demos, previews) to render untracked. + * + * Scoped to this call rather than to the message, so a surface that renders + * one message through two `SeerMarkdown` calls would give both embeds the + * same index. Pass the whole message in one call. + */ + scope?: SeerEmbedScope | null; structuredContent?: Record | null; }) { return ( - - - + + + + + ); } diff --git a/static/app/utils/marked/extensions/tag.ts b/static/app/utils/marked/extensions/tag.ts index 3ef551935841..4c9f76909d9b 100644 --- a/static/app/utils/marked/extensions/tag.ts +++ b/static/app/utils/marked/extensions/tag.ts @@ -7,6 +7,15 @@ export interface TagToken { name: string; raw: string; type: 'tag'; + /** + * Position of this tag among all tags in the message, in document order. + * + * Assigned by `Markdown` after lexing rather than here, because marked defers + * inline tokenization to a second pass -- so the order tokenizers run in does + * not match the order tags appear in. Undefined for tokens that were lexed + * without going through `Markdown`. + */ + index?: number; } const TAG_START_RE = /\{%\s+[\w-]/; diff --git a/static/app/views/seerExplorer/components/chat/assistant.tsx b/static/app/views/seerExplorer/components/chat/assistant.tsx index 47491d9730d6..afc54ded2ff1 100644 --- a/static/app/views/seerExplorer/components/chat/assistant.tsx +++ b/static/app/views/seerExplorer/components/chat/assistant.tsx @@ -4,6 +4,7 @@ import {css} from '@emotion/react'; import {AssistantActions, AssistantMessage, MessageRow} from '@sentry/scraps/chat'; import {SeerMarkdown} from 'sentry/components/seer/markdown'; +import type {SeerEmbedScope} from 'sentry/components/seer/markdown/embeds/renderTracking'; import {trackAnalytics} from 'sentry/utils/analytics'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; @@ -25,6 +26,19 @@ export function AssistantBlock({ const content = block.message.content ?? ''; const isStreamingEnabled = organization.features.includes('seer-explorer-stream'); + // Only the settled render carries a scope. While `block.loading`, the id is + // still the optimistic client-side one (`loading-N-optimistic`), which the + // server replaces on the next poll -- tracking both would count one embed + // twice. The settled render fires immediately after, so nothing is lost. + const embedScope: SeerEmbedScope | null = + runId === undefined + ? null + : { + conversationId: String(runId), + messageId: block.id, + surface: 'seer_explorer', + }; + if (block.loading) { if (isStreamingEnabled && hasValidContent(content)) { return ( @@ -43,7 +57,7 @@ export function AssistantBlock({ {hasValidContent(content) && ( - + )} From e2287bf9905850f5c9edfb5d9c30d8e1b95bae7f Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 12:43:45 -0400 Subject: [PATCH 2/2] ref(seer): Also write the embed conversation id under seer_embed.* The convention name is what correlates a render with everything else describing the same conversation -- spans, other producers -- but it leaves one field of this log namespaced away from the rest, so a query for embeds has to know that its conversation lives under a different prefix. Writing both keeps `seer_embed.*` self-contained without giving up the correlation. The message id gets no such pair. `gen_ai.response.id` means the provider's completion id, not a Seer block id, so writing a block id there would put two meanings behind one key. Claude-Session: https://claude.ai/code/session_01TEetW4KdYbgin31P9WWG5s --- .../seer/markdown/embeds/renderTracking.spec.tsx | 1 + .../seer/markdown/embeds/renderTracking.tsx | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx b/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx index 7eb0a13770cd..de42d37b2040 100644 --- a/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx +++ b/static/app/components/seer/markdown/embeds/renderTracking.spec.tsx @@ -52,6 +52,7 @@ describe('seer embed render tracking', () => { 'seer_embed.index': 0, 'seer_embed.surface': 'seer_explorer', [GEN_AI_CONVERSATION_ID]: current.conversationId, + 'seer_embed.conversation_id': current.conversationId, 'seer_embed.message_id': 'block-1', 'seer_embed.message_key': `${current.conversationId}:block-1`, 'seer_embed.embed_key': `${current.conversationId}:block-1:0`, diff --git a/static/app/components/seer/markdown/embeds/renderTracking.tsx b/static/app/components/seer/markdown/embeds/renderTracking.tsx index a8e892df3800..e4e21b83d078 100644 --- a/static/app/components/seer/markdown/embeds/renderTracking.tsx +++ b/static/app/components/seer/markdown/embeds/renderTracking.tsx @@ -84,10 +84,18 @@ export function useTrackEmbedRendered({ 'seer_embed.level': level, 'seer_embed.index': index, 'seer_embed.surface': scope.surface, - // The conversation has a convention name; the rest of these concepts are - // Seer's own. `gen_ai.response.id` is deliberately not used for the - // message: it means the provider's completion id, not a Seer block id. + // The conversation is written twice on purpose. The convention name is + // what correlates this render with everything else describing the same + // conversation -- spans, other producers -- while the `seer_embed.` + // copy keeps every attribute of this log under one prefix, so a query + // for embeds does not have to know that one of its fields is namespaced + // somewhere else. + // + // The message has no such pair: `gen_ai.response.id` means the + // provider's completion id, not a Seer block id, so writing a block id + // there would put two meanings behind one key. [GEN_AI_CONVERSATION_ID]: scope.conversationId, + 'seer_embed.conversation_id': scope.conversationId, 'seer_embed.message_id': scope.messageId, // Pre-composed because the query layer cannot concatenate attributes: // `count_unique(seer_embed.message_key)` counts messages that showed an