Skip to content

chore(trace ai queries): Human readable queries #97050

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import styled from '@emotion/styled';
import {ProvidedFormattedQuery} from 'sentry/components/searchQueryBuilder/formattedQuery';
import {parseQueryBuilderValue} from 'sentry/components/searchQueryBuilder/utils';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import {getFieldDefinition} from 'sentry/utils/fields';
import type {ChartType} from 'sentry/views/insights/common/components/chart';

interface QueryTokensProps {
export interface QueryTokensProps {
groupBys?: string[];
query?: string;
sort?: string;
Expand Down Expand Up @@ -91,14 +90,14 @@ export default QueryTokens;

const TokenContainer = styled('div')`
display: flex;
gap: ${space(1)};
padding: ${space(1)};
gap: ${p => p.theme.space.md};
padding: ${p => p.theme.space.md};
`;

const Token = styled('span')`
display: flex;
flex-direction: row;
gap: ${space(0.5)};
gap: ${p => p.theme.space.xs};
overflow: hidden;
flex-wrap: wrap;
align-items: center;
Expand All @@ -116,7 +115,7 @@ const ExploreParamTitle = styled('span')`
const ExploreVisualizes = styled('span')`
font-size: ${p => p.theme.form.sm.fontSize};
background: ${p => p.theme.background};
padding: ${space(0.25)} ${space(0.5)};
padding: ${p => p.theme.space['2xs']} ${p => p.theme.space.xs};
border: 1px solid ${p => p.theme.innerBorder};
border-radius: ${p => p.theme.borderRadius};
height: 24px;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ import {trackAnalytics} from 'sentry/utils/analytics';
import {useFeedbackForm} from 'sentry/utils/useFeedbackForm';
import useOrganization from 'sentry/utils/useOrganization';
import useOverlay from 'sentry/utils/useOverlay';
import QueryTokens from 'sentry/views/explore/components/queryTokens';
import {
type SeerSearchItem,
useApplySeerSearchQuery,
useSeerSearch,
} from 'sentry/views/explore/components/seerComboBox/hooks';
import QueryTokens from 'sentry/views/explore/components/seerComboBox/queryTokens';
import {SeerSearchHeader} from 'sentry/views/explore/components/seerComboBox/seerSearchHeader';
import {SeerSearchListBox} from 'sentry/views/explore/components/seerComboBox/seerSearchListBox';
import {SeerSearchPopover} from 'sentry/views/explore/components/seerComboBox/seerSearchPopover';
import {SeerSearchSkeleton} from 'sentry/views/explore/components/seerComboBox/seerSearchSkeleton';
import {
formatQueryToNaturalLanguage,
generateQueryTokensString,
} from 'sentry/views/explore/components/seerComboBox/utils';
import {useTraceExploreAiQuerySetup} from 'sentry/views/explore/hooks/useTraceExploreAiQuerySetup';
import {formatQueryToNaturalLanguage} from 'sentry/views/explore/utils';

// The menu size can change from things like loading states, long options,
// or custom menus like a date picker. This hook ensures that the overlay
Expand Down Expand Up @@ -247,8 +250,20 @@ export function SeerComboBox({initialQuery, ...props}: SeerComboBoxProps) {
);
}

const readableQuery = generateQueryTokensString({
groupBys: item.groupBys,
query: item.query,
sort: item.sort,
statsPeriod: item.statsPeriod,
visualizations: item.visualizations,
});

return (
<Item key={item.key} textValue={item.query}>
<Item
key={item.key}
textValue={readableQuery}
aria-label={`Query parameters: ${readableQuery}`}
>
<QueryTokens
groupBys={item.groupBys}
query={item.query}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,32 @@ export function SeerSearchListBox(props: SeerSearchListBoxProps) {

return (
<StyledUl {...listBoxProps} ref={listBoxRef}>
{[...state.collection].map(item => (
<SeerSearchOption key={item.key} item={item} state={state} />
))}
{[...state.collection].map(item => {
return (
<SeerSearchOption
key={item.key}
item={item}
state={state}
label={item['aria-label']}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Accessibility Issue: Missing aria-label Prop

The code attempts to access item['aria-label'] where item is a React Stately Node<unknown> object. The aria-label prop, passed to the <Item> component, is not directly available as a property on the Node object. This causes item['aria-label'] to be undefined, preventing the aria-label attribute from being set on the rendered option and defeating the intended accessibility enhancement.

Fix in Cursor Fix in Web

/>
);
})}
</StyledUl>
);
}

interface SeerSearchOptionProps {
item: Node<unknown>;
state: ListState<unknown>;
label?: string;
}

function SeerSearchOption({item, state}: SeerSearchOptionProps) {
function SeerSearchOption({item, state, label}: SeerSearchOptionProps) {
const ref = useRef<HTMLLIElement>(null);
const {optionProps, isFocused} = useOption({key: item.key}, state, ref);

return (
<StyledOption {...optionProps} ref={ref} isFocused={isFocused}>
<StyledOption {...optionProps} aria-label={label} ref={ref} isFocused={isFocused}>
{item.rendered}
</StyledOption>
);
Expand Down
108 changes: 108 additions & 0 deletions static/app/views/explore/components/seerComboBox/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type {QueryTokensProps} from 'sentry/views/explore/components/seerComboBox/queryTokens';

function formatToken(token: string): string {
const isNegated = token.startsWith('!') && token.includes(':');
const actualToken = isNegated ? token.slice(1) : token;

const operators = [
[':>=', 'greater than or equal to'],
[':<=', 'less than or equal to'],
[':!=', 'not'],
[':>', 'greater than'],
[':<', 'less than'],
['>=', 'greater than or equal to'],
['<=', 'less than or equal to'],
['!=', 'not'],
['!:', 'not'],
['>', 'greater than'],
['<', 'less than'],
[':', ''],
] as const;

for (const [op, desc] of operators) {
if (actualToken.includes(op)) {
const [key, value] = actualToken.split(op);
const cleanKey = key?.trim() || '';
const cleanVal = value?.trim() || '';

const negation = isNegated ? 'not ' : '';
const description = desc ? `${negation}${desc}` : negation ? 'not' : '';

return `${cleanKey} is ${description} ${cleanVal}`.replace(/\s+/g, ' ').trim();
}
}

return token;
}

export function formatQueryToNaturalLanguage(query: string): string {
if (!query.trim()) return '';
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
const formattedTokens = tokens.map(formatToken);

return formattedTokens.reduce((result, token, index) => {
if (index === 0) return token;

const currentOriginalToken = tokens[index] || '';
const prevOriginalToken = tokens[index - 1] || '';

const isLogicalOp = token.toUpperCase() === 'AND' || token.toUpperCase() === 'OR';
const prevIsLogicalOp =
formattedTokens[index - 1]?.toUpperCase() === 'AND' ||
formattedTokens[index - 1]?.toUpperCase() === 'OR';

if (isLogicalOp || prevIsLogicalOp) {
return `${result} ${token}`;
}

const isCurrentFilter = /[:>=<!]/.test(currentOriginalToken);
const isPrevFilter = /[:>=<!]/.test(prevOriginalToken);
Comment on lines +58 to +59
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly is this testing for? Just a heuristic to see if it's a filter?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe to see if there are multiple filters, and instead of giving them a space, it gives a comma than a space afaict. This was just something i moved to be located closer to where it's used.


if (isCurrentFilter && isPrevFilter) {
return `${result}, ${token}`;
}

return `${result} ${token}`;
}, '');
}

export function generateQueryTokensString({
groupBys,
query,
sort,
statsPeriod,
visualizations,
}: QueryTokensProps): string {
const parts = [];

if (query) {
const formattedFilter = formatQueryToNaturalLanguage(query.trim());
parts.push(`Filter is '${formattedFilter}'`);
}

if (visualizations && visualizations.length > 0) {
const vizParts = visualizations.flatMap(visualization =>
visualization.yAxes.map(yAxis => yAxis)
);
if (vizParts.length > 0) {
const vizText = vizParts.length === 1 ? vizParts[0] : vizParts.join(', ');
parts.push(`visualizations are '${vizText}'`);
}
}

if (groupBys && groupBys.length > 0) {
const groupByText = groupBys.length === 1 ? groupBys[0] : groupBys.join(', ');
parts.push(`groupBys are '${groupByText}'`);
}

if (statsPeriod && statsPeriod.length > 0) {
parts.push(`time range is '${statsPeriod}'`);
}

if (sort && sort.length > 0) {
const sortText = sort[0] === '-' ? `${sort.slice(1)} Desc` : `${sort} Asc`;
parts.push(`sort is '${sortText}'`);
}

return parts.length > 0 ? parts.join(', ') : 'No query parameters set';
}
66 changes: 0 additions & 66 deletions static/app/views/explore/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,72 +610,6 @@ function normalizeKey(key: string): string {
return key.startsWith('!') ? key.slice(1) : key;
}

export function formatQueryToNaturalLanguage(query: string): string {
if (!query.trim()) return '';
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
const formattedTokens = tokens.map(formatToken);

return formattedTokens.reduce((result, token, index) => {
if (index === 0) return token;

const currentOriginalToken = tokens[index] || '';
const prevOriginalToken = tokens[index - 1] || '';

const isLogicalOp = token.toUpperCase() === 'AND' || token.toUpperCase() === 'OR';
const prevIsLogicalOp =
formattedTokens[index - 1]?.toUpperCase() === 'AND' ||
formattedTokens[index - 1]?.toUpperCase() === 'OR';

if (isLogicalOp || prevIsLogicalOp) {
return `${result} ${token}`;
}

const isCurrentFilter = /[:>=<!]/.test(currentOriginalToken);
const isPrevFilter = /[:>=<!]/.test(prevOriginalToken);

if (isCurrentFilter && isPrevFilter) {
return `${result}, ${token}`;
}

return `${result} ${token}`;
}, '');
}

function formatToken(token: string): string {
const isNegated = token.startsWith('!') && token.includes(':');
const actualToken = isNegated ? token.slice(1) : token;

const operators = [
[':>=', 'greater than or equal to'],
[':<=', 'less than or equal to'],
[':!=', 'not'],
[':>', 'greater than'],
[':<', 'less than'],
['>=', 'greater than or equal to'],
['<=', 'less than or equal to'],
['!=', 'not'],
['!:', 'not'],
['>', 'greater than'],
['<', 'less than'],
[':', ''],
] as const;

for (const [op, desc] of operators) {
if (actualToken.includes(op)) {
const [key, value] = actualToken.split(op);
const cleanKey = key?.trim() || '';
const cleanVal = value?.trim() || '';

const negation = isNegated ? 'not ' : '';
const description = desc ? `${negation}${desc}` : negation ? 'not' : '';

return `${cleanKey} is ${description} ${cleanVal}`.replace(/\s+/g, ' ').trim();
}
}

return token;
}

export function prettifyAggregation(aggregation: string): string | null {
if (isEquation(aggregation)) {
const expression = new Expression(stripEquationPrefix(aggregation));
Expand Down
Loading