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 +1 -0
- upstrace/api.py +371 -0
- upstrace/cli.py +479 -0
- upstrace/config.py +26 -0
- upstrace/drift.py +524 -0
- upstrace/evaluate.py +255 -0
- upstrace/explain.py +121 -0
- upstrace/faults.py +86 -0
- upstrace/lineage.py +47 -0
- upstrace/llm.py +208 -0
- upstrace/manifest.py +84 -0
- upstrace/notify.py +237 -0
- upstrace/profiler.py +268 -0
- upstrace/rca.py +128 -0
- upstrace/report.py +640 -0
- upstrace/scenarios.py +221 -0
- upstrace/settings.py +286 -0
- upstrace/warehouse.py +66 -0
- upstrace-0.1.0.dist-info/METADATA +1078 -0
- upstrace-0.1.0.dist-info/RECORD +23 -0
- upstrace-0.1.0.dist-info/WHEEL +4 -0
- upstrace-0.1.0.dist-info/entry_points.txt +2 -0
- upstrace-0.1.0.dist-info/licenses/LICENSE +9 -0
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)
|