Skip to content

Commit d7fc9ac

Browse files
committed
Add "starts with" and "does not start with" filters for stacks/frames
This commit adds two new string filter types to match "contains" and "does not contain": - starts_with: matches strings that start with the specified prefix - not_starts_with: matches strings that do not start with the specified prefix Changes: - proto: Added starts_with and not_starts_with fields to StringCondition message - backend: Updated matchesStringCondition() to handle new filter types using bytes.HasPrefix - frontend UI: Added "Starts With" and "Not Starts With" options to filter dropdown - frontend types: Updated ProfileFilter interface and createStringCondition() for new match types - tests: Added 4 comprehensive tests covering stack and frame filters with both new filter types The implementation follows the same pattern as existing contains/not_contains filters, with case-insensitive matching and proper null handling.
1 parent 83c48ba commit d7fc9ac

File tree

6 files changed

+323
-1
lines changed

6 files changed

+323
-1
lines changed

pkg/query/columnquery.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -996,6 +996,12 @@ func matchesStringCondition(value []byte, condition *pb.StringCondition) bool {
996996
case *pb.StringCondition_NotContains:
997997
target := bytes.ToLower([]byte(condition.GetNotContains()))
998998
return !bytes.Contains(valueLower, target)
999+
case *pb.StringCondition_StartsWith:
1000+
target := bytes.ToLower([]byte(condition.GetStartsWith()))
1001+
return bytes.HasPrefix(valueLower, target)
1002+
case *pb.StringCondition_NotStartsWith:
1003+
target := bytes.ToLower([]byte(condition.GetNotStartsWith()))
1004+
return !bytes.HasPrefix(valueLower, target)
9991005
default:
10001006
return true
10011007
}

pkg/query/multiple_filters_test.go

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,3 +1414,279 @@ func TestStackFilterAndLogicValidation(t *testing.T) {
14141414
}
14151415
}
14161416
}
1417+
1418+
func TestStackFilterFunctionNameStartsWith(t *testing.T) {
1419+
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
1420+
defer mem.AssertSize(t, 0)
1421+
1422+
originalRecords, cleanup := createTestProfileData(mem)
1423+
defer cleanup()
1424+
1425+
// Test stack filter: keep only stacks containing functions that start with "app."
1426+
// Expected: Only sample 1 from record 1 should remain (contains app.server.handleRequest)
1427+
recs, _, err := FilterProfileData(
1428+
context.Background(),
1429+
noop.NewTracerProvider().Tracer(""),
1430+
mem,
1431+
originalRecords,
1432+
[]*pb.Filter{
1433+
{
1434+
Filter: &pb.Filter_StackFilter{
1435+
StackFilter: &pb.StackFilter{
1436+
Filter: &pb.StackFilter_Criteria{
1437+
Criteria: &pb.FilterCriteria{
1438+
FunctionName: &pb.StringCondition{
1439+
Condition: &pb.StringCondition_StartsWith{
1440+
StartsWith: "app.",
1441+
},
1442+
},
1443+
},
1444+
},
1445+
},
1446+
},
1447+
},
1448+
},
1449+
)
1450+
require.NoError(t, err)
1451+
defer func() {
1452+
for _, r := range recs {
1453+
r.Release()
1454+
}
1455+
}()
1456+
1457+
// Should have 3 samples remaining (samples containing app.* functions)
1458+
totalSamples := int64(0)
1459+
for _, rec := range recs {
1460+
totalSamples += rec.NumRows()
1461+
}
1462+
require.Equal(t, int64(3), totalSamples, "Should have 3 samples with functions starting with 'app.'")
1463+
1464+
// Validate the remaining samples contain at least one function starting with "app."
1465+
for _, rec := range recs {
1466+
r := profile.NewRecordReader(rec)
1467+
for i := 0; i < int(rec.NumRows()); i++ {
1468+
lOffsetStart, lOffsetEnd := r.Locations.ValueOffsets(i)
1469+
firstStart, _ := r.Lines.ValueOffsets(int(lOffsetStart))
1470+
_, lastEnd := r.Lines.ValueOffsets(int(lOffsetEnd - 1))
1471+
1472+
foundAppFunction := false
1473+
for k := int(firstStart); k < int(lastEnd); k++ {
1474+
fnIndex := r.LineFunctionNameIndices.Value(k)
1475+
functionName := string(r.LineFunctionNameDict.Value(int(fnIndex)))
1476+
1477+
if strings.HasPrefix(strings.ToLower(functionName), "app.") {
1478+
foundAppFunction = true
1479+
break
1480+
}
1481+
}
1482+
1483+
require.True(t, foundAppFunction, "Sample should contain at least one function starting with 'app.'")
1484+
}
1485+
}
1486+
}
1487+
1488+
func TestStackFilterFunctionNameNotStartsWith(t *testing.T) {
1489+
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
1490+
defer mem.AssertSize(t, 0)
1491+
1492+
originalRecords, cleanup := createTestProfileData(mem)
1493+
defer cleanup()
1494+
1495+
// Test stack filter: keep only stacks NOT containing functions that start with "database."
1496+
// Expected: Samples NOT containing database.query should remain (4 out of 5)
1497+
recs, _, err := FilterProfileData(
1498+
context.Background(),
1499+
noop.NewTracerProvider().Tracer(""),
1500+
mem,
1501+
originalRecords,
1502+
[]*pb.Filter{
1503+
{
1504+
Filter: &pb.Filter_StackFilter{
1505+
StackFilter: &pb.StackFilter{
1506+
Filter: &pb.StackFilter_Criteria{
1507+
Criteria: &pb.FilterCriteria{
1508+
FunctionName: &pb.StringCondition{
1509+
Condition: &pb.StringCondition_NotStartsWith{
1510+
NotStartsWith: "database.",
1511+
},
1512+
},
1513+
},
1514+
},
1515+
},
1516+
},
1517+
},
1518+
},
1519+
)
1520+
require.NoError(t, err)
1521+
defer func() {
1522+
for _, r := range recs {
1523+
r.Release()
1524+
}
1525+
}()
1526+
1527+
// Should have 2 samples remaining (all except those with database.* functions)
1528+
totalSamples := int64(0)
1529+
for _, rec := range recs {
1530+
totalSamples += rec.NumRows()
1531+
}
1532+
require.Equal(t, int64(2), totalSamples, "Should have 2 samples NOT containing functions starting with 'database.'")
1533+
1534+
// Validate none of the remaining samples contain functions starting with "database."
1535+
for _, rec := range recs {
1536+
r := profile.NewRecordReader(rec)
1537+
for i := 0; i < int(rec.NumRows()); i++ {
1538+
lOffsetStart, lOffsetEnd := r.Locations.ValueOffsets(i)
1539+
firstStart, _ := r.Lines.ValueOffsets(int(lOffsetStart))
1540+
_, lastEnd := r.Lines.ValueOffsets(int(lOffsetEnd - 1))
1541+
1542+
for k := int(firstStart); k < int(lastEnd); k++ {
1543+
fnIndex := r.LineFunctionNameIndices.Value(k)
1544+
functionName := string(r.LineFunctionNameDict.Value(int(fnIndex)))
1545+
1546+
require.False(t, strings.HasPrefix(strings.ToLower(functionName), "database."),
1547+
"Sample should NOT contain functions starting with 'database.', found: %s", functionName)
1548+
}
1549+
}
1550+
}
1551+
}
1552+
1553+
func TestFrameFilterFunctionNameStartsWith(t *testing.T) {
1554+
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
1555+
defer mem.AssertSize(t, 0)
1556+
1557+
originalRecords, cleanup := createTestProfileData(mem)
1558+
defer cleanup()
1559+
1560+
// Test frame filter: keep only frames starting with "runtime."
1561+
// Expected: All 5 samples remain, but only runtime.* frames are kept
1562+
recs, _, err := FilterProfileData(
1563+
context.Background(),
1564+
noop.NewTracerProvider().Tracer(""),
1565+
mem,
1566+
originalRecords,
1567+
[]*pb.Filter{
1568+
{
1569+
Filter: &pb.Filter_FrameFilter{
1570+
FrameFilter: &pb.FrameFilter{
1571+
Filter: &pb.FrameFilter_Criteria{
1572+
Criteria: &pb.FilterCriteria{
1573+
FunctionName: &pb.StringCondition{
1574+
Condition: &pb.StringCondition_StartsWith{
1575+
StartsWith: "runtime.",
1576+
},
1577+
},
1578+
},
1579+
},
1580+
},
1581+
},
1582+
},
1583+
},
1584+
)
1585+
require.NoError(t, err)
1586+
defer func() {
1587+
for _, r := range recs {
1588+
r.Release()
1589+
}
1590+
}()
1591+
1592+
// Should have all 5 samples remaining (with only runtime frames kept)
1593+
totalSamples := int64(0)
1594+
for _, rec := range recs {
1595+
totalSamples += rec.NumRows()
1596+
}
1597+
require.Equal(t, int64(5), totalSamples, "Should have 5 samples with only runtime.* frames")
1598+
1599+
// Validate the remaining frames all start with "runtime."
1600+
for _, rec := range recs {
1601+
r := profile.NewRecordReader(rec)
1602+
for i := 0; i < int(rec.NumRows()); i++ {
1603+
lOffsetStart, lOffsetEnd := r.Locations.ValueOffsets(i)
1604+
firstStart, _ := r.Lines.ValueOffsets(int(lOffsetStart))
1605+
_, lastEnd := r.Lines.ValueOffsets(int(lOffsetEnd - 1))
1606+
1607+
for k := int(firstStart); k < int(lastEnd); k++ {
1608+
if r.Line.IsValid(k) {
1609+
fnIndex := r.LineFunctionNameIndices.Value(k)
1610+
functionName := string(r.LineFunctionNameDict.Value(int(fnIndex)))
1611+
1612+
// All remaining frames must start with "runtime."
1613+
require.True(t, strings.HasPrefix(strings.ToLower(functionName), "runtime."),
1614+
"All remaining frames should start with 'runtime.', found: %s", functionName)
1615+
}
1616+
}
1617+
}
1618+
}
1619+
}
1620+
1621+
func TestFrameFilterFunctionNameNotStartsWith(t *testing.T) {
1622+
mem := memory.NewCheckedAllocator(memory.DefaultAllocator)
1623+
defer mem.AssertSize(t, 0)
1624+
1625+
originalRecords, cleanup := createTestProfileData(mem)
1626+
defer cleanup()
1627+
1628+
// Test frame filter: keep only frames NOT starting with "runtime."
1629+
// Expected: All 5 samples remain, but runtime.* frames are filtered out
1630+
recs, _, err := FilterProfileData(
1631+
context.Background(),
1632+
noop.NewTracerProvider().Tracer(""),
1633+
mem,
1634+
originalRecords,
1635+
[]*pb.Filter{
1636+
{
1637+
Filter: &pb.Filter_FrameFilter{
1638+
FrameFilter: &pb.FrameFilter{
1639+
Filter: &pb.FrameFilter_Criteria{
1640+
Criteria: &pb.FilterCriteria{
1641+
FunctionName: &pb.StringCondition{
1642+
Condition: &pb.StringCondition_NotStartsWith{
1643+
NotStartsWith: "runtime.",
1644+
},
1645+
},
1646+
},
1647+
},
1648+
},
1649+
},
1650+
},
1651+
},
1652+
)
1653+
require.NoError(t, err)
1654+
defer func() {
1655+
for _, r := range recs {
1656+
r.Release()
1657+
}
1658+
}()
1659+
1660+
// Should have all 5 samples remaining (with runtime frames filtered out)
1661+
totalSamples := int64(0)
1662+
for _, rec := range recs {
1663+
totalSamples += rec.NumRows()
1664+
}
1665+
require.Equal(t, int64(5), totalSamples, "Should have 5 samples with runtime.* frames filtered out")
1666+
1667+
// Validate none of the remaining frames start with "runtime."
1668+
for _, rec := range recs {
1669+
r := profile.NewRecordReader(rec)
1670+
for i := 0; i < int(rec.NumRows()); i++ {
1671+
lOffsetStart, lOffsetEnd := r.Locations.ValueOffsets(i)
1672+
firstStart, _ := r.Lines.ValueOffsets(int(lOffsetStart))
1673+
_, lastEnd := r.Lines.ValueOffsets(int(lOffsetEnd - 1))
1674+
1675+
validFrameCount := 0
1676+
for k := int(firstStart); k < int(lastEnd); k++ {
1677+
if r.Line.IsValid(k) {
1678+
fnIndex := r.LineFunctionNameIndices.Value(k)
1679+
functionName := string(r.LineFunctionNameDict.Value(int(fnIndex)))
1680+
1681+
// All remaining frames must NOT start with "runtime."
1682+
require.False(t, strings.HasPrefix(strings.ToLower(functionName), "runtime."),
1683+
"All remaining frames should NOT start with 'runtime.', found: %s", functionName)
1684+
validFrameCount++
1685+
}
1686+
}
1687+
1688+
// Should have at least 1 frame remaining after filtering out runtime frames
1689+
require.Greater(t, validFrameCount, 0, "Sample should have at least 1 non-runtime.* frame remaining")
1690+
}
1691+
}
1692+
}

proto/parca/query/v1alpha1/query.proto

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,10 @@ message StringCondition {
319319
string contains = 3;
320320
// not_contains matches strings that do not contain the specified substring
321321
string not_contains = 4;
322+
// starts_with matches strings that start with the specified prefix
323+
string starts_with = 5;
324+
// not_starts_with matches strings that do not start with the specified prefix
325+
string not_starts_with = 6;
322326
}
323327
}
324328

ui/packages/shared/client/src/parca/query/v1alpha1/query.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,22 @@ export interface StringCondition {
616616
* @generated from protobuf field: string not_contains = 4
617617
*/
618618
notContains: string;
619+
} | {
620+
oneofKind: "startsWith";
621+
/**
622+
* starts_with matches strings that start with the specified prefix
623+
*
624+
* @generated from protobuf field: string starts_with = 5
625+
*/
626+
startsWith: string;
627+
} | {
628+
oneofKind: "notStartsWith";
629+
/**
630+
* not_starts_with matches strings that do not start with the specified prefix
631+
*
632+
* @generated from protobuf field: string not_starts_with = 6
633+
*/
634+
notStartsWith: string;
619635
} | {
620636
oneofKind: undefined;
621637
};

ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/index.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,20 @@ const stringMatchTypeItems: SelectItem[] = [
150150
expanded: <>Not Contains</>,
151151
},
152152
},
153+
{
154+
key: 'starts_with',
155+
element: {
156+
active: <>Starts With</>,
157+
expanded: <>Starts With</>,
158+
},
159+
},
160+
{
161+
key: 'not_starts_with',
162+
element: {
163+
active: <>Not Starts With</>,
164+
expanded: <>Not Starts With</>,
165+
},
166+
},
153167
];
154168

155169
const numberMatchTypeItems: SelectItem[] = [

ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFilters.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface ProfileFilter {
2323
id: string;
2424
type?: 'stack' | 'frame' | string; // string allows preset keys
2525
field?: 'function_name' | 'binary' | 'system_name' | 'filename' | 'address' | 'line_number';
26-
matchType?: 'equal' | 'not_equal' | 'contains' | 'not_contains';
26+
matchType?: 'equal' | 'not_equal' | 'contains' | 'not_contains' | 'starts_with' | 'not_starts_with';
2727
value: string;
2828
}
2929

@@ -35,6 +35,12 @@ const createStringCondition = (matchType: string, value: string): StringConditio
3535
? {oneofKind: 'notEqual' as const, notEqual: value}
3636
: matchType === 'contains'
3737
? {oneofKind: 'contains' as const, contains: value}
38+
: matchType === 'not_contains'
39+
? {oneofKind: 'notContains' as const, notContains: value}
40+
: matchType === 'starts_with'
41+
? {oneofKind: 'startsWith' as const, startsWith: value}
42+
: matchType === 'not_starts_with'
43+
? {oneofKind: 'notStartsWith' as const, notStartsWith: value}
3844
: {oneofKind: 'notContains' as const, notContains: value},
3945
});
4046

0 commit comments

Comments
 (0)