From 6ba27eedd9484893515e55ed87c6d45ad474a249 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 08:40:47 -0400 Subject: [PATCH 1/9] feat(seer): give the profile embed a real block level The `profile` schema already declared `level: ['inline', 'block']`, but both levels rendered the same bare link. The block now previews the profile: a metadata strip (transaction, duration, threads, environment, release, OS, device, received) above a 200px flamechart, from a single fetch of the profile payload. Splits the embed into a directory per the embeds convention, so the flamegraph renderer and speedscope model layer stay out of the bundle until a block actually renders. Two page-level couplings are deliberately avoided: `importProfile` is called directly rather than mounting `ProfileGroupProvider`, which reads `?tid=` off the host URL, and the metadata comes from the payload rather than `useProfileEvents`, which reads host page filters that have nothing to do with a specific profile. The left-heavy/time-ordered toggle keeps its state local, with a test asserting it never touches the router. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../embeds/components/profile/profile.tsx | 17 ++++++++++++++ .../embeds/components/profile/profileLink.tsx | 23 +++++++++++++++++++ .../components/seer/markdown/embeds/index.ts | 2 +- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 static/app/components/seer/markdown/embeds/components/profile/profile.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx diff --git a/static/app/components/seer/markdown/embeds/components/profile/profile.tsx b/static/app/components/seer/markdown/embeds/components/profile/profile.tsx new file mode 100644 index 000000000000..74a9f9671236 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.tsx @@ -0,0 +1,17 @@ +import {lazy} from 'react'; + +import {LazyLoad} from 'sentry/components/lazyLoad'; +import {ProfileLink} from 'sentry/components/seer/markdown/embeds/components/profile/profileLink'; +import {defineSeerEmbed} from 'sentry/components/seer/markdown/embeds/utils'; + +const LazyProfileBlock = lazy(() => import('./profileBlock')); + +export const Profile = defineSeerEmbed({ + name: 'profile', + render(props, level) { + if (level === 'block') { + return ; + } + return ; + }, +}); diff --git a/static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx new file mode 100644 index 000000000000..84a124e05c48 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx @@ -0,0 +1,23 @@ +import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {IconProfiling} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {getShortEventId} from 'sentry/utils/events'; +import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +export function ProfileLink({projectSlug, profileId}: EmbedOutput<'profile'>) { + const organization = useOrganization(); + const href = normalizeUrl( + generateProfileFlamechartRoute({organization, projectSlug, profileId}) + ); + + return ( + + ); +} diff --git a/static/app/components/seer/markdown/embeds/index.ts b/static/app/components/seer/markdown/embeds/index.ts index ca31b55e1875..9a143c144c4f 100644 --- a/static/app/components/seer/markdown/embeds/index.ts +++ b/static/app/components/seer/markdown/embeds/index.ts @@ -11,7 +11,7 @@ import {IssuesQuery} from './components/issuesQuery'; import {LogsQuery} from './components/logsQuery'; import {MetricsQuery} from './components/metricsQuery'; import {Monitor} from './components/monitor/monitor'; -import {Profile} from './components/profile'; +import {Profile} from './components/profile/profile'; import {Release} from './components/release'; import {Replay} from './components/replay'; import {ReplaysQuery} from './components/replaysQuery'; From 0a056b8153e8a78e13f7aba3a77c5155234c5b15 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 08:41:52 -0400 Subject: [PATCH 2/9] feat(seer): add the profile block preview and its tests Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../components/profile/profile.spec.tsx | 143 ++++++++ .../components/profile/profileBlock.tsx | 308 ++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx create mode 100644 static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx diff --git a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx new file mode 100644 index 000000000000..8530eaf46748 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx @@ -0,0 +1,143 @@ +import {screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +import { + getEmbedLinkHref, + renderEmbed, +} from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; + +const PROJECT_SLUG = 'javascript'; +const PROFILE_ID = '7f3c2b1a9d8e4f60'; +const PROFILE_URL = `/projects/org-slug/${PROJECT_SLUG}/profiling/profiles/${PROFILE_ID}/`; + +function makeProfileSchema() { + return { + activeProfileIndex: 0, + profileID: PROFILE_ID, + projectID: 2, + metadata: { + androidAPILevel: 0, + deviceClassification: 'high', + deviceLocale: 'en_US', + deviceManufacturer: 'Apple', + deviceModel: 'iPhone14,3', + deviceOSName: 'iOS', + deviceOSVersion: '16.0', + environment: 'production', + organizationID: 1, + platform: 'cocoa', + profileID: PROFILE_ID, + projectID: 2, + received: '2026-08-25T16:37:12Z', + release: {version: '1.0.0'}, + timestamp: '2026-08-25T16:37:12Z', + traceID: 'ff62a8b040f34bbda121af0aac2b5f0d', + transactionID: '8b90e2f0b1a94b3e9b5b0a3d2c1e4f60', + transactionName: 'iOS-Swift.ViewController', + }, + profiles: [ + { + name: 'main', + startValue: 0, + endValue: 1000, + unit: 'milliseconds', + threadID: 0, + type: 'sampled', + weights: [10, 10], + samples: [[0], [0, 1]], + }, + ], + shared: {frames: [{name: 'main'}, {name: 'doWork'}]}, + }; +} + +function renderProfileBlock(body: unknown = makeProfileSchema(), statusCode = 200) { + MockApiClient.addMockResponse({url: PROFILE_URL, body, statusCode}); + + return renderEmbed({ + name: 'profile', + data: {projectSlug: PROJECT_SLUG, profileId: PROFILE_ID}, + }); +} + +describe('profile embed', () => { + it('links a profile to its flamegraph', () => { + expect( + getEmbedLinkHref('profile', 'Profile 7f3c2b1a', { + projectSlug: PROJECT_SLUG, + profileId: PROFILE_ID, + }) + ).toBe( + '/organizations/org-slug/explore/profiles/profile/javascript/7f3c2b1a9d8e4f60/flamegraph/' + ); + }); + + it('renders the metadata strip and the flamechart preview at block level', async () => { + renderProfileBlock(); + + expect(await screen.findByTestId('seer-profile-flamechart')).toBeInTheDocument(); + + // Metadata pulled straight out of the single profile payload + expect(screen.getByText('Transaction')).toBeInTheDocument(); + expect(screen.getByText('iOS-Swift.ViewController')).toBeInTheDocument(); + expect(screen.getByText('Environment')).toBeInTheDocument(); + expect(screen.getByText('production')).toBeInTheDocument(); + expect(screen.getByText('Release')).toBeInTheDocument(); + expect(screen.getByText('1.0.0')).toBeInTheDocument(); + expect(screen.getByText('OS')).toBeInTheDocument(); + expect(screen.getByText('iOS 16.0')).toBeInTheDocument(); + expect(screen.getByText('Device')).toBeInTheDocument(); + expect(screen.getByText('iPhone14,3 high')).toBeInTheDocument(); + expect(screen.getByText('Duration')).toBeInTheDocument(); + expect(screen.getByText('Threads')).toBeInTheDocument(); + + // The inline affordance is preserved in the card header + expect(screen.getByRole('link', {name: 'Profile 7f3c2b1a'})).toHaveAttribute( + 'href', + '/organizations/org-slug/explore/profiles/profile/javascript/7f3c2b1a9d8e4f60/flamegraph/' + ); + expect(screen.getByRole('button', {name: 'Open in Profiling'})).toBeInTheDocument(); + }); + + it('keeps the view toggle local to the embed', async () => { + const {router} = renderProfileBlock(); + + const timeOrdered = await screen.findByRole('radio', {name: 'Time-ordered'}); + const locationBefore = router.location; + + await userEvent.click(timeOrdered); + + await waitFor(() => { + expect(screen.getByRole('radio', {name: 'Time-ordered'})).toBeChecked(); + }); + + // The embed must not write its interaction state into the host page URL + expect(router.location.pathname).toBe(locationBefore.pathname); + expect(router.location.query).toEqual(locationBefore.query); + expect(screen.getByTestId('seer-profile-flamechart')).toBeInTheDocument(); + }); + + it('degrades to the link when the profile cannot be loaded', async () => { + renderProfileBlock({detail: 'Not found'}, 404); + + expect(await screen.findByText('Unable to load profile details')).toBeInTheDocument(); + expect(screen.getByRole('link', {name: 'Profile 7f3c2b1a'})).toBeInTheDocument(); + expect(screen.queryByTestId('seer-profile-flamechart')).not.toBeInTheDocument(); + }); + + it('degrades to the link for a continuous profile chunk payload', async () => { + renderProfileBlock({ + chunk_id: 'a1b2c3d4e5f60718', + profiler_id: 'b2c3d4e5f6071829', + environment: 'production', + platform: 'cocoa', + version: '2', + profile: {samples: [], stacks: [], frames: []}, + }); + + expect( + await screen.findByRole('link', {name: 'Profile 7f3c2b1a'}) + ).toBeInTheDocument(); + expect(screen.queryByTestId('seer-profile-flamechart')).not.toBeInTheDocument(); + expect(screen.queryByText('Transaction')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx new file mode 100644 index 000000000000..9966e588f326 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx @@ -0,0 +1,308 @@ +import {useMemo, useState, type ReactNode} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {LinkButton} from '@sentry/scraps/button'; +import {Container, Flex, Grid, Stack} from '@sentry/scraps/layout'; +import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Text} from '@sentry/scraps/text'; + +import {DateTime} from 'sentry/components/dateTime'; +import {ErrorBoundary} from 'sentry/components/errorBoundary'; +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {FlamegraphPreview} from 'sentry/components/profiling/flamegraph/flamegraphPreview'; +import {ProfileLink} from 'sentry/components/seer/markdown/embeds/components/profile/profileLink'; +import type {EmbedOutput} from 'sentry/components/seer/markdown/embeds/utils'; +import {Version} from 'sentry/components/version'; +import {t} from 'sentry/locale'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import type {CanvasView} from 'sentry/utils/profiling/canvasView'; +import {Flamegraph as FlamegraphModel} from 'sentry/utils/profiling/flamegraph'; +import {FlamegraphThemeProvider} from 'sentry/utils/profiling/flamegraph/flamegraphThemeProvider'; +import { + isSchema, + isSentryContinuousProfileChunk, + isSentrySampledProfile, +} from 'sentry/utils/profiling/guards/profile'; +import {importProfile} from 'sentry/utils/profiling/profile/importProfile'; +import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes'; +import {Rect} from 'sentry/utils/profiling/speedscope'; +import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +/** + * The flamechart canvas is absolutely positioned at 100%/100%, so the element + * wrapping it has to be `position: relative` with an explicit pixel height or + * nothing is painted. + */ +const PREVIEW_HEIGHT = '200px'; + +type ViewMode = 'aggregated' | 'timeline'; + +function profileApiOptions({ + organizationSlug, + profileId, + projectSlug, +}: { + organizationSlug: string; + profileId: string; + projectSlug: string; +}) { + return apiOptions.as()( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/profiling/profiles/$profileId/', + { + path: { + organizationIdOrSlug: organizationSlug, + projectIdOrSlug: projectSlug, + profileId, + }, + staleTime: 60_000, + } + ); +} + +interface ProfileMetadata { + device: string | undefined; + environment: string | undefined; + os: string | undefined; + receivedAt: string | undefined; + release: string | undefined; + transactionName: string | undefined; +} + +function joinDefined(parts: Array): string | undefined { + const joined = parts.filter(Boolean).join(' '); + return joined || undefined; +} + +/** + * Everything the metadata strip shows comes out of the single profile payload + * we already fetched -- deliberately no `useProfileEvents`/`useProfileFunctions`, + * which resolve their scope from the host page's filters rather than this profile. + */ +function getProfileMetadata(input: Profiling.ProfileInput): ProfileMetadata | null { + if (isSentryContinuousProfileChunk(input)) { + // A continuous chunk is addressed by profiler id + time range, which this + // embed's schema does not carry. Nothing reliable to show. + return null; + } + + if (isSchema(input)) { + const {metadata} = input; + return { + device: joinDefined([metadata.deviceModel, metadata.deviceClassification]), + environment: metadata.environment, + os: joinDefined([metadata.deviceOSName, metadata.deviceOSVersion]), + receivedAt: metadata.timestamp ?? metadata.received, + release: metadata.release?.version, + transactionName: metadata.transactionName, + }; + } + + if (isSentrySampledProfile(input)) { + return { + device: joinDefined([input.device?.manufacturer, input.device?.model]), + environment: input.environment, + os: joinDefined([input.os?.name, input.os?.version]), + receivedAt: input.timestamp ?? input.received, + release: input.release?.version, + transactionName: input.transaction?.name, + }; + } + + return null; +} + +function MetadataItem({label, children}: {children: ReactNode; label: string}) { + return ( + + + {label} + + {children} + + ); +} + +export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'profile'>) { + const organization = useOrganization(); + // Local to the embed on purpose: toggling the view must not touch the host + // conversation's URL or history. + const [viewMode, setViewMode] = useState('aggregated'); + const [canvasView, setCanvasView] = useState | null>(null); + + const {data, isError, isPending} = useQuery({ + ...profileApiOptions({organizationSlug: organization.slug, projectSlug, profileId}), + retry: false, + }); + + const metadata = useMemo(() => (data ? getProfileMetadata(data) : null), [data]); + + // `importProfile` is real CPU work over the whole payload, so keep it memoized. + const profileGroup = useMemo(() => { + if (!data || isSentryContinuousProfileChunk(data)) { + return null; + } + + try { + return importProfile( + data, + isSchema(data) ? data.metadata.traceID : '', + null, + viewMode === 'timeline' ? 'flamechart' : 'flamegraph' + ); + } catch { + // An unrecognized payload degrades to the metadata strip below. + return null; + } + }, [data, viewMode]); + + const activeProfile = + profileGroup?.profiles[profileGroup.activeProfileIndex] ?? + profileGroup?.profiles[0] ?? + null; + + const flamegraph = useMemo( + () => + activeProfile + ? new FlamegraphModel(activeProfile, { + sort: viewMode === 'timeline' ? 'call order' : 'left heavy', + }) + : null, + [activeProfile, viewMode] + ); + + const target = useMemo(() => { + // Deep link to the same viewport the preview is showing. + const query = canvasView?.configView + ? { + fov: Rect.encode(canvasView.configView), + view: 'top down', + type: 'flamechart', + } + : undefined; + + return normalizeUrl( + generateProfileFlamechartRouteWithQuery({ + organization, + projectSlug, + profileId, + query, + }) + ); + }, [canvasView, organization, profileId, projectSlug]); + + return ( + + + + + + {flamegraph ? ( + + + {t('Left-heavy')} + + + {t('Time-ordered')} + + + ) : null} + + {t('Open in Profiling')} + + + + + {isPending ? ( + + + + ) : isError || !data ? ( + {t('Unable to load profile details')} + ) : ( + + {metadata ? ( + + {metadata.transactionName ? ( + + {metadata.transactionName} + + ) : null} + {flamegraph && activeProfile ? ( + + {flamegraph.formatter(activeProfile.duration)} + + ) : null} + {profileGroup ? ( + + {profileGroup.profiles.length} + + ) : null} + {metadata.environment ? ( + + {metadata.environment} + + ) : null} + {metadata.release ? ( + + + + ) : null} + {metadata.os ? ( + {metadata.os} + ) : null} + {metadata.device ? ( + {metadata.device} + ) : null} + {metadata.receivedAt ? ( + + + + ) : null} + + ) : null} + + {flamegraph ? ( + + + + + + + + ) : null} + + )} + + + ); +} From 0e237052f7137c3b683141cb7073f63170a6de85 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 08:41:59 -0400 Subject: [PATCH 3/9] refactor(seer): drop the old flat profile embed file Moved to components/profile/profileLink.tsx. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../markdown/embeds/components/profile.tsx | 33 ------------------- 1 file changed, 33 deletions(-) delete mode 100644 static/app/components/seer/markdown/embeds/components/profile.tsx diff --git a/static/app/components/seer/markdown/embeds/components/profile.tsx b/static/app/components/seer/markdown/embeds/components/profile.tsx deleted file mode 100644 index 5040116cf827..000000000000 --- a/static/app/components/seer/markdown/embeds/components/profile.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; -import { - defineSeerEmbed, - type EmbedOutput, -} from 'sentry/components/seer/markdown/embeds/utils'; -import {IconProfiling} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import {getShortEventId} from 'sentry/utils/events'; -import {generateProfileFlamechartRoute} from 'sentry/utils/profiling/routes'; -import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -function ProfileLink({projectSlug, profileId}: EmbedOutput<'profile'>) { - const organization = useOrganization(); - const href = normalizeUrl( - generateProfileFlamechartRoute({organization, projectSlug, profileId}) - ); - - return ( - - ); -} - -export const Profile = defineSeerEmbed({ - name: 'profile', - render(props) { - return ; - }, -}); From e501657c63e3168eec130b36f21194777ee62937 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 08:42:05 -0400 Subject: [PATCH 4/9] test(seer): drop the old flat profile embed spec Moved to components/profile/profile.spec.tsx. Claude-Session: https://claude.ai/code/session_016YUmhMZLo8geXJa93daPEQ --- .../markdown/embeds/components/profile.spec.tsx | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 static/app/components/seer/markdown/embeds/components/profile.spec.tsx diff --git a/static/app/components/seer/markdown/embeds/components/profile.spec.tsx b/static/app/components/seer/markdown/embeds/components/profile.spec.tsx deleted file mode 100644 index ca0adc79f6d3..000000000000 --- a/static/app/components/seer/markdown/embeds/components/profile.spec.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {getEmbedLinkHref} from './resourceEmbedTestUtils'; - -describe('profile embed', () => { - it('links a profile to its flamegraph', () => { - expect( - getEmbedLinkHref('profile', 'Profile 7f3c2b1a', { - projectSlug: 'javascript', - profileId: '7f3c2b1a9d8e4f60', - }) - ).toBe( - '/organizations/org-slug/explore/profiles/profile/javascript/7f3c2b1a9d8e4f60/flamegraph/' - ); - }); -}); From 34bf8a3bfd6e0c21d2f8226fca8947160ae95c6c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 10:38:09 -0400 Subject: [PATCH 5/9] docs(seer): add a live storybook story for the profile embed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The profile embed's story rendered the schema's static example, whose profile id is made up — so the new block level always 404'd there and fell back to the bare link. Swap it for a `ProfileEmbedStory` that pulls the org's most recent transaction-based profile, matching what the other block-level embeds (release, replay, savedQuery) already do. `has:profile.id` keeps the search off continuous profiles, which are addressed by profiler id plus a time range and so cannot be expressed in this embed's schema. Claude-Session: https://claude.ai/code/session_01PHE7yFBmGH1Rv1nmzGqwys --- .../__stories__/profileEmbedStory.spec.tsx | 64 +++++++++++++++++++ .../__stories__/profileEmbedStory.tsx | 64 +++++++++++++++++++ .../components/seer/markdown/seerMarkdown.mdx | 3 +- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 static/app/components/seer/markdown/__stories__/profileEmbedStory.spec.tsx create mode 100644 static/app/components/seer/markdown/__stories__/profileEmbedStory.tsx diff --git a/static/app/components/seer/markdown/__stories__/profileEmbedStory.spec.tsx b/static/app/components/seer/markdown/__stories__/profileEmbedStory.spec.tsx new file mode 100644 index 000000000000..7600df7ba5f8 --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/profileEmbedStory.spec.tsx @@ -0,0 +1,64 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {ProfileEmbedStory} from './profileEmbedStory'; + +const PROJECT_SLUG = 'javascript'; +const PROFILE_ID = '7f3c2b1a9d8e4f60'; + +function mockProfileSearch(data: Array>) { + return MockApiClient.addMockResponse({ + url: '/organizations/org-slug/events/', + body: {data, meta: {fields: {}, units: {}}}, + }); +} + +describe('ProfileEmbedStory', () => { + beforeEach(() => { + // The block level fetches the payload itself, and the embed's own spec + // covers what it renders, so degrade it to the inline link here. + MockApiClient.addMockResponse({ + url: `/projects/org-slug/${PROJECT_SLUG}/profiling/profiles/${PROFILE_ID}/`, + body: {}, + statusCode: 404, + }); + }); + + it('embeds the most recent transaction-based profile', async () => { + const eventsRequest = mockProfileSearch([ + { + 'profile.id': PROFILE_ID, + 'project.name': PROJECT_SLUG, + timestamp: '2026-08-25T16:37:12Z', + }, + ]); + + render(); + + // Both `profile.id` and `project.name` have to land in the embed's data for + // the flamegraph route to resolve. + const links = await screen.findAllByRole('link', {name: 'Profile 7f3c2b1a'}); + expect(links[0]).toHaveAttribute( + 'href', + `/organizations/org-slug/explore/profiles/profile/${PROJECT_SLUG}/${PROFILE_ID}/flamegraph/` + ); + + // Continuous profiles carry a profiler id instead, which the embed schema + // cannot address, so the search must exclude them. + expect(eventsRequest).toHaveBeenCalledWith( + '/organizations/org-slug/events/', + expect.objectContaining({ + query: expect.objectContaining({query: 'is_transaction:true has:profile.id'}), + }) + ); + }); + + it('says so when the organization has no profiles', async () => { + mockProfileSearch([]); + + render(); + + expect( + await screen.findByText('No profile is available for this organization.') + ).toBeInTheDocument(); + }); +}); diff --git a/static/app/components/seer/markdown/__stories__/profileEmbedStory.tsx b/static/app/components/seer/markdown/__stories__/profileEmbedStory.tsx new file mode 100644 index 000000000000..513f42107a6a --- /dev/null +++ b/static/app/components/seer/markdown/__stories__/profileEmbedStory.tsx @@ -0,0 +1,64 @@ +import {useQuery} from '@tanstack/react-query'; + +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import type {EventsResults} from 'sentry/utils/profiling/hooks/types'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +import {EmbedStory, EmbedVariant} from './embedStory'; + +type ProfileField = 'profile.id' | 'project.name' | 'timestamp'; + +/** + * `has:profile.id` keeps this to transaction-based profiles. A continuous + * profile is addressed by profiler id plus a time range, which the embed's + * schema does not carry, so its block would degrade back to a bare link. + */ +const PROFILE_QUERY = 'is_transaction:true has:profile.id'; + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value ? value : undefined; +} + +export function ProfileEmbedStory() { + const organization = useOrganization(); + const {data, isError, isPending} = useQuery( + apiOptions.as>()( + '/organizations/$organizationIdOrSlug/events/', + { + path: {organizationIdOrSlug: organization.slug}, + query: { + dataset: 'spans', + referrer: 'api.profiling.landing-table', + project: [-1], + statsPeriod: '14d', + field: ['profile.id', 'project.name', 'timestamp'], + query: PROFILE_QUERY, + sort: '-timestamp', + per_page: 1, + }, + staleTime: 30_000, + } + ) + ); + + const row = data?.data?.[0]; + const profileId = asNonEmptyString(row?.['profile.id']); + const projectSlug = asNonEmptyString(row?.['project.name']); + + return ( + + {isPending ? ( + + ) : isError ? ( + Unable to load a profile example. + ) : profileId && projectSlug ? ( + + ) : ( + No profile is available for this organization. + )} + + ); +} diff --git a/static/app/components/seer/markdown/seerMarkdown.mdx b/static/app/components/seer/markdown/seerMarkdown.mdx index 7336a5b6952e..78b6d053935b 100644 --- a/static/app/components/seer/markdown/seerMarkdown.mdx +++ b/static/app/components/seer/markdown/seerMarkdown.mdx @@ -13,6 +13,7 @@ import {AlertEmbedStory} from './__stories__/alertEmbedStory'; import {DashboardEmbedStory} from './__stories__/dashboardEmbedStory'; import {EmbedStory} from './__stories__/embedStory'; import {MonitorEmbedStory} from './__stories__/monitorEmbedStory'; +import {ProfileEmbedStory} from './__stories__/profileEmbedStory'; import {ReleaseEmbedStory} from './__stories__/releaseEmbedStory'; import {ReplayEmbedStory} from './__stories__/replayEmbedStory'; import {SavedIssueViewEmbedStory} from './__stories__/savedIssueViewEmbedStory'; @@ -110,7 +111,7 @@ Tag syntax: `{% name %}{"key":"value"}{% /name %}`. The JSON body is validated a ### profile - + ### issuesQuery From 8009a56cf8b757dd87de3684ef94cbac3bb310a9 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 11:27:51 -0400 Subject: [PATCH 6/9] feat(seer): tell the agent what the profile block renders The profile schema was written when both levels rendered the same link, so its description says nothing about the levels and its single example is implicitly inline. Level is decided by placement -- a tag alone in a paragraph is block, a tag inside a sentence is inline -- so the description and the per-level examples are the only things that teach the agent to put a profile on its own line. Without them the new block preview would rarely be what renders. Follows how `issue` and `replay` document their levels, including the "do NOT duplicate any of that data as text" instruction that keeps the agent from restating the metadata strip in prose. Claude-Session: https://claude.ai/code/session_01PHE7yFBmGH1Rv1nmzGqwys --- src/sentry/seer/agent/embed_widgets.generated.json | 11 +++++++++-- .../app/components/seer/markdown/embeds/schemas.ts | 12 +++++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/sentry/seer/agent/embed_widgets.generated.json b/src/sentry/seer/agent/embed_widgets.generated.json index e6a2fe417d95..4f251cc93117 100644 --- a/src/sentry/seer/agent/embed_widgets.generated.json +++ b/src/sentry/seer/agent/embed_widgets.generated.json @@ -685,7 +685,7 @@ }, { "name": "profile", - "description": "The ONLY way to reference a Sentry profile (the flamegraph view). Requires both the profile ID and the slug of the project it belongs to. Never use a markdown link for profile references.", + "description": "The ONLY way to reference a Sentry profile (the flamegraph view). Requires both the profile ID and the slug of the project it belongs to. Inline: renders a compact link with the short profile id. Block: renders a preview with the transaction, duration, thread count, environment, release, OS, device, received time, and a flamechart — do NOT duplicate any of that data as text. Never use a markdown link for profile references.", "level": ["inline", "block"], "body": { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -705,7 +705,14 @@ }, "examples": [ { - "label": "Profile", + "label": "Inline", + "data": { + "projectSlug": "javascript", + "profileId": "7f3c2b1a9d8e4f60" + } + }, + { + "label": "Block", "data": { "projectSlug": "javascript", "profileId": "7f3c2b1a9d8e4f60" diff --git a/static/app/components/seer/markdown/embeds/schemas.ts b/static/app/components/seer/markdown/embeds/schemas.ts index 52b2f5d3e4f1..d38f943df575 100644 --- a/static/app/components/seer/markdown/embeds/schemas.ts +++ b/static/app/components/seer/markdown/embeds/schemas.ts @@ -496,6 +496,10 @@ export const SEER_EMBED_SCHEMAS = { description: 'The ONLY way to reference a Sentry profile (the flamegraph view). ' + 'Requires both the profile ID and the slug of the project it belongs to. ' + + 'Inline: renders a compact link with the short profile id. ' + + 'Block: renders a preview with the transaction, duration, thread count, ' + + 'environment, release, OS, device, received time, and a flamechart — ' + + 'do NOT duplicate any of that data as text. ' + 'Never use a markdown link for profile references.', level: ['inline', 'block'], schema: z.object({ @@ -504,7 +508,13 @@ export const SEER_EMBED_SCHEMAS = { }), examples: [ { - label: 'Profile', + label: 'Inline', + level: 'inline', + data: {projectSlug: 'javascript', profileId: '7f3c2b1a9d8e4f60'}, + }, + { + label: 'Block', + level: 'block', data: {projectSlug: 'javascript', profileId: '7f3c2b1a9d8e4f60'}, }, ], From de942110eb26c45e4e8871f081e683b502919874 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 12:42:53 -0400 Subject: [PATCH 7/9] fix(seer): keep the profile deep link in the preview's sort "Open in Profiling" encoded the previewed viewport as `fov` alongside `type: 'flamechart'`, but the flamegraph page's query decoder has no `type` key -- it picks call-order vs left-heavy from `sorting`, which defaults to call order. The preview defaults to left-heavy, so the rect was replayed against a differently sorted tree and pointed at unrelated frames. Send `sorting` instead, from a single `VIEW_MODE_SORT` map that also feeds the preview's own FlamegraphModel, so the two cannot drift apart again. The spec only asserted the button existed, which is how this got through; it now asserts the href carries the sort, in both toggle positions. Claude-Session: https://claude.ai/code/session_01PHE7yFBmGH1Rv1nmzGqwys --- .../components/profile/profile.spec.tsx | 25 +++++++++++++++++++ .../components/profile/profileBlock.tsx | 19 ++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx index 8530eaf46748..4bafde9a6a33 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx @@ -98,6 +98,31 @@ describe('profile embed', () => { expect(screen.getByRole('button', {name: 'Open in Profiling'})).toBeInTheDocument(); }); + it('deep-links the previewed viewport under the sort it was captured with', async () => { + renderProfileBlock(); + await screen.findByTestId('seer-profile-flamechart'); + + const openInProfiling = () => screen.getByRole('button', {name: 'Open in Profiling'}); + + // `fov` is a rect in the sorted tree's coordinate space, and the flamegraph + // page defaults to 'call order', so the link has to name the preview's sort + // or the encoded viewport lands on unrelated frames. + expect(openInProfiling()).toHaveAttribute('href', expect.stringContaining('fov=')); + expect(openInProfiling()).toHaveAttribute( + 'href', + expect.stringContaining('sorting=left%20heavy') + ); + + await userEvent.click(screen.getByRole('radio', {name: 'Time-ordered'})); + + await waitFor(() => { + expect(openInProfiling()).toHaveAttribute( + 'href', + expect.stringContaining('sorting=call%20order') + ); + }); + }); + it('keeps the view toggle local to the embed', async () => { const {router} = renderProfileBlock(); diff --git a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx index 9966e588f326..20cc17d77101 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx @@ -38,6 +38,17 @@ const PREVIEW_HEIGHT = '200px'; type ViewMode = 'aggregated' | 'timeline'; +/** + * Drives both the preview's own sort and the `sorting` param on the deep link. + * `fov` is a rect in the sorted tree's coordinate space, so opening the full + * view under a different sort would land the viewport on unrelated frames -- + * and the flamegraph page defaults to 'call order', not the preview's default. + */ +const VIEW_MODE_SORT: Record = { + aggregated: 'left heavy', + timeline: 'call order', +}; + function profileApiOptions({ organizationSlug, profileId, @@ -164,9 +175,7 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof const flamegraph = useMemo( () => activeProfile - ? new FlamegraphModel(activeProfile, { - sort: viewMode === 'timeline' ? 'call order' : 'left heavy', - }) + ? new FlamegraphModel(activeProfile, {sort: VIEW_MODE_SORT[viewMode]}) : null, [activeProfile, viewMode] ); @@ -177,7 +186,7 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof ? { fov: Rect.encode(canvasView.configView), view: 'top down', - type: 'flamechart', + sorting: VIEW_MODE_SORT[viewMode], } : undefined; @@ -189,7 +198,7 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof query, }) ); - }, [canvasView, organization, profileId, projectSlug]); + }, [canvasView, organization, profileId, projectSlug, viewMode]); return ( Date: Wed, 9 Sep 2026 15:14:38 -0400 Subject: [PATCH 8/9] fix(seer): open the profile preview at the root, not the deepest frames The 200px preview opened scrolled to the bottom of the stack, showing a slab of leaf frames with no root context -- hard to even recognize as a flamechart, and worse in Time-ordered, where it was reliably wrong. Two causes: `FlamegraphPreview`'s window props are in seconds -- it converts them with `formatTo(v, 'second', flamegraph.unit)`. We passed `configSpace.width`, which is already in the flamegraph's unit, inflating the window (1000ms became 1000s). No frame could then wrap it, so the "innermost parent frame" bias never applied and the view fell through to the max-depth branch. That branch is the second cause: `computePreviewConfigView` biases towards the innermost frames by design, which is right for a preview scoped to a span but backwards for a preview of a whole profile. Add an opt-in `anchorAtRoot` that takes the existing y = 0 / anchorTop path unconditionally. The four existing call sites are unchanged. On a 40-deep profile whose samples share no root, the preview went from y = 32 (bottom 8 rows) to y = 0, in both view modes. Claude-Session: https://claude.ai/code/session_01PHE7yFBmGH1Rv1nmzGqwys --- .../flamegraph/flamegraphPreview.spec.tsx | 39 ++++++++++++++ .../flamegraph/flamegraphPreview.tsx | 20 +++++-- .../components/profile/profile.spec.tsx | 53 +++++++++++++++++++ .../components/profile/profileBlock.tsx | 8 ++- 4 files changed, 116 insertions(+), 4 deletions(-) diff --git a/static/app/components/profiling/flamegraph/flamegraphPreview.spec.tsx b/static/app/components/profiling/flamegraph/flamegraphPreview.spec.tsx index 88a9fe19b601..65bd3adbf327 100644 --- a/static/app/components/profiling/flamegraph/flamegraphPreview.spec.tsx +++ b/static/app/components/profiling/flamegraph/flamegraphPreview.spec.tsx @@ -84,6 +84,45 @@ describe('computePreviewConfigView', () => { expect(mode).toBe('anchorBottom'); }); + it('anchors at the root when asked to', () => { + const rawProfile: Profiling.SampledProfile = { + name: 'profile', + startValue: 0, + endValue: 1000, + unit: 'milliseconds', + threadID: 0, + type: 'sampled', + weights: [1, 1], + samples: [ + [0, 1, 0], + [1, 0, 1], + ], + }; + + const profile = SampledProfile.FromProfile( + rawProfile, + createFrameIndex('mobile', [{name: 'f0'}, {name: 'f1'}]), + {type: 'flamechart'} + ); + + const flamegraph = new Flamegraph(profile, {}); + + // the same too-short view as 'uses max depth', which lands on y = 1 + const configView = new Rect(0, 0, 2, 2); + + const {configView: previewConfigView, mode} = computePreviewConfigView( + flamegraph, + configView, + 0, + 2, + {anchorAtRoot: true} + ); + + // ...but a whole-profile preview wants the wide root frames, not the leaves + expect(previewConfigView).toEqual(new Rect(0, 0, 2, 2)); + expect(mode).toBe('anchorTop'); + }); + it('uses max depth in window', () => { const rawProfile: Profiling.SampledProfile = { name: 'profile', diff --git a/static/app/components/profiling/flamegraph/flamegraphPreview.tsx b/static/app/components/profiling/flamegraph/flamegraphPreview.tsx index dae020b264b9..8f475a1ba5a3 100644 --- a/static/app/components/profiling/flamegraph/flamegraphPreview.tsx +++ b/static/app/components/profiling/flamegraph/flamegraphPreview.tsx @@ -28,10 +28,16 @@ interface FlamegraphPreviewProps { flamegraph: FlamegraphModel; relativeStartTimestamp: number; relativeStopTimestamp: number; + /** + * Start the preview at the root instead of the innermost frames in the + * window. Use it when previewing a whole profile rather than a span. + */ + anchorAtRoot?: boolean; updateFlamegraphView?: (canvasView: CanvasView | null) => void; } export function FlamegraphPreview({ + anchorAtRoot, flamegraph, relativeStartTimestamp, relativeStopTimestamp, @@ -69,7 +75,8 @@ export function FlamegraphPreview({ flamegraph, canvasView.configView, formatTo(relativeStartTimestamp, 'second', flamegraph.unit), - formatTo(relativeStopTimestamp, 'second', flamegraph.unit) + formatTo(relativeStopTimestamp, 'second', flamegraph.unit), + {anchorAtRoot} ); canvasView.setConfigView(configView); @@ -77,6 +84,7 @@ export function FlamegraphPreview({ return canvasView; }, [ + anchorAtRoot, flamegraph, flamegraphCanvas, flamegraphTheme, @@ -264,17 +272,23 @@ export function FlamegraphPreview({ * on using the maximum depth of the whole flamechart and adjusting the config * view because the window selected may be shallower and would result in the * preview to show a lot of whitespace. + * + * Both of those bias towards the innermost frames, which is what a preview + * scoped to a span wants. A preview of a whole profile wants the opposite: pass + * `anchorAtRoot` to start at the root, so the wide top frames make the preview + * legible as a flamechart instead of opening on a slab of leaf frames. */ export function computePreviewConfigView( flamegraph: FlamegraphModel, configView: Rect, relativeStartNs: number, - relativeStopNs: number + relativeStopNs: number, + {anchorAtRoot = false}: {anchorAtRoot?: boolean} = {} ): { configView: Rect; mode: CanvasView['mode']; } { - if (flamegraph.depth < configView.height) { + if (anchorAtRoot || flamegraph.depth < configView.height) { // if the flamegraph height is less than the config view height, // the whole flamechart will fit on the view so we can just use y = 0 return { diff --git a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx index 4bafde9a6a33..fc5c35773083 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx @@ -50,6 +50,38 @@ function makeProfileSchema() { }; } +const DEEP_PROFILE_DEPTH = 40; + +/** + * Deep enough to overflow the 200px preview, with two samples that share no + * root frame -- the shape that made the preview open on the deepest rows. + */ +function makeDeepProfileSchema() { + return { + ...makeProfileSchema(), + profiles: [ + { + name: 'main', + startValue: 0, + endValue: 1000, + unit: 'milliseconds', + threadID: 0, + type: 'sampled', + weights: [500, 500], + samples: [ + [DEEP_PROFILE_DEPTH, DEEP_PROFILE_DEPTH + 1], + Array.from({length: DEEP_PROFILE_DEPTH}, (_, i) => i), + ], + }, + ], + shared: { + frames: Array.from({length: DEEP_PROFILE_DEPTH + 2}, (_, i) => ({ + name: `frame${i}`, + })), + }, + }; +} + function renderProfileBlock(body: unknown = makeProfileSchema(), statusCode = 200) { MockApiClient.addMockResponse({url: PROFILE_URL, body, statusCode}); @@ -98,6 +130,27 @@ describe('profile embed', () => { expect(screen.getByRole('button', {name: 'Open in Profiling'})).toBeInTheDocument(); }); + it('opens the preview at the root of a deep profile in both views', async () => { + renderProfileBlock(makeDeepProfileSchema()); + await screen.findByTestId('seer-profile-flamechart'); + + // The viewport the preview settled on is observable through the deep link's + // `fov` rect: "x,y,width,height", so y === 0 means it starts at the root. + const viewportY = () => + decodeURIComponent( + screen.getByRole('button', {name: 'Open in Profiling'}).getAttribute('href') ?? '' + ).replace(/^.*fov=[^,]*,([^,]*).*$/, '$1'); + + expect(viewportY()).toBe('0'); + + await userEvent.click(screen.getByRole('radio', {name: 'Time-ordered'})); + + await waitFor(() => { + expect(screen.getByRole('radio', {name: 'Time-ordered'})).toBeChecked(); + }); + expect(viewportY()).toBe('0'); + }); + it('deep-links the previewed viewport under the sort it was captured with', async () => { renderProfileBlock(); await screen.findByTestId('seer-profile-flamechart'); diff --git a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx index 20cc17d77101..abf494dbeb95 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx @@ -26,6 +26,7 @@ import { import {importProfile} from 'sentry/utils/profiling/profile/importProfile'; import {generateProfileFlamechartRouteWithQuery} from 'sentry/utils/profiling/routes'; import {Rect} from 'sentry/utils/profiling/speedscope'; +import {formatTo} from 'sentry/utils/profiling/units/units'; import {normalizeUrl} from 'sentry/utils/url/normalizeUrl'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -300,9 +301,14 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof position="relative" > From 1c9203ceaf7d7666589aff84b38925a4d8a0f3e8 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 9 Sep 2026 15:25:08 -0400 Subject: [PATCH 9/9] fix(seer): stop the profile view toggle from crashing the conversation Clicking Time-ordered could raise "TypeError: Flamegraph does not support call order sorting". The import type and the sort are a single choice -- `Flamegraph` rejects 'call order' on a profile imported as 'flamegraph' -- but the block derived them from `viewMode` in two separate memos, so nothing enforced the pair. Two changes: Derive both from one `VIEW_MODES` table and build the group and the model in the same memo, so the halves cannot come from different renders. Do that work inside the existing try/catch. The model was previously constructed in the component body while the ErrorBoundary sits below it in the JSX, so a throw escaped the embed and took the surrounding conversation down with it. A payload either step rejects now degrades to the metadata strip, which is what the catch already promised for the import. Claude-Session: https://claude.ai/code/session_01PHE7yFBmGH1Rv1nmzGqwys --- .../components/profile/profile.spec.tsx | 35 +++++++++ .../components/profile/profileBlock.tsx | 71 +++++++++++-------- 2 files changed, 76 insertions(+), 30 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx index fc5c35773083..019b5add52df 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx @@ -4,6 +4,10 @@ import { getEmbedLinkHref, renderEmbed, } from 'sentry/components/seer/markdown/embeds/components/resourceEmbedTestUtils'; +import {Flamegraph} from 'sentry/utils/profiling/flamegraph'; +import * as importProfileModule from 'sentry/utils/profiling/profile/importProfile'; + +const {importProfile} = importProfileModule; const PROJECT_SLUG = 'javascript'; const PROFILE_ID = '7f3c2b1a9d8e4f60'; @@ -92,6 +96,10 @@ function renderProfileBlock(body: unknown = makeProfileSchema(), statusCode = 20 } describe('profile embed', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + it('links a profile to its flamegraph', () => { expect( getEmbedLinkHref('profile', 'Profile 7f3c2b1a', { @@ -130,6 +138,33 @@ describe('profile embed', () => { expect(screen.getByRole('button', {name: 'Open in Profiling'})).toBeInTheDocument(); }); + it('pairs each view with an import type its sort accepts', () => { + // `Flamegraph` throws "does not support call order sorting" if a profile + // imported as 'flamegraph' is sorted by call order, which crashed the whole + // conversation because the model is built outside the embed's boundary. + for (const importType of ['flamegraph', 'flamechart'] as const) { + const group = importProfile(makeProfileSchema() as any, 't', null, importType); + const profile = group.profiles[0]!; + const sort = importType === 'flamechart' ? 'call order' : 'left heavy'; + + expect(() => new Flamegraph(profile, {sort})).not.toThrow(); + } + }); + + it('degrades to the metadata strip when the chart cannot be built', async () => { + jest.spyOn(importProfileModule, 'importProfile').mockImplementation(() => { + throw new TypeError('Flamegraph does not support call order sorting'); + }); + + renderProfileBlock(); + + // The card still renders; only the chart is missing. + expect(await screen.findByText('Transaction')).toBeInTheDocument(); + expect(screen.getByTestId('seer-profile-embed')).toBeInTheDocument(); + expect(screen.queryByTestId('seer-profile-flamechart')).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', {name: 'Time-ordered'})).not.toBeInTheDocument(); + }); + it('opens the preview at the root of a deep profile in both views', async () => { renderProfileBlock(makeDeepProfileSchema()); await screen.findByTestId('seer-profile-flamechart'); diff --git a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx index abf494dbeb95..5655c0d14923 100644 --- a/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx @@ -40,14 +40,22 @@ const PREVIEW_HEIGHT = '200px'; type ViewMode = 'aggregated' | 'timeline'; /** - * Drives both the preview's own sort and the `sorting` param on the deep link. - * `fov` is a rect in the sorted tree's coordinate space, so opening the full - * view under a different sort would land the viewport on unrelated frames -- - * and the flamegraph page defaults to 'call order', not the preview's default. + * The import type and the sort are one choice, not two: `Flamegraph` throws + * `TypeError: Flamegraph does not support call order sorting` when a profile + * imported as 'flamegraph' is sorted by call order. Deriving them from a single + * table keeps the pair honest. + * + * `sort` doubles as the `sorting` param on the deep link -- `fov` is a rect in + * the sorted tree's coordinate space, so opening the full view under a different + * sort would land the viewport on unrelated frames, and the flamegraph page + * defaults to 'call order' rather than the preview's default. */ -const VIEW_MODE_SORT: Record = { - aggregated: 'left heavy', - timeline: 'call order', +const VIEW_MODES: Record< + ViewMode, + {importType: 'flamechart' | 'flamegraph'; sort: FlamegraphModel['sort']} +> = { + aggregated: {importType: 'flamegraph', sort: 'left heavy'}, + timeline: {importType: 'flamechart', sort: 'call order'}, }; function profileApiOptions({ @@ -150,36 +158,41 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof const metadata = useMemo(() => (data ? getProfileMetadata(data) : null), [data]); // `importProfile` is real CPU work over the whole payload, so keep it memoized. - const profileGroup = useMemo(() => { + // Importing and sorting happen together so the two halves of the view mode can + // never come from different renders, and so a payload that either step rejects + // degrades to the metadata strip instead of throwing out of the embed and + // taking the surrounding conversation with it. + const chart = useMemo(() => { if (!data || isSentryContinuousProfileChunk(data)) { return null; } + const {importType, sort} = VIEW_MODES[viewMode]; + try { - return importProfile( + const group = importProfile( data, isSchema(data) ? data.metadata.traceID : '', null, - viewMode === 'timeline' ? 'flamechart' : 'flamegraph' + importType ); + const profile = group.profiles[group.activeProfileIndex] ?? group.profiles[0]; + + if (!profile) { + return null; + } + + return { + flamegraph: new FlamegraphModel(profile, {sort}), + duration: profile.duration, + threadCount: group.profiles.length, + }; } catch { - // An unrecognized payload degrades to the metadata strip below. return null; } }, [data, viewMode]); - const activeProfile = - profileGroup?.profiles[profileGroup.activeProfileIndex] ?? - profileGroup?.profiles[0] ?? - null; - - const flamegraph = useMemo( - () => - activeProfile - ? new FlamegraphModel(activeProfile, {sort: VIEW_MODE_SORT[viewMode]}) - : null, - [activeProfile, viewMode] - ); + const flamegraph = chart?.flamegraph ?? null; const target = useMemo(() => { // Deep link to the same viewport the preview is showing. @@ -187,7 +200,7 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof ? { fov: Rect.encode(canvasView.configView), view: 'top down', - sorting: VIEW_MODE_SORT[viewMode], + sorting: VIEW_MODES[viewMode].sort, } : undefined; @@ -258,15 +271,13 @@ export default function ProfileBlock({projectSlug, profileId}: EmbedOutput<'prof {metadata.transactionName} ) : null} - {flamegraph && activeProfile ? ( + {chart ? ( - {flamegraph.formatter(activeProfile.duration)} + {chart.flamegraph.formatter(chart.duration)} ) : null} - {profileGroup ? ( - - {profileGroup.profiles.length} - + {chart ? ( + {chart.threadCount} ) : null} {metadata.environment ? (