nthlayer-workers 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Paging brief — what the SRE sees when paged.
|
|
2
|
+
|
|
3
|
+
Queries the verdict store for triage, correlation, and remediation
|
|
4
|
+
verdicts linked to a specific incident, then renders a concise brief
|
|
5
|
+
answering three questions: what's broken, why, and what can I do about it.
|
|
6
|
+
|
|
7
|
+
Every field comes from a verdict field. No model call.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
SEVERITY_EMOJI = {1: "\U0001f534", 2: "\U0001f7e0", 3: "\U0001f7e1", 4: "\U0001f535"}
|
|
20
|
+
SEVERITY_LABEL = {1: "P1", 2: "P2", 3: "P3", 4: "P4"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PagingBrief:
|
|
25
|
+
"""Structured paging brief built from verdicts."""
|
|
26
|
+
|
|
27
|
+
incident_id: str
|
|
28
|
+
service: str
|
|
29
|
+
severity: int
|
|
30
|
+
summary: str
|
|
31
|
+
likely_cause: str | None = None
|
|
32
|
+
cause_confidence: float | None = None
|
|
33
|
+
blast_radius: list[str] = field(default_factory=list)
|
|
34
|
+
recommended_action: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_paging_brief(incident_id: str, verdict_store: Any) -> PagingBrief:
|
|
38
|
+
"""Build a paging brief from verdict store queries.
|
|
39
|
+
|
|
40
|
+
Uses lineage walking from the triage verdict to find related
|
|
41
|
+
correlation and remediation verdicts for this specific incident.
|
|
42
|
+
Falls back to filtered queries when lineage is unavailable.
|
|
43
|
+
"""
|
|
44
|
+
from nthlayer_common.verdicts import VerdictFilter
|
|
45
|
+
|
|
46
|
+
# Find triage verdict for this incident by querying and filtering
|
|
47
|
+
triage_verdicts = verdict_store.query(
|
|
48
|
+
VerdictFilter(
|
|
49
|
+
producer_system="nthlayer-respond",
|
|
50
|
+
subject_type="triage",
|
|
51
|
+
limit=20,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
# Filter to this incident by checking subject.ref or metadata
|
|
55
|
+
triage = None
|
|
56
|
+
for v in triage_verdicts:
|
|
57
|
+
ref = getattr(v.subject, "ref", None) or getattr(v.subject, "service", None)
|
|
58
|
+
if ref:
|
|
59
|
+
triage = v
|
|
60
|
+
break
|
|
61
|
+
if not triage and triage_verdicts:
|
|
62
|
+
triage = triage_verdicts[0]
|
|
63
|
+
|
|
64
|
+
# Walk lineage from triage to find related verdicts
|
|
65
|
+
related = []
|
|
66
|
+
if triage:
|
|
67
|
+
try:
|
|
68
|
+
related = verdict_store.by_lineage(triage.id, direction="both")
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
# Classify related verdicts
|
|
73
|
+
correlation_verdicts = [
|
|
74
|
+
v for v in related
|
|
75
|
+
if v.producer.system == "nthlayer-correlate"
|
|
76
|
+
and v.subject.type == "correlation"
|
|
77
|
+
]
|
|
78
|
+
remediation_verdicts = [
|
|
79
|
+
v for v in related
|
|
80
|
+
if v.producer.system == "nthlayer-respond"
|
|
81
|
+
and v.subject.type == "remediation"
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Fallback: query directly if lineage didn't find them
|
|
85
|
+
if not correlation_verdicts:
|
|
86
|
+
correlation_verdicts = verdict_store.query(
|
|
87
|
+
VerdictFilter(
|
|
88
|
+
producer_system="nthlayer-correlate",
|
|
89
|
+
subject_type="correlation",
|
|
90
|
+
limit=5,
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
if not remediation_verdicts:
|
|
94
|
+
remediation_verdicts = verdict_store.query(
|
|
95
|
+
VerdictFilter(
|
|
96
|
+
producer_system="nthlayer-respond",
|
|
97
|
+
subject_type="remediation",
|
|
98
|
+
limit=5,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Extract triage fields
|
|
103
|
+
service = "unknown"
|
|
104
|
+
severity = 3
|
|
105
|
+
blast_radius: list[str] = []
|
|
106
|
+
summary = "No triage available"
|
|
107
|
+
if triage:
|
|
108
|
+
service = getattr(triage.subject, "ref", None) or getattr(triage.subject, "service", "unknown")
|
|
109
|
+
custom = triage.metadata.custom
|
|
110
|
+
severity = custom.get("severity", 3)
|
|
111
|
+
if severity is None:
|
|
112
|
+
severity = 3
|
|
113
|
+
logger.warning("paging_brief_severity_missing", incident_id=incident_id)
|
|
114
|
+
blast_radius = custom.get("blast_radius", [])
|
|
115
|
+
summary = triage.judgment.reasoning
|
|
116
|
+
|
|
117
|
+
# Extract correlation root cause (highest confidence, skip None)
|
|
118
|
+
likely_cause = None
|
|
119
|
+
cause_confidence = None
|
|
120
|
+
valid_correlations = [
|
|
121
|
+
v for v in correlation_verdicts
|
|
122
|
+
if v.judgment.confidence is not None
|
|
123
|
+
]
|
|
124
|
+
if valid_correlations:
|
|
125
|
+
top = max(valid_correlations, key=lambda v: v.judgment.confidence)
|
|
126
|
+
likely_cause = top.judgment.reasoning
|
|
127
|
+
cause_confidence = top.judgment.confidence
|
|
128
|
+
|
|
129
|
+
# Extract remediation
|
|
130
|
+
recommended_action = None
|
|
131
|
+
if remediation_verdicts:
|
|
132
|
+
rem_custom = remediation_verdicts[0].metadata.custom
|
|
133
|
+
recommended_action = rem_custom.get("proposed_action")
|
|
134
|
+
|
|
135
|
+
return PagingBrief(
|
|
136
|
+
incident_id=incident_id,
|
|
137
|
+
service=service,
|
|
138
|
+
severity=severity,
|
|
139
|
+
summary=summary,
|
|
140
|
+
likely_cause=likely_cause,
|
|
141
|
+
cause_confidence=cause_confidence,
|
|
142
|
+
blast_radius=blast_radius,
|
|
143
|
+
recommended_action=recommended_action,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render_brief(brief: PagingBrief) -> str:
|
|
148
|
+
"""Render a PagingBrief to a human-readable text string."""
|
|
149
|
+
emoji = SEVERITY_EMOJI.get(brief.severity, "")
|
|
150
|
+
label = SEVERITY_LABEL.get(brief.severity, f"P{brief.severity}")
|
|
151
|
+
|
|
152
|
+
lines = [
|
|
153
|
+
f"{emoji} {label}: {brief.service}",
|
|
154
|
+
"",
|
|
155
|
+
f"What's happening: {brief.summary}",
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
if brief.likely_cause:
|
|
159
|
+
conf = f" (confidence: {brief.cause_confidence:.2f})" if brief.cause_confidence is not None else ""
|
|
160
|
+
lines.append(f"Likely cause: {brief.likely_cause}{conf}")
|
|
161
|
+
else:
|
|
162
|
+
lines.append("Likely cause: Investigation in progress")
|
|
163
|
+
|
|
164
|
+
if brief.blast_radius:
|
|
165
|
+
lines.append(f"Blast radius: {', '.join(brief.blast_radius)}")
|
|
166
|
+
|
|
167
|
+
if brief.recommended_action:
|
|
168
|
+
lines.append(f"Recommended: {brief.recommended_action}")
|
|
169
|
+
|
|
170
|
+
lines.extend([
|
|
171
|
+
"",
|
|
172
|
+
f"Incident: {brief.incident_id}",
|
|
173
|
+
])
|
|
174
|
+
|
|
175
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Delegation mode — 'I'm busy, handle it'.
|
|
2
|
+
|
|
3
|
+
During a multi-incident situation, the SRE delegates a lower-priority
|
|
4
|
+
incident to autonomous handling. The coordinator continues with
|
|
5
|
+
pre-approved safe actions only. Notifications are suppressed except
|
|
6
|
+
for resolution or escalation.
|
|
7
|
+
|
|
8
|
+
No model call. Delegation is a governance configuration change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from enum import Enum
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
_DEFAULT_MAX_DURATION = timedelta(hours=2)
|
|
22
|
+
|
|
23
|
+
# Only these event types break through delegation silence
|
|
24
|
+
_NOTIFY_EVENTS = frozenset({"resolution", "escalation"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DelegationStatus(Enum):
|
|
28
|
+
"""Lifecycle of a delegation."""
|
|
29
|
+
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
EXPIRED = "expired"
|
|
32
|
+
ESCALATED = "escalated"
|
|
33
|
+
RESOLVED = "resolved"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Delegation:
|
|
38
|
+
"""Tracks delegation state for an incident."""
|
|
39
|
+
|
|
40
|
+
incident_id: str
|
|
41
|
+
delegated_by: str
|
|
42
|
+
delegated_at: datetime
|
|
43
|
+
expires_at: datetime
|
|
44
|
+
max_duration: timedelta
|
|
45
|
+
safe_actions_only: bool = True
|
|
46
|
+
status: DelegationStatus = DelegationStatus.ACTIVE
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_delegation(
|
|
50
|
+
*,
|
|
51
|
+
incident_id: str,
|
|
52
|
+
delegated_by: str,
|
|
53
|
+
safe_actions_only: bool = True,
|
|
54
|
+
max_duration: timedelta = _DEFAULT_MAX_DURATION,
|
|
55
|
+
) -> Delegation:
|
|
56
|
+
"""Create a delegation for an incident.
|
|
57
|
+
|
|
58
|
+
The SRE won't receive updates until resolution or escalation.
|
|
59
|
+
If safe actions are insufficient, the delegation escalates back.
|
|
60
|
+
Auto-expires after ``max_duration``.
|
|
61
|
+
"""
|
|
62
|
+
now = datetime.now(timezone.utc)
|
|
63
|
+
|
|
64
|
+
logger.info(
|
|
65
|
+
"delegation_created",
|
|
66
|
+
incident_id=incident_id,
|
|
67
|
+
delegated_by=delegated_by,
|
|
68
|
+
safe_actions_only=safe_actions_only,
|
|
69
|
+
max_duration_hours=max_duration.total_seconds() / 3600,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return Delegation(
|
|
73
|
+
incident_id=incident_id,
|
|
74
|
+
delegated_by=delegated_by,
|
|
75
|
+
delegated_at=now,
|
|
76
|
+
expires_at=now + max_duration,
|
|
77
|
+
max_duration=max_duration,
|
|
78
|
+
safe_actions_only=safe_actions_only,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def check_delegation_expired(delegation: Delegation, now: datetime) -> bool:
|
|
83
|
+
"""Check if a delegation has expired.
|
|
84
|
+
|
|
85
|
+
Already-resolved or escalated delegations are not considered expired.
|
|
86
|
+
"""
|
|
87
|
+
if delegation.status != DelegationStatus.ACTIVE:
|
|
88
|
+
return False
|
|
89
|
+
return now >= delegation.expires_at
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def should_notify_delegator(delegation: Delegation, event_type: str) -> bool:
|
|
93
|
+
"""Determine if the delegator should be notified for this event.
|
|
94
|
+
|
|
95
|
+
Only resolution and escalation events break through the delegation
|
|
96
|
+
silence while the delegation is active. Inactive delegations
|
|
97
|
+
(resolved, expired, escalated) do not filter notifications.
|
|
98
|
+
"""
|
|
99
|
+
if delegation.status != DelegationStatus.ACTIVE:
|
|
100
|
+
return True # No longer delegated — all events pass through
|
|
101
|
+
return event_type in _NOTIFY_EVENTS
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Post-incident review — auto-generated from the verdict chain.
|
|
2
|
+
|
|
3
|
+
Reconstructs the incident timeline, classifies what worked vs what
|
|
4
|
+
needs improvement, and shows per-verdict accuracy. Every section
|
|
5
|
+
is built from verdict queries. The optional model call for action
|
|
6
|
+
item suggestions is clearly bounded and off by default.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import structlog
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
# Agent systems whose verdicts are included in accuracy tracking
|
|
20
|
+
_AGENT_SYSTEMS = {"nthlayer-respond", "nthlayer-correlate", "nthlayer-measure"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TimelineEntry:
|
|
25
|
+
"""A single event in the incident timeline."""
|
|
26
|
+
|
|
27
|
+
timestamp: datetime
|
|
28
|
+
source: str
|
|
29
|
+
agent: str
|
|
30
|
+
summary: str
|
|
31
|
+
confidence: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class PostIncidentReview:
|
|
36
|
+
"""Structured post-incident review."""
|
|
37
|
+
|
|
38
|
+
incident_id: str
|
|
39
|
+
timeline: list[TimelineEntry] = field(default_factory=list)
|
|
40
|
+
worked: list[str] = field(default_factory=list)
|
|
41
|
+
improve: list[str] = field(default_factory=list)
|
|
42
|
+
accuracy: list[str] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build_timeline(verdicts: list[Any]) -> list[TimelineEntry]:
|
|
46
|
+
"""Reconstruct the incident timeline from verdict timestamps.
|
|
47
|
+
|
|
48
|
+
Pure query + sort. No model call.
|
|
49
|
+
"""
|
|
50
|
+
entries = []
|
|
51
|
+
for v in verdicts:
|
|
52
|
+
entries.append(
|
|
53
|
+
TimelineEntry(
|
|
54
|
+
timestamp=v.timestamp,
|
|
55
|
+
source=v.producer.system,
|
|
56
|
+
agent=v.producer.instance,
|
|
57
|
+
summary=v.subject.summary,
|
|
58
|
+
confidence=v.judgment.confidence,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
entries.sort(key=lambda e: e.timestamp)
|
|
62
|
+
return entries
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_review_sections(verdicts: list[Any]) -> tuple[list[str], list[str]]:
|
|
66
|
+
"""Build 'what worked' and 'what to improve' from verdict outcomes.
|
|
67
|
+
|
|
68
|
+
Confirmed verdicts → worked. Overridden verdicts → improve.
|
|
69
|
+
Pending/expired verdicts are excluded.
|
|
70
|
+
"""
|
|
71
|
+
worked = []
|
|
72
|
+
improve = []
|
|
73
|
+
|
|
74
|
+
for v in verdicts:
|
|
75
|
+
status = v.outcome.status
|
|
76
|
+
instance = v.producer.instance
|
|
77
|
+
|
|
78
|
+
if status == "confirmed":
|
|
79
|
+
worked.append(f"{instance}: {v.subject.summary} (confirmed \u2713)")
|
|
80
|
+
elif status == "overridden":
|
|
81
|
+
reason = ""
|
|
82
|
+
if v.outcome.override and v.outcome.override.reasoning:
|
|
83
|
+
reason = f" — {v.outcome.override.reasoning}"
|
|
84
|
+
improve.append(f"{instance}: overridden{reason}")
|
|
85
|
+
|
|
86
|
+
return worked, improve
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_accuracy_section(verdicts: list[Any]) -> list[str]:
|
|
90
|
+
"""Show confirmed/overridden status for each agent verdict.
|
|
91
|
+
|
|
92
|
+
Only includes verdicts from agent systems (nthlayer-respond,
|
|
93
|
+
nthlayer-correlate, nthlayer-measure). Human/manual verdicts excluded.
|
|
94
|
+
"""
|
|
95
|
+
lines = []
|
|
96
|
+
for v in verdicts:
|
|
97
|
+
if v.producer.system not in _AGENT_SYSTEMS:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
status = v.outcome.status
|
|
101
|
+
instance = v.producer.instance
|
|
102
|
+
|
|
103
|
+
if status == "confirmed":
|
|
104
|
+
lines.append(f" {instance}: confirmed \u2713")
|
|
105
|
+
elif status == "overridden":
|
|
106
|
+
reason = ""
|
|
107
|
+
if v.outcome.override and v.outcome.override.reasoning:
|
|
108
|
+
reason = f" ({v.outcome.override.reasoning})"
|
|
109
|
+
lines.append(f" {instance}: overridden \u2717{reason}")
|
|
110
|
+
|
|
111
|
+
return lines
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_post_incident(review: PostIncidentReview) -> str:
|
|
115
|
+
"""Render a PostIncidentReview to human-readable text."""
|
|
116
|
+
lines = [
|
|
117
|
+
f"Post-Incident Review: {review.incident_id}",
|
|
118
|
+
"Auto-generated from verdict chain.",
|
|
119
|
+
"",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
if review.timeline:
|
|
123
|
+
lines.append("Timeline:")
|
|
124
|
+
for entry in review.timeline:
|
|
125
|
+
ts = entry.timestamp.strftime("%H:%M")
|
|
126
|
+
conf = f" ({entry.confidence:.0%})" if entry.confidence is not None else ""
|
|
127
|
+
lines.append(f" {ts} {entry.source}: {entry.summary}{conf}")
|
|
128
|
+
lines.append("")
|
|
129
|
+
|
|
130
|
+
if review.worked:
|
|
131
|
+
lines.append("What worked:")
|
|
132
|
+
for item in review.worked:
|
|
133
|
+
lines.append(f" \u2022 {item}")
|
|
134
|
+
lines.append("")
|
|
135
|
+
|
|
136
|
+
if review.improve:
|
|
137
|
+
lines.append("What to improve:")
|
|
138
|
+
for item in review.improve:
|
|
139
|
+
lines.append(f" \u2022 {item}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
if review.accuracy:
|
|
143
|
+
lines.append("Verdict accuracy:")
|
|
144
|
+
lines.extend(review.accuracy)
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Shift report — summary of what happened during an on-call shift.
|
|
2
|
+
|
|
3
|
+
At shift start, the SRE receives a summary of incidents, governance
|
|
4
|
+
changes, evaluations, and pending reviews from the verdict store.
|
|
5
|
+
Every field comes from verdict queries. No model call.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ShiftReport:
|
|
21
|
+
"""Structured shift report built from verdict queries."""
|
|
22
|
+
|
|
23
|
+
window_start: datetime
|
|
24
|
+
window_end: datetime
|
|
25
|
+
incidents: list[Any] = field(default_factory=list)
|
|
26
|
+
evaluations_count: int = 0
|
|
27
|
+
pending_reviews: int = 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_shift_report(
|
|
31
|
+
shift_start: datetime,
|
|
32
|
+
shift_end: datetime,
|
|
33
|
+
verdict_store: Any,
|
|
34
|
+
) -> ShiftReport:
|
|
35
|
+
"""Build a shift report from verdict store queries.
|
|
36
|
+
|
|
37
|
+
Queries for incidents (triage + custom incident verdicts),
|
|
38
|
+
evaluations, and pending reviews within the shift window.
|
|
39
|
+
"""
|
|
40
|
+
from nthlayer_common.verdicts import VerdictFilter
|
|
41
|
+
|
|
42
|
+
# Triage verdicts in window (real incidents)
|
|
43
|
+
triage_verdicts = verdict_store.query(
|
|
44
|
+
VerdictFilter(
|
|
45
|
+
producer_system="nthlayer-respond",
|
|
46
|
+
subject_type="triage",
|
|
47
|
+
from_time=shift_start,
|
|
48
|
+
to_time=shift_end,
|
|
49
|
+
limit=0,
|
|
50
|
+
)
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Custom verdicts with incident_type (incident summaries)
|
|
54
|
+
custom_verdicts = verdict_store.query(
|
|
55
|
+
VerdictFilter(
|
|
56
|
+
subject_type="custom",
|
|
57
|
+
from_time=shift_start,
|
|
58
|
+
to_time=shift_end,
|
|
59
|
+
limit=0,
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
incident_summaries = [
|
|
63
|
+
v for v in custom_verdicts
|
|
64
|
+
if v.metadata.custom.get("incident_type") == "incident"
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# Combine — triage verdicts are incidents, plus explicit incident summaries
|
|
68
|
+
incidents = triage_verdicts + incident_summaries
|
|
69
|
+
|
|
70
|
+
# Evaluations in window
|
|
71
|
+
evaluations = verdict_store.query(
|
|
72
|
+
VerdictFilter(
|
|
73
|
+
producer_system="nthlayer-measure",
|
|
74
|
+
subject_type="evaluation",
|
|
75
|
+
from_time=shift_start,
|
|
76
|
+
to_time=shift_end,
|
|
77
|
+
limit=100,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Pending reviews (no time filter — all currently pending)
|
|
82
|
+
pending = verdict_store.query(
|
|
83
|
+
VerdictFilter(
|
|
84
|
+
status="pending",
|
|
85
|
+
limit=100,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
return ShiftReport(
|
|
90
|
+
window_start=shift_start,
|
|
91
|
+
window_end=shift_end,
|
|
92
|
+
incidents=incidents,
|
|
93
|
+
evaluations_count=len(evaluations),
|
|
94
|
+
pending_reviews=len(pending),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_quiet(report: ShiftReport) -> bool:
|
|
99
|
+
"""Determine if a shift was quiet — no incidents and no pending reviews."""
|
|
100
|
+
return len(report.incidents) == 0 and report.pending_reviews == 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_shift_report(report: ShiftReport) -> str:
|
|
104
|
+
"""Render a ShiftReport to human-readable text."""
|
|
105
|
+
start = report.window_start.strftime("%b %d, %H:%M")
|
|
106
|
+
end = report.window_end.strftime("%b %d, %H:%M")
|
|
107
|
+
|
|
108
|
+
lines = [f"Shift Report: {start} \u2192 {end}", ""]
|
|
109
|
+
|
|
110
|
+
if is_quiet(report):
|
|
111
|
+
lines.append("Nothing required your attention. \u2713")
|
|
112
|
+
else:
|
|
113
|
+
count = len(report.incidents)
|
|
114
|
+
if count > 0:
|
|
115
|
+
lines.append(f"{count} incident{'s' if count != 1 else ''} during this shift.")
|
|
116
|
+
for inc in report.incidents:
|
|
117
|
+
custom = inc.metadata.custom
|
|
118
|
+
sev = custom.get("severity", "?")
|
|
119
|
+
dur = custom.get("duration", "")
|
|
120
|
+
dur_str = f" ({dur})" if dur else ""
|
|
121
|
+
lines.append(f" P{sev}: {inc.subject.summary}{dur_str}")
|
|
122
|
+
|
|
123
|
+
if report.evaluations_count > 0:
|
|
124
|
+
lines.append(f"\nEvaluations: {report.evaluations_count}")
|
|
125
|
+
|
|
126
|
+
if report.pending_reviews > 0:
|
|
127
|
+
lines.append(f"Pending reviews: {report.pending_reviews}")
|
|
128
|
+
|
|
129
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Alert suppression — 'don't page me for this'.
|
|
2
|
+
|
|
3
|
+
Suppression rules let SREs mute known non-issues (e.g., nightly backup
|
|
4
|
+
latency spikes) with a baseline + override threshold. If the current
|
|
5
|
+
value exceeds the override threshold, the suppression is overridden
|
|
6
|
+
and the SRE is paged with context explaining why.
|
|
7
|
+
|
|
8
|
+
No model call. Baseline is arithmetic (historical mean). Override
|
|
9
|
+
detection is a single comparison.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import structlog
|
|
19
|
+
|
|
20
|
+
logger = structlog.get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_DEFAULT_OVERRIDE_MULTIPLIER = 3.0
|
|
23
|
+
_DEFAULT_REVIEW_DAYS = 30
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Suppression:
|
|
28
|
+
"""A suppression rule for a service metric."""
|
|
29
|
+
|
|
30
|
+
service: str
|
|
31
|
+
metric: str
|
|
32
|
+
window: dict[str, Any] # {type, start, end, timezone?}
|
|
33
|
+
reason: str
|
|
34
|
+
baseline: float
|
|
35
|
+
override_threshold: float
|
|
36
|
+
created_by: str = "unknown"
|
|
37
|
+
created_at: datetime | None = None
|
|
38
|
+
review_after: datetime | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_suppression(
|
|
42
|
+
*,
|
|
43
|
+
service: str,
|
|
44
|
+
metric: str,
|
|
45
|
+
window: dict[str, Any],
|
|
46
|
+
reason: str,
|
|
47
|
+
baseline: float,
|
|
48
|
+
override_multiplier: float = _DEFAULT_OVERRIDE_MULTIPLIER,
|
|
49
|
+
created_by: str = "unknown",
|
|
50
|
+
) -> Suppression:
|
|
51
|
+
"""Create a suppression rule from a baseline measurement.
|
|
52
|
+
|
|
53
|
+
The override threshold is ``baseline * override_multiplier``.
|
|
54
|
+
If the metric exceeds this during the suppressed window,
|
|
55
|
+
the suppression is overridden and the SRE is paged.
|
|
56
|
+
"""
|
|
57
|
+
if baseline <= 0:
|
|
58
|
+
msg = f"Baseline must be positive, got {baseline}"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
|
|
61
|
+
now = datetime.now(timezone.utc)
|
|
62
|
+
override_threshold = baseline * override_multiplier
|
|
63
|
+
|
|
64
|
+
logger.info(
|
|
65
|
+
"suppression_created",
|
|
66
|
+
service=service,
|
|
67
|
+
metric=metric,
|
|
68
|
+
baseline=baseline,
|
|
69
|
+
override_threshold=override_threshold,
|
|
70
|
+
reason=reason,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
return Suppression(
|
|
74
|
+
service=service,
|
|
75
|
+
metric=metric,
|
|
76
|
+
window=window,
|
|
77
|
+
reason=reason,
|
|
78
|
+
baseline=baseline,
|
|
79
|
+
override_threshold=override_threshold,
|
|
80
|
+
created_by=created_by,
|
|
81
|
+
created_at=now,
|
|
82
|
+
review_after=now + timedelta(days=_DEFAULT_REVIEW_DAYS),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def check_suppression_override(suppression: Suppression, current_value: float) -> bool:
|
|
87
|
+
"""Return True if the current value exceeds the override threshold.
|
|
88
|
+
|
|
89
|
+
At exactly the threshold, suppression holds (must strictly exceed).
|
|
90
|
+
"""
|
|
91
|
+
return current_value > suppression.override_threshold
|