Merge pull request #23 from Coffey-Labs/feat/processing-conformance-suite
Build the Phase 8 conformance corpus
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
name: Processing conformance corpus
|
||||
|
||||
# /processing/conformance/cases IS the Phase 8 rule language spec: two
|
||||
# implementations (Rust in the agent, Go at ingest) will each be held to
|
||||
# it, and two hand-written implementations of one language diverge
|
||||
# unless something shared pins them.
|
||||
#
|
||||
# Neither implementation exists yet, so nothing here executes rules.
|
||||
# What this checks is that the corpus stays well-formed in the meantime
|
||||
# -- known actions, addressable fields, compilable patterns, no case
|
||||
# quietly depending on record_id. Cheap insurance against the spec
|
||||
# rotting during the gap between writing it and building against it.
|
||||
#
|
||||
# Its own workflow, following web-routes.yml's reasoning: license-
|
||||
# compliance.yml already carries one unrelated check for historical
|
||||
# reasons, and that is not worth compounding.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
conformance-corpus:
|
||||
name: processing conformance corpus check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 processing/conformance/validate.py
|
||||
@@ -79,6 +79,7 @@ metadata/ PostgreSQL schema and migrations
|
||||
web/ SvelteKit frontend
|
||||
cli/ cairnobsctl
|
||||
proto/ gRPC service definitions
|
||||
processing/ Phase 8 rule language spec and conformance corpus (no implementation yet)
|
||||
terraform/ Terraform provider
|
||||
enterprise/ SSO, multi-tenancy, per-tenant provisioning (off the roadmap)
|
||||
deploy/ Helm charts and Kubernetes operator
|
||||
|
||||
@@ -153,15 +153,24 @@ the same thing in both places" is the whole promise, and two
|
||||
hand-written implementations will diverge — not maybe, eventually.
|
||||
|
||||
The deliverable that prevents it is a **language-neutral conformance
|
||||
suite**: a directory of cases, each an input record, a rule set, and the
|
||||
expected output record, in JSON. Both implementations run it in their own
|
||||
CI. A case is added for every bug found in either.
|
||||
suite**: a directory of cases, each a rule set, a sequence of input
|
||||
records, and the records expected out, in JSON. Both implementations run
|
||||
it in their own CI. A case is added for every bug found in either.
|
||||
|
||||
This is the same discipline `/hack`'s fixtures already apply to ingest
|
||||
shapes, applied to semantics instead. It should be built **first**, not
|
||||
last — the suite is the specification, and the prose above is a summary
|
||||
of it.
|
||||
|
||||
**Built:** [`/processing`](../processing/README.md), 38 cases. Nothing
|
||||
executes them yet, since neither implementation exists; a structural
|
||||
validator runs in CI so the corpus cannot rot in the meantime. Writing
|
||||
the cases first has already paid for itself — it forced two determinism
|
||||
decisions that prose had left vague (see that README's "Two determinism
|
||||
decisions the suite forces"), and it made the absence of an
|
||||
`aggregate_count` answer concrete enough that the validator rejects any
|
||||
case using it.
|
||||
|
||||
## Decision 4: distribution reuses the channel that exists
|
||||
|
||||
Fleet management already delivers desired state
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# processing
|
||||
|
||||
The Phase 8 processing rule language: its specification, and the
|
||||
conformance suite that *is* that specification.
|
||||
|
||||
> **Status:** the suite exists; the implementations do not. Nothing in
|
||||
> Phase 8 is built. See
|
||||
> [`/docs/phase-8-processing-design.md`](../docs/phase-8-processing-design.md)
|
||||
> for the design and its open questions.
|
||||
|
||||
## Why this is a top-level directory
|
||||
|
||||
Rules run in two places, in two languages: the Rust agent
|
||||
(`/agent`) and the Go ingest tier (`/ingest`). Neither owns the
|
||||
definition. This is the same shape as `/proto` — a language-neutral
|
||||
contract that several modules consume and none of them is the source of
|
||||
truth for.
|
||||
|
||||
## Why the cases are the specification
|
||||
|
||||
Two hand-written implementations of one language will diverge. Not
|
||||
maybe, eventually. The only thing that reliably prevents it is a shared
|
||||
corpus both must satisfy, written down before either exists.
|
||||
|
||||
So the prose below is a summary of `conformance/cases/`, not the other
|
||||
way round. When they disagree, the cases win, and the prose is the bug.
|
||||
|
||||
## Running it
|
||||
|
||||
Nothing executes the cases yet, because neither implementation exists.
|
||||
What runs today is a structural check that every case is well-formed —
|
||||
valid JSON, known actions, no unknown keys, declared fields that exist:
|
||||
|
||||
```sh
|
||||
python3 processing/conformance/validate.py
|
||||
```
|
||||
|
||||
That is worth having on its own: it stops the corpus rotting between now
|
||||
and the first implementation, and it fails loudly if someone adds a case
|
||||
using an action the spec does not define.
|
||||
|
||||
Each implementation is expected to add a test that walks
|
||||
`conformance/cases/*.json`, feeds `inputs` through `rules`, and asserts
|
||||
the emitted records equal `expect`. Those runners are part of building
|
||||
each side, not part of this directory.
|
||||
|
||||
## Case format
|
||||
|
||||
One JSON object per file:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "drop_discards_the_record",
|
||||
"description": "A matched drop emits nothing at all.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [{"field": "message", "op": "eq", "value": "noise"}],
|
||||
"actions": [{"action": "drop"}]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{"timestamp_unix_nano": 1000, "host": "h1", "service": "s1",
|
||||
"severity": "SEVERITY_INFO", "message": "noise", "attributes": {}}
|
||||
],
|
||||
"expect": []
|
||||
}
|
||||
```
|
||||
|
||||
`inputs` is always a list, even for a single record, because windowed
|
||||
actions need a sequence. `expect` is the records emitted, in order.
|
||||
|
||||
### Records
|
||||
|
||||
Mirrors `LogRecord` in `/proto/sentry/logs/v1/logs.proto`, snake_case,
|
||||
with `severity` as the enum's name string.
|
||||
|
||||
`record_id` is deliberately absent from every case. The agent never sets
|
||||
it, and whether an ingest-side rule can see one depends on where in the
|
||||
ingest path rules run — an open question. No case may depend on it, so
|
||||
that decision stays free.
|
||||
|
||||
### Matching
|
||||
|
||||
A rule matches when **every** clause in `match` holds. An empty `match`
|
||||
matches every record.
|
||||
|
||||
| `op` | Meaning |
|
||||
|---|---|
|
||||
| `eq` / `ne` | exact string equality |
|
||||
| `contains` | substring |
|
||||
| `prefix` / `suffix` | string boundary |
|
||||
| `regex` | linear-time regex, unanchored |
|
||||
| `exists` / `not_exists` | field present (no `value`) |
|
||||
|
||||
Addressable fields: `message`, `host`, `service`, `severity`, and
|
||||
`attributes.<key>`. A field that does not exist compares as absent, not
|
||||
as empty string — `eq ""` does not match a missing attribute.
|
||||
|
||||
### Actions
|
||||
|
||||
Applied in order. A `drop` ends processing for that record immediately;
|
||||
no later action or rule runs.
|
||||
|
||||
| Action | Parameters |
|
||||
|---|---|
|
||||
| `drop` | — |
|
||||
| `drop_fields` | `fields` |
|
||||
| `keep_fields` | `fields` (attributes only; never removes top-level fields) |
|
||||
| `rename` | `from`, `to` |
|
||||
| `derive` | `field`, and one of `value` / `from_field` |
|
||||
| `mask` | `field`, `pattern`, `replacement` |
|
||||
| `parse_json` | `field` (default `message`), optional `prefix` |
|
||||
| `parse_regex` | `field`, `pattern` (named captures become attributes) |
|
||||
| `sample` | `keep_one_in` |
|
||||
| `suppress_duplicates` | `window_ms`, optional `key_fields` |
|
||||
|
||||
Rules are evaluated in the order given. Every matching rule's actions
|
||||
apply, to the record as left by the rule before it.
|
||||
|
||||
### Two determinism decisions the suite forces
|
||||
|
||||
Conformance testing cannot assert on nondeterminism, so two things that
|
||||
are usually left vague are pinned here:
|
||||
|
||||
**`sample` is counter-based, not random.** `keep_one_in: 3` keeps the
|
||||
first record and every third thereafter, per rule, per process. Random
|
||||
sampling is statistically nicer and untestable; a counter is testable and
|
||||
close enough at volume.
|
||||
|
||||
**Windows are measured on record timestamps, not wall-clock.** A
|
||||
`suppress_duplicates` window of 5000ms compares
|
||||
`timestamp_unix_nano` values, so replaying the same input always gives
|
||||
the same output regardless of how fast the test runs. This also means the
|
||||
behaviour is correct under backfill, which wall-clock windows are not.
|
||||
|
||||
### `aggregate_count` has no cases, deliberately
|
||||
|
||||
The design lists it as an action but does not answer what it emits, or
|
||||
what a query that is not expecting a synthetic record sees. Writing cases
|
||||
now would invent that answer by accident and freeze it. It stays
|
||||
unspecified until that question is decided —
|
||||
[`phase-8-processing-design.md`](../docs/phase-8-processing-design.md)
|
||||
open question 5.
|
||||
|
||||
## Adding a case
|
||||
|
||||
One behaviour per file, named for the behaviour rather than the action.
|
||||
A case is added for every bug found in either implementation — that is
|
||||
the mechanism that keeps the two from drifting, and it only works if the
|
||||
case lands with the fix.
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "actions_apply_in_order",
|
||||
"description": "The second action sees what the first produced: parse then drop one of the parsed fields.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_json",
|
||||
"field": "message"
|
||||
},
|
||||
{
|
||||
"action": "drop_fields",
|
||||
"fields": [
|
||||
"attributes.secret"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"keep\":\"k\",\"secret\":\"s\"}",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"keep\":\"k\",\"secret\":\"s\"}",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "derive_copies_from_another_field",
|
||||
"description": "derive with from_field copies, leaving the source in place.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.origin_host",
|
||||
"from_field": "host"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "web-01",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "web-01",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"origin_host": "web-01"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "derive_overwrites_an_existing_value",
|
||||
"description": "derive is a set, not an insert-if-absent.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.env",
|
||||
"value": "dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"env": "prod"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"env": "dev"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "derive_sets_a_literal_value",
|
||||
"description": "derive with value writes a constant.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.env",
|
||||
"value": "dev"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"env": "dev"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "drop_fields_on_absent_attribute_is_not_an_error",
|
||||
"description": "Removing something that is not there succeeds and changes nothing.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop_fields",
|
||||
"fields": [
|
||||
"attributes.nope"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "drop_fields_removes_only_named_attributes",
|
||||
"description": "drop_fields removes the named attributes and leaves the rest alone.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop_fields",
|
||||
"fields": [
|
||||
"attributes.secret",
|
||||
"attributes.gone"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"secret": "s",
|
||||
"gone": "g",
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "drop_stops_later_actions_in_the_same_rule",
|
||||
"description": "Nothing after a drop runs; the record is gone, so a subsequent derive cannot resurrect it.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
},
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.late",
|
||||
"value": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": []
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "drop_stops_later_rules",
|
||||
"description": "A drop in rule 1 means rule 2 never sees the record.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "eq",
|
||||
"value": "gone"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.seen",
|
||||
"value": "yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "gone",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "stays",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "stays",
|
||||
"attributes": {
|
||||
"seen": "yes"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "empty_rule_set_is_a_pass_through",
|
||||
"description": "No rules means every record is emitted exactly as it arrived. This is the state an agent falls back to when it refuses a rule set.",
|
||||
"rules": [],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"k": "v"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"k": "v"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "keep_fields_is_an_attribute_allowlist",
|
||||
"description": "keep_fields drops every attribute not named, and never touches top-level fields.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "keep_fields",
|
||||
"fields": [
|
||||
"attributes.keep"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h9",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "message survives",
|
||||
"attributes": {
|
||||
"keep": "k",
|
||||
"a": "1",
|
||||
"b": "2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h9",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "message survives",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "mask_leaves_non_matching_records_untouched",
|
||||
"description": "A mask whose pattern does not hit changes nothing at all.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "mask",
|
||||
"field": "message",
|
||||
"pattern": "[0-9]{16}",
|
||||
"replacement": "[REDACTED]"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "nothing sensitive here",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "nothing sensitive here",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "mask_replaces_every_occurrence",
|
||||
"description": "mask replaces all matches in the field, not just the first -- redaction that stops at the first hit is a leak.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "mask",
|
||||
"field": "message",
|
||||
"pattern": "[0-9]{16}",
|
||||
"replacement": "[REDACTED]"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "card 4111111111111111 and 5500000000000004 seen",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "card [REDACTED] and [REDACTED] seen",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "match_absent_attribute_is_not_empty_string",
|
||||
"description": "A missing attribute must not compare equal to \"\" -- absence and emptiness are different.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "attributes.tier",
|
||||
"op": "eq",
|
||||
"value": ""
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "missing",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "empty",
|
||||
"attributes": {
|
||||
"tier": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "missing",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "match_all_clauses_must_hold",
|
||||
"description": "Multiple clauses are AND, never OR.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "host",
|
||||
"op": "eq",
|
||||
"value": "h1"
|
||||
},
|
||||
{
|
||||
"field": "severity",
|
||||
"op": "eq",
|
||||
"value": "SEVERITY_ERROR"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_ERROR",
|
||||
"message": "both",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "host only",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h2",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_ERROR",
|
||||
"message": "sev only",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "host only",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h2",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_ERROR",
|
||||
"message": "sev only",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "match_contains_prefix_suffix",
|
||||
"description": "The three substring operators, each matching exactly one of three records.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "prefix",
|
||||
"value": "start"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.hit",
|
||||
"value": "prefix"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "suffix",
|
||||
"value": "end"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.hit",
|
||||
"value": "suffix"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "contains",
|
||||
"value": "middle"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.hit",
|
||||
"value": "contains"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "start of line",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "line to end",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a middle b",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "start of line",
|
||||
"attributes": {
|
||||
"hit": "prefix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "line to end",
|
||||
"attributes": {
|
||||
"hit": "suffix"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a middle b",
|
||||
"attributes": {
|
||||
"hit": "contains"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "match_empty_matcher_matches_everything",
|
||||
"description": "An empty match list is 'always', not 'never'.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "anything",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "anything else",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": []
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "match_eq_is_exact_not_substring",
|
||||
"description": "eq compares the whole value; a superstring does not match.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "eq",
|
||||
"value": "noise"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "noise",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "noise and more",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "noise and more",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "match_exists_on_attribute",
|
||||
"description": "exists/not_exists test presence and take no value.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "attributes.trace_id",
|
||||
"op": "exists"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.traced",
|
||||
"value": "yes"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"trace_id": "abc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"trace_id": "abc",
|
||||
"traced": "yes"
|
||||
}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "match_ne_matches_when_different",
|
||||
"description": "ne is the complement of eq, including for absent fields.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "service",
|
||||
"op": "ne",
|
||||
"value": "keep-me"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "keep-me",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "other",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "keep-me",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "match_on_severity_by_enum_name",
|
||||
"description": "severity is matched by its enum name string, not its number.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "severity",
|
||||
"op": "eq",
|
||||
"value": "SEVERITY_DEBUG"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_DEBUG",
|
||||
"message": "debug",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "info",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "info",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "match_regex_is_unanchored",
|
||||
"description": "regex matches anywhere in the value unless the pattern anchors itself.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "regex",
|
||||
"value": "conn(ect|ected)"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "client connected ok",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "disconnected",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "unrelated",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 3000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "unrelated",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "non_matching_rule_leaves_the_record_untouched",
|
||||
"description": "A rule that does not match is not a rule that empties things.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "host",
|
||||
"op": "eq",
|
||||
"value": "other"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop_fields",
|
||||
"fields": [
|
||||
"attributes.keep"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"keep": "k"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "parse_json_lifts_top_level_keys_to_attributes",
|
||||
"description": "parse_json on message adds string attributes and leaves message intact.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_json",
|
||||
"field": "message"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"level\":\"warn\",\"code\":\"503\"}",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"level\":\"warn\",\"code\":\"503\"}",
|
||||
"attributes": {
|
||||
"level": "warn",
|
||||
"code": "503"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "parse_json_on_invalid_json_is_a_no_op",
|
||||
"description": "A record that is not JSON passes through unchanged rather than being dropped or erroring -- same schema-on-read fallback the agent parser already uses.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_json",
|
||||
"field": "message"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "this is not json {",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "this is not json {",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "parse_json_prefixes_keys_when_asked",
|
||||
"description": "prefix namespaces the lifted keys so they cannot collide with existing attributes.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_json",
|
||||
"field": "message",
|
||||
"prefix": "json."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"code\":\"503\"}",
|
||||
"attributes": {
|
||||
"code": "keep-me"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "{\"code\":\"503\"}",
|
||||
"attributes": {
|
||||
"code": "keep-me",
|
||||
"json.code": "503"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "parse_regex_named_captures_become_attributes",
|
||||
"description": "Only named groups are lifted; unnamed groups are ignored.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_regex",
|
||||
"field": "message",
|
||||
"pattern": "user=(?P<user>[a-z]+) code=(?P<code>[0-9]+)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "user=alice code=403 extra",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "user=alice code=403 extra",
|
||||
"attributes": {
|
||||
"user": "alice",
|
||||
"code": "403"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "parse_regex_that_does_not_match_is_a_no_op",
|
||||
"description": "A non-matching pattern adds nothing and does not drop the record.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "parse_regex",
|
||||
"field": "message",
|
||||
"pattern": "user=(?P<user>[a-z]+)"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "no user here",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "no user here",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "rename_from_absent_field_is_a_no_op",
|
||||
"description": "Renaming something absent does not create an empty destination.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "rename",
|
||||
"from": "attributes.old",
|
||||
"to": "attributes.new"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"other": "v"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"other": "v"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "rename_moves_an_attribute",
|
||||
"description": "rename removes the source key and creates the destination.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "rename",
|
||||
"from": "attributes.old",
|
||||
"to": "attributes.new"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"old": "v"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {
|
||||
"new": "v"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "rules_apply_in_sequence_to_the_same_record",
|
||||
"description": "Every matching rule runs, each seeing the record as the previous rule left it.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.a",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "attributes.a",
|
||||
"op": "eq",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "derive",
|
||||
"field": "attributes.b",
|
||||
"value": "2"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "x",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "x",
|
||||
"attributes": {
|
||||
"a": "1",
|
||||
"b": "2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "sample_counts_only_matching_records",
|
||||
"description": "The counter advances on records the rule matched, so an unrelated record between two matches does not consume a slot.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "service",
|
||||
"op": "eq",
|
||||
"value": "noisy"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "sample",
|
||||
"keep_one_in": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "noisy",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "n1",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "quiet",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "other",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000000,
|
||||
"host": "h1",
|
||||
"service": "noisy",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "n2",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000,
|
||||
"host": "h1",
|
||||
"service": "noisy",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "n3",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "noisy",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "n1",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "quiet",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "other",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000,
|
||||
"host": "h1",
|
||||
"service": "noisy",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "n3",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"name": "sample_keeps_the_first_then_every_nth",
|
||||
"description": "sample is counter-based, not random: keep_one_in 3 keeps records 1, 4, 7. Random sampling is untestable; a counter is testable and close enough at volume.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "sample",
|
||||
"keep_one_in": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m1",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m2",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m3",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m4",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 5000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m5",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 6000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m6",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 7000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m7",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m1",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m4",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 7000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "m7",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "sample_of_one_keeps_everything",
|
||||
"description": "keep_one_in 1 is a no-op, not an off switch.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "sample",
|
||||
"keep_one_in": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "suppress_duplicates_collapses_within_the_window",
|
||||
"description": "Identical messages inside the window collapse to the first; the window is measured on record timestamps, not wall-clock, so replay is deterministic and backfill behaves.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "suppress_duplicates",
|
||||
"window_ms": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4999000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "suppress_duplicates_emits_again_after_the_window",
|
||||
"description": "A duplicate outside the window is a new first occurrence.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "suppress_duplicates",
|
||||
"window_ms": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 5000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 5000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "suppress_duplicates_honours_key_fields",
|
||||
"description": "With key_fields, identical messages from different hosts are not duplicates of each other.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "suppress_duplicates",
|
||||
"window_ms": 5000,
|
||||
"key_fields": [
|
||||
"host",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h2",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h2",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "same",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "suppress_duplicates_keys_on_message_by_default",
|
||||
"description": "Two different messages never suppress each other.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [],
|
||||
"actions": [
|
||||
{
|
||||
"action": "suppress_duplicates",
|
||||
"window_ms": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "a",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "b",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "workstation_noise_reduction",
|
||||
"description": "The v1 acceptance shape, from real measured data: Discord repeating a byte-identical version string and a polling daemon repeating a fixed message, against one kernel message worth keeping. See /docs/phase-8-processing-design.md.",
|
||||
"rules": [
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "eq",
|
||||
"value": "Discord 1.0.155"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "drop"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": [
|
||||
{
|
||||
"field": "message",
|
||||
"op": "eq",
|
||||
"value": "Checking active tasks"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"action": "suppress_duplicates",
|
||||
"window_ms": 60000
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"timestamp_unix_nano": 0,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 1000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Checking active tasks",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 2000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 3000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Checking active tasks",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_WARN",
|
||||
"message": "[UFW BLOCK] IN=wlan0 SRC=172.16.16.1",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 5000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Discord 1.0.155",
|
||||
"attributes": {}
|
||||
}
|
||||
],
|
||||
"expect": [
|
||||
{
|
||||
"timestamp_unix_nano": 1000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_INFO",
|
||||
"message": "Checking active tasks",
|
||||
"attributes": {}
|
||||
},
|
||||
{
|
||||
"timestamp_unix_nano": 4000000000,
|
||||
"host": "h1",
|
||||
"service": "s1",
|
||||
"severity": "SEVERITY_WARN",
|
||||
"message": "[UFW BLOCK] IN=wlan0 SRC=172.16.16.1",
|
||||
"attributes": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Structural check for the processing conformance corpus.
|
||||
|
||||
The cases are the specification (see ../README.md), so they have to stay
|
||||
well-formed even while neither implementation exists to execute them.
|
||||
This validates shape only -- it does not run rules against records,
|
||||
because there is nothing yet to run them with.
|
||||
|
||||
Stdlib only, on purpose: this must be runnable anywhere without a
|
||||
dependency install, including from a bare CI container.
|
||||
|
||||
Exit 0 if every case is valid, 1 otherwise.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CASES = os.path.join(HERE, "cases")
|
||||
|
||||
CASE_KEYS = {"name", "description", "rules", "inputs", "expect"}
|
||||
RECORD_KEYS = {"timestamp_unix_nano", "host", "service", "severity",
|
||||
"message", "attributes"}
|
||||
|
||||
SEVERITIES = {"SEVERITY_UNSPECIFIED", "SEVERITY_TRACE", "SEVERITY_DEBUG",
|
||||
"SEVERITY_INFO", "SEVERITY_WARN", "SEVERITY_ERROR",
|
||||
"SEVERITY_FATAL"}
|
||||
|
||||
OPS_WITH_VALUE = {"eq", "ne", "contains", "prefix", "suffix", "regex"}
|
||||
OPS_WITHOUT_VALUE = {"exists", "not_exists"}
|
||||
|
||||
TOP_LEVEL_FIELDS = {"message", "host", "service", "severity"}
|
||||
|
||||
# action -> (required params, optional params)
|
||||
ACTIONS = {
|
||||
"drop": (set(), set()),
|
||||
"drop_fields": ({"fields"}, set()),
|
||||
"keep_fields": ({"fields"}, set()),
|
||||
"rename": ({"from", "to"}, set()),
|
||||
"derive": ({"field"}, {"value", "from_field"}),
|
||||
"mask": ({"field", "pattern", "replacement"}, set()),
|
||||
"parse_json": ({"field"}, {"prefix"}),
|
||||
"parse_regex": ({"field", "pattern"}, set()),
|
||||
"sample": ({"keep_one_in"}, set()),
|
||||
"suppress_duplicates": ({"window_ms"}, {"key_fields"}),
|
||||
}
|
||||
|
||||
# Deliberately absent from ACTIONS. The design does not say what it emits
|
||||
# or what a query that is not expecting a synthetic record sees, so a
|
||||
# case using it would invent that answer and freeze it by accident.
|
||||
UNSPECIFIED_ACTIONS = {"aggregate_count"}
|
||||
|
||||
|
||||
def valid_field(name):
|
||||
return name in TOP_LEVEL_FIELDS or name.startswith("attributes.")
|
||||
|
||||
|
||||
def check_record(rec, where, err):
|
||||
if not isinstance(rec, dict):
|
||||
return err(f"{where}: record must be an object")
|
||||
missing = RECORD_KEYS - set(rec)
|
||||
unknown = set(rec) - RECORD_KEYS
|
||||
if missing:
|
||||
err(f"{where}: record missing {sorted(missing)}")
|
||||
if "record_id" in unknown:
|
||||
err(f"{where}: record_id must never appear -- no case may depend "
|
||||
f"on it (see ../README.md)")
|
||||
if unknown - {"record_id"}:
|
||||
err(f"{where}: record has unknown keys {sorted(unknown - {'record_id'})}")
|
||||
if rec.get("severity") not in SEVERITIES:
|
||||
err(f"{where}: unknown severity {rec.get('severity')!r}")
|
||||
if not isinstance(rec.get("timestamp_unix_nano"), int):
|
||||
err(f"{where}: timestamp_unix_nano must be an integer")
|
||||
attrs = rec.get("attributes")
|
||||
if not isinstance(attrs, dict):
|
||||
err(f"{where}: attributes must be an object")
|
||||
else:
|
||||
for k, v in attrs.items():
|
||||
if not isinstance(k, str) or not isinstance(v, str):
|
||||
err(f"{where}: attributes must be string->string, got {k!r}={v!r}")
|
||||
|
||||
|
||||
def check_action(a, where, err):
|
||||
if not isinstance(a, dict) or "action" not in a:
|
||||
return err(f"{where}: action must be an object with an 'action' key")
|
||||
name = a["action"]
|
||||
if name in UNSPECIFIED_ACTIONS:
|
||||
return err(f"{where}: '{name}' is deliberately unspecified -- decide "
|
||||
f"its output shape in the design doc before writing cases "
|
||||
f"for it")
|
||||
if name not in ACTIONS:
|
||||
return err(f"{where}: unknown action {name!r}")
|
||||
required, optional = ACTIONS[name]
|
||||
given = set(a) - {"action"}
|
||||
if required - given:
|
||||
err(f"{where}: action {name!r} missing {sorted(required - given)}")
|
||||
if given - required - optional:
|
||||
err(f"{where}: action {name!r} has unknown params "
|
||||
f"{sorted(given - required - optional)}")
|
||||
if name == "derive" and not ({"value", "from_field"} & given):
|
||||
err(f"{where}: derive needs exactly one of value/from_field")
|
||||
if name == "derive" and {"value", "from_field"} <= given:
|
||||
err(f"{where}: derive needs exactly one of value/from_field, not both")
|
||||
for key in ("field", "from", "to"):
|
||||
if key in a and not valid_field(a[key]):
|
||||
err(f"{where}: {key}={a[key]!r} is not an addressable field")
|
||||
for key in ("fields", "key_fields"):
|
||||
for f in a.get(key, []):
|
||||
if not valid_field(f):
|
||||
err(f"{where}: {key} entry {f!r} is not an addressable field")
|
||||
for key in ("pattern",):
|
||||
if key in a:
|
||||
try:
|
||||
re.compile(a[key])
|
||||
except re.error as exc:
|
||||
err(f"{where}: {key} is not a valid regex: {exc}")
|
||||
if name == "sample":
|
||||
n = a.get("keep_one_in")
|
||||
if not isinstance(n, int) or n < 1:
|
||||
err(f"{where}: keep_one_in must be an integer >= 1")
|
||||
if name == "suppress_duplicates":
|
||||
w = a.get("window_ms")
|
||||
if not isinstance(w, int) or w < 1:
|
||||
err(f"{where}: window_ms must be an integer >= 1")
|
||||
|
||||
|
||||
def check_case(path, err):
|
||||
with open(path) as fh:
|
||||
try:
|
||||
case = json.load(fh)
|
||||
except json.JSONDecodeError as exc:
|
||||
return err(f"invalid JSON: {exc}")
|
||||
|
||||
missing = CASE_KEYS - set(case)
|
||||
unknown = set(case) - CASE_KEYS
|
||||
if missing:
|
||||
err(f"missing top-level keys {sorted(missing)}")
|
||||
if unknown:
|
||||
err(f"unknown top-level keys {sorted(unknown)}")
|
||||
|
||||
expected_name = os.path.basename(path)[:-len(".json")]
|
||||
if case.get("name") != expected_name:
|
||||
err(f"name {case.get('name')!r} does not match filename "
|
||||
f"{expected_name!r}")
|
||||
if not case.get("description"):
|
||||
err("description must not be empty -- it is what the case is for")
|
||||
|
||||
for i, rule in enumerate(case.get("rules", [])):
|
||||
where = f"rules[{i}]"
|
||||
if not isinstance(rule, dict) or set(rule) != {"match", "actions"}:
|
||||
err(f"{where}: a rule is exactly {{match, actions}}")
|
||||
continue
|
||||
for j, clause in enumerate(rule["match"]):
|
||||
cw = f"{where}.match[{j}]"
|
||||
if not isinstance(clause, dict):
|
||||
err(f"{cw}: clause must be an object")
|
||||
continue
|
||||
op = clause.get("op")
|
||||
if op in OPS_WITHOUT_VALUE:
|
||||
if "value" in clause:
|
||||
err(f"{cw}: {op} takes no value")
|
||||
allowed = {"field", "op"}
|
||||
elif op in OPS_WITH_VALUE:
|
||||
if "value" not in clause:
|
||||
err(f"{cw}: {op} requires a value")
|
||||
allowed = {"field", "op", "value"}
|
||||
else:
|
||||
err(f"{cw}: unknown op {op!r}")
|
||||
continue
|
||||
if set(clause) - allowed:
|
||||
err(f"{cw}: unknown keys {sorted(set(clause) - allowed)}")
|
||||
if not valid_field(clause.get("field", "")):
|
||||
err(f"{cw}: {clause.get('field')!r} is not an addressable field")
|
||||
if op == "regex":
|
||||
try:
|
||||
re.compile(clause["value"])
|
||||
except re.error as exc:
|
||||
err(f"{cw}: not a valid regex: {exc}")
|
||||
if not rule["actions"]:
|
||||
err(f"{where}: a rule with no actions does nothing")
|
||||
for j, action in enumerate(rule["actions"]):
|
||||
check_action(action, f"{where}.actions[{j}]", err)
|
||||
|
||||
for i, rec in enumerate(case.get("inputs", [])):
|
||||
check_record(rec, f"inputs[{i}]", err)
|
||||
if not case.get("inputs"):
|
||||
err("inputs must not be empty -- a case with no input asserts nothing")
|
||||
for i, rec in enumerate(case.get("expect", [])):
|
||||
check_record(rec, f"expect[{i}]", err)
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.isdir(CASES):
|
||||
print(f"no cases directory at {CASES}", file=sys.stderr)
|
||||
return 1
|
||||
files = sorted(f for f in os.listdir(CASES) if f.endswith(".json"))
|
||||
if not files:
|
||||
print("no cases found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
failures = 0
|
||||
for name in files:
|
||||
errors = []
|
||||
check_case(os.path.join(CASES, name), errors.append)
|
||||
if errors:
|
||||
failures += 1
|
||||
print(f"FAIL {name}")
|
||||
for e in errors:
|
||||
print(f" {e}")
|
||||
print(f"\n{len(files) - failures}/{len(files)} cases valid")
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user