Skip to content

Commit c60e646

Browse files
Improve Search Filter Parsing (#1426)
Adds better support for nested Requested Throughput deeplinking, and adds better support for passing string filters which contain JSON | Before | After | |---|---| | <img width="568" height="948" alt="Screenshot 2025-12-02 at 10 14 11 AM" src="https://github.com/user-attachments/assets/396785b0-cfa3-4b54-abf4-fe4d6b1aa9b2" /> | <img width="538" height="1166" alt="Screenshot 2025-12-02 at 10 14 41 AM" src="https://github.com/user-attachments/assets/efe384f9-c615-4ca3-b719-559c9b668874" /> | | <img width="538" height="864" alt="Screenshot 2025-12-02 at 10 13 52 AM" src="https://github.com/user-attachments/assets/375990db-45d7-45cd-a6de-d4eedf09b5bd" /> | <img width="554" height="784" alt="Screenshot 2025-12-02 at 10 13 45 AM" src="https://github.com/user-attachments/assets/9bd05162-1bc9-425d-9105-8a8626863278" /> Fixes HDX-2932, HDX-2933
1 parent 238c36f commit c60e646

File tree

5 files changed

+309
-35
lines changed

5 files changed

+309
-35
lines changed

.changeset/beige-eyes-tap.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hyperdx/app": patch
3+
---
4+
5+
Improve how filters are parsed on the search page

packages/app/src/__tests__/searchFilters.test.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,163 @@ describe('searchFilters', () => {
118118
const result = parseQuery([{ type: 'lucene', condition: `app:*` }]);
119119
expect(result.filters).toEqual({});
120120
});
121+
122+
it('extracts IN clauses from complex conditions with AND operator', () => {
123+
const result = parseQuery([
124+
{
125+
type: 'sql',
126+
condition: `SpanName = 'flagd.evaluation.v1.Service/EventStream' AND SpanKind IN ('Server', 'SPAN_KIND_SERVER')`,
127+
},
128+
]);
129+
expect(result.filters).toEqual({
130+
SpanKind: {
131+
included: new Set(['Server', 'SPAN_KIND_SERVER']),
132+
excluded: new Set(),
133+
},
134+
});
135+
});
136+
137+
it('skips conditions with OR operator (not supported)', () => {
138+
const result = parseQuery([
139+
{
140+
type: 'sql',
141+
condition: `level IN ('error') OR severity IN ('high')`,
142+
},
143+
]);
144+
// OR is not supported, so it just tries to parse as-is and should fail cleanly
145+
expect(result.filters).toEqual({});
146+
});
147+
148+
it('skips conditions with only equality operators', () => {
149+
const result = parseQuery([
150+
{
151+
type: 'sql',
152+
condition: `status_code = 200`,
153+
},
154+
]);
155+
expect(result.filters).toEqual({});
156+
});
157+
158+
it('skips conditions with only comparison operators', () => {
159+
const result = parseQuery([
160+
{
161+
type: 'sql',
162+
condition: `duration > 1000`,
163+
},
164+
]);
165+
expect(result.filters).toEqual({});
166+
});
167+
168+
it('parses simple IN conditions alongside extracting from complex conditions', () => {
169+
const result = parseQuery([
170+
{ type: 'sql', condition: `service IN ('app', 'api')` },
171+
{
172+
type: 'sql',
173+
condition: `SpanName = 'test' AND SpanKind IN ('Server')`,
174+
},
175+
{ type: 'sql', condition: `level IN ('error')` },
176+
]);
177+
expect(result.filters).toEqual({
178+
service: { included: new Set(['app', 'api']), excluded: new Set() },
179+
SpanKind: { included: new Set(['Server']), excluded: new Set() },
180+
level: { included: new Set(['error']), excluded: new Set() },
181+
});
182+
});
183+
184+
it('handles multiple IN clauses with AND', () => {
185+
const result = parseQuery([
186+
{
187+
type: 'sql',
188+
condition: `service IN ('app') AND level IN ('error', 'warn')`,
189+
},
190+
]);
191+
expect(result.filters).toEqual({
192+
service: { included: new Set(['app']), excluded: new Set() },
193+
level: { included: new Set(['error', 'warn']), excluded: new Set() },
194+
});
195+
});
196+
197+
it('extracts NOT IN clauses from complex conditions', () => {
198+
const result = parseQuery([
199+
{
200+
type: 'sql',
201+
condition: `status = 'active' AND level NOT IN ('debug')`,
202+
},
203+
]);
204+
expect(result.filters).toEqual({
205+
level: { included: new Set(), excluded: new Set(['debug']) },
206+
});
207+
});
208+
209+
it('handles string values with special characters in AND conditions', () => {
210+
const result = parseQuery([
211+
{
212+
type: 'sql',
213+
condition: `SpanName = 'flagd.evaluation.v1.Service/EventStream' AND SpanKind IN ('Server', 'SPAN_KIND_SERVER')`,
214+
},
215+
]);
216+
expect(result.filters).toEqual({
217+
SpanKind: {
218+
included: new Set(['Server', 'SPAN_KIND_SERVER']),
219+
excluded: new Set(),
220+
},
221+
});
222+
});
223+
224+
it('handles JSON values with commas and special characters', () => {
225+
const result = parseQuery([
226+
{
227+
type: 'sql',
228+
condition: `Body IN ('{"orderId": "123", "total": 100}')`,
229+
},
230+
]);
231+
expect(result.filters).toEqual({
232+
Body: {
233+
included: new Set(['{"orderId": "123", "total": 100}']),
234+
excluded: new Set(),
235+
},
236+
});
237+
});
238+
239+
it('handles complex multi-line JSON values', () => {
240+
const result = parseQuery([
241+
{
242+
type: 'sql',
243+
condition: `Body IN ('Order details: { "orderId": "7b54ad99", "items": [{"id": 1}, {"id": 2}] }')`,
244+
},
245+
]);
246+
expect(result.filters).toEqual({
247+
Body: {
248+
included: new Set([
249+
'Order details: { "orderId": "7b54ad99", "items": [{"id": 1}, {"id": 2}] }',
250+
]),
251+
excluded: new Set(),
252+
},
253+
});
254+
});
255+
256+
it('handles multiple simple values alongside single complex JSON value', () => {
257+
const result = parseQuery([
258+
{
259+
type: 'sql',
260+
condition: `status IN ('active', 'pending')`,
261+
},
262+
{
263+
type: 'sql',
264+
condition: `data IN ('{"key": "value", "nested": {"a": 1}}')`,
265+
},
266+
]);
267+
expect(result.filters).toEqual({
268+
status: {
269+
included: new Set(['active', 'pending']),
270+
excluded: new Set(),
271+
},
272+
data: {
273+
included: new Set(['{"key": "value", "nested": {"a": 1}}']),
274+
excluded: new Set(),
275+
},
276+
});
277+
});
121278
});
122279

123280
describe('areFiltersEqual', () => {

packages/app/src/components/DBTable/DBRowTableFieldWithPopover.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,7 @@ export const DBRowTableFieldWithPopover = ({
4949
const { onPropertyAddClick } = useContext(RowSidePanelContext);
5050

5151
// Check if we have both the column name and filter function available
52-
// Only show filter for ServiceName and SeverityText
53-
const canFilter =
54-
columnName &&
55-
(columnName === 'ServiceName' || columnName === 'SeverityText') &&
56-
onPropertyAddClick &&
57-
cellValue != null;
52+
const canFilter = columnName && onPropertyAddClick && cellValue != null;
5853

5954
const handleMouseEnter = () => {
6055
if (hoverDisabled) return;

packages/app/src/components/ServiceDashboardEndpointSidePanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,13 @@ export default function ServiceDashboardEndpointSidePanel({
4949
const filters: Filter[] = [
5050
{
5151
type: 'sql',
52-
condition: `${expressions.spanName} = '${endpoint}' AND ${expressions.isSpanKindServer}`,
52+
condition: `${expressions.spanName} IN ('${endpoint}') AND ${expressions.isSpanKindServer}`,
5353
},
5454
];
5555
if (service) {
5656
filters.push({
5757
type: 'sql',
58-
condition: `${expressions.service} = '${service}'`,
58+
condition: `${expressions.service} IN ('${service}')`,
5959
});
6060
}
6161
return filters;

packages/app/src/searchFilters.tsx

Lines changed: 144 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,135 @@ export const areFiltersEqual = (a: FilterState, b: FilterState) => {
8484
return true;
8585
};
8686

87+
// Helper function to split on commas while respecting quoted strings
88+
function splitValuesOnComma(valuesStr: string): string[] {
89+
const values: string[] = [];
90+
let currentValue = '';
91+
let inString = false;
92+
93+
for (let i = 0; i < valuesStr.length; i++) {
94+
const char = valuesStr[i];
95+
96+
if (char === "'" && (i === 0 || valuesStr[i - 1] !== '\\')) {
97+
inString = !inString;
98+
currentValue += char;
99+
continue;
100+
}
101+
102+
if (!inString && char === ',') {
103+
if (currentValue.trim()) {
104+
// Remove surrounding quotes if present
105+
const trimmed = currentValue.trim();
106+
const unquoted =
107+
trimmed.startsWith("'") && trimmed.endsWith("'")
108+
? trimmed.slice(1, -1)
109+
: trimmed;
110+
values.push(unquoted);
111+
}
112+
currentValue = '';
113+
continue;
114+
}
115+
116+
currentValue += char;
117+
}
118+
119+
// Add the last value
120+
if (currentValue.trim()) {
121+
const trimmed = currentValue.trim();
122+
const unquoted =
123+
trimmed.startsWith("'") && trimmed.endsWith("'")
124+
? trimmed.slice(1, -1)
125+
: trimmed;
126+
values.push(unquoted);
127+
}
128+
129+
return values;
130+
}
131+
132+
// Helper function to extract simple IN/NOT IN clauses from a condition
133+
// This handles both simple conditions and compound conditions with AND
134+
function extractInClauses(condition: string): Array<{
135+
key: string;
136+
values: string[];
137+
isExclude: boolean;
138+
}> {
139+
const results: Array<{
140+
key: string;
141+
values: string[];
142+
isExclude: boolean;
143+
}> = [];
144+
145+
// Split on ' AND ' while respecting quoted strings
146+
const parts: string[] = [];
147+
let currentPart = '';
148+
let inString = false;
149+
150+
for (let i = 0; i < condition.length; i++) {
151+
const char = condition[i];
152+
153+
if (char === "'" && (i === 0 || condition[i - 1] !== '\\')) {
154+
inString = !inString;
155+
currentPart += char;
156+
continue;
157+
}
158+
159+
if (!inString && condition.slice(i, i + 5).toUpperCase() === ' AND ') {
160+
if (currentPart.trim()) {
161+
parts.push(currentPart.trim());
162+
}
163+
currentPart = '';
164+
i += 4; // Skip past ' AND '
165+
continue;
166+
}
167+
168+
currentPart += char;
169+
}
170+
171+
if (currentPart.trim()) {
172+
parts.push(currentPart.trim());
173+
}
174+
175+
// Process each part to extract IN/NOT IN clauses
176+
for (const part of parts) {
177+
// Skip parts that contain OR (not supported) or comparison operators
178+
if (
179+
part.toUpperCase().includes(' OR ') ||
180+
part.includes('=') ||
181+
part.includes('<') ||
182+
part.includes('>')
183+
) {
184+
continue;
185+
}
186+
187+
const isExclude = part.includes('NOT IN');
188+
189+
// Check if this is an IN clause
190+
if (part.includes(' IN ') || part.includes(' NOT IN ')) {
191+
const [key, values] = part.split(isExclude ? ' NOT IN ' : ' IN ');
192+
193+
if (key && values) {
194+
const keyStr = key.trim();
195+
// Remove outer parentheses and split on commas while respecting quotes
196+
const trimmedValues = values.trim();
197+
const withoutParens =
198+
trimmedValues.startsWith('(') && trimmedValues.endsWith(')')
199+
? trimmedValues.slice(1, -1)
200+
: trimmedValues;
201+
202+
const valuesArray = splitValuesOnComma(withoutParens);
203+
204+
results.push({
205+
key: keyStr,
206+
values: valuesArray,
207+
isExclude,
208+
});
209+
}
210+
}
211+
}
212+
213+
return results;
214+
}
215+
87216
export const parseQuery = (
88217
q: Filter[],
89218
): {
@@ -125,35 +254,23 @@ export const parseQuery = (
125254
}
126255
}
127256

128-
// Handle IN/NOT IN conditions
129-
const isExclude = filter.condition.includes('NOT IN');
130-
const [key, values] = filter.condition.split(
131-
isExclude ? ' NOT IN ' : ' IN ',
132-
);
133-
134-
// Skip if key or values is not present
135-
if (!key || !values) {
136-
continue;
137-
}
138-
139-
const keyStr = key.trim();
140-
const valuesStr = values
141-
.replace('(', '')
142-
.replace(')', '')
143-
.split(',')
144-
.map(v => v.trim().replace(/'/g, ''));
257+
// Extract all simple IN/NOT IN clauses from the condition
258+
// This handles both simple conditions and compound conditions with AND/OR
259+
const inClauses = extractInClauses(filter.condition);
145260

146-
if (!state.has(keyStr)) {
147-
state.set(keyStr, { included: new Set(), excluded: new Set() });
148-
}
149-
const sets = state.get(keyStr)!;
150-
valuesStr.forEach(v => {
151-
if (isExclude) {
152-
sets.excluded.add(v);
153-
} else {
154-
sets.included.add(v);
261+
for (const clause of inClauses) {
262+
if (!state.has(clause.key)) {
263+
state.set(clause.key, { included: new Set(), excluded: new Set() });
155264
}
156-
});
265+
const sets = state.get(clause.key)!;
266+
clause.values.forEach(v => {
267+
if (clause.isExclude) {
268+
sets.excluded.add(v);
269+
} else {
270+
sets.included.add(v);
271+
}
272+
});
273+
}
157274
}
158275
return { filters: Object.fromEntries(state) };
159276
};

0 commit comments

Comments
 (0)