Skip to content

Commit 31632ce

Browse files
committed
Free hosting (Render + Firebase), Evidence panel, live problem picker, README
1 parent 18a3bc7 commit 31632ce

17 files changed

Lines changed: 504 additions & 78 deletions

.firebaserc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"projects": {
3+
"default": "specterops-848e3"
4+
}
5+
}

README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ cascade across three services — with **94% confidence, in ~0.3s of compute.**
5454
## ✨ What's inside
5555

5656
- 🤖 **Four autonomous agents** orchestrated end-to-end, reasoning with **Google Gemini 2.5 Flash**.
57-
- 🟣 **Live Dynatrace integration**one click pulls the latest **real open problem** from your tenant and investigates it; the finished RCA can be **pushed back onto the problem as a comment** (closing the loop).
57+
- 🟣 **Live Dynatrace integration****browse all open problems** in your tenant and investigate any one you pick (or auto-grab the latest); the finished RCA can be **pushed back onto the problem as a comment** (closing the loop).
5858
- 🧪 **Scenario library + custom incidents** — four real-world failure scenarios (missing index, memory leak, bad deploy, dependency outage) plus a **"describe your own"** form.
5959
- 📡 **Real-time SSE streaming** — every agent's start/finish streams live to the dashboard.
6060
- 🕸️ **D3 causal graph** + **Three.js** 3D hero + **Framer Motion** — a business-grade UI, not a toy.
@@ -75,7 +75,7 @@ SpecterOps is a pipeline of four specialized agents. Each one does a single job
7575
hands structured state to the next. Every step streams to the dashboard live over SSE.
7676

7777
```
78-
Dynatrace Alert (webhook · or "⚡ Trigger Demo Incident")
78+
Dynatrace problem (live pull · webhook · scenario · custom incident)
7979
8080
8181
┌──────────────────────────────────────────────────────────────────┐
@@ -108,8 +108,11 @@ hands structured state to the next. Every step streams to the dashboard live ove
108108

109109
## 🎬 The demo
110110

111-
> Open the dashboard, click **⚡ Trigger Demo Incident**, and watch the whole diagnosis
112-
> happen live in ~30–60 seconds.
111+
> Sign in, then **choose what to investigate** — a **live open problem pulled straight from
112+
> your Dynatrace tenant**, a built-in failure scenario, or your own typed incident — and watch
113+
> all four agents diagnose it live in ~30–60 seconds.
114+
115+
**Example — the "missing index" scenario:**
113116

114117
1. 🛡️ SentinelAgent classifies it as a `DATABASE_ISSUE` on `payment-service`.
115118
2. 🔍 TraceArchaeologist reconstructs a 7-event timeline and surfaces the impact strip:
@@ -119,12 +122,14 @@ hands structured state to the next. Every step streams to the dashboard live ove
119122
```sql
120123
CREATE INDEX CONCURRENTLY idx_payment_methods_user_id ON payment_methods (user_id);
121124
```
125+
5. 🟣 One click **pushes the finished RCA back onto the Dynatrace problem** as a comment.
122126

123127
A live **"Time to Diagnose"** counter proves the speed claim on screen.
124128

125-
> 💡 **Zero-account demo:** with `DEMO_MODE=true` (default), every Dynatrace call returns
126-
> realistic simulation data. If no `GEMINI_API_KEY` is set, each agent falls back to a
127-
> scenario-accurate offline response — so the full pipeline always completes end-to-end.
129+
> 💡 **Runs with zero accounts:** `DEMO_MODE=true` uses realistic simulated telemetry, and
130+
> without a `GEMINI_API_KEY` each agent uses a scenario-accurate offline response — so the
131+
> full pipeline always completes. **Add a Gemini key + a Dynatrace token to make it fully
132+
> real:** live problems, real LLM reasoning, RCA pushed back to Dynatrace.
128133
129134
---
130135

@@ -145,7 +150,7 @@ uvicorn main:app --reload --port 8000
145150
# 4 — Frontend (new terminal) → http://localhost:5173
146151
cd frontend && npm install && npm run dev
147152

148-
# 5 — Open the dashboard and click "⚡ Trigger Demo Incident"
153+
# 5 — Open the dashboard, sign in, and pick a scenario (or a live Dynatrace problem)
149154
```
150155

151156
> For **live Gemini reasoning**, drop a key from [Google AI Studio](https://aistudio.google.com/apikey)

backend/Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ COPY requirements.txt .
44
RUN pip install --no-cache-dir -r requirements.txt
55
COPY . .
66
EXPOSE 8080
7-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "1"]
7+
# Bind the platform-provided $PORT (Render, Cloud Run, etc.); default 8080 locally.
8+
CMD ["sh", "-c", "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8080} --workers 1"]

backend/agents/blame_agent.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,11 @@ async def run_blame_mapper(incident: Incident) -> Incident:
8282
try:
8383
raw = await gemini_json(prompt)
8484
result = _parse_json_safe(raw, offline)
85-
if not result.get("nodes") or len(result.get("nodes", [])) < 2:
86-
result = offline
85+
# Require a proper chain; a 1-node "just the root" graph isn't useful, so
86+
# fall back to the richer scenario-built chain.
87+
if not result.get("nodes") or len(result.get("nodes", [])) < 3:
88+
if len(offline.get("nodes", [])) > len(result.get("nodes", [])):
89+
result = offline
8790
except Exception:
8891
result = offline
8992

backend/mcp/dynatrace_client.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,14 +89,26 @@ async def get_problem_by_id(self, problem_id: str) -> Dict[str, Any]:
8989
return await self._try_get(f"/api/v2/problems/{problem_id}", fallback={})
9090

9191
async def add_problem_comment(self, problem_id: str, message: str) -> None:
92-
"""Post a comment back onto a Dynatrace problem (requires problems.write)."""
92+
"""
93+
Post a comment back onto a Dynatrace problem.
94+
95+
Needs `problems.write` on classic tenants, or the `storage:events:write`
96+
platform permission on Grail/Gen3 tenants. On failure we surface
97+
Dynatrace's own error message so the cause is obvious.
98+
"""
9399
async with httpx.AsyncClient(timeout=15.0) as client:
94100
resp = await client.post(
95101
f"{self.base_url}/api/v2/problems/{problem_id}/comments",
96102
headers=self._headers(),
97103
json={"message": message, "context": "SpecterOps"},
98104
)
99-
resp.raise_for_status()
105+
if resp.status_code >= 400:
106+
detail = ""
107+
try:
108+
detail = (resp.json().get("error", {}) or {}).get("message", "")
109+
except Exception:
110+
detail = resp.text[:200]
111+
raise RuntimeError(f"HTTP {resp.status_code}: {detail or 'request failed'}")
100112

101113
async def get_problem_details(self) -> Dict[str, Any]:
102114
if self.demo:

backend/mcp/scenarios.py

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -418,27 +418,46 @@ def build_dynatrace_scenario(problem: Dict[str, Any]) -> Dict[str, Any]:
418418

419419
err, p99 = _parse_impact(evidence + [title])
420420

421-
ev = evidence[:6]
422-
n = len(ev)
421+
others = [s for s in services if s != primary][:3]
422+
sev_log = "ERROR" if severity in ("CRITICAL", "HIGH") else "WARN"
423+
primary_short = primary.split(" in ")[0].split(":")[0].strip()
424+
425+
# Build a coherent, staged causal chain so the graph and timeline are always
426+
# complete — even when a live problem has a single affected entity and one
427+
# line of evidence. Stages: (service, description, is_root).
428+
stages: List[Tuple[str, str, bool]] = []
429+
stages.append((primary, evidence[0], True))
430+
for d in evidence[1:4]: # extra evidence becomes middle stages
431+
stages.append((primary, d, False))
432+
if len(stages) < 2: # guarantee a propagation stage
433+
stages.append((primary, f"{primary_short} degraded — errors and latency rising", False))
434+
if others: # real downstream services
435+
for s in others:
436+
stages.append((s, f"Impact propagated to {s}", False))
437+
else: # synthesize user-facing impact
438+
stages.append((f"users of {primary_short}", f"User-facing requests through {primary_short} degraded", False))
439+
440+
stages = stages[:6]
441+
n = len(stages)
423442
start = 1380
424-
step = max(60, (start - 120) // max(1, n))
425-
timeline = []
426-
logs = []
427-
for i, d in enumerate(ev):
428-
off = start - i * step
429-
timeline.append([off, primary, "evidence", d, severity, i == 0])
430-
logs.append([off, "ERROR" if severity in ("CRITICAL", "HIGH") else "WARN", d, primary])
443+
step = max(120, (start - 120) // max(1, n - 1)) if n > 1 else 600
431444

432-
others = [s for s in services if s != primary][:3]
433-
nodes = [["node-1", primary, evidence[0], start, True]]
434-
edges = []
435-
prev = "node-1"
436-
for j, s in enumerate(others, start=2):
437-
nid = f"node-{j}"
438-
nodes.append([nid, s, f"Impacted downstream by {primary}", start - (j - 1) * step, False])
439-
edges.append([prev, nid, "cascaded to", 0.85])
445+
timeline, logs, nodes, edges = [], [], [], []
446+
prev = None
447+
for i, (svc, desc, is_root) in enumerate(stages):
448+
off = start - i * step
449+
etype = "evidence" if i == 0 else ("cascade" if svc != primary else "error")
450+
timeline.append([off, svc, etype, desc, severity, is_root])
451+
logs.append([off, sev_log, desc, svc])
452+
nid = f"node-{i + 1}"
453+
nodes.append([nid, svc, desc, off, is_root])
454+
if prev:
455+
rel = "cascaded to" if svc != primary else "led to"
456+
edges.append([prev, nid, rel, 0.86])
440457
prev = nid
441458

459+
cascade_chain = list(dict.fromkeys(s for s, _, _ in stages))
460+
442461
explanation = (
443462
f"Dynatrace flagged '{title}'. The root cause is localized to {primary}"
444463
+ (f", cascading to {', '.join(others)}" if others else "")
@@ -472,7 +491,7 @@ def build_dynatrace_scenario(problem: Dict[str, Any]) -> Dict[str, Any]:
472491
"analysis": {
473492
"classification_reasoning": f"Dynatrace problem on {primary}: {title}",
474493
"first_anomaly": evidence[0],
475-
"cascade_chain": [primary] + others,
494+
"cascade_chain": cascade_chain,
476495
"root_cause_category": "OTHER",
477496
"confidence_pct": 88,
478497
"root_cause_explanation": explanation,

backend/routes/incidents.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def _demo_mode() -> bool:
3737
class TriggerRequest(BaseModel):
3838
scenario_id: Optional[str] = "db_index"
3939
owner: Optional[str] = None
40+
problem_id: Optional[str] = None # specific Dynatrace problem to investigate
4041

4142

4243
class CustomIncidentRequest(BaseModel):
@@ -123,20 +124,29 @@ async def trigger_from_dynatrace(body: Optional[TriggerRequest] = None):
123124
)
124125

125126
client = DynatraceClient()
126-
try:
127-
data = await client.get_open_problems()
128-
except Exception as exc:
129-
raise HTTPException(status_code=502, detail=f"Dynatrace API error: {exc}")
130-
131-
problems = data.get("problems", [])
132-
if not problems:
133-
raise HTTPException(status_code=404, detail="No open problems found in Dynatrace.")
134127

135-
problem = problems[0]
136-
problem_id = problem.get("problemId") or problem.get("displayId")
128+
# A specific problem was chosen from the picker, or fall back to the latest.
129+
if body.problem_id:
130+
try:
131+
full = await client.get_problem_by_id(body.problem_id)
132+
except Exception as exc:
133+
raise HTTPException(status_code=502, detail=f"Dynatrace API error: {exc}")
134+
if not full or not full.get("title"):
135+
raise HTTPException(status_code=404, detail="That Dynatrace problem was not found.")
136+
problem = full
137+
problem_id = body.problem_id
138+
else:
139+
try:
140+
data = await client.get_open_problems()
141+
except Exception as exc:
142+
raise HTTPException(status_code=502, detail=f"Dynatrace API error: {exc}")
143+
problems = data.get("problems", [])
144+
if not problems:
145+
raise HTTPException(status_code=404, detail="No open problems found in Dynatrace.")
146+
problem = problems[0]
147+
problem_id = problem.get("problemId") or problem.get("displayId")
148+
full = await client.get_problem_by_id(problem_id) if problem_id else {}
137149

138-
# Fetch the full problem (richer evidence) and build a real-data scenario.
139-
full = await client.get_problem_by_id(problem_id) if problem_id else {}
140150
scenario = scenarios.build_dynatrace_scenario(full or problem)
141151

142152
incident_id = str(uuid.uuid4())
@@ -216,7 +226,11 @@ async def push_rca_to_dynatrace(incident_id: str):
216226
except Exception as exc:
217227
raise HTTPException(
218228
status_code=502,
219-
detail=f"Could not post comment (token may lack 'problems.write' scope): {exc}",
229+
detail=(
230+
"Dynatrace rejected the comment. The token needs 'problems.write' "
231+
"(classic) or the 'storage:events:write' permission (Grail/Gen3 tenants). "
232+
f"Details: {exc}"
233+
),
220234
)
221235
return {"posted": True, "problem_id": incident.dynatrace_problem_id}
222236

backend/routes/meta.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@
77

88
import storage
99
from mcp import scenarios
10-
from mcp.dynatrace_client import dynatrace_configured
10+
from mcp.dynatrace_client import DynatraceClient, dynatrace_configured
1111
from utils.gemini_client import active_model, gemini_available
1212

1313
router = APIRouter(prefix="/api", tags=["meta"])
1414

1515

16+
def _demo_mode() -> bool:
17+
return os.getenv("DEMO_MODE", "true").lower() in ("1", "true", "yes")
18+
19+
1620
@router.get("/config")
1721
async def get_config():
1822
"""What's actually wired up — used by the dashboard's connection badges."""
@@ -43,3 +47,31 @@ def _slack_configured() -> bool:
4347
@router.get("/scenarios")
4448
async def get_scenarios():
4549
return scenarios.list_scenarios()
50+
51+
52+
@router.get("/dynatrace/problems")
53+
async def list_dynatrace_problems():
54+
"""List open problems from the live Dynatrace tenant so the user can pick one."""
55+
if _demo_mode() or not dynatrace_configured():
56+
return {"connected": False, "problems": []}
57+
58+
client = DynatraceClient()
59+
try:
60+
data = await client.get_open_problems()
61+
except Exception as exc: # surface, don't crash
62+
return {"connected": True, "error": str(exc)[:200], "problems": []}
63+
64+
out = []
65+
for p in (data.get("problems", []) or [])[:25]:
66+
services = [e.get("name", "") for e in p.get("affectedEntities", []) if e.get("name")]
67+
out.append({
68+
"id": p.get("problemId") or p.get("displayId"),
69+
"title": p.get("title", "Problem"),
70+
"severity": scenarios._DT_SEVERITY.get(p.get("severityLevel", ""), "HIGH"),
71+
"severity_level": p.get("severityLevel"),
72+
"status": p.get("status"),
73+
"services": services[:4],
74+
"service_count": len(services),
75+
"start_time": p.get("startTime"),
76+
})
77+
return {"connected": True, "problems": out}

firebase.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"hosting": {
3+
"public": "frontend/dist",
4+
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
5+
"rewrites": [
6+
{
7+
"source": "**",
8+
"destination": "/index.html"
9+
}
10+
],
11+
"headers": [
12+
{
13+
"source": "**/*.@(js|css)",
14+
"headers": [{ "key": "Cache-Control", "value": "max-age=31536000,immutable" }]
15+
}
16+
]
17+
}
18+
}

frontend/src/components/CTA.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default function CTA() {
2424
<Zap className="h-4 w-4" /> Launch the live demo
2525
</a>
2626
<a
27-
href="https://github.com"
27+
href="https://github.com/skypank-coder/SpecterOps-Autonomous-Post-Incident-Forensics-Agent"
2828
target="_blank"
2929
rel="noreferrer"
3030
className="btn-ghost"

0 commit comments

Comments
 (0)