Skip to content

Commit 552a519

Browse files
committed
Bump version to 0.9.4-dev in __init__.py and package.json; enhance error handling in filter and select commands
1 parent eaa611c commit 552a519

5 files changed

Lines changed: 76 additions & 14 deletions

File tree

delve/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
# This file is part of the Delve project, which is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).
33
# See the LICENSE file in the root of this repository for details.
44

5-
__version__ = "0.9.3-dev"
5+
__version__ = "0.9.4-dev"

doc/user/Searching_Filtering_and_More.md

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ counterparts.
4141
- `dedup`: Remove duplicate entries from the result set.
4242
- `sort`: Sort the result set based on specified criteria.
4343

44+
`select` and `filter` can walk nested fields with double-underscore paths
45+
(eg. `extracted_fields__auth__display_name`), which is useful when different
46+
sourcetypes populate `extracted_fields` with different shapes. A path segment
47+
that's missing on a given event resolves to `None` rather than raising an
48+
error, and a `filter` predicate (`icontains`, `startswith`, etc.) that can't
49+
be evaluated against `None` is treated as not matching, instead of aborting
50+
the whole search - the same way a database `NULL` behaves for most lookups.
51+
4452
## Visualization Commands
4553
Visualization commands take a result set and generate a table, chart, or other visualization. These commands help you to display your data in a meaningful and interactive way.
4654

@@ -61,25 +69,28 @@ Here are some example commands to use the `qs_group_by` functionality:
6169

6270
```bash
6371
# Group records by a single field and calculate the average of another field
64-
search index=test | qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar, IntegerField))
72+
search index=test | qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar,IntegerField))
6573

6674
# Group records by multiple fields and count the number of records in each group
6775
search index=test | qs_group_by extracted_fields__foo extracted_fields__bar count=Count('id')
6876

6977
# Group records by a field and filter the groups based on an aggregate condition
70-
search index=test | qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar, IntegerField)) | qs_having avg_bar__gt=1
78+
search index=test | qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar,IntegerField)) | qs_having avg_bar__gt=1
7179

7280
# Filiter records, select nested fields and calculate the difference in days between the created field (index timestamp) and the timestamp field (timestamp from the text of the event)
7381
search
7482
| qs_values created event_host=KT(extracted_fields__host) timestamp=KT(extracted_fields__timestamp) message=KT(extracted_fields__msg)
7583
| qs_annotate index_delay=ExpressionWrapper((F(created)-Cast(timestamp,DateTimeField))/60/60/24,output_field=DurationField)
7684
```
7785

86+
> **Note:** these expressions have no spaces inside the parentheses on purpose - see
87+
> [Quoting Multi-Word Arguments](#quoting-multi-word-arguments) below for why.
88+
7889
### Explanation
7990

80-
- `qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar, IntegerField))`: Groups records by the `extracted_fields__foo` field and calculates the average of the `extracted_fields__bar` field, casting it to an integer.
91+
- `qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar,IntegerField))`: Groups records by the `extracted_fields__foo` field and calculates the average of the `extracted_fields__bar` field, casting it to an integer.
8192
- `qs_group_by extracted_fields__foo extracted_fields__bar count=Count('id')`: Groups records by the `extracted_fields__foo` and `extracted_fields__bar` fields and counts the number of records in each group.
82-
- `qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar, IntegerField)) | qs_having avg_bar__gt=1`: Groups records by the `extracted_fields__foo` field, calculates the average of the `extracted_fields__bar` field, and filters the groups to include only those with an average greater than 1.
93+
- `qs_group_by extracted_fields__foo avg_bar=Avg(Cast(extracted_fields__bar,IntegerField)) | qs_having avg_bar__gt=1`: Groups records by the `extracted_fields__foo` field, calculates the average of the `extracted_fields__bar` field, and filters the groups to include only those with an average greater than 1.
8394

8495
The `qs_group_by` command is a powerful tool for aggregating and analyzing data within Delve, allowing you to perform complex queries and calculations on grouped records.
8596

@@ -236,6 +247,26 @@ The `qs_*` commands are designed to modify a Django QuerySet using methods on th
236247
237248
When you use `qs_*` commands, the arguments are parsed into expressions that Django can understand. This means you can use functions and complex expressions to manipulate your data.
238249
250+
Each argument token is run through Python's `ast.parse` so that function calls
251+
(`Cast(...)`, `Trunc(...)`, `F(...)`, etc.) and arithmetic can be used directly.
252+
This has two consequences worth knowing before you write one of these commands:
253+
254+
#### Quoting Multi-Word Arguments
255+
256+
A query line is first split into pipeline stages on `|`, then each stage is
257+
tokenized on whitespace (via `shlex.split`) before argument parsing even
258+
starts. That means:
259+
260+
- **An expression containing `, ` (a comma followed by a space) gets split
261+
into two tokens and fails with a `SyntaxError`.** Either drop the space
262+
(`Cast(field,IntegerField)`) or wrap the whole expression in quotes:
263+
`qs_annotate status_code="Cast(KT('extracted_fields__code'),IntegerField)"`.
264+
- **A value that should be treated as a literal string but starts with `-`
265+
(eg. a descending sort field) looks like a CLI flag and needs to be a quoted
266+
Python string literal:** `qs_order_by "'-count'"`, not `qs_order_by -count`
267+
(the latter gets parsed as unary negation of the name `count` and raises a
268+
`TypeError`).
269+
239270
### Available Field Classes
240271
241272
The following Django model field classes are available for use in query annotations and transformations (Can be passed to Cast, ExpressionWrapper, etc.):
@@ -524,7 +555,7 @@ Here are some example usages of the `qs_*` commands, starting with the `search`
524555
525556
```bash
526557
# Search for events and annotate them with additional fields
527-
search field1=value1 | qs_annotate new_field1=Lower(old_field1) new_field2=Concat(field2, Value('suffix'))
558+
search field1=value1 | qs_annotate new_field1=Lower(old_field1) new_field2="Concat(field2, Value('suffix'))"
528559
529560
# Search for events, filter them, and then count the results
530561
search field1=value1 | qs_filter field2__gt=10 | qs_count
@@ -534,6 +565,20 @@ search field1=value1 | qs_aggregate total=Sum(field2) average=Avg(field2) | qs_a
534565
535566
# Search for events and retrieve dates from the queryset
536567
search field1=value1 | qs_dates field=date_field kind=month order=ASC
568+
569+
# Group by a deeply nested JSON field, threshold the groups, and sort
570+
# descending - useful for things like flagging repeated failed logins
571+
search --last-day index=keycloak extracted_fields__type=LOGIN_ERROR
572+
| qs_group_by extracted_fields__user_id extracted_fields__ip_address count=Count('id')
573+
| qs_having count__gte=5
574+
| qs_order_by "'-count'"
575+
576+
# Same idea as a bar chart, split into one series per index and bucketed
577+
# by hour
578+
search --last-day
579+
| qs_annotate hour=Trunc(created,kind='hour',output_field=DateTimeField)
580+
| qs_group_by hour index count=Count('id')
581+
| chart --type bar --x-field hour --y-field count --by-field index --time-x hour
537582
```
538583
539584
### Notes

events/search_commands/filter.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,12 @@ def resolve_field_lookup(expression: str, item: Dict[str, Any]) -> Any:
4747
log.debug(f"ret: {ret}, subexpr: {subexpr}")
4848
try:
4949
ret = ret[subexpr]
50-
except KeyError:
50+
except (KeyError, TypeError):
51+
# A missing key at any depth (or a None left over from one)
52+
# means the rest of the path can't resolve either - stop here
53+
# instead of indexing into None on the next segment.
5154
ret = None
52-
continue
55+
break
5356
log.debug(f"Built ret: {ret}")
5457
return expression.split("__")[-1], ret
5558

@@ -114,14 +117,23 @@ def filter(request: HttpRequest, events: Union[QuerySet, List[Dict[str, Any]]],
114117
if not args.no_cast:
115118
rhs = cast(rhs)
116119
log.debug(f"Cast rhs: {rhs} to {type(rhs)}")
120+
try:
121+
matched = predicate(lhs, rhs)
122+
except (AttributeError, TypeError):
123+
# lhs is missing/None (eg. the field doesn't exist on this
124+
# event) and the predicate assumes a real value (icontains,
125+
# startswith, gt, etc.) - treat that the same as a database
126+
# NULL: it doesn't match, rather than crashing the search.
127+
log.debug(f"{predicate} raised on lhs={lhs!r}, rhs={rhs!r}; treating as no match")
128+
matched = False
117129
if negate:
118-
if predicate(lhs, rhs):
119-
log.debug(f"negated {predicate}({lhs}, {rhs}) returned: {predicate(lhs, rhs)}")
130+
if matched:
131+
log.debug(f"negated {predicate}({lhs}, {rhs}) returned: {matched}")
120132
allowed = False
121133
break
122134
else:
123-
if not predicate(lhs, rhs):
124-
log.debug(f"{predicate}({lhs}, {rhs}) returned: {predicate(lhs, rhs)}")
135+
if not matched:
136+
log.debug(f"{predicate}({lhs}, {rhs}) returned: {matched}")
125137
allowed = False
126138
break
127139
if allowed:

events/search_commands/select.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,19 @@ def select(request: HttpRequest, events: Union[QuerySet, List[Dict[str, Any]]],
4848
item = event
4949
for segment in field.split("__"):
5050
log.debug(f"Trying segment: {segment}")
51+
if item is None:
52+
break
5153
try:
5254
item = item[segment]
5355
log.debug(f"Found segment {segment}: {item}")
5456
except KeyError:
5557
log.warning(f"Received KeyError, field {segment} not in {item}")
5658
item = None
5759
except TypeError:
58-
item = getattr(item, segment)
60+
try:
61+
item = getattr(item, segment)
62+
except AttributeError:
63+
item = None
5964
row[field] = item
6065
log.debug(f"row[field]: {row[field]}")
6166
log.debug(f"Yielding row: {row}")

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "delve",
3-
"version": "0.9.3-dev",
3+
"version": "0.9.4-dev",
44
"description": "A data tool",
55
"private": true,
66
"scripts": {

0 commit comments

Comments
 (0)