upstrace 0.1.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.
upstrace/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
upstrace/api.py ADDED
@@ -0,0 +1,371 @@
1
+ """HTTP API for the dashboard.
2
+
3
+ Two constraints shape everything here:
4
+
5
+ 1. DuckDB allows one writer at a time, and dbt runs as a separate process. So
6
+ every mutating request takes a lock, opens a connection only for as long as
7
+ it needs one, and closes it before dbt starts.
8
+ 2. Injecting a fault means rebuild + reprofile, which takes seconds, not
9
+ milliseconds. These endpoints are deliberately synchronous: the UI shows a
10
+ spinner and waits. A job queue would be more correct at scale and is not
11
+ worth the moving parts for a single-user demo.
12
+ """
13
+
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import threading
18
+ from datetime import date, datetime
19
+
20
+ import duckdb
21
+ from fastapi import FastAPI, HTTPException
22
+ from fastapi.responses import FileResponse
23
+ from fastapi.staticfiles import StaticFiles
24
+
25
+ from . import drift as drift_mod
26
+ from . import explain as explain_mod
27
+ from . import faults as faults_mod
28
+ from . import rca as rca_mod
29
+ from .config import DBT_PROJECT_DIR, METRICS_SCHEMA, PROJECT_ROOT, WAREHOUSE_DB
30
+ from .lineage import graph
31
+ from .profiler import run_profile
32
+
33
+ app = FastAPI(title="Upstrace", docs_url="/api/docs", openapi_url="/api/openapi.json")
34
+
35
+ # One lock for the whole warehouse. Reads are cheap; writes must not overlap.
36
+ WAREHOUSE_LOCK = threading.Lock()
37
+
38
+ STATIC_DIR = PROJECT_ROOT / "app" / "static"
39
+ DEMO_SAMPLE = int(os.environ.get("UPSTRACE_DEMO_SAMPLE", "0")) or None
40
+
41
+
42
+ def _connect() -> duckdb.DuckDBPyConnection:
43
+ if not WAREHOUSE_DB.exists():
44
+ raise HTTPException(
45
+ 503,
46
+ "No warehouse yet. Check the 'warehouse:' path in upstrace.yml, then "
47
+ "build it with dbt. For this repo's demo: python scripts/load_duckdb.py",
48
+ )
49
+ return duckdb.connect(str(WAREHOUSE_DB))
50
+
51
+
52
+ def _dbt(*args: str) -> None:
53
+ binary = shutil.which("dbt")
54
+ if not binary:
55
+ raise HTTPException(500, "dbt is not on PATH. Activate the venv first.")
56
+
57
+ result = subprocess.run(
58
+ [binary, *args],
59
+ cwd=DBT_PROJECT_DIR,
60
+ env={**os.environ, "DBT_PROFILES_DIR": "."},
61
+ capture_output=True,
62
+ text=True,
63
+ )
64
+ if result.returncode != 0:
65
+ raise HTTPException(500, f"dbt {' '.join(args)} failed: {result.stdout[-800:]}")
66
+
67
+
68
+ def _json_safe(value):
69
+ if isinstance(value, (datetime, date)):
70
+ return value.isoformat()
71
+ return value
72
+
73
+
74
+ # --------------------------------------------------------------------------
75
+ # read endpoints
76
+ # --------------------------------------------------------------------------
77
+
78
+ @app.get("/api/health")
79
+ def health() -> dict:
80
+ return {
81
+ "status": "ok",
82
+ "warehouse": WAREHOUSE_DB.exists(),
83
+ "demo_sample": DEMO_SAMPLE,
84
+ }
85
+
86
+
87
+ @app.get("/api/overview")
88
+ def overview() -> dict:
89
+ con = _connect()
90
+ try:
91
+ runs = con.execute(f"""
92
+ select run_id, started_at, models_count
93
+ from {METRICS_SCHEMA}.profile_runs
94
+ order by started_at desc limit 2
95
+ """).fetchall()
96
+
97
+ if not runs:
98
+ return {"runs": [], "incidents": 0, "signals": 0, "nodes": 0}
99
+
100
+ latest = runs[0][0]
101
+ signals = con.execute(
102
+ f"select count(*) from {METRICS_SCHEMA}.drift_signals where run_id = ?",
103
+ [latest],
104
+ ).fetchone()[0]
105
+ incidents = con.execute(
106
+ f"select count(*) from {METRICS_SCHEMA}.incidents where run_id = ?",
107
+ [latest],
108
+ ).fetchone()[0]
109
+ rows = con.execute(f"""
110
+ select sum(row_count) from (
111
+ select model_name, max(row_count) as row_count
112
+ from {METRICS_SCHEMA}.column_profiles
113
+ where run_id = ? group by model_name
114
+ )
115
+ """, [latest]).fetchone()[0]
116
+ finally:
117
+ con.close()
118
+
119
+ nodes, _ = graph()
120
+ return {
121
+ "runs": [
122
+ {"run_id": r[0], "started_at": _json_safe(r[1]), "models": r[2]}
123
+ for r in runs
124
+ ],
125
+ "latest_run": latest,
126
+ "incidents": incidents,
127
+ "signals": signals,
128
+ "nodes": len(nodes),
129
+ "rows_profiled": int(rows or 0),
130
+ }
131
+
132
+
133
+ @app.get("/api/lineage")
134
+ def lineage() -> dict:
135
+ con = _connect()
136
+ try:
137
+ affected = {
138
+ row[0] for row in con.execute(f"""
139
+ select distinct model_name from {METRICS_SCHEMA}.drift_signals
140
+ where run_id = (
141
+ select run_id from {METRICS_SCHEMA}.profile_runs
142
+ order by started_at desc limit 1
143
+ )
144
+ """).fetchall()
145
+ }
146
+ roots = {
147
+ row[0] for row in con.execute(f"""
148
+ select root from {METRICS_SCHEMA}.incidents
149
+ where run_id = (
150
+ select run_id from {METRICS_SCHEMA}.profile_runs
151
+ order by started_at desc limit 1
152
+ )
153
+ """).fetchall()
154
+ }
155
+ finally:
156
+ con.close()
157
+
158
+ nodes, parents = graph()
159
+ return {
160
+ "nodes": [
161
+ {
162
+ "name": name,
163
+ "kind": "source" if node.is_source else "model",
164
+ "materialized": node.materialized,
165
+ "state": "root" if name in roots
166
+ else "affected" if name in affected
167
+ else "ok",
168
+ }
169
+ for name, node in nodes.items()
170
+ ],
171
+ "edges": [
172
+ {"source": parent, "target": child}
173
+ for child, ps in parents.items() for parent in ps
174
+ ],
175
+ }
176
+
177
+
178
+ @app.get("/api/incidents")
179
+ def incidents() -> list[dict]:
180
+ con = _connect()
181
+ try:
182
+ rows = con.execute(f"""
183
+ select detected_at, root, is_source, severity, columns,
184
+ blast_radius, signal_count
185
+ from {METRICS_SCHEMA}.incidents
186
+ where run_id = (
187
+ select run_id from {METRICS_SCHEMA}.profile_runs
188
+ order by started_at desc limit 1
189
+ )
190
+ order by case severity
191
+ when 'critical' then 0 when 'high' then 1 else 2 end
192
+ """).fetchall()
193
+ finally:
194
+ con.close()
195
+
196
+ return [
197
+ {
198
+ "detected_at": _json_safe(r[0]),
199
+ "root": r[1],
200
+ "is_source": r[2],
201
+ "severity": r[3],
202
+ "columns": [c for c in (r[4] or "").split(", ") if c],
203
+ "blast_radius": [c for c in (r[5] or "").split(", ") if c],
204
+ "signal_count": r[6],
205
+ }
206
+ for r in rows
207
+ ]
208
+
209
+
210
+ @app.get("/api/incidents/{root}")
211
+ def incident_detail(root: str, explain: bool = False) -> dict:
212
+ con = _connect()
213
+ try:
214
+ found = [i for i in rca_mod.analyse(con) if i.root == root]
215
+ if not found:
216
+ raise HTTPException(404, f"No current incident rooted at {root!r}")
217
+ incident = found[0]
218
+ finally:
219
+ con.close()
220
+
221
+ payload = {
222
+ "root": incident.root,
223
+ "is_source": incident.is_source,
224
+ "severity": incident.severity,
225
+ "columns": incident.columns,
226
+ "blast_radius": incident.blast_radius,
227
+ "downstream_columns": incident.downstream_columns,
228
+ "evidence": [
229
+ {
230
+ "column": e.column_name,
231
+ "metric": e.metric,
232
+ "baseline": e.baseline,
233
+ "current": e.current,
234
+ "change": e.change,
235
+ "severity": e.severity,
236
+ "partitions": e.partitions,
237
+ }
238
+ for e in incident.evidence
239
+ ],
240
+ "explanation": None,
241
+ }
242
+
243
+ if explain:
244
+ # Cached by prompt hash, so this is usually free and always reproducible.
245
+ try:
246
+ payload["explanation"] = explain_mod.explain(incident)
247
+ except SystemExit as exc:
248
+ payload["explanation"] = {"error": str(exc)}
249
+
250
+ return payload
251
+
252
+
253
+ @app.get("/api/metrics/{model}/{column}")
254
+ def metric_history(model: str, column: str, metric: str = "mean_value") -> dict:
255
+ allowed = {"mean_value", "null_rate", "distinct_count", "row_count"}
256
+ if metric not in allowed:
257
+ raise HTTPException(400, f"metric must be one of {sorted(allowed)}")
258
+
259
+ con = _connect()
260
+ try:
261
+ runs = [
262
+ r[0] for r in con.execute(f"""
263
+ select run_id from {METRICS_SCHEMA}.profile_runs
264
+ order by started_at desc limit 2
265
+ """).fetchall()
266
+ ]
267
+ if not runs:
268
+ raise HTTPException(404, "No profile runs yet")
269
+
270
+ series = {}
271
+ for label, run_id in zip(("current", "baseline"), runs):
272
+ series[label] = [
273
+ {"date": _json_safe(r[0]), "value": r[1]}
274
+ for r in con.execute(f"""
275
+ select partition_value, {metric}
276
+ from {METRICS_SCHEMA}.partition_profiles
277
+ where run_id = ? and model_name = ? and column_name = ?
278
+ order by partition_value
279
+ """, [run_id, model, column]).fetchall()
280
+ ]
281
+ finally:
282
+ con.close()
283
+
284
+ return {"model": model, "column": column, "metric": metric, **series}
285
+
286
+
287
+ @app.get("/api/faults")
288
+ def list_faults() -> list[dict]:
289
+ return [
290
+ {"key": f.key, "description": f.description}
291
+ for f in faults_mod.FAULTS.values()
292
+ ]
293
+
294
+
295
+ # --------------------------------------------------------------------------
296
+ # write endpoints - these rebuild the warehouse, so they take the lock
297
+ # --------------------------------------------------------------------------
298
+
299
+ def _profile_and_analyse() -> dict:
300
+ con = _connect()
301
+ try:
302
+ run_id = run_profile(con)
303
+ signals = drift_mod.detect(con, run_id=run_id)
304
+ incidents = rca_mod.analyse(con, run_id=run_id)
305
+ finally:
306
+ con.close()
307
+
308
+ return {
309
+ "run_id": run_id,
310
+ "signals": len(signals),
311
+ "incidents": [
312
+ {"root": i.root, "severity": i.severity, "columns": i.columns}
313
+ for i in incidents
314
+ ],
315
+ }
316
+
317
+
318
+ def _rebuild_and_analyse() -> dict:
319
+ _dbt("run")
320
+ return _profile_and_analyse()
321
+
322
+
323
+ @app.post("/api/faults/{key}")
324
+ def inject_fault(key: str, since: str = "2024-03-01") -> dict:
325
+ if key not in faults_mod.FAULTS:
326
+ raise HTTPException(404, f"Unknown fault {key!r}")
327
+
328
+ with WAREHOUSE_LOCK:
329
+ con = _connect()
330
+ try:
331
+ faults_mod.reset(con, sample=DEMO_SAMPLE)
332
+ faults_mod.inject(con, key, since)
333
+ finally:
334
+ con.close()
335
+
336
+ result = _rebuild_and_analyse()
337
+
338
+ return {"injected": key, "since": since, **result}
339
+
340
+
341
+ @app.post("/api/reset")
342
+ def reset_warehouse() -> dict:
343
+ with WAREHOUSE_LOCK:
344
+ con = _connect()
345
+ try:
346
+ rows = faults_mod.reset(con, sample=DEMO_SAMPLE)
347
+ finally:
348
+ con.close()
349
+
350
+ _rebuild_and_analyse()
351
+ # Profile a second time so the two most recent runs are both clean.
352
+ # Without this the dashboard would show the repair itself as drift.
353
+ result = _profile_and_analyse()
354
+
355
+ return {"reset": True, "rows": rows, **result}
356
+
357
+
358
+ # --------------------------------------------------------------------------
359
+ # the built React app, when it exists
360
+ # --------------------------------------------------------------------------
361
+
362
+ if STATIC_DIR.exists():
363
+ app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
364
+
365
+ @app.get("/{full_path:path}")
366
+ def spa(full_path: str):
367
+ """Every non-API path returns index.html so client-side routing works."""
368
+ index = STATIC_DIR / "index.html"
369
+ if not index.exists():
370
+ raise HTTPException(404, "UI not built. Run: cd app && npm run build")
371
+ return FileResponse(index)