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/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") 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; 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 = { 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, + })} + /> + )} + void}) { - + + + {introduction && {introduction}} { 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'); +} 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()', 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; 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}) 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"],