feat: trackio alerts

#2
by burtenshaw HF Staff - opened
Files changed (3) hide show
  1. README.md +19 -0
  2. ingest.py +100 -35
  3. test_ingest.py +163 -0
README.md CHANGED
@@ -40,6 +40,25 @@ Optional config accepts strings `participant`, `submission_id`, `run_id`, `model
40
  Supply it on the first batch; it cannot be changed after a run exists. Repeating
41
  the same config on subsequent events is supported.
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  The existing `openenvarena/training-dashboard` bucket stays mounted at `/data`, with
44
  `TRACKIO_DIR=/data/trackio`. The app uses this mounted storage directly and does not
45
  provision Spaces, buckets or credentials. Run one server process for Trackio's
 
40
  Supply it on the first batch; it cannot be changed after a run exists. Repeating
41
  the same config on subsequent events is supported.
42
 
43
+ The same endpoint accepts an `alerts` list, alone or alongside `logs`:
44
+
45
+ ```json
46
+ {"alerts":[{"project":"burtenshaw","run":"arena-run-id","run_id":"arena-run-id","alert_id":"sandbox-cleanup-39","title":"Sandbox cleanup timed out","text":"Training ended after 39 updates. Evaluation will use the last checkpoint.","level":"error","step":39}]}
47
+ ```
48
+
49
+ An alert has a required stable `alert_id`, a nonempty `title` of up to 200 characters,
50
+ optional plain `text` of up to 4,000 characters, and `level` of `info`, `warn`, or
51
+ `error`. Its optional `step` is a nonnegative integer or null. Identity and config
52
+ follow the same rules as logs. The combined batch limit is 128 events; the entire
53
+ batch must describe one run. Retry with the same `alert_id`; retries cannot replace
54
+ an existing alert. Alert-only failures register a run without inventing numeric metrics.
55
+
56
+ Alerts are public. Send bounded messages describing the failure and next action;
57
+ never include credentials, private trajectories, or raw exception payloads. They
58
+ appear in Trackio's run-filtered Reports page at
59
+ `/reports?project=<participant>&runs=<full-run-id>`. Trackio 0.40.0 does not expose
60
+ individual-alert anchors or a URL parameter for the severity filter.
61
+
62
  The existing `openenvarena/training-dashboard` bucket stays mounted at `/data`, with
63
  `TRACKIO_DIR=/data/trackio`. The app uses this mounted storage directly and does not
64
  provision Spaces, buckets or credentials. Run one server process for Trackio's
ingest.py CHANGED
@@ -1,4 +1,4 @@
1
- """Append-only numeric metric ingestion for trusted Arena workers."""
2
 
3
  import json
4
  import math
@@ -26,6 +26,17 @@ STRING_CONFIG = {
26
  }
27
  NUMBER_CONFIG = {"num_updates", "train_tasks"}
28
  LOG_FIELDS = {"project", "run", "run_id", "step", "log_id", "metrics", "config"}
 
 
 
 
 
 
 
 
 
 
 
29
  # Trackio's bucket-backed SQLite storage supports one server process. Serialize
30
  # the configuration check with ingestion so two first batches cannot replace it.
31
  _write_lock = Lock()
@@ -59,17 +70,28 @@ def _config(value):
59
 
60
 
61
  def validate(payload):
62
- if not isinstance(payload, dict) or set(payload) != {"logs"}:
63
- raise ValueError("expected an object containing only logs")
64
- logs = payload["logs"]
65
- if not isinstance(logs, list) or not 1 <= len(logs) <= MAX_LOGS:
66
- raise ValueError(f"logs must contain 1 to {MAX_LOGS} entries")
 
 
 
 
 
 
 
 
67
  identity = None
68
  config = None
69
- for entry in logs:
70
- if not isinstance(entry, dict) or entry.keys() - LOG_FIELDS:
71
- raise ValueError("log contains unsupported fields")
72
- for key in ("project", "run", "log_id"):
 
 
 
73
  if not _identifier(entry.get(key)):
74
  raise ValueError(f"{key} must contain 1 to 128 letters, digits, _ or -")
75
  if entry.get("run_id", entry["run"]) != entry["run"]:
@@ -79,26 +101,45 @@ def validate(payload):
79
  raise ValueError("a batch must contain one project and run")
80
  identity = current
81
  step = entry.get("step")
82
- if type(step) is not int or not 0 <= step <= 2**63 - 1:
 
 
83
  raise ValueError("step must be a nonnegative 64-bit integer")
84
- metrics = entry.get("metrics")
85
- if not isinstance(metrics, dict) or not 1 <= len(metrics) <= MAX_METRICS:
86
- raise ValueError(f"metrics must contain 1 to {MAX_METRICS} numeric values")
87
- for key, value in metrics.items():
88
- if not isinstance(key, str) or not 1 <= len(key) <= 128:
89
- raise ValueError("metric names must contain 1 to 128 characters")
90
- key.encode("utf-8")
91
- if not _number(value):
92
- raise ValueError("metrics must contain only finite numbers")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  entry_config = _config(entry.get("config"))
94
  if entry_config is not None:
95
  if config is not None and entry_config != config:
96
  raise ValueError("config must be consistent within a batch")
97
  config = entry_config
98
- return logs, identity, config
99
 
100
 
101
- def append(logs, identity, config):
102
  project, run = identity
103
  with _write_lock:
104
  existing = next(
@@ -116,16 +157,40 @@ def append(logs, identity, config):
116
  409, "configuration of an existing run is immutable"
117
  )
118
  config = None
119
- SQLiteStorage.bulk_log(
120
- project=project,
121
- run=run,
122
- run_id=run,
123
- metrics_list=[entry["metrics"] for entry in logs],
124
- steps=[entry["step"] for entry in logs],
125
- # Scope event IDs to the run; retries cannot replace existing rows.
126
- log_ids=[f"{run}:{entry['log_id']}" for entry in logs],
127
- config=config,
128
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
 
131
  async def ingest(request):
@@ -143,8 +208,8 @@ async def ingest(request):
143
  return JSONResponse({"error": "metric batch is too large"}, status_code=413)
144
  body.extend(chunk)
145
  try:
146
- logs, identity, config = validate(json.loads(body))
147
  except (ValueError, OverflowError, RecursionError):
148
  return JSONResponse({"error": "invalid metric batch"}, status_code=400)
149
- await run_in_threadpool(append, logs, identity, config)
150
- return JSONResponse({"accepted": len(logs)})
 
1
+ """Append-only numeric metric and public alert ingestion for trusted Arena workers."""
2
 
3
  import json
4
  import math
 
26
  }
27
  NUMBER_CONFIG = {"num_updates", "train_tasks"}
28
  LOG_FIELDS = {"project", "run", "run_id", "step", "log_id", "metrics", "config"}
29
+ ALERT_FIELDS = {
30
+ "project",
31
+ "run",
32
+ "run_id",
33
+ "step",
34
+ "alert_id",
35
+ "title",
36
+ "text",
37
+ "level",
38
+ "config",
39
+ }
40
  # Trackio's bucket-backed SQLite storage supports one server process. Serialize
41
  # the configuration check with ingestion so two first batches cannot replace it.
42
  _write_lock = Lock()
 
70
 
71
 
72
  def validate(payload):
73
+ if (
74
+ not isinstance(payload, dict)
75
+ or not payload
76
+ or payload.keys() - {"logs", "alerts"}
77
+ ):
78
+ raise ValueError("expected an object containing logs or alerts")
79
+ logs, alerts = payload.get("logs", []), payload.get("alerts", [])
80
+ if (
81
+ not isinstance(logs, list)
82
+ or not isinstance(alerts, list)
83
+ or not 1 <= len(logs) + len(alerts) <= MAX_LOGS
84
+ ):
85
+ raise ValueError(f"batch must contain 1 to {MAX_LOGS} entries")
86
  identity = None
87
  config = None
88
+ for entry, is_alert in [(item, False) for item in logs] + [
89
+ (item, True) for item in alerts
90
+ ]:
91
+ fields = ALERT_FIELDS if is_alert else LOG_FIELDS
92
+ if not isinstance(entry, dict) or entry.keys() - fields:
93
+ raise ValueError("entry contains unsupported fields")
94
+ for key in ("project", "run", "alert_id" if is_alert else "log_id"):
95
  if not _identifier(entry.get(key)):
96
  raise ValueError(f"{key} must contain 1 to 128 letters, digits, _ or -")
97
  if entry.get("run_id", entry["run"]) != entry["run"]:
 
101
  raise ValueError("a batch must contain one project and run")
102
  identity = current
103
  step = entry.get("step")
104
+ if not (is_alert and step is None) and (
105
+ type(step) is not int or not 0 <= step <= 2**63 - 1
106
+ ):
107
  raise ValueError("step must be a nonnegative 64-bit integer")
108
+ if is_alert:
109
+ if entry.get("level") not in ("info", "warn", "error"):
110
+ raise ValueError("alert level must be info, warn or error")
111
+ for key, limit in (("title", 200), ("text", 4000)):
112
+ value = entry.get(key)
113
+ if key == "text" and value is None:
114
+ continue
115
+ if (
116
+ not isinstance(value, str)
117
+ or len(value) > limit
118
+ or (key == "title" and not value.strip())
119
+ ):
120
+ raise ValueError("alert text must be bounded plain text")
121
+ value.encode("utf-8")
122
+ else:
123
+ metrics = entry.get("metrics")
124
+ if not isinstance(metrics, dict) or not 1 <= len(metrics) <= MAX_METRICS:
125
+ raise ValueError(
126
+ f"metrics must contain 1 to {MAX_METRICS} numeric values"
127
+ )
128
+ for key, value in metrics.items():
129
+ if not isinstance(key, str) or not 1 <= len(key) <= 128:
130
+ raise ValueError("metric names must contain 1 to 128 characters")
131
+ key.encode("utf-8")
132
+ if not _number(value):
133
+ raise ValueError("metrics must contain only finite numbers")
134
  entry_config = _config(entry.get("config"))
135
  if entry_config is not None:
136
  if config is not None and entry_config != config:
137
  raise ValueError("config must be consistent within a batch")
138
  config = entry_config
139
+ return logs, alerts, identity, config
140
 
141
 
142
+ def append(logs, alerts, identity, config):
143
  project, run = identity
144
  with _write_lock:
145
  existing = next(
 
157
  409, "configuration of an existing run is immutable"
158
  )
159
  config = None
160
+ if logs:
161
+ SQLiteStorage.bulk_log(
162
+ project=project,
163
+ run=run,
164
+ run_id=run,
165
+ metrics_list=[entry["metrics"] for entry in logs],
166
+ steps=[entry["step"] for entry in logs],
167
+ # Scope event IDs to the run; retries cannot replace existing rows.
168
+ log_ids=[f"{run}:{entry['log_id']}" for entry in logs],
169
+ config=config,
170
+ )
171
+ elif existing is None:
172
+ # Trackio discovers runs from metrics. Register early failures without
173
+ # inventing a metric so the run-filtered Reports link can find them.
174
+ SQLiteStorage.bulk_log(
175
+ project=project,
176
+ run=run,
177
+ run_id=run,
178
+ metrics_list=[{}],
179
+ steps=[alerts[0].get("step") or 0],
180
+ log_ids=[f"{run}:alert:init"],
181
+ config=config,
182
+ )
183
+ if alerts:
184
+ SQLiteStorage.bulk_alert(
185
+ project=project,
186
+ run=run,
187
+ run_id=run,
188
+ titles=[entry["title"] for entry in alerts],
189
+ texts=[entry.get("text") for entry in alerts],
190
+ levels=[entry["level"] for entry in alerts],
191
+ steps=[entry.get("step") for entry in alerts],
192
+ alert_ids=[f"{run}:{entry['alert_id']}" for entry in alerts],
193
+ )
194
 
195
 
196
  async def ingest(request):
 
208
  return JSONResponse({"error": "metric batch is too large"}, status_code=413)
209
  body.extend(chunk)
210
  try:
211
+ logs, alerts, identity, config = validate(json.loads(body))
212
  except (ValueError, OverflowError, RecursionError):
213
  return JSONResponse({"error": "invalid metric batch"}, status_code=400)
214
+ await run_in_threadpool(append, logs, alerts, identity, config)
215
+ return JSONResponse({"accepted": len(logs) + len(alerts)})
test_ingest.py CHANGED
@@ -43,6 +43,22 @@ def batch(**updates):
43
  return {"logs": [entry]}
44
 
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  def test_missing_or_invalid_auth_cannot_write(client, monkeypatch):
47
  for authorization in ("", "Bearer wrong", "test-ingest-token"):
48
  assert (
@@ -182,6 +198,7 @@ def test_batches_cannot_mix_runs_or_mutate_partial_data(client):
182
 
183
  def test_dashboard_still_serves_without_ingest_credentials(client, monkeypatch):
184
  import huggingface_hub
 
185
  from app import create_app
186
 
187
  def unexpected_mutation(*args, **kwargs):
@@ -254,3 +271,149 @@ def test_dashboard_does_not_accept_ingest_token_for_delete_or_upload(
254
  },
255
  )
256
  assert response.status_code >= 400 or response.json().get("error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return {"logs": [entry]}
44
 
45
 
46
+ def alert_batch(**updates):
47
+ entry = {
48
+ "project": "burtenshaw",
49
+ "run": "arena-run-123",
50
+ "run_id": "arena-run-123",
51
+ "step": 39,
52
+ "alert_id": "failure-39",
53
+ "title": "Sandbox cleanup timed out",
54
+ "text": "Training ended after 39 updates. Evaluation will use the last checkpoint.",
55
+ "level": "error",
56
+ "config": batch()["logs"][0]["config"],
57
+ }
58
+ entry.update(updates)
59
+ return {"alerts": [entry]}
60
+
61
+
62
  def test_missing_or_invalid_auth_cannot_write(client, monkeypatch):
63
  for authorization in ("", "Bearer wrong", "test-ingest-token"):
64
  assert (
 
198
 
199
  def test_dashboard_still_serves_without_ingest_credentials(client, monkeypatch):
200
  import huggingface_hub
201
+
202
  from app import create_app
203
 
204
  def unexpected_mutation(*args, **kwargs):
 
271
  },
272
  )
273
  assert response.status_code >= 400 or response.json().get("error")
274
+
275
+
276
+ def test_alert_retry_is_idempotent_and_does_not_replace_the_original(client):
277
+ from trackio.sqlite_storage import SQLiteStorage
278
+
279
+ assert client.post("/ingest", json=alert_batch()).json() == {"accepted": 1}
280
+ assert client.post("/ingest", json=alert_batch()).status_code == 200
281
+ assert (
282
+ client.post("/ingest", json=alert_batch(text="replacement")).status_code == 200
283
+ )
284
+ assert (
285
+ client.post("/ingest", json=alert_batch(alert_id="failure-40")).status_code
286
+ == 200
287
+ )
288
+ alerts = SQLiteStorage.get_alerts("burtenshaw", run_id="arena-run-123")
289
+ assert len(alerts) == 2
290
+ assert all(alert["text"] == alert_batch()["alerts"][0]["text"] for alert in alerts)
291
+ assert all(alert["level"] == "error" and alert["step"] == 39 for alert in alerts)
292
+
293
+
294
+ def test_early_alert_registers_a_visible_run_without_fabricating_metrics(client):
295
+ from trackio.sqlite_storage import SQLiteStorage
296
+
297
+ from app import create_app
298
+
299
+ assert client.post("/ingest", json=alert_batch(step=None)).status_code == 200
300
+ records = SQLiteStorage.get_run_records("burtenshaw")
301
+ assert [record["id"] for record in records] == ["arena-run-123"]
302
+ with SQLiteStorage._get_connection(
303
+ SQLiteStorage.get_project_db_path("burtenshaw")
304
+ ) as conn:
305
+ metrics = conn.execute("SELECT metrics FROM metrics").fetchall()
306
+ assert [json.loads(row[0]) for row in metrics] == [{}]
307
+ with TestClient(create_app()) as dashboard:
308
+ assert (
309
+ dashboard.get("/reports?project=burtenshaw&runs=arena-run-123").status_code
310
+ == 200
311
+ )
312
+ response = dashboard.post(
313
+ "/api/get_alerts",
314
+ json={
315
+ "kwargs": {
316
+ "project": "burtenshaw",
317
+ "run": "arena-run-123",
318
+ "level": "error",
319
+ }
320
+ },
321
+ )
322
+ assert response.status_code == 200
323
+ alerts = response.json()["data"]
324
+ assert len(alerts) == 1
325
+ assert alerts[0]["title"] == "Sandbox cleanup timed out"
326
+ assert alerts[0]["step"] is None
327
+ # The next real metric can use the same immutable configuration.
328
+ assert client.post("/ingest", json=batch()).status_code == 200
329
+
330
+
331
+ def test_metrics_and_alerts_share_run_identity_and_immutable_config(client):
332
+ from trackio.sqlite_storage import SQLiteStorage
333
+
334
+ payload = {**batch(), **alert_batch()}
335
+ assert client.post("/ingest", json=payload).json() == {"accepted": 2}
336
+ assert (
337
+ client.post(
338
+ "/ingest", json=alert_batch(config={"model": "changed"})
339
+ ).status_code
340
+ == 409
341
+ )
342
+ assert len(SQLiteStorage.get_alerts("burtenshaw", run_id="arena-run-123")) == 1
343
+ another = alert_batch(run="another", run_id="another")
344
+ assert client.post("/ingest", json=another).status_code == 200
345
+ assert len(SQLiteStorage.get_alerts("burtenshaw", run_id="another")) == 1
346
+
347
+
348
+ @pytest.mark.parametrize(
349
+ "updates",
350
+ [
351
+ {"alert_id": ""},
352
+ {"title": ""},
353
+ {"title": " "},
354
+ {"title": "x" * 201},
355
+ {"title": {"html": "not plain text"}},
356
+ {"text": "x" * 4001},
357
+ {"text": 5},
358
+ {"level": "ERROR"},
359
+ {"level": ["error"]},
360
+ {"step": -1},
361
+ {"step": True},
362
+ {"step": 2**64},
363
+ {"run_id": "another"},
364
+ {"HF_TOKEN": "forbidden"},
365
+ ],
366
+ )
367
+ def test_invalid_alert_rejected_before_any_metric_write(client, updates):
368
+ from trackio.sqlite_storage import SQLiteStorage
369
+
370
+ payload = {**batch(), **alert_batch(**updates)}
371
+ assert client.post("/ingest", json=payload).status_code == 400
372
+ assert SQLiteStorage.get_run_records("burtenshaw") == []
373
+
374
+
375
+ def test_alerts_have_the_same_auth_batch_and_unicode_limits(client):
376
+ for authorization in ("", "Bearer wrong"):
377
+ assert (
378
+ client.post(
379
+ "/ingest", json=alert_batch(), headers={"Authorization": authorization}
380
+ ).status_code
381
+ == 401
382
+ )
383
+ payload = {**batch(), "alerts": alert_batch()["alerts"] * 128}
384
+ assert client.post("/ingest", json=payload).status_code == 400
385
+ for payload in (
386
+ {},
387
+ {"alerts": []},
388
+ {"logs": [], "alerts": []},
389
+ {"alerts": {}},
390
+ {**batch(), **alert_batch(run="another", run_id="another")},
391
+ ):
392
+ assert client.post("/ingest", json=payload).status_code == 400
393
+ for field in ("title", "text"):
394
+ assert (
395
+ client.post(
396
+ "/ingest", content=json.dumps(alert_batch(**{field: "\ud800"}))
397
+ ).status_code
398
+ == 400
399
+ )
400
+
401
+
402
+ def test_alerts_survive_fresh_process_with_same_storage(client, tmp_path):
403
+ assert client.post("/ingest", json=alert_batch()).status_code == 200
404
+ result = subprocess.run(
405
+ [
406
+ sys.executable,
407
+ "-c",
408
+ (
409
+ "from trackio.sqlite_storage import SQLiteStorage; "
410
+ "alerts = SQLiteStorage.get_alerts('burtenshaw', run_id='arena-run-123'); "
411
+ "assert len(alerts) == 1 and alerts[0]['step'] == 39"
412
+ ),
413
+ ],
414
+ env={**os.environ, "TRACKIO_DIR": str(tmp_path)},
415
+ capture_output=True,
416
+ text=True,
417
+ check=False,
418
+ )
419
+ assert result.returncode == 0, result.stderr