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/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/__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/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/' - ); - }); -}); 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..019b5add52df --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profile.spec.tsx @@ -0,0 +1,256 @@ +import {screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; + +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'; +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'}]}, + }; +} + +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}); + + return renderEmbed({ + name: 'profile', + data: {projectSlug: PROJECT_SLUG, profileId: PROFILE_ID}, + }); +} + +describe('profile embed', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + 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('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'); + + // 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'); + + 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(); + + 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/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/profileBlock.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx new file mode 100644 index 000000000000..5655c0d14923 --- /dev/null +++ b/static/app/components/seer/markdown/embeds/components/profile/profileBlock.tsx @@ -0,0 +1,334 @@ +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 {formatTo} from 'sentry/utils/profiling/units/units'; +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'; + +/** + * 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_MODES: Record< + ViewMode, + {importType: 'flamechart' | 'flamegraph'; sort: FlamegraphModel['sort']} +> = { + aggregated: {importType: 'flamegraph', sort: 'left heavy'}, + timeline: {importType: 'flamechart', sort: 'call order'}, +}; + +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. + // 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 { + const group = importProfile( + data, + isSchema(data) ? data.metadata.traceID : '', + null, + 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 { + return null; + } + }, [data, viewMode]); + + const flamegraph = chart?.flamegraph ?? null; + + 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', + sorting: VIEW_MODES[viewMode].sort, + } + : undefined; + + return normalizeUrl( + generateProfileFlamechartRouteWithQuery({ + organization, + projectSlug, + profileId, + query, + }) + ); + }, [canvasView, organization, profileId, projectSlug, viewMode]); + + 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} + {chart ? ( + + {chart.flamegraph.formatter(chart.duration)} + + ) : null} + {chart ? ( + {chart.threadCount} + ) : 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} + + )} + + + ); +} diff --git a/static/app/components/seer/markdown/embeds/components/profile.tsx b/static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx similarity index 70% rename from static/app/components/seer/markdown/embeds/components/profile.tsx rename to static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx index 5040116cf827..84a124e05c48 100644 --- a/static/app/components/seer/markdown/embeds/components/profile.tsx +++ b/static/app/components/seer/markdown/embeds/components/profile/profileLink.tsx @@ -1,8 +1,5 @@ import {ResourceLink} from 'sentry/components/seer/markdown/embeds/components/resourceLink'; -import { - defineSeerEmbed, - type EmbedOutput, -} from 'sentry/components/seer/markdown/embeds/utils'; +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'; @@ -10,7 +7,7 @@ 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'>) { +export function ProfileLink({projectSlug, profileId}: EmbedOutput<'profile'>) { const organization = useOrganization(); const href = normalizeUrl( generateProfileFlamechartRoute({organization, projectSlug, profileId}) @@ -24,10 +21,3 @@ function ProfileLink({projectSlug, profileId}: EmbedOutput<'profile'>) { /> ); } - -export const Profile = defineSeerEmbed({ - name: 'profile', - render(props) { - 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'; 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'}, }, ], 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