Webhook sources
A single POST /webhook endpoint accepts any source. Pluggable parsers
normalise each payload into a common NormalisedContext; the resolver matches
it to a pipeline; idempotent deduplication stops repeat deliveries triggering
repeat runs.
Webhook intake & source detection
Section titled “Webhook intake & source detection”Single endpoint: POST /webhook
Source is identified via query parameter: /webhook?source=alertmanager
Header fallback also supported: X-Pipeline-Source: alertmanager
The source value maps to a registered parser class. Registered sources: alertmanager, generic.
Normalisation layer
Section titled “Normalisation layer”Each source parser implements BaseParser and produces a NormalisedContext object. This is the universal data model that all downstream pipeline logic operates on.
class NormalisedContext(BaseModel): source: str # e.g. "alertmanager" — for audit only pipeline: str # pipeline config name to resolve severity: str | None # critical / warning / info labels: dict[str, str] # service, environment, etc. summary: str | None # human readable description of the event fingerprint: str | None # dedup key — see Idempotency & Deduplication below raw: dict # original unmodified payload metadata: dict # source-specific extras received_at: datetimeGeneric source
Section titled “Generic source”Any tool that can send HTTP can target POST /webhook?source=generic using a standardised schema — no bespoke parser needed. The generic source always requires an explicit pipeline name, which bypasses the resolver’s trigger matching entirely.
Generic payload schema:
{ "pipeline": "my-pipeline", // required — names the pipeline explicitly "event": "order.placed", // optional — stored in labels["event"] for audit "source": "shopify", // optional — defaults to "generic" "summary": "Human readable...", // optional "idempotency_key": "order-12345", // optional — dedup key, see Idempotency & Deduplication below. Omit to disable dedup. "data": { ... } // optional — free-form dict, lands in metadata}Mapping to NormalisedContext:
pipeline→pipeline(resolver uses this directly, skips trigger matching)event→labels["event"]source→sourcesummary→summaryidempotency_key→fingerprintdata→metadata(accessible in prompts as{{metadata.field_name}}or just{{field_name}}via leaf flattening)
Pipeline resolution
Section titled “Pipeline resolution”The resolver loads all YAML configs from PIPELINE_CONFIG_DIR and matches the incoming NormalisedContext against each config’s trigger.match block. First match wins. Configs should be ordered by specificity (more specific matches first).
Pipeline name can also be explicitly set by the source parser if the webhook payload contains a pipeline attribute (e.g. an Alertmanager label pipeline: alert-triage-critical).
Match operators: a trigger.match value can be a plain scalar (exact equality, the original behaviour) or a single-key operator dict for richer matching:
trigger: match: severity: critical # exact match (unchanged) environment: in: [prod, staging] # membership service: not_in: [test-runner] # exclusion summary: regex: "(?i)timeout" # regex search (re.search, not full match) error_rate: gt: "5" # numeric comparison — gt | gte | lt | lte severity: ne: info # not-equal| Operator | Behaviour |
|---|---|
eq |
Same as a plain scalar — exact equality |
ne |
Not equal |
in |
Value is a member of the given list |
not_in |
Value is not a member of the given list |
regex |
re.search(pattern, str(actual)) — None actual never matches |
gt / gte / lt / lte |
Numeric comparison — both sides are cast with float(); a non-numeric actual or expected value never matches |
A match value dict must have exactly one key; an unknown operator or a multi-key dict logs a warning and never matches (fails closed).
Idempotency & deduplication
Section titled “Idempotency & deduplication”Alertmanager (and similar sources) re-send the same alert repeatedly — every evaluation interval while it’s firing, and again on resolve. Without dedup, each resend spawns a fresh pipeline run: redundant LLM spend, and worse, overlapping remediation runs for the same alert.
Fingerprint
Section titled “Fingerprint”Each parser populates NormalisedContext.fingerprint — the dedup key:
| Source | Fingerprint source |
|---|---|
alertmanager |
The matched alert’s fingerprint field (Alertmanager’s own label-hash), or the group’s groupKey for the common_labels strategy. Falls back to a hash of the relevant labels if neither is present. The alert status (firing/resolved) is appended, so a resolve notification is never suppressed as a duplicate of the firing run. |
generic |
The optional idempotency_key field (see Generic source above). If omitted, fingerprint is None and dedup is skipped for that webhook — generic triggers (orders, etc.) are opt-in. |
Dedup check
Section titled “Dedup check”On POST /webhook, after the pipeline is resolved and before a run is started, VectorStep
looks for an existing pipeline_runs row with the same pipeline_name + fingerprint:
- In-flight (
status="running") — always suppressed, regardless of config. This is the race-prevention case: two overlapping triage/remediation runs for the same alert never run concurrently. - Recent (
triggered_atwithinwindow_secondsof now) — suppressed even if the prior run has completed. This absorbs Alertmanager’s repeat-fire on a flapping alert.
If either matches, no new run is created. The webhook still gets a 202, but with
status: "deduplicated" and the matching run’s run_id:
{ "status": "deduplicated", "run_id": "<existing-run-id>", "source": "alertmanager", "pipeline": "alert-triage-critical", "severity": "critical", "summary": "...", "reason": "Duplicate of run <existing-run-id> (status=running)"}Configuration
Section titled “Configuration”Service-wide defaults in config.yaml:
dedup: enabled: true window_seconds: 300Per-pipeline override via trigger.dedup (both fields optional — None falls back to
the service default):
trigger: match: severity: critical dedup: window_seconds: 600 # this pipeline's triage takes a while — widen the window # enabled: false # or opt this pipeline out of dedup entirelyAdding a new source parser
Section titled “Adding a new source parser”- Create
src/normaliser/<source>.py - Implement
BaseParser— produce aNormalisedContext - Register in
src/normaliser/__init__.pysource map
See Extending VectorStep for the full checklist shared across parsers, executors, and library steps.