Skip to content

Commit 4bcc52f

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 c75b87f commit 4bcc52f

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
@@ -1003,6 +1003,12 @@ func matchesStringCondition(value []byte, condition *pb.StringCondition) bool {
10031003
case *pb.StringCondition_NotContains:
10041004
target := bytes.ToLower([]byte(condition.GetNotContains()))
10051005
return !bytes.Contains(valueLower, target)
1006+
case *pb.StringCondition_StartsWith:
1007+
target := bytes.ToLower([]byte(condition.GetStartsWith()))
1008+
return bytes.HasPrefix(valueLower, target)
1009+
case *pb.StringCondition_NotStartsWith:
1010+
target := bytes.ToLower([]byte(condition.GetNotStartsWith()))
1011+
return !bytes.HasPrefix(valueLower, target)
10061012
default:
10071013
return true
10081014
}

pkg/query/multiple_filters_test.go

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

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)