)}
diff --git a/dashboard/src/lib/investigation.test.ts b/dashboard/src/lib/investigation.test.ts
index c580678..c5dc371 100644
--- a/dashboard/src/lib/investigation.test.ts
+++ b/dashboard/src/lib/investigation.test.ts
@@ -22,6 +22,8 @@ const record = (
outcome: "complete",
success: true,
response_source: "upstream",
+ upstream_start_time: "2026-09-22T12:00:00Z",
+ upstream_end_time: "2026-09-22T12:00:00.001Z",
upstream_latency_us: 1000,
total_duration_us: 30000000,
...overrides,
@@ -101,3 +103,43 @@ test("trend bucket boundaries partition records exactly and produce matching dri
assert.equal(buckets([record("a"), record("b")]).length, 1);
assert.equal(summary([]).p95, null);
});
+
+test("alert links use exact signal populations and exclude unrelated failures", () => {
+ const records = [
+ record("http5xx", { response_status: 503 }),
+ record("401", { response_status: 401 }),
+ record("transport", {
+ outcome: "upstream_error",
+ response_status: 502,
+ response_source: "proxy",
+ }),
+ record("canceled", {
+ outcome: "client_canceled",
+ response_status: 0,
+ response_source: "proxy",
+ }),
+ ];
+ assert.deepEqual(
+ records
+ .filter((r) => matches(r, { ...emptyScope, signal: "http5xx" }))
+ .map((r) => r.id),
+ ["http5xx"],
+ );
+ assert.deepEqual(
+ records
+ .filter((r) => matches(r, { ...emptyScope, signal: "transport" }))
+ .map((r) => r.id),
+ ["transport"],
+ );
+ assert.deepEqual(
+ records
+ .filter((r) => matches(r, { ...emptyScope, signal: "headers" }))
+ .map((r) => r.id),
+ ["http5xx", "401"],
+ );
+ assert.equal(
+ records.filter((r) => matches(r, { ...emptyScope, signal: "unknown" }))
+ .length,
+ 0,
+ );
+});
diff --git a/dashboard/src/lib/investigation.ts b/dashboard/src/lib/investigation.ts
index d37c46d..df5b424 100644
--- a/dashboard/src/lib/investigation.ts
+++ b/dashboard/src/lib/investigation.ts
@@ -1,5 +1,6 @@
import type { BackendRequestRecord as Record } from "../services/api";
export type Scope = {
+ signal: string;
host: string;
route: string;
method: string;
@@ -8,6 +9,7 @@ export type Scope = {
to: string;
};
export const emptyScope: Scope = {
+ signal: "",
host: "",
route: "",
method: "",
@@ -42,6 +44,16 @@ export function matches(record: Record, scope: Scope) {
const target = endpoint(record);
const time = Date.parse(record.timestamp);
return (
+ (!scope.signal ||
+ (scope.signal === "http5xx"
+ ? record.response_source === "upstream" &&
+ record.response_status >= 500 &&
+ record.response_status <= 599
+ : scope.signal === "transport"
+ ? ["upstream_error", "proxy_error"].includes(record.outcome)
+ : scope.signal === "headers"
+ ? headersEligible(record)
+ : false)) &&
(!scope.host || scope.host === target.host) &&
(!scope.route || scope.route === target.route) &&
(!scope.method || scope.method === record.method) &&
@@ -58,6 +70,10 @@ export function headersEligible(record: Record) {
record.response_source === "upstream" &&
record.response_status > 0 &&
record.method !== "CONNECT" &&
+ Number.isFinite(Date.parse(record.upstream_start_time)) &&
+ Date.parse(record.upstream_start_time) > 0 &&
+ Date.parse(record.upstream_end_time) >=
+ Date.parse(record.upstream_start_time) &&
record.upstream_latency_us >= 0
);
}
diff --git a/examples/alerting/README.md b/examples/alerting/README.md
new file mode 100644
index 0000000..10806ed
--- /dev/null
+++ b/examples/alerting/README.md
@@ -0,0 +1,94 @@
+# Focused alerting
+
+Prometheus evaluates rules; Alertmanager groups, silences and delivers notifications. Netkit exports HTTP signals and provides investigation and bounded incident metadata. This example does not configure a real email/chat destination or deploy any service.
+
+## Rules and populations
+
+| Rule | Condition | Gate / hold | Interpretation |
+| --- | --- | --- | --- |
+| NetkitUpstreamFailureRate | Upstream HTTP 5xx / non-tunnel HTTP observations > 10% over 5m | At least 20 observations; 3m sustained | Does not call 401/429 or inspection rejection an upstream outage. |
+| NetkitTransportFailureRate | Proxy errors or upstream connection failures / HTTP observations > 10% over 5m | At least 20 observations; 3m sustained | Client cancellation and inspection rejection are excluded from the numerator. |
+| NetkitHeaderLatencyHigh | p95 upstream response-header latency > 1 second over 5m | At least 20 header samples; 5m sustained; **ordinary profile only** | Excludes response body lifetime, tunnels and failed attempts without headers. |
+| NetkitUnavailable | External Prometheus scrape `up == 0` | 2m sustained | Reachability, not proof of capture completeness. |
+| NetkitInsufficientSamples | Fewer than 20 HTTP observations over 5m | 5m sustained; informational | No data/low volume is unknown, not healthy or a missing-traffic failure. |
+
+Thresholds are examples, not universal defaults. The sample `netkit_latency_profile: unclassified` deliberately disables the latency rule. Enable `ordinary` only on an isolated proxy serving a comparable ordinary HTTP workload. Netkit does not infer request types, dependencies or route templates. Never enable a blanket latency rule for mixed long-polling/streaming traffic. Existing metrics support instance-level scope, not host/route alerts; the UI must not imply otherwise.
+
+Each rule includes a value, threshold, window and investigation link. Static `netkit_dashboard` and `netkit_incidents` external labels must contain public URLs without credentials or query secrets. Set the dashboard URL to include any deployment prefix, without a trailing slash. Metric/alert labels must never contain request URLs, credentials or arbitrary payload fields.
+
+## Local setup
+
+Use Prometheus 3.14.0 and Alertmanager 0.34.1 (the versions validated in CI), plus a current Netkit build. Start Netkit normally with admin port 8081 and dashboard port 3000. Run the incident receiver as a **separate process**, ideally on a different host or failure domain from the monitored proxy in a real deployment.
+
+From this directory:
+
+```sh
+umask 077
+openssl rand -hex 32 > webhook-token
+mkdir -m 700 incidents
+NETKIT_ALERT_TOKEN="$(cat webhook-token)" netkit alerts \
+ --dir "$PWD/incidents" \
+ --listen 127.0.0.1:8090 \
+ --admin-url http://127.0.0.1:8081 \
+ --dashboard-url http://localhost:3000 \
+ --instance netkit-local
+```
+
+In separate terminals, from this same directory:
+
+```sh
+alertmanager --config.file=alertmanager.yml --storage.path=./alertmanager-data
+prometheus --config.file=prometheus.yml --storage.tsdb.path=./prometheus-data
+```
+
+The webhook token is mandatory (at least 16 characters), provided only through the receiver environment and Alertmanager's credentials file. Keep it out of Git. The receiver binds loopback by default. Its read-only HTML interface has **no built-in authentication**: use authenticated private ingress before exposing it. The fixed admin URL is trusted configuration; redirects and alert-supplied target URLs are not followed. Keep the existing Netkit admin endpoint private as well.
+
+The example expects one proxy with the safe Prometheus instance alias `netkit-local`. For multiple instances, use one receiver and directory per instance and route Alertmanager by instance; mismatched instances are rejected rather than snapshotting unrelated traffic.
+
+Open Prometheus's Alerts page to see pending/firing evaluation, Alertmanager to manage silences, and `http://localhost:8090/` for received incident evidence. Absence of an incident is not a health signal: pending or silenced alerts may never notify this receiver.
+
+## Notification routing
+
+The supplied receiver writes local evidence only. Add your operator-owned notification integration to the same Alertmanager receiver, using its documented email/chat/webhook schema. For a generic endpoint that accepts Alertmanager's webhook JSON, add another entry under `webhook_configs`:
+
+```yaml
+- url: https://notifications.example.invalid/alertmanager
+ send_resolved: true
+ http_config:
+ authorization:
+ credentials_file: ./notification-token
+```
+
+Replace the `.invalid` placeholder and create the private token file before enabling it. Keep grouping by alertname/job/instance, 30s initial group delay, 5m group interval and 4h repeat interval unless your response policy calls for different values. A NetkitUnavailable alert inhibits the traffic rules for the same job/instance. Alertmanager silences affect both evidence delivery and other integrations; snapshots therefore reflect **delivered notifications**, not an exhaustive incident history. Monitor Prometheus, Alertmanager notification failures, and the receiver independently; Netkit cannot report its own total monitoring-stack failure.
+
+## Evidence contract and retention
+
+`GET /requests/evidence?signal=http5xx|transport|headers&from=&to=` is an admin-only metadata boundary. It returns at most 20 matching retained records and the total matching retained count. It excludes URLs, host/path names, headers, bodies and error text entirely. The receiver validates a fixed schema and discards all arbitrary alert annotations/labels, storing only known rule identity, lifecycle timestamps, finite numeric value/threshold and typed evidence.
+
+The first delivered notification snapshots the available evidence from five minutes before condition onset through delivery. This contextual window includes evaluation and pending time; it is not an immutable copy of every metric sample. Later retries and recovery update lifecycle metadata without replacing the initial evidence. An out-of-order firing retry cannot reopen a resolved incident with the same start time. A resolved notification received without an earlier firing delivery is recorded as such, with whatever evidence is available at delivery.
+
+Snapshots are atomic mode-0600 JSON files in a required private mode-0700 directory. Defaults are 100 incidents and seven days, configurable with `--capacity` (1–1000) and `--retention` (1m–30d). TTL is measured from snapshot creation, not renewed on each retry. Pruning runs on reads/writes and once per minute while the process is running; offline expiry is enforced on restart. The receiver never deletes files outside its incident naming convention. Disk errors return non-success so Alertmanager can retry.
+
+Evidence states distinguish captured, no retained matches, unavailable (including failed fetch or invalid schema), and not applicable (availability/insufficient-sample alerts). A snapshot can survive request-history clearing, eviction and proxy/receiver restarts. Incident links return explicit 404/expired state after retention. Investigation links apply the signal and snapshot time bounds to the **current** Traffic buffer; that buffer may no longer contain the saved evidence. Resolved means the alert expression stopped firing, not proof of health—scrape failure and insufficient data have separate signals.
+
+The receiver preserves metadata only. Payload reproduction, durable full traffic storage, per-route metrics, rate-limiting/stream-interruption rules and native monitor editing remain later work.
+
+## Validation
+
+```sh
+promtool check config prometheus.yml
+promtool check rules rules.yml
+promtool test rules rules.test.yml
+amtool check-config alertmanager.yml
+# From repository root, with real binaries available:
+go test -race ./internal/alerting ./internal/proxy
+go build -o /tmp/netkit-alert-test ./cmd/netkit
+python3 scripts/test-alerting-integration.py \
+ --netkit /tmp/netkit-alert-test --alertmanager /path/to/alertmanager
+```
+
+Rule tests use synthetic clocks/series to verify brief spikes, sustained failures, sample gates, low/no-data, external downtime/recovery, cancellation/inspection exclusions and latency profile gating. The integration test runs a real Alertmanager and real receiver on disposable loopback ports, verifying grouping, repeat delivery, silence suppression, recovery, deduplication and safe evidence persistence. It sends no external notifications.
+
+A scrape target removed from configuration produces no `up` series; `NetkitUnavailable` monitors configured targets, not inventory deletion. Maintain target inventory and independently monitor the monitoring stack. Missing expected traffic requires an explicit cadence policy and is not inferred from ordinary silence.
+
+References: [Prometheus alerting rules](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/), [rule unit tests](https://prometheus.io/docs/prometheus/latest/configuration/unit_testing_rules/), [Alertmanager configuration](https://prometheus.io/docs/alerting/latest/configuration/).
diff --git a/examples/alerting/alertmanager.yml b/examples/alerting/alertmanager.yml
new file mode 100644
index 0000000..e6c75b1
--- /dev/null
+++ b/examples/alerting/alertmanager.yml
@@ -0,0 +1,21 @@
+route:
+ receiver: incident-evidence
+ group_by: [alertname, job, instance]
+ group_wait: 30s
+ group_interval: 5m
+ repeat_interval: 4h
+receivers:
+ - name: incident-evidence
+ webhook_configs:
+ - url: http://127.0.0.1:8090/webhook
+ send_resolved: true
+ max_alerts: 8
+ http_config:
+ authorization:
+ credentials_file: ./webhook-token
+# Add an operator-owned notification receiver to this route when ready.
+# The example deliberately sends only to the local evidence receiver.
+inhibit_rules:
+ - source_matchers: ['alertname="NetkitUnavailable"']
+ target_matchers: ['alertname=~"NetkitUpstreamFailureRate|NetkitTransportFailureRate|NetkitHeaderLatencyHigh|NetkitInsufficientSamples"']
+ equal: [job, instance]
diff --git a/examples/alerting/prometheus.yml b/examples/alerting/prometheus.yml
new file mode 100644
index 0000000..3d7d106
--- /dev/null
+++ b/examples/alerting/prometheus.yml
@@ -0,0 +1,21 @@
+global:
+ scrape_interval: 15s
+ evaluation_interval: 15s
+ external_labels:
+ netkit_dashboard: http://localhost:3000
+ netkit_incidents: http://localhost:8090
+rule_files:
+ - rules.yml
+alerting:
+ alertmanagers:
+ - static_configs:
+ - targets: ['127.0.0.1:9093']
+scrape_configs:
+ - job_name: netkit
+ static_configs:
+ - targets: ['127.0.0.1:8081']
+ labels:
+ instance: netkit-local
+ # Enable ONLY for an isolated, comparable ordinary HTTP workload.
+ # Do not enable for a mixed streaming/long-polling proxy.
+ netkit_latency_profile: unclassified
diff --git a/examples/alerting/rules.test.yml b/examples/alerting/rules.test.yml
new file mode 100644
index 0000000..470dd0f
--- /dev/null
+++ b/examples/alerting/rules.test.yml
@@ -0,0 +1,314 @@
+{
+ "rule_files": [
+ "rules.yml"
+ ],
+ "evaluation_interval": "1m",
+ "tests": [
+ {
+ "name": "sustained HTTP failure fires after hold and recovers",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"2xx\"}",
+ "values": "0+80x30"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"5xx\"}",
+ "values": "0+20x8 160+0x21"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "2m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "5m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"warning\"}",
+ "value": 1
+ }
+ ]
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "15m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "short failure spike stays quiet",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"2xx\"}",
+ "values": "0+80x20"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"5xx\"}",
+ "values": "0 20+0x19"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "3m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "8m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "low sample counts do not page",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"5xx\"}",
+ "values": "0+1x20"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "10m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitInsufficientSamples\",alertstate=\"firing\"}",
+ "eval_time": "10m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitInsufficientSamples\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"info\"}",
+ "value": 1
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "fresh reachable proxy has no data, not healthy",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitInsufficientSamples\",alertstate=\"firing\"}",
+ "eval_time": "6m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitInsufficientSamples\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"info\"}",
+ "value": 1
+ }
+ ]
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "6m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "external downtime and recovery",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1 0+0x5 1+0x10"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitUnavailable\",alertstate=\"firing\"}",
+ "eval_time": "2m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUnavailable\",alertstate=\"firing\"}",
+ "eval_time": "3m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitUnavailable\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"critical\"}",
+ "value": 1
+ }
+ ]
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUnavailable\",alertstate=\"firing\"}",
+ "eval_time": "7m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "transport errors fire independently",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"complete\",http_status_class=\"2xx\"}",
+ "values": "0+80x20"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"upstream_error\",http_status_class=\"none\"}",
+ "values": "0+20x20"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitTransportFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "5m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitTransportFailureRate\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"warning\"}",
+ "value": 1
+ }
+ ]
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "5m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "latency profile ordinary",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"ordinary\",method=\"GET\",le=\"1\"}",
+ "values": "0+0x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"ordinary\",method=\"GET\",le=\"2.5\"}",
+ "values": "0+100x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"ordinary\",method=\"GET\",le=\"+Inf\"}",
+ "values": "0+100x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_count{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"ordinary\",method=\"GET\"}",
+ "values": "0+100x20"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitHeaderLatencyHigh\",alertstate=\"firing\"}",
+ "eval_time": "3m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitHeaderLatencyHigh\",alertstate=\"firing\"}",
+ "eval_time": "7m",
+ "exp_samples": [
+ {
+ "labels": "ALERTS{alertname=\"NetkitHeaderLatencyHigh\",alertstate=\"firing\",instance=\"netkit-local\",job=\"netkit\",severity=\"warning\"}",
+ "value": 1
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "latency profile unclassified",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"unclassified\",method=\"GET\",le=\"1\"}",
+ "values": "0+0x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"unclassified\",method=\"GET\",le=\"2.5\"}",
+ "values": "0+100x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_bucket{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"unclassified\",method=\"GET\",le=\"+Inf\"}",
+ "values": "0+100x20"
+ },
+ {
+ "series": "netkit_response_headers_seconds_count{job=\"netkit\",instance=\"netkit-local\",netkit_latency_profile=\"unclassified\",method=\"GET\"}",
+ "values": "0+100x20"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitHeaderLatencyHigh\",alertstate=\"firing\"}",
+ "eval_time": "3m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitHeaderLatencyHigh\",alertstate=\"firing\"}",
+ "eval_time": "7m",
+ "exp_samples": []
+ }
+ ]
+ },
+ {
+ "name": "cancellations and inspection rejection do not become transport failures",
+ "interval": "1m",
+ "input_series": [
+ {
+ "series": "up{job=\"netkit\",instance=\"netkit-local\"}",
+ "values": "1+0x40"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"client_canceled\",http_status_class=\"none\"}",
+ "values": "0+100x20"
+ },
+ {
+ "series": "netkit_exchanges_total{job=\"netkit\",instance=\"netkit-local\",method=\"GET\",outcome=\"blocked_by_inspection\",http_status_class=\"none\"}",
+ "values": "0+100x20"
+ }
+ ],
+ "promql_expr_test": [
+ {
+ "expr": "ALERTS{alertname=\"NetkitTransportFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "10m",
+ "exp_samples": []
+ },
+ {
+ "expr": "ALERTS{alertname=\"NetkitUpstreamFailureRate\",alertstate=\"firing\"}",
+ "eval_time": "10m",
+ "exp_samples": []
+ }
+ ]
+ }
+ ]
+}
diff --git a/examples/alerting/rules.yml b/examples/alerting/rules.yml
new file mode 100644
index 0000000..66492d9
--- /dev/null
+++ b/examples/alerting/rules.yml
@@ -0,0 +1,68 @@
+groups:
+ - name: netkit.focused
+ rules:
+ - record: netkit:http_observations:increase5m
+ expr: sum by (job, instance) (increase(netkit_exchanges_total{job="netkit",method!="CONNECT",outcome!="tunnel_only"}[5m])) or on (job, instance) (sum by (job, instance) (0 * up{job="netkit"}))
+ - record: netkit:upstream_failures:ratio5m
+ expr: (sum by (job, instance) (increase(netkit_exchanges_total{job="netkit",method!="CONNECT",http_status_class="5xx"}[5m])) or on (job, instance) (sum by (job, instance) (0 * up{job="netkit"}))) / netkit:http_observations:increase5m
+ - record: netkit:transport_failures:ratio5m
+ expr: (sum by (job, instance) (increase(netkit_exchanges_total{job="netkit",method!="CONNECT",outcome=~"upstream_error|proxy_error"}[5m])) or on (job, instance) (sum by (job, instance) (0 * up{job="netkit"}))) / netkit:http_observations:increase5m
+ - record: netkit:headers:p95_5m
+ expr: histogram_quantile(0.95, sum by (job, instance, le) (rate(netkit_response_headers_seconds_bucket{job="netkit",netkit_latency_profile="ordinary",method!="CONNECT"}[5m])))
+ - record: netkit:headers:count5m
+ expr: sum by (job, instance) (increase(netkit_response_headers_seconds_count{job="netkit",netkit_latency_profile="ordinary",method!="CONNECT"}[5m]))
+ - alert: NetkitUpstreamFailureRate
+ expr: (netkit:upstream_failures:ratio5m > 0.10) and on (job, instance) (netkit:http_observations:increase5m >= 20) and on (job, instance) (up{job="netkit"} == 1)
+ for: 3m
+ labels: {severity: warning}
+ annotations:
+ summary: Sustained upstream HTTP 5xx
+ value: '{{ $value }}'
+ threshold: '0.10'
+ window: '5m; at least 20 HTTP observations; sustained 3m'
+ investigate_url: '{{ $externalLabels.netkit_dashboard }}/?signal=http5xx'
+ incident_url: '{{ $externalLabels.netkit_incidents }}/'
+ - alert: NetkitTransportFailureRate
+ expr: (netkit:transport_failures:ratio5m > 0.10) and on (job, instance) (netkit:http_observations:increase5m >= 20) and on (job, instance) (up{job="netkit"} == 1)
+ for: 3m
+ labels: {severity: warning}
+ annotations:
+ summary: Sustained proxy or upstream connection failures
+ value: '{{ $value }}'
+ threshold: '0.10'
+ window: '5m; at least 20 HTTP observations; sustained 3m; excludes client cancellations and inspection rejection'
+ investigate_url: '{{ $externalLabels.netkit_dashboard }}/?signal=transport'
+ incident_url: '{{ $externalLabels.netkit_incidents }}/'
+ - alert: NetkitHeaderLatencyHigh
+ expr: (netkit:headers:p95_5m > 1) and on (job, instance) (netkit:headers:count5m >= 20) and on (job, instance) (up{job="netkit"} == 1)
+ for: 5m
+ labels: {severity: warning}
+ annotations:
+ summary: High time to response headers for ordinary HTTP profile
+ value: '{{ $value }}'
+ threshold: '1'
+ window: '5m; at least 20 header samples; sustained 5m; ordinary profile only'
+ investigate_url: '{{ $externalLabels.netkit_dashboard }}/?signal=headers'
+ incident_url: '{{ $externalLabels.netkit_incidents }}/'
+ - alert: NetkitUnavailable
+ expr: min by (job, instance) (up{job="netkit"}) == 0
+ for: 2m
+ labels: {severity: critical}
+ annotations:
+ summary: Netkit scrape unavailable
+ value: '{{ $value }}'
+ threshold: '1'
+ window: 'External scrape failed for 2m; checks reachability, not capture completeness'
+ investigate_url: '{{ $externalLabels.netkit_dashboard }}/'
+ incident_url: '{{ $externalLabels.netkit_incidents }}/'
+ - alert: NetkitInsufficientSamples
+ expr: (netkit:http_observations:increase5m < 20) and on (job, instance) (up{job="netkit"} == 1)
+ for: 5m
+ labels: {severity: info}
+ annotations:
+ summary: Insufficient samples; traffic health is unknown
+ value: '{{ $value }}'
+ threshold: '20'
+ window: 'Fewer than 20 HTTP observations in 5m; not a missing-traffic failure'
+ investigate_url: '{{ $externalLabels.netkit_dashboard }}/'
+ incident_url: '{{ $externalLabels.netkit_incidents }}/'
diff --git a/internal/alerting/evidence.go b/internal/alerting/evidence.go
new file mode 100644
index 0000000..d137b95
--- /dev/null
+++ b/internal/alerting/evidence.go
@@ -0,0 +1,50 @@
+// Package alerting provides an opt-in, separately operated incident receiver.
+// Alert evaluation and notification ownership remain in Prometheus/Alertmanager.
+package alerting
+
+import (
+ "regexp"
+ "time"
+)
+
+type Evidence struct {
+ ID string `json:"id"`
+ Timestamp time.Time `json:"timestamp"`
+ Method string `json:"method"`
+ Outcome string `json:"outcome"`
+ Status int `json:"status"`
+ Source string `json:"source"`
+ HeadersUS int64 `json:"headers_us"`
+ DurationUS int64 `json:"duration_us"`
+}
+
+var recordID = regexp.MustCompile(`^[a-f0-9]{32}$`)
+
+func (e Evidence) Valid() bool {
+ if !recordID.MatchString(e.ID) || e.Timestamp.IsZero() || e.HeadersUS < 0 || e.DurationUS < 0 || e.Status < 0 || e.Status > 599 {
+ return false
+ }
+ switch e.Method {
+ case "GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "CONNECT", "OTHER":
+ default:
+ return false
+ }
+ switch e.Outcome {
+ case "complete", "interrupted", "client_canceled", "blocked_by_inspection", "upstream_error", "proxy_error", "tunnel_only", "unknown":
+ default:
+ return false
+ }
+ return e.Source == "upstream" || e.Source == "proxy" || e.Source == "unknown"
+}
+func (e Evidence) Matches(signal string) bool {
+ switch signal {
+ case "http5xx":
+ return e.Source == "upstream" && e.Status >= 500 && e.Status <= 599
+ case "transport":
+ return e.Outcome == "upstream_error" || e.Outcome == "proxy_error"
+ case "headers":
+ return e.Source == "upstream" && e.Status > 0 && e.Method != "CONNECT"
+ default:
+ return false
+ }
+}
diff --git a/internal/alerting/incident.html b/internal/alerting/incident.html
new file mode 100644
index 0000000..3a5307a
--- /dev/null
+++ b/internal/alerting/incident.html
@@ -0,0 +1,121 @@
+
+
+ Netkit incident evidence
+
Incident evidence
+
+ Prometheus evaluates rules; Alertmanager owns grouping, silences and
+ delivery. This receiver stores bounded metadata snapshots. “Resolved” means
+ the rule stopped firing, not proof of health.
+
+
+ Instance: {{.Instance}} · up to {{.Capacity}} incidents · retention
+ {{retention .TTL}}. Evidence excludes URLs, headers, bodies and arbitrary alert text.
+
+ Snapshot may cover only part of the evaluation window. Current traffic
+ can expire independently. No matching evidence does not disprove the
+ alert.
+