From fa53fdc26519c532eff7b1681a58928226068de3 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 08:59:16 +0200 Subject: [PATCH 1/9] chore(traceDrawer): replace local SectionDivider/VerticalLine with Scraps Separator (#116168) --- .../traceDrawer/details/styles.tsx | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx index cb570b706bbfff..ed488630adaa92 100644 --- a/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx +++ b/static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx @@ -8,6 +8,7 @@ import {Button, LinkButton} from '@sentry/scraps/button'; import {Container, Flex, Stack} from '@sentry/scraps/layout'; import {Link} from '@sentry/scraps/link'; import {SegmentedControl} from '@sentry/scraps/segmentedControl'; +import {Separator} from '@sentry/scraps/separator'; import {Tooltip} from '@sentry/scraps/tooltip'; import {ClippedBox} from 'sentry/components/clippedBox'; @@ -463,7 +464,7 @@ function Highlights({ return ( - + - + + + {node.op} @@ -525,7 +528,8 @@ function Highlights({ )} - + {/* margin (deprecated) kept for parity with surrounding margin-based sections in BodyContainer */} + ); } @@ -714,19 +718,6 @@ const StyledPanelHeader = styled(PanelHeader)` overflow: hidden; `; -const SectionDivider = styled('hr')` - border-color: ${p => p.theme.tokens.border.transparent.neutral.muted}; - margin: ${p => p.theme.space.md} 0; -`; - -const VerticalLine = styled('div')` - width: 1px; - height: 100%; - /* eslint-disable-next-line @sentry/scraps/use-semantic-token */ - background-color: ${p => p.theme.tokens.border.primary}; - margin-top: ${p => p.theme.space.xs}; -`; - const HighlightsWrapper = styled('div')` display: flex; align-items: stretch; From fb60bc3af3bcb96856be80fb77422b231b6057f5 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 09:13:17 +0200 Subject: [PATCH 2/9] fix(conversations): Restore side-by-side layout for platform option dropdown (#116272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the inline `with ` layout on the conversations onboarding panel. In #116082 the `` wrapper around `PlatformOptionDropdown` was replaced by a `` that now wraps the dropdown, the introduction, and the guided steps together. `PlatformOptionDropdown` returns a Fragment containing the `"with"` text node and the `OptionControl` dropdown(s) as siblings — so the Stack hoisted them as separate vertical items and the label ended up above the selector. Wrap just the dropdown row in a `` inside the Stack. The Stack's own `gap="md"` already handles spacing to the next row, so the previous `paddingBottom="md"` is no longer needed. Refs GH-116082 --- static/app/views/explore/conversations/onboarding.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/views/explore/conversations/onboarding.tsx b/static/app/views/explore/conversations/onboarding.tsx index 48e0c902ed1902..0b2b8e2ab5b7f8 100644 --- a/static/app/views/explore/conversations/onboarding.tsx +++ b/static/app/views/explore/conversations/onboarding.tsx @@ -377,7 +377,9 @@ export function ConversationOnboarding({onDismiss}: {onDismiss: () => void}) { - + + + {introduction && {introduction}} Date: Wed, 27 May 2026 07:42:09 +0000 Subject: [PATCH 3/9] Revert "perf(sdk): Add regex pattern support to traces sampler route matching (#115874)" This reverts commit 0f88e7b6dc46a58e286c46adf8f9ed274d986dd9. Co-authored-by: obostjancic <86684834+obostjancic@users.noreply.github.com> --- src/sentry/utils/sdk.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/sentry/utils/sdk.py b/src/sentry/utils/sdk.py index a84219d86ccf64..b278c4d2c7fad5 100644 --- a/src/sentry/utils/sdk.py +++ b/src/sentry/utils/sdk.py @@ -3,7 +3,6 @@ import asyncio import copy import logging -import re import sys import typing from collections.abc import Generator, Mapping, Sequence, Sized @@ -93,12 +92,6 @@ "/api/0/auth/validate/": 0.0, } -# List of (compiled_pattern, sample_rate) tried in order when no exact route matches. -SAMPLED_ROUTE_PATTERNS: list[tuple[re.Pattern[str], float]] = [ - # Temporary: monitoring AI Conversations rollout - (re.compile(r"^/api/0/organizations/[^/]+/ai-conversations/"), 1.0), -] - if settings.ADDITIONAL_SAMPLED_TASKS: SAMPLED_TASKS.update(settings.ADDITIONAL_SAMPLED_TASKS) @@ -189,12 +182,8 @@ def get_project_key(): def traces_sampler(sampling_context): wsgi_path = sampling_context.get("wsgi_environ", {}).get("PATH_INFO") - if wsgi_path: - if wsgi_path in SAMPLED_ROUTES: - return SAMPLED_ROUTES[wsgi_path] - for pattern, rate in SAMPLED_ROUTE_PATTERNS: - if pattern.search(wsgi_path): - return rate + if wsgi_path and wsgi_path in SAMPLED_ROUTES: + return SAMPLED_ROUTES[wsgi_path] # Apply sample_rate from custom_sampling_context custom_sample_rate = sampling_context.get("sample_rate") From cc3c7aeb701a2b343cee9ca71df55bd99c159710 Mon Sep 17 00:00:00 2001 From: Lukas Stracke Date: Wed, 27 May 2026 10:05:52 +0200 Subject: [PATCH 4/9] feat(dashboards): Add span-first support for web vital dashboard (#115882) Adds support for the new `browser.web_vital..value` attributes to the web vital pre built dashboard queries. This ensures the pre-built dashboard is compatible with streamed v2 web vital spans and attributes. I left the hard-coded `score` attributes as-is, since these seem to be handled correctly already. Reviewers, please let me know if you want to tackle this differently or are already working on it. I mainly oriented myself on https://github.com/getsentry/sentry/pull/113046 but happy to change anything or close the PR! My naive assumption was that through coalescing we can switch to querying the new attributes and it works for the old ones as well. Tested with projects sending transactions as well as projects sending v2 spans and both still seem to work. image --- .../prebuiltConfigs/webVitals/pageDetails.ts | 40 ++++++------ .../prebuiltConfigs/webVitals/webVitals.ts | 36 +++++------ .../charts/webVitalStatusLineChart.tsx | 5 +- .../pageOverviewWebVitalsDetailPanel.tsx | 10 +-- .../performanceScoreRingWithTooltips.tsx | 21 ++++--- .../components/webVitalsDetailPanel.spec.tsx | 20 +++--- .../components/webVitalsDetailPanel.tsx | 10 +-- .../useProjectRawWebVitalsQuery.tsx | 10 +-- .../useTransactionWebVitalsScoresQuery.tsx | 10 +-- .../useSpanSamplesCategorizedQuery.tsx | 19 +++--- .../queries/useSpanSamplesWebVitalsQuery.tsx | 16 ++--- .../insights/browser/webVitals/settings.ts | 11 ++-- .../insights/browser/webVitals/types.tsx | 62 +++++++++++-------- static/app/views/insights/types.tsx | 31 +++++----- .../components/widgetContainer.spec.tsx | 10 +-- 15 files changed, 167 insertions(+), 144 deletions(-) diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/pageDetails.ts b/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/pageDetails.ts index fb4152ae99d347..f5cb3b68d79982 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/pageDetails.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/pageDetails.ts @@ -4,7 +4,7 @@ import {type PrebuiltDashboard} from 'sentry/views/dashboards/utils/prebuiltConf import {DETAILS_DASHBOARD_TITLE} from 'sentry/views/dashboards/utils/prebuiltConfigs/webVitals/settings'; import {ISSUE_TYPES} from 'sentry/views/dashboards/utils/prebuiltConfigs/webVitals/webVitals'; import {DEFAULT_QUERY_FILTER} from 'sentry/views/insights/browser/webVitals/settings'; -import {ModuleName} from 'sentry/views/insights/types'; +import {ModuleName, SpanFields} from 'sentry/views/insights/types'; export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { dateCreated: '', @@ -117,8 +117,8 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.lcp)'], - aggregates: ['p75(measurements.lcp)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.LCP_SUMMARY, @@ -152,8 +152,8 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.inp)'], - aggregates: ['p75(measurements.inp)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.INP_SUMMARY, @@ -187,8 +187,8 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.cls)'], - aggregates: ['p75(measurements.cls)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.CLS_SUMMARY, @@ -222,8 +222,8 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.ttfb)'], - aggregates: ['p75(measurements.ttfb)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.TTFB_SUMMARY, @@ -279,12 +279,12 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { queries: [ { name: '', - conditions: 'has:measurements.lcp', + conditions: `has:${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE}`, fields: [ 'project', 'trace', - 'lcp.element', - 'measurements.lcp', + SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT, + SpanFields.BROWSER_WEB_VITAL_LCP_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.lcp', @@ -294,8 +294,8 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { columns: [ 'project', 'trace', - 'lcp.element', - 'measurements.lcp', + SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT, + SpanFields.BROWSER_WEB_VITAL_LCP_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.lcp', @@ -321,11 +321,11 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { queries: [ { name: '', - conditions: 'has:measurements.inp', + conditions: `has:${SpanFields.BROWSER_WEB_VITAL_INP_VALUE}`, fields: [ 'project', 'trace', - 'measurements.inp', + SpanFields.BROWSER_WEB_VITAL_INP_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.inp', @@ -335,7 +335,7 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { columns: [ 'project', 'trace', - 'measurements.inp', + SpanFields.BROWSER_WEB_VITAL_INP_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.inp', @@ -361,11 +361,11 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { queries: [ { name: '', - conditions: 'has:measurements.cls', + conditions: `has:${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE}`, fields: [ 'project', 'trace', - 'measurements.cls', + SpanFields.BROWSER_WEB_VITAL_CLS_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.cls', @@ -375,7 +375,7 @@ export const WEB_VITALS_DETAILS_PREBUILT_CONFIG: PrebuiltDashboard = { columns: [ 'project', 'trace', - 'measurements.cls', + SpanFields.BROWSER_WEB_VITAL_CLS_VALUE, 'profile.id', 'replay.id', 'measurements.score.ratio.cls', diff --git a/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/webVitals.ts b/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/webVitals.ts index 9978ca91023965..9b1f2124a302f3 100644 --- a/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/webVitals.ts +++ b/static/app/views/dashboards/utils/prebuiltConfigs/webVitals/webVitals.ts @@ -107,8 +107,8 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.lcp)'], - aggregates: ['p75(measurements.lcp)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.LCP, @@ -142,8 +142,8 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.inp)'], - aggregates: ['p75(measurements.inp)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.INP, @@ -177,8 +177,8 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.cls)'], - aggregates: ['p75(measurements.cls)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.CLS, @@ -212,8 +212,8 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { { name: '', conditions: DEFAULT_QUERY_FILTER, - fields: ['p75(measurements.ttfb)'], - aggregates: ['p75(measurements.ttfb)'], + fields: [`p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`], + aggregates: [`p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`], columns: [], orderby: '', slideOutId: SlideoutId.TTFB, @@ -274,11 +274,11 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { SpanFields.TRANSACTION, SpanFields.PROJECT, 'count()', - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`, 'performance_score(measurements.score.total)', 'opportunity_score(measurements.score.total)', ], @@ -287,11 +287,11 @@ export const WEB_VITALS_PREBUILT_CONFIG: PrebuiltDashboard = { SpanFields.TRANSACTION, SpanFields.PROJECT, 'count()', - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`, 'performance_score(measurements.score.total)', 'opportunity_score(measurements.score.total)', ], diff --git a/static/app/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart.tsx b/static/app/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart.tsx index 6fc3ed8eba608d..db236fd826ef03 100644 --- a/static/app/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart.tsx +++ b/static/app/views/insights/browser/webVitals/components/charts/webVitalStatusLineChart.tsx @@ -11,6 +11,7 @@ import { FIELD_ALIASES, } from 'sentry/views/insights/browser/webVitals/settings'; import type {WebVitals} from 'sentry/views/insights/browser/webVitals/types'; +import {WEB_VITAL_TO_FIELD} from 'sentry/views/insights/browser/webVitals/types'; import type {BrowserType} from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType'; import { PERFORMANCE_SCORE_MEDIANS, @@ -57,7 +58,7 @@ export function WebVitalStatusLineChart({ } = useFetchSpanTimeSeries( { query: search, - yAxis: webVital ? [`p75(measurements.${webVital})`] : [], + yAxis: webVital ? [`p75(${WEB_VITAL_TO_FIELD[webVital]})`] : [], enabled: !!webVital, }, referrer @@ -65,7 +66,7 @@ export function WebVitalStatusLineChart({ const timeSeries = timeseriesData?.timeSeries || []; const webVitalTimeSeries = webVital - ? timeSeries.find(ts => ts.yAxis === `p75(measurements.${webVital})`) + ? timeSeries.find(ts => ts.yAxis === `p75(${WEB_VITAL_TO_FIELD[webVital]})`) : undefined; const includePoorThreshold = webVitalTimeSeries?.values.some( diff --git a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx index 951b935a9f5a93..fe4ac70e91d954 100644 --- a/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx +++ b/static/app/views/insights/browser/webVitals/components/pageOverviewWebVitalsDetailPanel.tsx @@ -37,6 +37,7 @@ import type { TransactionSampleRowWithScore, WebVitals, } from 'sentry/views/insights/browser/webVitals/types'; +import {WEB_VITAL_TO_FIELD} from 'sentry/views/insights/browser/webVitals/types'; import {decode as decodeBrowserTypes} from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType'; import {useProfileExists} from 'sentry/views/insights/browser/webVitals/utils/useProfileExists'; import {SampleDrawerBody} from 'sentry/views/insights/common/components/sampleDrawerBody'; @@ -204,8 +205,7 @@ export function PageOverviewWebVitalsDetailPanel({ return null; } if (col.key === 'webVital') { - // @ts-expect-error TS(2551): Property 'measurements.cls' does not exist on type... Remove this comment to see the full error message - const value = row[`measurements.${webVital}`]; + const value = webVital ? row[WEB_VITAL_TO_FIELD[webVital]] : undefined; if (value === undefined) { return ( @@ -267,9 +267,9 @@ export function PageOverviewWebVitalsDetailPanel({ if (key === SpanFields.SPAN_DESCRIPTION) { const description = webVital === 'lcp' && row[SpanFields.SPAN_OP] === 'pageload' - ? row[SpanFields.LCP_ELEMENT] + ? row[SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT] : webVital === 'cls' && row[SpanFields.SPAN_OP] === 'pageload' - ? row[SpanFields.CLS_SOURCE] + ? row[SpanFields.BROWSER_WEB_VITAL_CLS_SOURCE_1] : row[key]; if (description) { @@ -310,7 +310,7 @@ export function PageOverviewWebVitalsDetailPanel({ // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message const webVitalScore = projectScore[`${webVital}Score`]; const webVitalValue = webVital - ? projectData?.[0]?.[`p75(measurements.${webVital})`] + ? projectData?.[0]?.[`p75(${WEB_VITAL_TO_FIELD[webVital]})`] : undefined; return ( diff --git a/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx b/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx index 23871473bcfe8b..90dfd367453e8b 100644 --- a/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx +++ b/static/app/views/insights/browser/webVitals/components/performanceScoreRingWithTooltips.tsx @@ -11,13 +11,14 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useMouseTracking} from 'sentry/utils/useMouseTracking'; import {useOrganization} from 'sentry/utils/useOrganization'; import {PerformanceScoreRing} from 'sentry/views/insights/browser/webVitals/components/performanceScoreRing'; -import {ORDER} from 'sentry/views/insights/browser/webVitals/types'; +import {ORDER, WEB_VITAL_TO_FIELD} from 'sentry/views/insights/browser/webVitals/types'; import type { ProjectScore, WebVitals, } from 'sentry/views/insights/browser/webVitals/types'; import {getWeights} from 'sentry/views/insights/browser/webVitals/utils/getWeights'; import {useModuleURL} from 'sentry/views/insights/common/utils/useModuleURL'; +import {SpanFields} from 'sentry/views/insights/types'; import {getFormattedDuration} from './webVitalMeters'; @@ -29,11 +30,11 @@ type Coordinates = { type WebVitalsLabelCoordinates = Partial>; type ProjectData = { - 'p75(measurements.cls)': number; - 'p75(measurements.fcp)': number; - 'p75(measurements.inp)': number; - 'p75(measurements.lcp)': number; - 'p75(measurements.ttfb)': number; + 'p75(browser.web_vital.cls.value)': number; + 'p75(browser.web_vital.fcp.value)': number; + 'p75(browser.web_vital.inp.value)': number; + 'p75(browser.web_vital.lcp.value)': number; + 'p75(browser.web_vital.ttfb.value)': number; }; type Props = { @@ -85,8 +86,12 @@ function WebVitalLabel({ const yOffset = webVitalLabelCoordinates?.[webVital]?.y ?? 0; const webvitalInfo = webVital === 'cls' - ? Math.round(projectData?.[0]?.['p75(measurements.cls)']! * 100) / 100 - : getFormattedDuration(projectData?.[0]?.[`p75(measurements.${webVital})`]! / 1000); + ? Math.round( + projectData?.[0]?.[`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`]! * 100 + ) / 100 + : getFormattedDuration( + projectData?.[0]?.[`p75(${WEB_VITAL_TO_FIELD[webVital]})`]! / 1000 + ); const diffValue = differenceToPreviousPeriod?.[`${webVital}Score`]; diff --git a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.spec.tsx b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.spec.tsx index 648fda81ded282..5887b0b92fe9bd 100644 --- a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.spec.tsx +++ b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.spec.tsx @@ -45,11 +45,11 @@ describe('WebVitalsDetailPanel', () => { query: expect.objectContaining({ dataset: 'spans', field: [ - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + 'p75(browser.web_vital.lcp.value)', + 'p75(browser.web_vital.fcp.value)', + 'p75(browser.web_vital.cls.value)', + 'p75(browser.web_vital.ttfb.value)', + 'p75(browser.web_vital.inp.value)', 'count()', ], query: @@ -101,11 +101,11 @@ describe('WebVitalsDetailPanel', () => { 'project.id', 'project', 'transaction', - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + 'p75(browser.web_vital.lcp.value)', + 'p75(browser.web_vital.fcp.value)', + 'p75(browser.web_vital.cls.value)', + 'p75(browser.web_vital.ttfb.value)', + 'p75(browser.web_vital.inp.value)', 'performance_score(measurements.score.lcp)', 'opportunity_score(measurements.score.lcp)', 'performance_score(measurements.score.total)', diff --git a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx index 5838e9be85a7e5..0150fff5527bae 100644 --- a/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx +++ b/static/app/views/insights/browser/webVitals/components/webVitalsDetailPanel.tsx @@ -301,15 +301,15 @@ export function WebVitalsDetailPanel({ const mapWebVitalToColumn = (webVital?: WebVitals | null) => { switch (webVital) { case 'lcp': - return 'p75(measurements.lcp)'; + return `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`; case 'fcp': - return 'p75(measurements.fcp)'; + return `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`; case 'cls': - return 'p75(measurements.cls)'; + return `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`; case 'ttfb': - return 'p75(measurements.ttfb)'; + return `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`; case 'inp': - return 'p75(measurements.inp)'; + return `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`; default: return 'count()'; } diff --git a/static/app/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery.tsx b/static/app/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery.tsx index 016a881ba6c0ae..d50a7cc1d3ad76 100644 --- a/static/app/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery.tsx +++ b/static/app/views/insights/browser/webVitals/queries/rawWebVitalsQueries/useProjectRawWebVitalsQuery.tsx @@ -38,11 +38,11 @@ export const useProjectRawWebVitalsQuery = ({ search: [DEFAULT_QUERY_FILTER, search.formatString()].join(' ').trim(), limit: 50, fields: [ - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`, 'count()', ], }, diff --git a/static/app/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery.tsx b/static/app/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery.tsx index eef4d5959bfc5c..3d7dc3d960c631 100644 --- a/static/app/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery.tsx +++ b/static/app/views/insights/browser/webVitals/queries/storedScoreQueries/useTransactionWebVitalsScoresQuery.tsx @@ -77,11 +77,11 @@ export const useTransactionWebVitalsScoresQuery = ({ 'project.id', 'project', 'transaction', - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`, ...(webVital === 'total' ? [] : [`performance_score(measurements.score.${webVital})` as const]), diff --git a/static/app/views/insights/browser/webVitals/queries/useSpanSamplesCategorizedQuery.tsx b/static/app/views/insights/browser/webVitals/queries/useSpanSamplesCategorizedQuery.tsx index ac1409c4f23e7d..e65a589bea8d3b 100644 --- a/static/app/views/insights/browser/webVitals/queries/useSpanSamplesCategorizedQuery.tsx +++ b/static/app/views/insights/browser/webVitals/queries/useSpanSamplesCategorizedQuery.tsx @@ -6,6 +6,7 @@ import { SPANS_FILTER, useSpanSamplesWebVitalsQuery, } from 'sentry/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery'; +import {WEB_VITAL_TO_FIELD} from 'sentry/views/insights/browser/webVitals/types'; import type {WebVitals} from 'sentry/views/insights/browser/webVitals/types'; import type {BrowserType} from 'sentry/views/insights/browser/webVitals/utils/queryParameterDecoders/browserType'; import { @@ -37,12 +38,14 @@ export function useSpanSamplesCategorizedQuery({ : webVital === 'cls' ? CLS_SPANS_FILTER : SPANS_FILTER; + const vitalField = webVital ? WEB_VITAL_TO_FIELD[webVital] : undefined; + const {data: goodData, isFetching: isGoodDataLoading} = useSpanSamplesWebVitalsQuery({ transaction, enabled: enabled && defined(webVital), limit: 3, filter: defined(webVital) - ? `measurements.${webVital}:<${PERFORMANCE_SCORE_P90S[webVital]} ${webVitalFilter}` + ? `${vitalField}:<${PERFORMANCE_SCORE_P90S[webVital]} ${webVitalFilter}` : undefined, browserTypes, subregions, @@ -52,9 +55,10 @@ export function useSpanSamplesCategorizedQuery({ transaction, enabled: enabled && defined(webVital), limit: 3, - filter: defined(webVital) - ? `measurements.${webVital}:>=${PERFORMANCE_SCORE_P90S[webVital]} measurements.${webVital}:<${PERFORMANCE_SCORE_MEDIANS[webVital]} ${webVitalFilter}` - : undefined, + filter: + defined(webVital) && vitalField + ? `${vitalField}:>=${PERFORMANCE_SCORE_P90S[webVital]} ${vitalField}:<${PERFORMANCE_SCORE_MEDIANS[webVital]} ${webVitalFilter}` + : undefined, browserTypes, subregions, webVital: webVital ?? undefined, @@ -63,9 +67,10 @@ export function useSpanSamplesCategorizedQuery({ transaction, enabled: enabled && defined(webVital), limit: 3, - filter: defined(webVital) - ? `measurements.${webVital}:>=${PERFORMANCE_SCORE_MEDIANS[webVital]} ${webVitalFilter}` - : undefined, + filter: + defined(webVital) && vitalField + ? `${vitalField}:>=${PERFORMANCE_SCORE_MEDIANS[webVital]} ${webVitalFilter}` + : undefined, browserTypes, subregions, webVital: webVital ?? undefined, diff --git a/static/app/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery.tsx b/static/app/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery.tsx index e2e31bb1663c99..a4edc8e1c714e7 100644 --- a/static/app/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery.tsx +++ b/static/app/views/insights/browser/webVitals/queries/useSpanSamplesWebVitalsQuery.tsx @@ -71,24 +71,24 @@ export function useSpanSamplesWebVitalsQuery({ let ratioField: SpanFields | undefined; switch (webVital) { case 'lcp': - field = SpanFields.LCP; + field = SpanFields.BROWSER_WEB_VITAL_LCP_VALUE; ratioField = SpanFields.LCP_SCORE_RATIO; break; case 'cls': - field = SpanFields.CLS; + field = SpanFields.BROWSER_WEB_VITAL_CLS_VALUE; ratioField = SpanFields.CLS_SCORE_RATIO; break; case 'fcp': - field = SpanFields.FCP; + field = SpanFields.BROWSER_WEB_VITAL_FCP_VALUE; ratioField = SpanFields.FCP_SCORE_RATIO; break; case 'ttfb': - field = SpanFields.TTFB; + field = SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE; ratioField = SpanFields.TTFB_SCORE_RATIO; break; case 'inp': default: - field = SpanFields.INP; + field = SpanFields.BROWSER_WEB_VITAL_INP_VALUE; ratioField = SpanFields.INP_SCORE_RATIO; break; } @@ -115,8 +115,8 @@ export function useSpanSamplesWebVitalsQuery({ SpanFields.SPAN_SELF_TIME, SpanFields.TRANSACTION, SpanFields.SPAN_OP, - SpanFields.LCP_ELEMENT, - SpanFields.CLS_SOURCE, + SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT, + SpanFields.BROWSER_WEB_VITAL_CLS_SOURCE_1, SpanFields.ID, ], enabled, @@ -129,7 +129,7 @@ export function useSpanSamplesWebVitalsQuery({ ? data.map(row => { return { ...row, - [`measurements.${webVital}`]: row[ratioField] > 0 ? row[field] : undefined, + [field]: row[ratioField] > 0 ? row[field] : undefined, 'user.display': row[SpanFields.USER_EMAIL] ?? row[SpanFields.USER_USERNAME] ?? diff --git a/static/app/views/insights/browser/webVitals/settings.ts b/static/app/views/insights/browser/webVitals/settings.ts index d4016865a53117..bfcc569ad128c6 100644 --- a/static/app/views/insights/browser/webVitals/settings.ts +++ b/static/app/views/insights/browser/webVitals/settings.ts @@ -1,4 +1,5 @@ import {t} from 'sentry/locale'; +import {SpanFields} from 'sentry/views/insights/types'; import type {SpanProperty} from 'sentry/views/insights/types'; export const MODULE_TITLE = t('Web Vitals'); @@ -15,9 +16,9 @@ export const DEFAULT_QUERY_FILTER = export const MODULE_FEATURES = ['insight-modules']; export const FIELD_ALIASES = { - 'p75(measurements.lcp)': 'LCP', - 'p75(measurements.fcp)': 'FCP', - 'p75(measurements.inp)': 'INP', - 'p75(measurements.cls)': 'CLS', - 'p75(measurements.ttfb)': 'TTFB', + [`p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`]: 'LCP', + [`p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`]: 'FCP', + [`p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`]: 'INP', + [`p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`]: 'CLS', + [`p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`]: 'TTFB', } satisfies Partial>; diff --git a/static/app/views/insights/browser/webVitals/types.tsx b/static/app/views/insights/browser/webVitals/types.tsx index dfb0009d9d3244..65918789c1ea17 100644 --- a/static/app/views/insights/browser/webVitals/types.tsx +++ b/static/app/views/insights/browser/webVitals/types.tsx @@ -1,13 +1,21 @@ import type {Sort} from 'sentry/utils/discover/fields'; import {SpanFields} from 'sentry/views/insights/types'; +export const WEB_VITAL_TO_FIELD = { + lcp: SpanFields.BROWSER_WEB_VITAL_LCP_VALUE, + fcp: SpanFields.BROWSER_WEB_VITAL_FCP_VALUE, + cls: SpanFields.BROWSER_WEB_VITAL_CLS_VALUE, + ttfb: SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE, + inp: SpanFields.BROWSER_WEB_VITAL_INP_VALUE, +} as const; + export type Row = { 'count()': number; - 'p75(measurements.cls)': number; - 'p75(measurements.fcp)': number; - 'p75(measurements.inp)': number; - 'p75(measurements.lcp)': number; - 'p75(measurements.ttfb)': number; + 'p75(browser.web_vital.cls.value)': number; + 'p75(browser.web_vital.fcp.value)': number; + 'p75(browser.web_vital.inp.value)': number; + 'p75(browser.web_vital.lcp.value)': number; + 'p75(browser.web_vital.ttfb.value)': number; project: string; 'project.id': number; transaction: string; @@ -22,10 +30,10 @@ type TransactionSampleRow = { trace: string; transaction: string; 'user.display': string; - 'measurements.cls'?: number; - 'measurements.fcp'?: number; - 'measurements.lcp'?: number; - 'measurements.ttfb'?: number; + [SpanFields.BROWSER_WEB_VITAL_CLS_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_FCP_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_LCP_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE]?: number; 'transaction.duration'?: number; }; @@ -50,14 +58,14 @@ type SpanSampleRow = { [SpanFields.TIMESTAMP]: string; [SpanFields.TRACE]: string; 'user.display'?: string; - [SpanFields.INP]?: number; - [SpanFields.CLS]?: number; - [SpanFields.LCP]?: number; - [SpanFields.FCP]?: number; - [SpanFields.TTFB]?: number; - [SpanFields.LCP_ELEMENT]?: string; + [SpanFields.BROWSER_WEB_VITAL_INP_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_CLS_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_LCP_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_FCP_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE]?: number; + [SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT]?: string; [SpanFields.SPAN_OP]?: string; - [SpanFields.CLS_SOURCE]?: string; + [SpanFields.BROWSER_WEB_VITAL_CLS_SOURCE_1]?: string; }; export type SpanSampleRowWithScore = SpanSampleRow & Score; @@ -84,11 +92,11 @@ const SORTABLE_SCORE_FIELDS = [ export const SORTABLE_FIELDS = [ 'count()', - 'p75(measurements.cls)', - 'p75(measurements.fcp)', - 'p75(measurements.inp)', - 'p75(measurements.lcp)', - 'p75(measurements.ttfb)', + `p75(${SpanFields.BROWSER_WEB_VITAL_CLS_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_FCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_INP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_LCP_VALUE})`, + `p75(${SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE})`, ...SORTABLE_SCORE_FIELDS, ] as const; @@ -100,11 +108,11 @@ const SORTABLE_INDEXED_SCORE_FIELDS = [ ]; export const SORTABLE_INDEXED_FIELDS = [ - 'measurements.lcp', - 'measurements.fcp', - 'measurements.cls', - 'measurements.ttfb', - 'measurements.inp', + SpanFields.BROWSER_WEB_VITAL_LCP_VALUE, + SpanFields.BROWSER_WEB_VITAL_FCP_VALUE, + SpanFields.BROWSER_WEB_VITAL_CLS_VALUE, + SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE, + SpanFields.BROWSER_WEB_VITAL_INP_VALUE, ...SORTABLE_INDEXED_SCORE_FIELDS, ] as const; @@ -114,7 +122,7 @@ export const DEFAULT_SORT: Sort = { }; export const SORTABLE_INDEXED_INTERACTION_FIELDS = [ - SpanFields.INP, + SpanFields.BROWSER_WEB_VITAL_INP_VALUE, SpanFields.INP_SCORE, SpanFields.INP_SCORE_WEIGHT, SpanFields.TOTAL_SCORE, diff --git a/static/app/views/insights/types.tsx b/static/app/views/insights/types.tsx index 65c4f97de9d361..e7b8c4b0983f2d 100644 --- a/static/app/views/insights/types.tsx +++ b/static/app/views/insights/types.tsx @@ -187,25 +187,28 @@ export enum SpanFields { USER_DISPLAY = 'user.display', // Note: this is not implemented yet, waiting for EAP-123 // Web vital fields - LCP_ELEMENT = 'lcp.element', - CLS_SOURCE = 'cls.source.1', - INP = 'measurements.inp', + BROWSER_WEB_VITAL_LCP_VALUE = 'browser.web_vital.lcp.value', + BROWSER_WEB_VITAL_FCP_VALUE = 'browser.web_vital.fcp.value', + BROWSER_WEB_VITAL_CLS_VALUE = 'browser.web_vital.cls.value', + BROWSER_WEB_VITAL_TTFB_VALUE = 'browser.web_vital.ttfb.value', + BROWSER_WEB_VITAL_INP_VALUE = 'browser.web_vital.inp.value', + + // Web vital meta fields + BROWSER_WEB_VITAL_LCP_ELEMENT = 'browser.web_vital.lcp.element', + BROWSER_WEB_VITAL_CLS_SOURCE_1 = 'browser.web_vital.cls.source.1', + INP_SCORE = 'measurements.score.inp', INP_SCORE_RATIO = 'measurements.score.ratio.inp', INP_SCORE_WEIGHT = 'measurements.score.weight.inp', - LCP = 'measurements.lcp', LCP_SCORE = 'measurements.score.lcp', LCP_SCORE_RATIO = 'measurements.score.ratio.lcp', LCP_SCORE_WEIGHT = 'measurements.score.weight.lcp', - CLS = 'measurements.cls', CLS_SCORE = 'measurements.score.cls', CLS_SCORE_RATIO = 'measurements.score.ratio.cls', CLS_SCORE_WEIGHT = 'measurements.score.weight.cls', - TTFB = 'measurements.ttfb', TTFB_SCORE = 'measurements.score.ttfb', TTFB_SCORE_RATIO = 'measurements.score.ratio.ttfb', TTFB_SCORE_WEIGHT = 'measurements.score.weight.ttfb', - FCP = 'measurements.fcp', FCP_SCORE = 'measurements.score.fcp', FCP_SCORE_RATIO = 'measurements.score.ratio.fcp', FCP_SCORE_WEIGHT = 'measurements.score.weight.fcp', @@ -235,6 +238,11 @@ type SpanNumberFields = | SpanFields.APP_VITALS_FRAMES_FROZEN_COUNT | SpanFields.APP_VITALS_FRAMES_TOTAL_COUNT | SpanFields.APP_VITALS_FRAMES_DELAY_VALUE + | SpanFields.BROWSER_WEB_VITAL_LCP_VALUE + | SpanFields.BROWSER_WEB_VITAL_FCP_VALUE + | SpanFields.BROWSER_WEB_VITAL_CLS_VALUE + | SpanFields.BROWSER_WEB_VITAL_TTFB_VALUE + | SpanFields.BROWSER_WEB_VITAL_INP_VALUE | SpanFields.MOBILE_FRAMES_DELAY | SpanFields.MOBILE_FROZEN_FRAMES | SpanFields.MOBILE_TOTAL_FRAMES @@ -253,23 +261,18 @@ type SpanNumberFields = | SpanFields.GEN_AI_USAGE_INPUT_TOKENS_CACHED | SpanFields.GEN_AI_USAGE_OUTPUT_TOKENS_REASONING | SpanFields.TOTAL_SCORE - | SpanFields.INP | SpanFields.INP_SCORE | SpanFields.INP_SCORE_RATIO | SpanFields.INP_SCORE_WEIGHT - | SpanFields.LCP | SpanFields.LCP_SCORE | SpanFields.LCP_SCORE_RATIO | SpanFields.LCP_SCORE_WEIGHT - | SpanFields.CLS | SpanFields.CLS_SCORE | SpanFields.CLS_SCORE_RATIO | SpanFields.CLS_SCORE_WEIGHT - | SpanFields.TTFB | SpanFields.TTFB_SCORE | SpanFields.TTFB_SCORE_RATIO | SpanFields.TTFB_SCORE_WEIGHT - | SpanFields.FCP | SpanFields.FCP_SCORE | SpanFields.FCP_SCORE_RATIO | SpanFields.FCP_SCORE_WEIGHT @@ -326,8 +329,8 @@ type NonNullableStringFields = | SpanFields.USER_USERNAME | SpanFields.USER_ID | SpanFields.USER_IP - | SpanFields.CLS_SOURCE - | SpanFields.LCP_ELEMENT + | SpanFields.BROWSER_WEB_VITAL_LCP_ELEMENT + | SpanFields.BROWSER_WEB_VITAL_CLS_SOURCE_1 | SpanFields.TRANSACTION_SPAN_ID | SpanFields.TRANSACTION_EVENT_ID | SpanFields.DB_SYSTEM diff --git a/static/app/views/performance/landing/widgets/components/widgetContainer.spec.tsx b/static/app/views/performance/landing/widgets/components/widgetContainer.spec.tsx index 5c32611a65a013..effc69e08699b2 100644 --- a/static/app/views/performance/landing/widgets/components/widgetContainer.spec.tsx +++ b/static/app/views/performance/landing/widgets/components/widgetContainer.spec.tsx @@ -795,11 +795,11 @@ describe('Performance > Widgets > WidgetContainer', () => { 'project.id', 'project', 'transaction', - 'p75(measurements.lcp)', - 'p75(measurements.fcp)', - 'p75(measurements.cls)', - 'p75(measurements.ttfb)', - 'p75(measurements.inp)', + 'p75(browser.web_vital.lcp.value)', + 'p75(browser.web_vital.fcp.value)', + 'p75(browser.web_vital.cls.value)', + 'p75(browser.web_vital.ttfb.value)', + 'p75(browser.web_vital.inp.value)', 'opportunity_score(measurements.score.total)', 'performance_score(measurements.score.total)', 'count()', From 14cb0e77d872b10e2d5f1a6ed58fae9d354507d1 Mon Sep 17 00:00:00 2001 From: Ogi Date: Wed, 27 May 2026 10:13:44 +0200 Subject: [PATCH 5/9] feat(conversations): Add copy conversation as markdown button (#116171) --- .../conversationsAnalyticsEvents.tsx | 2 + .../components/conversationView.tsx | 48 ++++++-- .../utils/conversationMessages.spec.ts | 116 ++++++++++++++++++ .../utils/conversationMessages.ts | 31 +++++ 4 files changed, 188 insertions(+), 9 deletions(-) diff --git a/static/app/utils/analytics/conversationsAnalyticsEvents.tsx b/static/app/utils/analytics/conversationsAnalyticsEvents.tsx index 1fd3e1ceefeb0f..20d98b3433599b 100644 --- a/static/app/utils/analytics/conversationsAnalyticsEvents.tsx +++ b/static/app/utils/analytics/conversationsAnalyticsEvents.tsx @@ -3,6 +3,7 @@ type ConversationOpenSource = 'table_conversation_id' | 'table_input' | 'table_o export type ConversationsEventParameters = { 'conversations.detail.click-errors-link': Record; 'conversations.detail.click-trace-link': Record; + 'conversations.detail.copy-conversation': Record; 'conversations.detail.copy-conversation-id': Record; 'conversations.detail.page-view': Record; 'conversations.detail.select-span': Record; @@ -28,6 +29,7 @@ export const conversationsEventMap: Record extractMessagesFromNodes(nodes), [nodes]); + if (isLoading) { return ; } @@ -122,14 +129,37 @@ function ConversationView({ left={ - - - - {t('Chat')} - {t('Spans')} - - - + + + + + {t('Chat')} + {t('Spans')} + + + + {activeTab === 'messages' && messages.length > 0 && ( + { + trackAnalytics('conversations.detail.copy-conversation', { + organization, + }); + return messagesToMarkdown(messages); + }, + text: undefined, + json: undefined, + })} + /> + )} + { expect(extractMessagesFromNodes([tool as any])).toEqual([]); }); }); + + describe('messagesToMarkdown', () => { + it('formats user messages with email', () => { + const result = messagesToMarkdown([ + { + id: 'user-1', + role: 'user', + content: 'Hello world', + timestamp: 1000, + nodeId: 'n1', + userEmail: 'dev@example.com', + }, + ]); + expect(result).toBe('### dev@example.com\n\nHello world'); + }); + + it('formats user messages without email as User', () => { + const result = messagesToMarkdown([ + { + id: 'user-1', + role: 'user', + content: 'Hello', + timestamp: 1000, + nodeId: 'n1', + }, + ]); + expect(result).toBe('### User\n\nHello'); + }); + + it('formats assistant messages with model and duration', () => { + const result = messagesToMarkdown([ + { + id: 'assistant-1', + role: 'assistant', + content: 'Here is the answer', + timestamp: 1000, + nodeId: 'n1', + modelName: 'claude-sonnet-4-20250514', + duration: 2.5, + }, + ]); + expect(result).toBe('### claude-sonnet-4-20250514 — 2.5s\n\nHere is the answer'); + }); + + it('formats assistant messages with agent name', () => { + const result = messagesToMarkdown([ + { + id: 'assistant-1', + role: 'assistant', + content: 'Done', + timestamp: 1000, + nodeId: 'n1', + agentName: 'My Agent', + }, + ]); + expect(result).toBe('### My Agent\n\nDone'); + }); + + it('includes tool calls', () => { + const result = messagesToMarkdown([ + { + id: 'assistant-1', + role: 'assistant', + content: 'I ran the tools', + timestamp: 1000, + nodeId: 'n1', + toolCalls: [ + {name: 'bash', nodeId: 't1', hasError: false}, + {name: 'read', nodeId: 't2', hasError: false}, + ], + }, + ]); + expect(result).toContain('> Called tools: `bash`, `read`'); + expect(result).toContain('I ran the tools'); + }); + + it('formats a full conversation with separators between messages', () => { + const result = messagesToMarkdown([ + { + id: 'user-1', + role: 'user', + content: 'What files?', + timestamp: 1000, + nodeId: 'n1', + userEmail: 'dev@example.com', + }, + { + id: 'assistant-1', + role: 'assistant', + content: 'Here they are', + timestamp: 1001, + nodeId: 'n1', + modelName: 'gpt-4o', + duration: 1.2, + }, + ]); + expect(result).toBe( + [ + '### dev@example.com', + '', + 'What files?', + '', + '---', + '', + '### gpt-4o — 1.2s', + '', + 'Here they are', + ].join('\n') + ); + }); + + it('returns empty string for empty messages', () => { + expect(messagesToMarkdown([])).toBe(''); + }); + }); }); diff --git a/static/app/views/explore/conversations/utils/conversationMessages.ts b/static/app/views/explore/conversations/utils/conversationMessages.ts index 0897d1b731e962..b76cb3ebff96dd 100644 --- a/static/app/views/explore/conversations/utils/conversationMessages.ts +++ b/static/app/views/explore/conversations/utils/conversationMessages.ts @@ -1,3 +1,4 @@ +import {getDuration} from 'sentry/utils/duration/getDuration'; import { extractAssistantOutput, normalizeToMessages, @@ -307,3 +308,33 @@ function getNodeEndTimestamp(node: AITraceSpanNode): number { function getGenAiOpType(node: AITraceSpanNode): string | undefined { return getStringAttr(node, SpanFields.GEN_AI_OPERATION_TYPE); } + +export function messagesToMarkdown(messages: ConversationMessage[]): string { + const blocks: string[] = []; + + for (const message of messages) { + const lines: string[] = []; + + if (message.role === 'user') { + const sender = message.userEmail || 'User'; + lines.push(`### ${sender}`); + } else { + const sender = message.agentName || message.modelName || 'Assistant'; + const durationStr = + message.duration !== undefined && message.duration > 0 + ? ` — ${getDuration(message.duration, 1, true)}` + : ''; + lines.push(`### ${sender}${durationStr}`); + + if (message.toolCalls && message.toolCalls.length > 0) { + const toolNames = message.toolCalls.map(tc => `\`${tc.name}\``).join(', '); + lines.push(`> Called tools: ${toolNames}`); + } + } + + lines.push(message.content); + blocks.push(lines.join('\n\n')); + } + + return blocks.join('\n\n---\n\n'); +} From 0fa77da4b4179209b87c902b1ad87492bff588cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Wed, 27 May 2026 11:03:02 +0200 Subject: [PATCH 6/9] fix(webauthn): Handle missing WebAuthn challenge data (#116167) The `webAuthnRegisterData` and `webAuthnAuthenticationData` fields on `ChallengeData` can be undefined when the server doesn't include WebAuthn data in the challenge response. Without guards, `handleEnroll` and `handleSign` crash when trying to decode `undefined`. This adds early-return null checks and updates the types to reflect the actual API shape. Fixes JAVASCRIPT-397K --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Cursor Agent Co-authored-by: Evan Purkhiser Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com> --- static/app/components/webAuthn/handlers.tsx | 7 +++++++ static/app/types/auth.tsx | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/static/app/components/webAuthn/handlers.tsx b/static/app/components/webAuthn/handlers.tsx index 46756296af6a05..2cbfddf9d0f498 100644 --- a/static/app/components/webAuthn/handlers.tsx +++ b/static/app/components/webAuthn/handlers.tsx @@ -40,6 +40,10 @@ function isAssertion(r: AuthenticatorResponse): r is AuthenticatorAssertionRespo * Register a new credential using WebAuthn (FIDO2) and return its attestation data. */ export async function handleEnroll(challengeData: ChallengeData) { + if (!challengeData.webAuthnRegisterData) { + return null; + } + const binaryChallenge = base64urlToUint8(challengeData.webAuthnRegisterData); const {publicKey}: CredentialCreationOptions = decode(binaryChallenge); const credential = await navigator.credentials.create({publicKey}); @@ -69,6 +73,9 @@ export async function handleEnroll(challengeData: ChallengeData) { * Perform a WebAuthn assertion (login) using an existing credential. */ export async function handleSign(challengeData: ChallengeData) { + if (!challengeData.webAuthnAuthenticationData) { + return null; + } const binaryChallenge = base64urlToUint8(challengeData.webAuthnAuthenticationData); const options = decode(binaryChallenge); const credential = await navigator.credentials.get({publicKey: options}); diff --git a/static/app/types/auth.tsx b/static/app/types/auth.tsx index 09b36bfcb0e0c1..274647c5ecaa18 100644 --- a/static/app/types/auth.tsx +++ b/static/app/types/auth.tsx @@ -110,9 +110,9 @@ export type ChallengeData = { authenticateRequests: SignRequest; registerRequests: RegisterRequest; registeredKeys: RegisteredKey[]; - webAuthnAuthenticationData: string; + webAuthnAuthenticationData: string | undefined; // for WebAuthn register - webAuthnRegisterData: string; + webAuthnRegisterData: string | undefined; }; type EnrolledAuthenticator = { From a3fd473cd66f4009369ea391def496e44eab69c6 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Wed, 27 May 2026 11:15:18 +0200 Subject: [PATCH 7/9] fix(tests): don't include trace context in symbolicator snapshots (#116275) - Changes necessary due to [a change in relay](https://github.com/getsentry/relay/commit/eca6948491ae2bf09ec493adf1e01a54d294fbc8) - [Example for failing tests on some PRs](https://github.com/getsentry/sentry/actions/runs/26496465875/job/78025964034?pr=114286) - Snapshots generated using `SENTRY_SNAPSHOTS_WRITEBACK=new pytest tests/symbolicator/` locally with devservices `relay` and `symbolicator` ended up being volatile (unique IDs) - Use approach to exclude the trace context from the snapshot as has been done with the reprocessing context --- tests/symbolicator/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/symbolicator/__init__.py b/tests/symbolicator/__init__.py index 5459472b5bc485..44cf5d5aa9d703 100644 --- a/tests/symbolicator/__init__.py +++ b/tests/symbolicator/__init__.py @@ -38,6 +38,7 @@ def strip_stacktrace(stacktrace): STRIP_TRAILING_ADDR_RE = re.compile(" ?/ 0x[0-9a-fA-F]+$") +SNAPSHOT_CONTEXTS_TO_STRIP = frozenset(("reprocessing", "trace")) def strip_trailing_addr(value): @@ -82,7 +83,9 @@ def insta_snapshot_native_stacktrace_data(self, event: NodeData, **kwargs: Any): }, "debug_meta": event.get("debug_meta"), "contexts": { - k: v for k, v in (event.get("contexts") or {}).items() if k != "reprocessing" + k: v + for k, v in (event.get("contexts") or {}).items() + if k not in SNAPSHOT_CONTEXTS_TO_STRIP } or None, "errors": [e for e in event.get("errors") or () if e.get("name") != "timestamp"], From b767245837f6c9326de733f24cf5ebcf60562249 Mon Sep 17 00:00:00 2001 From: Simon Hellmayr Date: Wed, 27 May 2026 11:32:27 +0200 Subject: [PATCH 8/9] feat(dynamic-sampling): add per-project volume query (#114286) --- .../per_org/tasks/configuration.py | 2 + .../dynamic_sampling/per_org/tasks/queries.py | 81 ++++++- .../per_org/tasks/scheduler.py | 12 +- .../per_org/tasks/telemetry.py | 3 +- src/sentry/snuba/referrer.py | 3 + .../per_org/tasks/test_queries.py | 219 +++++++++++++----- .../per_org/tasks/test_scheduler.py | 166 ++++++++++++- 7 files changed, 413 insertions(+), 73 deletions(-) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py index 17bb7932f4ac18..24c91aace8f12f 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/configuration.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/configuration.py @@ -45,6 +45,7 @@ def get_configuration(organization_id: int) -> BaseDynamicSamplingConfiguration: class BaseDynamicSamplingConfiguration(ABC): measure: SamplingMeasure + should_balance_projects: bool = True projects: list[Project] def __init__(self, organization: Organization) -> None: @@ -123,6 +124,7 @@ def is_enabled(self) -> bool: class CustomDynamicSamplingProjectConfiguration(BaseDynamicSamplingConfiguration): project_target_sample_rates: ProjectTargetSampleRates + should_balance_projects: bool = False def __init__(self, organization: Organization) -> None: super().__init__(organization) diff --git a/src/sentry/dynamic_sampling/per_org/tasks/queries.py b/src/sentry/dynamic_sampling/per_org/tasks/queries.py index 63f708279006d0..5c234e7635a361 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/queries.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/queries.py @@ -1,12 +1,15 @@ from __future__ import annotations from collections.abc import Iterator, Mapping +from dataclasses import dataclass from datetime import UTC, datetime, timedelta +from enum import StrEnum from typing import Any from sentry_protos.snuba.v1.trace_item_attribute_pb2 import ExtrapolationMode from sentry.dynamic_sampling.per_org.tasks.configuration import BaseDynamicSamplingConfiguration +from sentry.dynamic_sampling.rules.utils import ProjectId from sentry.dynamic_sampling.tasks.common import ( ACTIVE_ORGS_VOLUMES_DEFAULT_TIME_INTERVAL, OrganizationDataVolume, @@ -18,6 +21,24 @@ from sentry.snuba.spans_rpc import Spans +class DynamicSamplingQueryFilters(StrEnum): + IS_SEGMENT = "sentry.is_segment:true" + + +class DynamicSamplingQueryFields(StrEnum): + ROOT_PROJECT = "sentry.dsc.root_project" + COUNT = "count()" + COUNT_SAMPLE = "count_sample()" + + +@dataclass(order=True) +class ProjectVolume: + project_id: ProjectId + total: int + keep: int + drop: int + + def _get_aggregate_int(row: Mapping[str, Any], column: str) -> int: return int(row.get(column, 0)) @@ -58,8 +79,11 @@ def get_eap_organization_volume( projects=config.projects, organization=config.organization, ), - query_string="is_transaction:true", - selected_columns=["count()", "count_sample()"], + query_string=DynamicSamplingQueryFilters.IS_SEGMENT, + selected_columns=[ + DynamicSamplingQueryFields.COUNT, + DynamicSamplingQueryFields.COUNT_SAMPLE, + ], orderby=None, offset=0, limit=1, @@ -76,9 +100,58 @@ def get_eap_organization_volume( return None row = data[0] - total = _get_aggregate_int(row, "count()") + total = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT) if total <= 0: return None - indexed = _get_aggregate_int(row, "count_sample()") + indexed = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT_SAMPLE) return OrganizationDataVolume(org_id=config.organization.id, total=total, indexed=indexed) + + +def get_eap_project_volumes( + config: BaseDynamicSamplingConfiguration, + time_interval: timedelta = timedelta(hours=1), +) -> list[ProjectVolume]: + end_time = datetime.now(UTC) + start_time = end_time - time_interval + project_volumes: list[ProjectVolume] = [] + + for row in run_eap_spans_table_query_in_chunks( + { + "params": SnubaParams( + start=start_time, + end=end_time, + projects=config.projects, + organization=config.organization, + ), + "query_string": DynamicSamplingQueryFilters.IS_SEGMENT, + "selected_columns": [ + DynamicSamplingQueryFields.ROOT_PROJECT, + DynamicSamplingQueryFields.COUNT, + DynamicSamplingQueryFields.COUNT_SAMPLE, + ], + "orderby": [DynamicSamplingQueryFields.ROOT_PROJECT], + "referrer": Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES.value, + "config": SearchResolverConfig( + auto_fields=True, + extrapolation_mode=ExtrapolationMode.EXTRAPOLATION_MODE_SERVER_ONLY, + ), + "sampling_mode": SAMPLING_MODE_HIGHEST_ACCURACY, + } + ): + total = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT) + keep = _get_aggregate_int(row, DynamicSamplingQueryFields.COUNT_SAMPLE) + root_project = row.get(DynamicSamplingQueryFields.ROOT_PROJECT) + if root_project is None: + continue + + project_volumes.append( + ProjectVolume( + project_id=ProjectId(int(root_project)), + total=total, + keep=keep, + drop=max(total - keep, 0), + ) + ) + + return project_volumes diff --git a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py index 91924b6f53573a..ca767af45fe793 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/scheduler.py @@ -10,7 +10,10 @@ from sentry.dynamic_sampling.per_org.tasks.configuration import get_configuration from sentry.dynamic_sampling.per_org.tasks.gate import is_org_in_rollout -from sentry.dynamic_sampling.per_org.tasks.queries import get_eap_organization_volume +from sentry.dynamic_sampling.per_org.tasks.queries import ( + get_eap_organization_volume, + get_eap_project_volumes, +) from sentry.dynamic_sampling.per_org.tasks.telemetry import ( SCHEDULER_BUCKET_ORG_STATUS_METRIC, DynamicSamplingStatus, @@ -106,6 +109,11 @@ def run_calculations_per_org_task(org_id: OrganizationId) -> DynamicSamplingStat org_volume = get_eap_organization_volume(config) if org_volume is None: - return DynamicSamplingStatus.NO_VOLUME + return DynamicSamplingStatus.NO_ORG_VOLUME + + if config.should_balance_projects: + project_volumes = get_eap_project_volumes(config) + if not project_volumes: + return DynamicSamplingStatus.NO_PROJECT_VOLUMES return None diff --git a/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py b/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py index 392ef243d39022..9faf0c9d2c39b3 100644 --- a/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py +++ b/src/sentry/dynamic_sampling/per_org/tasks/telemetry.py @@ -33,7 +33,8 @@ class DynamicSamplingStatus(StrEnum): FAILED = "failed" KILLSWITCHED = "killswitched" NO_SUBSCRIPTION = "no_subscription" - NO_VOLUME = "no_volume" + NO_ORG_VOLUME = "no_org_volume" + NO_PROJECT_VOLUMES = "no_project_volumes" NOT_IN_ROLLOUT = "not_in_rollout" ORG_HAS_NO_DYNAMIC_SAMPLING = "org_has_no_dynamic_sampling" ORG_HAS_NO_PROJECTS = "org_has_no_projects" diff --git a/src/sentry/snuba/referrer.py b/src/sentry/snuba/referrer.py index af7c2a2a424de8..2517702f5df392 100644 --- a/src/sentry/snuba/referrer.py +++ b/src/sentry/snuba/referrer.py @@ -640,6 +640,9 @@ class Referrer(StrEnum): "dynamic_sampling.distribution.fetch_projects_with_count_per_root_total_volumes" ) DYNAMIC_SAMPLING_PER_ORG_GET_EAP_ORG_VOLUME = "dynamic_sampling.per_org.get_eap_org_volume" + DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES = ( + "dynamic_sampling.per_org.get_eap_project_volumes" + ) DYNAMIC_SAMPLING_COUNTERS_FETCH_PROJECTS_WITH_COUNT_PER_TRANSACTION = ( "dynamic_sampling.counters.fetch_projects_with_count_per_transaction_volumes" ) diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py index 5eee5b058cbc50..4290e7516bfb01 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_queries.py @@ -10,7 +10,11 @@ get_configuration, ) from sentry.dynamic_sampling.per_org.tasks.queries import ( + DynamicSamplingQueryFields, + DynamicSamplingQueryFilters, + ProjectVolume, get_eap_organization_volume, + get_eap_project_volumes, run_eap_spans_table_query_in_chunks, ) from sentry.dynamic_sampling.tasks.common import OrganizationDataVolume @@ -56,7 +60,7 @@ def test_iterates_query_data_in_offset_chunks(self) -> None: projects=[project, other_project], organization=organization, ), - "query_string": "is_transaction:true", + "query_string": DynamicSamplingQueryFilters.IS_SEGMENT, "selected_columns": ["project.id", "count()", "count_sample()"], "orderby": ["project.id"], "referrer": Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_ORG_VOLUME.value, @@ -78,7 +82,10 @@ def test_iterates_query_data_in_offset_chunks(self) -> None: class EAPOrganizationVolumeTest(TestCase, SnubaTestCase, SpanTestCase): - def get_config(self, organization: Organization) -> BaseDynamicSamplingConfiguration: + def get_config( + self, + organization: Organization, + ) -> BaseDynamicSamplingConfiguration: with patch( "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", return_value=1.0, @@ -88,67 +95,42 @@ def get_config(self, organization: Organization) -> BaseDynamicSamplingConfigura def test_get_eap_organization_volume_existing_org(self) -> None: organization = self.create_organization() project = self.create_project(organization=organization) - other_organization = self.create_organization() - other_project = self.create_project(organization=other_organization) - timestamp = before_now(minutes=15) - self.store_spans( - [ - self.create_span( - {"is_segment": True}, - organization=organization, - project=project, - start_ts=timestamp, - ), - self.create_span( - {"is_segment": True}, - organization=organization, - project=project, - start_ts=timestamp + timedelta(seconds=1), - ), - self.create_span( - {"is_segment": False}, - organization=organization, - project=project, - start_ts=timestamp + timedelta(seconds=2), - ), - self.create_span( - {"is_segment": True}, - organization=other_organization, - project=other_project, - start_ts=timestamp, - ), - ] - ) - - org_volume = get_eap_organization_volume( - self.get_config(organization), time_interval=timedelta(hours=1) - ) + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", + return_value={"data": [{DynamicSamplingQueryFields.COUNT: 2, "count_sample()": 2}]}, + ) as run_table_query: + org_volume = get_eap_organization_volume( + self.get_config(organization), time_interval=timedelta(hours=1) + ) assert org_volume == OrganizationDataVolume(org_id=organization.id, total=2, indexed=2) + run_table_query.assert_called_once() + assert run_table_query.call_args.kwargs["params"].projects == [project] + assert ( + run_table_query.call_args.kwargs["query_string"] + == DynamicSamplingQueryFilters.IS_SEGMENT + ) + assert run_table_query.call_args.kwargs["selected_columns"] == [ + DynamicSamplingQueryFields.COUNT, + DynamicSamplingQueryFields.COUNT_SAMPLE, + ] + assert ( + run_table_query.call_args.kwargs["referrer"] + == Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_ORG_VOLUME.value + ) def test_get_eap_organization_volume_returns_raw_and_extrapolated_counts(self) -> None: organization = self.create_organization() - project = self.create_project(organization=organization) - timestamp = before_now(minutes=15) - - self.store_spans( - [ - self.create_span( - { - "is_segment": True, - "measurements": {"server_sample_rate": {"value": 0.1}}, - }, - organization=organization, - project=project, - start_ts=timestamp, - ), - ] - ) + self.create_project(organization=organization) - org_volume = get_eap_organization_volume( - self.get_config(organization), time_interval=timedelta(hours=1) - ) + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", + return_value={"data": [{"count()": 10, DynamicSamplingQueryFields.COUNT_SAMPLE: 1}]}, + ): + org_volume = get_eap_organization_volume( + self.get_config(organization), time_interval=timedelta(hours=1) + ) assert org_volume == OrganizationDataVolume(org_id=organization.id, total=10, indexed=1) @@ -165,8 +147,127 @@ def test_get_eap_organization_volume_without_traffic(self) -> None: def test_get_eap_organization_volume_without_projects(self) -> None: organization = self.create_organization() - org_volume = get_eap_organization_volume( - self.get_config(organization), time_interval=timedelta(hours=1) - ) + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", + return_value={"data": []}, + ) as run_table_query: + org_volume = get_eap_organization_volume( + self.get_config(organization), time_interval=timedelta(hours=1) + ) assert org_volume is None + run_table_query.assert_called_once() + assert run_table_query.call_args.kwargs["params"].projects == [] + + def test_get_eap_project_volumes_existing_org(self) -> None: + organization = self.create_organization() + project = self.create_project(organization=organization) + other_project = self.create_project(organization=organization) + other_organization = self.create_organization() + self.create_project(organization=other_organization) + + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", + return_value=[ + { + "sentry.dsc.root_project": project.id, + "count()": 2, + "count_sample()": 2, + }, + { + "sentry.dsc.root_project": other_project.id, + "count()": 1, + "count_sample()": 1, + }, + ], + ) as run_table_query: + project_volumes = get_eap_project_volumes( + self.get_config(organization), time_interval=timedelta(hours=1) + ) + + assert sorted(project_volumes) == [ + ProjectVolume(project_id=project.id, total=2, keep=2, drop=0), + ProjectVolume(project_id=other_project.id, total=1, keep=1, drop=0), + ] + run_table_query.assert_called_once() + query = run_table_query.call_args.args[0] + assert sorted(query["params"].projects, key=lambda p: p.id) == [ + project, + other_project, + ] + assert query["query_string"] == DynamicSamplingQueryFilters.IS_SEGMENT + assert query["selected_columns"] == [ + DynamicSamplingQueryFields.ROOT_PROJECT, + DynamicSamplingQueryFields.COUNT, + DynamicSamplingQueryFields.COUNT_SAMPLE, + ] + assert query["orderby"] == [DynamicSamplingQueryFields.ROOT_PROJECT] + assert query["referrer"] == Referrer.DYNAMIC_SAMPLING_PER_ORG_GET_EAP_PROJECT_VOLUMES.value + + def test_get_eap_project_volumes_without_traffic(self) -> None: + organization = self.create_organization() + self.create_project(organization=organization) + + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", + return_value={"data": []}, + ): + project_volumes = get_eap_project_volumes( + self.get_config(organization), time_interval=timedelta(hours=1) + ) + + assert project_volumes == [] + + def test_get_eap_project_volumes_handles_missing_aggregate_values(self) -> None: + organization = self.create_organization() + project = self.create_project(organization=organization) + + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", + return_value=[ + { + "sentry.dsc.root_project": project.id, + } + ], + ): + project_volumes = get_eap_project_volumes(self.get_config(organization)) + + assert project_volumes == [ProjectVolume(project_id=project.id, total=0, keep=0, drop=0)] + + def test_get_eap_project_volumes_skips_rows_without_root_project(self) -> None: + organization = self.create_organization() + project = self.create_project(organization=organization) + + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.run_eap_spans_table_query_in_chunks", + return_value=[ + { + "sentry.dsc.root_project": None, + "count()": 3, + "count_sample()": 1, + }, + { + "sentry.dsc.root_project": project.id, + "count()": 2, + "count_sample()": 1, + }, + ], + ): + project_volumes = get_eap_project_volumes(self.get_config(organization)) + + assert project_volumes == [ProjectVolume(project_id=project.id, total=2, keep=1, drop=1)] + + def test_get_eap_project_volumes_without_projects(self) -> None: + organization = self.create_organization() + + with patch( + "sentry.dynamic_sampling.per_org.tasks.queries.Spans.run_table_query", + return_value={"data": []}, + ) as run_table_query: + project_volumes = get_eap_project_volumes( + self.get_config(organization), time_interval=timedelta(hours=1) + ) + + assert project_volumes == [] + run_table_query.assert_called_once() + assert run_table_query.call_args.kwargs["params"].projects == [] diff --git a/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py b/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py index 403f0470d7218d..0e50657f90e33f 100644 --- a/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py +++ b/tests/sentry/dynamic_sampling/per_org/tasks/test_scheduler.py @@ -1,6 +1,6 @@ from __future__ import annotations -from unittest.mock import patch +from unittest.mock import Mock, patch from django.core.exceptions import ObjectDoesNotExist @@ -15,17 +15,22 @@ from sentry.dynamic_sampling.per_org.tasks.telemetry import DynamicSamplingStatus from sentry.dynamic_sampling.rules.utils import get_redis_client_for_ds from sentry.dynamic_sampling.tasks.common import OrganizationDataVolume +from sentry.dynamic_sampling.types import DynamicSamplingMode from sentry.models.organization import OrganizationStatus from sentry.testutils.cases import TestCase from sentry.testutils.helpers.options import override_options from sentry.testutils.helpers.task_runner import BurstTaskRunner -def _assert_called_once_with_config(mock, organization_id: int) -> None: +def _assert_called_once_with_config( + mock: Mock, + organization_id: int, +) -> BaseDynamicSamplingConfiguration: mock.assert_called_once() config = mock.call_args.args[0] assert isinstance(config, BaseDynamicSamplingConfiguration) assert config.organization.id == organization_id + return config def _drain_dispatched_org_ids(burst) -> list[int]: @@ -132,16 +137,21 @@ def test_run_calculations_per_org_returns_no_volume_without_traffic(self) -> Non patch( "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", return_value=1.0, - ), + ) as get_blended_sample_rate, patch( "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", return_value=None, ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes" + ) as get_project_volumes, ): result = run_calculations_per_org_task(org.id) - assert result == DynamicSamplingStatus.NO_VOLUME + assert result == DynamicSamplingStatus.NO_ORG_VOLUME _assert_called_once_with_config(get_volume, org.id) + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + get_project_volumes.assert_not_called() @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) def test_run_calculations_per_org_continues_with_traffic(self) -> None: @@ -153,26 +163,167 @@ def test_run_calculations_per_org_continues_with_traffic(self) -> None: patch( "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", return_value=1.0, - ), + ) as get_blended_sample_rate, patch( "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", return_value=org_volume, ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + return_value=[(1, 100, 25, 75)], + ) as get_project_volumes, ): result = run_calculations_per_org_task(org.id) assert result is None _assert_called_once_with_config(get_volume, org.id) + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + _assert_called_once_with_config(get_project_volumes, org.id) @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) - def test_run_calculations_per_org_skips_org_without_dynamic_sampling(self) -> None: + def test_run_calculations_per_org_returns_no_volume_without_project_volumes(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + org_volume = OrganizationDataVolume(org_id=org.id, total=100, indexed=25) + + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=1.0, + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", + return_value=org_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + return_value=[], + ) as get_project_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result == DynamicSamplingStatus.NO_PROJECT_VOLUMES + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + _assert_called_once_with_config(get_volume, org.id) + _assert_called_once_with_config(get_project_volumes, org.id) + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_skips_project_balancing_for_project_mode(self) -> None: + org = self.create_organization() + project = self.create_project(organization=org) + org.update_option("sentry:sampling_mode", DynamicSamplingMode.PROJECT) + project.update_option("sentry:target_sample_rate", 0.2) + org_volume = OrganizationDataVolume(org_id=org.id, total=100, indexed=25) + + with ( + self.feature("organizations:dynamic-sampling-custom"), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate" + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", + return_value=org_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + ) as get_project_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result is None + get_blended_sample_rate.assert_not_called() + _assert_called_once_with_config(get_volume, org.id) + get_project_volumes.assert_not_called() + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_queries_projects_for_am3_org_mode(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + org.update_option("sentry:sampling_mode", DynamicSamplingMode.ORGANIZATION) + org_volume = OrganizationDataVolume(org_id=org.id, total=100, indexed=25) + + with ( + self.feature("organizations:dynamic-sampling-custom"), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate" + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", + return_value=org_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + return_value=[(1, 100, 25, 75)], + ) as get_project_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result is None + get_blended_sample_rate.assert_not_called() + _assert_called_once_with_config(get_volume, org.id) + _assert_called_once_with_config(get_project_volumes, org.id) + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_skips_project_mode_without_project_rates(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + org.update_option("sentry:sampling_mode", DynamicSamplingMode.PROJECT) + + with ( + self.feature("organizations:dynamic-sampling-custom"), + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate" + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume" + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes" + ) as get_project_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result == DynamicSamplingStatus.ORG_HAS_NO_DYNAMIC_SAMPLING + get_blended_sample_rate.assert_not_called() + get_volume.assert_not_called() + get_project_volumes.assert_not_called() + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_queries_projects_for_am2(self) -> None: + org = self.create_organization() + self.create_project(organization=org) + org_volume = OrganizationDataVolume(org_id=org.id, total=100, indexed=25) + + with ( + patch( + "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", + return_value=1.0, + ) as get_blended_sample_rate, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume", + return_value=org_volume, + ) as get_volume, + patch( + "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_project_volumes", + return_value=[(1, 100, 25, 75)], + ) as get_project_volumes, + ): + result = run_calculations_per_org_task(org.id) + + assert result is None + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) + _assert_called_once_with_config(get_volume, org.id) + _assert_called_once_with_config(get_project_volumes, org.id) + + @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) + def test_run_calculations_per_org_skips_org_without_transaction_sample_rate(self) -> None: org = self.create_organization() with ( patch( "sentry.dynamic_sampling.per_org.tasks.configuration.quotas.backend.get_blended_sample_rate", return_value=None, - ), + ) as get_blended_sample_rate, patch( "sentry.dynamic_sampling.per_org.tasks.scheduler.get_eap_organization_volume" ) as get_volume, @@ -180,6 +331,7 @@ def test_run_calculations_per_org_skips_org_without_dynamic_sampling(self) -> No result = run_calculations_per_org_task(org.id) assert result == DynamicSamplingStatus.ORG_HAS_NO_DYNAMIC_SAMPLING + get_blended_sample_rate.assert_called_once_with(organization_id=org.id) get_volume.assert_not_called() @override_options({"dynamic-sampling.per_org.rollout-rate": 1.0}) From e7d30a8e75ba1b8b2be26d0e92c5e08d422f8cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20Dorfmeister=20=F0=9F=94=AE?= Date: Wed, 27 May 2026 11:49:29 +0200 Subject: [PATCH 9/9] fix(ui): Add inset focus ring to SimpleTable header cells (#116276) Sortable header cells in `SimpleTable` now show a visible focus ring when navigated via keyboard. The ring uses an inset `box-shadow` so it isn't clipped by the Panel's `overflow: hidden` and border. before: https://github.com/user-attachments/assets/a0f87102-dee4-4231-ad59-c23cf8121a61 after: https://github.com/user-attachments/assets/62a74372-e18a-4b76-b275-48b119daa23c Co-authored-by: Claude Opus 4.6 --- static/app/components/tables/simpleTable/index.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/app/components/tables/simpleTable/index.tsx b/static/app/components/tables/simpleTable/index.tsx index 888a811efa0e80..96a4f9e1d2d9b2 100644 --- a/static/app/components/tables/simpleTable/index.tsx +++ b/static/app/components/tables/simpleTable/index.tsx @@ -165,6 +165,10 @@ const ColumnHeaderCell = styled('div')<{isSorted?: boolean}>` gap: ${p => p.theme.space.md}; height: 100%; + &:focus-visible { + box-shadow: inset 0 0 0 2px ${p => p.theme.tokens.focus.default}; + } + &:first-child { ${HeaderDivider} { display: none;