Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions static/app/components/core/markdown/markdown.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,91 @@ describe('Markdown', () => {
});
});

describe('tag index', () => {
function IndexProbe({name, index}: {name: string; index?: number}) {
return <output role="log">{`${name}=${index}`}</output>;
}

const indexes = () => screen.getAllByRole('log').map(el => el.textContent);

it('numbers tags in document order across blocks', () => {
render(
<Markdown
raw={'{% a /%}\n\n## Heading\n\n{% b /%}\n\n{% c /%}'}
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'b=1', 'c=2']);
});

it('numbers two inline tags in the same paragraph separately', () => {
render(
<Markdown
raw="See {% a /%} and also {% b /%} here"
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'b=1']);
});

it('numbers identical tags separately', () => {
render(
<Markdown
raw={'{% a %}{"id":"1"}{% /a %}\n\n{% a %}{"id":"1"}{% /a %}'}
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'a=1']);
});

it('numbers tags nested in lists', () => {
render(
<Markdown
raw={'- first {% a /%}\n- second {% b /%}'}
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'b=1']);
});

it('numbers tags in table headers before table rows', () => {
render(
<Markdown
raw={'| {% a /%} |\n| --- |\n| {% b /%} |'}
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'b=1']);
});

it('keeps existing indexes when content is appended', () => {
const {rerender} = render(
<Markdown raw="Start {% a /%}" components={{Tag: IndexProbe}} />
);
expect(indexes()).toEqual(['a=0']);

rerender(
<Markdown raw="Start {% a /%} then {% b /%}" components={{Tag: IndexProbe}} />
);
expect(indexes()).toEqual(['a=0', 'b=1']);
});

it('does not count a tag whose closing marker has not arrived', () => {
const {rerender} = render(
<Markdown raw='{% a /%} then {% b %}{"id"' components={{Tag: IndexProbe}} />
);
expect(indexes()).toEqual(['a=0']);

rerender(
<Markdown
raw='{% a /%} then {% b %}{"id":"1"}{% /b %}'
components={{Tag: IndexProbe}}
/>
);
expect(indexes()).toEqual(['a=0', 'b=1']);
});
});

describe('token caching', () => {
it('renders correctly when raw prop changes', () => {
const {rerender} = render(<Markdown raw="First" />);
Expand Down
65 changes: 63 additions & 2 deletions static/app/components/core/markdown/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WithDefault<{children: ReactNode}>>;
Expand All @@ -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<HTMLDivElement>(null);
const prevTextLensRef = useRef(new Map<number, number>());
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) => (
<Token
key={isStreaming ? `${i}:${token.raw.length}` : i}
token={token as ExtendedToken}
token={token}
components={components}
/>
)),
Expand Down
1 change: 1 addition & 0 deletions static/app/components/core/markdown/token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export function Token({
attrs={token.attrs}
data={token.data}
raw={token.raw}
index={token.index}
/>
);
}
Expand Down
5 changes: 5 additions & 0 deletions static/app/components/seer/markdown/embeds/registry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
146 changes: 146 additions & 0 deletions static/app/components/seer/markdown/embeds/renderTracking.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
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> = {}): 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<string, unknown>;

it('records a render with its conversation, message and index', () => {
const current = scope();
render(
<SeerMarkdown raw={`at ${timestamp('2025-07-15T14:30:00Z')}`} scope={current} />
);

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.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(
<SeerMarkdown
raw={`${timestamp('2025-07-15T14:30:00Z')} and ${timestamp('2025-07-16T14:30:00Z')}`}
scope={scope()}
/>
);

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(<SeerMarkdown raw={`${same} and ${same}`} scope={scope()} />);

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(<SeerMarkdown raw={raw} scope={current} />);
expect(info).toHaveBeenCalledTimes(1);

// Streaming remounts the paragraph holding an inline embed on every chunk.
rerender(<SeerMarkdown raw={`${raw} and more`} scope={current} />);
rerender(<SeerMarkdown raw={`${raw} and more text`} scope={current} />);

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(
<SeerMarkdown
raw={raw}
scope={{...scope({conversationId}), messageId: 'block-1'}}
/>
);
render(
<SeerMarkdown
raw={raw}
scope={{...scope({conversationId}), messageId: 'block-2'}}
/>
);

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(<SeerMarkdown raw={`at ${timestamp('2025-07-15T14:30:00Z')}`} />);
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(
<SeerMarkdown
raw='{% timestamp %}{"format":"absolute"}{% /timestamp %}'
scope={scope()}
/>
);

expect(info).not.toHaveBeenCalled();

warn.mockRestore();
captureException.mockRestore();
});
});
Loading
Loading