Skip to content
Merged
11 changes: 9 additions & 2 deletions src/sentry/seer/agent/embed_widgets.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -705,7 +705,14 @@
},
"examples": [
{
"label": "Profile",
"label": "Inline",
"data": {
"projectSlug": "javascript",
"profileId": "7f3c2b1a9d8e4f60"
}
},
{
"label": "Block",
"data": {
"projectSlug": "javascript",
"profileId": "7f3c2b1a9d8e4f60"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FlamegraphModel> | null) => void;
}

export function FlamegraphPreview({
anchorAtRoot,
flamegraph,
relativeStartTimestamp,
relativeStopTimestamp,
Expand Down Expand Up @@ -69,14 +75,16 @@ 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);
canvasView.mode = mode;

return canvasView;
}, [
anchorAtRoot,
flamegraph,
flamegraphCanvas,
flamegraphTheme,
Expand Down Expand Up @@ -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<FlamegraphModel>['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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) {
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(<ProfileEmbedStory />);

// 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(<ProfileEmbedStory />);

expect(
await screen.findByText('No profile is available for this organization.')
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -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<EventsResults<ProfileField>>()(
'/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 (
<EmbedStory name="profile">
{isPending ? (
<LoadingIndicator />
) : isError ? (
<Text variant="muted">Unable to load a profile example.</Text>
) : profileId && projectSlug ? (
<EmbedVariant name="profile" label="Profile" data={{profileId, projectSlug}} />
) : (
<Text variant="muted">No profile is available for this organization.</Text>
)}
</EmbedStory>
);
}

This file was deleted.

Loading
Loading