feat: secure metrics ingestion

#1
by burtenshaw HF Staff - opened
Files changed (5) hide show
  1. .gitignore +3 -0
  2. README.md +31 -1
  3. app.py +22 -2
  4. ingest.py +150 -0
  5. test_ingest.py +256 -0
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .ruff_cache/
README.md CHANGED
@@ -12,5 +12,35 @@ hf_oauth_scopes:
12
  ---
13
 
14
  Every [OpenEnv Arena](https://huggingface.co/spaces/openenvarena/arena) training run streams its numbers here while it
15
- trains: reward, loss and how long each step took. Runs are named after the participant and their submission, and the
16
  arena's own submissions page links each run to its curves. Built with [Trackio](https://github.com/gradio-app/trackio).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
  Every [OpenEnv Arena](https://huggingface.co/spaces/openenvarena/arena) training run streams its numbers here while it
15
+ trains: reward, loss and how long each step took. Each participant has a project with one run per training run, and the
16
  arena's own submissions page links each run to its curves. Built with [Trackio](https://github.com/gradio-app/trackio).
17
+
18
+ ## Worker ingestion
19
+
20
+ Set the Space secret `TRACKIO_INGEST_TOKEN` to the same dedicated random credential
21
+ configured on Arena's workers. It only authorizes `POST /ingest`; it is not a Hugging
22
+ Face token or Trackio's dashboard administration token. When unset, ingestion is
23
+ disabled and the dashboard continues serving existing runs.
24
+
25
+ Send `Authorization: Bearer <credential>` with a JSON body:
26
+
27
+ ```json
28
+ {"logs":[{"project":"burtenshaw","run":"arena-run-id","run_id":"arena-run-id","step":1,"log_id":"unique-event-id","metrics":{"reward":0.75,"arena/step_seconds":1.2},"config":{"submission_id":"submission-id"}}]}
29
+ ```
30
+
31
+ A batch contains 1–128 events for one project and run, up to 256 KiB. Each event
32
+ contains 1–128 finite numeric metrics. Project, run and event IDs contain only
33
+ ASCII letters, digits, `_` and `-`, up to 128 characters. `run_id` may be omitted;
34
+ when present it must equal `run`. Preserve each `log_id` when retrying delivery;
35
+ duplicates cannot replace existing metrics. Distinct events at the same step need
36
+ distinct IDs.
37
+
38
+ Optional config accepts strings `participant`, `submission_id`, `run_id`, `model`,
39
+ `model_revision`, `recipe`, and nonnegative numbers `num_updates`, `train_tasks`.
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
46
+ bucket-backed SQLite storage. Trackio remains pinned to `0.40.0`.
app.py CHANGED
@@ -1,2 +1,22 @@
1
- import trackio
2
- trackio.show()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import uvicorn
4
+ from starlette.routing import Route
5
+ from trackio.server import build_starlette_app_only
6
+
7
+ from ingest import ingest
8
+
9
+
10
+ def create_app():
11
+ # Keep Trackio's dashboard, authentication and mounted bucket configuration.
12
+ app, _ = build_starlette_app_only(mcp_server=False)
13
+ app.router.routes.insert(0, Route("/ingest", ingest, methods=["POST"]))
14
+ return app
15
+
16
+
17
+ if __name__ == "__main__":
18
+ uvicorn.run(
19
+ create_app(),
20
+ host="0.0.0.0",
21
+ port=int(os.environ.get("GRADIO_SERVER_PORT", "7860")),
22
+ )
ingest.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Append-only numeric metric ingestion for trusted Arena workers."""
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import re
7
+ import secrets
8
+ from threading import Lock
9
+
10
+ from starlette.concurrency import run_in_threadpool
11
+ from starlette.exceptions import HTTPException
12
+ from starlette.responses import JSONResponse
13
+ from trackio.sqlite_storage import SQLiteStorage
14
+
15
+ MAX_BODY_BYTES = 256 * 1024
16
+ MAX_LOGS = 128
17
+ MAX_METRICS = 128
18
+ IDENTIFIER = re.compile(r"[A-Za-z0-9_-]{1,128}\Z")
19
+ STRING_CONFIG = {
20
+ "participant",
21
+ "submission_id",
22
+ "run_id",
23
+ "model",
24
+ "model_revision",
25
+ "recipe",
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()
32
+
33
+
34
+ def _number(value):
35
+ if type(value) is int:
36
+ return -(2**63) <= value <= 2**63 - 1
37
+ return type(value) is float and math.isfinite(value)
38
+
39
+
40
+ def _identifier(value):
41
+ return isinstance(value, str) and IDENTIFIER.fullmatch(value) is not None
42
+
43
+
44
+ def _config(value):
45
+ if value is None:
46
+ return None
47
+ if not isinstance(value, dict) or value.keys() - STRING_CONFIG - NUMBER_CONFIG:
48
+ raise ValueError("config contains unsupported fields")
49
+ for key, item in value.items():
50
+ if key in STRING_CONFIG:
51
+ if not isinstance(item, str) or len(item) > 512:
52
+ raise ValueError(
53
+ "config string values must have at most 512 characters"
54
+ )
55
+ item.encode("utf-8")
56
+ elif not _number(item) or item < 0:
57
+ raise ValueError("config counts must be finite nonnegative numbers")
58
+ return value or None
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"]:
76
+ raise ValueError("run_id must equal run")
77
+ current = (entry["project"], entry["run"])
78
+ if identity is not None and current != identity:
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(
105
+ (
106
+ record
107
+ for record in SQLiteStorage.get_run_records(project)
108
+ if record["id"] == run
109
+ ),
110
+ None,
111
+ )
112
+ stored = SQLiteStorage.get_run_config(project, run, run_id=run)
113
+ if existing is not None or stored is not None:
114
+ if config is not None and config != stored:
115
+ raise HTTPException(
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):
132
+ expected = os.environ.get("TRACKIO_INGEST_TOKEN", "")
133
+ if not expected:
134
+ return JSONResponse(
135
+ {"error": "metric ingestion is not configured"}, status_code=503
136
+ )
137
+ supplied = request.headers.get("authorization", "")
138
+ if not secrets.compare_digest(supplied.encode(), f"Bearer {expected}".encode()):
139
+ return JSONResponse({"error": "invalid ingest credential"}, status_code=401)
140
+ body = bytearray()
141
+ async for chunk in request.stream():
142
+ if len(body) + len(chunk) > MAX_BODY_BYTES:
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)})
test_ingest.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import importlib
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import sys
7
+
8
+ import pytest
9
+ from starlette.applications import Starlette
10
+ from starlette.routing import Route
11
+ from starlette.testclient import TestClient
12
+
13
+
14
+ @pytest.fixture
15
+ def client(tmp_path, monkeypatch):
16
+ import trackio.sqlite_storage
17
+ import trackio.utils
18
+
19
+ monkeypatch.setattr(trackio.sqlite_storage, "TRACKIO_DIR", tmp_path)
20
+ monkeypatch.setattr(trackio.utils, "TRACKIO_DIR", tmp_path)
21
+ monkeypatch.setenv("TRACKIO_INGEST_TOKEN", "test-ingest-token")
22
+ module = importlib.import_module("ingest")
23
+ app = Starlette(routes=[Route("/ingest", module.ingest, methods=["POST"])])
24
+ with TestClient(app) as test_client:
25
+ test_client.headers["Authorization"] = "Bearer test-ingest-token"
26
+ yield test_client
27
+
28
+
29
+ def batch(**updates):
30
+ entry = {
31
+ "project": "burtenshaw",
32
+ "run": "arena-run-123",
33
+ "run_id": "arena-run-123",
34
+ "step": 1,
35
+ "log_id": "event-1",
36
+ "metrics": {"reward/mean": 0.75, "arena/step_seconds": 1.2},
37
+ "config": {
38
+ "participant": "space:burtenshaw",
39
+ "submission_id": "submission-123",
40
+ },
41
+ }
42
+ entry.update(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 (
49
+ client.post(
50
+ "/ingest", json=batch(), headers={"Authorization": authorization}
51
+ ).status_code
52
+ == 401
53
+ )
54
+ monkeypatch.delenv("TRACKIO_INGEST_TOKEN")
55
+ assert client.post("/ingest", json=batch()).status_code == 503
56
+
57
+
58
+ @pytest.mark.parametrize(
59
+ "updates",
60
+ [
61
+ {"project": "../another-project"},
62
+ {"run_id": "another-run"},
63
+ {"step": -1},
64
+ {"step": True},
65
+ {"log_id": ""},
66
+ {"metrics": {"loss": "private text"}},
67
+ {"metrics": {"loss": True}},
68
+ {"metrics": {"loss": 2**80}},
69
+ {"metrics": {"loss": {"_type": "html", "html": "<script>"}}},
70
+ {"metrics": {str(i): i for i in range(129)}},
71
+ {"config": {"HF_TOKEN": "not-allowed"}},
72
+ {"config": {"model": ["not-a-string"]}},
73
+ {"config": {"num_updates": 2**80}},
74
+ {"delete": True},
75
+ ],
76
+ )
77
+ def test_invalid_payload_rejected(client, updates):
78
+ assert client.post("/ingest", json=batch(**updates)).status_code == 400
79
+
80
+
81
+ def test_nonfinite_and_oversized_payload_rejected(client):
82
+ assert (
83
+ client.post(
84
+ "/ingest", content=json.dumps(batch(metrics={"loss": float("nan")}))
85
+ ).status_code
86
+ == 400
87
+ )
88
+ assert (
89
+ client.post(
90
+ "/ingest", content=json.dumps(batch(metrics={"loss": float("inf")}))
91
+ ).status_code
92
+ == 400
93
+ )
94
+ assert client.post("/ingest", content=b" " * (256 * 1024 + 1)).status_code == 413
95
+ assert (
96
+ client.post("/ingest", json={"logs": batch()["logs"] * 129}).status_code == 400
97
+ )
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "updates",
102
+ [
103
+ {"metrics": {"\ud800": 0.2}},
104
+ {"config": {"model": "\ud800"}},
105
+ ],
106
+ )
107
+ def test_unpaired_surrogates_rejected(client, updates):
108
+ response = client.post("/ingest", content=json.dumps(batch(**updates)))
109
+ assert response.status_code == 400
110
+
111
+
112
+ def test_retry_is_idempotent_and_cannot_overwrite_metric(client):
113
+ from trackio.sqlite_storage import SQLiteStorage
114
+
115
+ assert client.post("/ingest", json=batch()).json() == {"accepted": 1}
116
+ assert client.post("/ingest", json=batch()).status_code == 200
117
+ assert (
118
+ client.post("/ingest", json=batch(metrics={"reward/mean": 999})).status_code
119
+ == 200
120
+ )
121
+ with SQLiteStorage._get_connection(
122
+ SQLiteStorage.get_project_db_path("burtenshaw")
123
+ ) as conn:
124
+ rows = conn.execute("SELECT metrics FROM metrics").fetchall()
125
+ assert len(rows) == 1
126
+ assert json.loads(rows[0][0])["reward/mean"] == 0.75
127
+ assert (
128
+ client.post("/ingest", json=batch(log_id="event-2", config=None)).status_code
129
+ == 200
130
+ )
131
+
132
+
133
+ def test_existing_config_cannot_be_changed(client):
134
+ from trackio.sqlite_storage import SQLiteStorage
135
+
136
+ assert client.post("/ingest", json=batch()).status_code == 200
137
+ assert (
138
+ client.post(
139
+ "/ingest", json=batch(config={"model": "changed"}, log_id="event-2")
140
+ ).status_code
141
+ == 409
142
+ )
143
+ assert (
144
+ SQLiteStorage.get_run_config(
145
+ "burtenshaw", "arena-run-123", run_id="arena-run-123"
146
+ )
147
+ == batch()["logs"][0]["config"]
148
+ )
149
+
150
+
151
+ def test_config_cannot_be_added_to_an_existing_run(client):
152
+ assert client.post("/ingest", json=batch(config=None)).status_code == 200
153
+ assert client.post("/ingest", json=batch(log_id="event-2")).status_code == 409
154
+
155
+
156
+ def test_config_without_metric_rows_cannot_be_overwritten(client):
157
+ from trackio.sqlite_storage import SQLiteStorage
158
+
159
+ assert client.post("/ingest", json=batch()).status_code == 200
160
+ with SQLiteStorage._get_connection(
161
+ SQLiteStorage.get_project_db_path("burtenshaw")
162
+ ) as conn:
163
+ conn.execute("DELETE FROM metrics")
164
+ assert SQLiteStorage.get_run_records("burtenshaw") == []
165
+ assert (
166
+ client.post("/ingest", json=batch(config={"model": "changed"})).status_code
167
+ == 409
168
+ )
169
+ assert (
170
+ SQLiteStorage.get_all_run_configs("burtenshaw")["arena-run-123"]
171
+ == batch()["logs"][0]["config"]
172
+ )
173
+
174
+
175
+ def test_batches_cannot_mix_runs_or_mutate_partial_data(client):
176
+ from trackio.sqlite_storage import SQLiteStorage
177
+
178
+ payload = {"logs": batch()["logs"] + batch(run="another", run_id="another")["logs"]}
179
+ assert client.post("/ingest", json=payload).status_code == 400
180
+ assert SQLiteStorage.get_run_records("burtenshaw") == []
181
+
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):
188
+ pytest.fail("starting the dashboard must not provision or upload Hub resources")
189
+
190
+ for name in (
191
+ "create_repo",
192
+ "create_bucket",
193
+ "set_space_volumes",
194
+ "upload_file",
195
+ "add_space_secret",
196
+ ):
197
+ monkeypatch.setattr(huggingface_hub, name, unexpected_mutation)
198
+ monkeypatch.setattr(huggingface_hub.HfApi, name, unexpected_mutation)
199
+ monkeypatch.delenv("TRACKIO_INGEST_TOKEN")
200
+ monkeypatch.setenv("TRACKIO_DIR", "/data/trackio")
201
+ with TestClient(create_app()) as dashboard:
202
+ page = dashboard.get("/")
203
+ assert page.status_code == 200
204
+ asset = re.search(r'<script[^>]+src="([^"]+)"', page.text)
205
+ assert asset is not None
206
+ assert dashboard.get(asset.group(1)).status_code == 200
207
+ assert dashboard.post("/ingest", json=batch()).status_code == 503
208
+ assert os.environ["TRACKIO_DIR"] == "/data/trackio"
209
+
210
+
211
+ def test_metrics_survive_fresh_process_with_same_storage(client, tmp_path):
212
+ assert client.post("/ingest", json=batch()).status_code == 200
213
+ result = subprocess.run(
214
+ [
215
+ sys.executable,
216
+ "-c",
217
+ """
218
+ import json
219
+ from trackio.sqlite_storage import SQLiteStorage
220
+ rows = SQLiteStorage.get_run_records('burtenshaw')
221
+ assert rows[0]['id'] == 'arena-run-123'
222
+ with SQLiteStorage._get_connection(SQLiteStorage.get_project_db_path('burtenshaw')) as conn:
223
+ row = conn.execute('SELECT metrics FROM metrics').fetchone()
224
+ assert json.loads(row[0])['reward/mean'] == 0.75
225
+ print('persisted metrics recovered')
226
+ """,
227
+ ],
228
+ env={**os.environ, "TRACKIO_DIR": str(tmp_path)},
229
+ capture_output=True,
230
+ text=True,
231
+ check=True,
232
+ )
233
+ assert "persisted metrics recovered" in result.stdout
234
+
235
+
236
+ def test_dashboard_does_not_accept_ingest_token_for_delete_or_upload(
237
+ client, monkeypatch
238
+ ):
239
+ monkeypatch.setenv("SYSTEM", "spaces")
240
+ from app import create_app
241
+
242
+ assert client.post("/ingest", json=batch()).status_code == 200
243
+ with TestClient(create_app()) as dashboard:
244
+ dashboard.headers["Authorization"] = "Bearer test-ingest-token"
245
+ response = dashboard.post(
246
+ "/api/delete_run",
247
+ json={"kwargs": {"project": "burtenshaw", "run": "arena-run-123"}},
248
+ )
249
+ assert response.status_code >= 400 or response.json().get("error")
250
+ response = dashboard.post(
251
+ "/api/upload_db_to_space",
252
+ json={
253
+ "kwargs": {"project": "burtenshaw", "uploaded_db": {}, "hf_token": None}
254
+ },
255
+ )
256
+ assert response.status_code >= 400 or response.json().get("error")