av-test: generic page and bounded numeric-only receipts
This commit is contained in:
parent
1e06701d69
commit
dd394b3eb3
@ -1,4 +1,4 @@
|
||||
# Static assets only; no API, uploads, credentials or outbound connections.
|
||||
# Static page plus numeric-only receipts; no media, installed credentials or egress.
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
@ -36,14 +36,30 @@ spec:
|
||||
requests: {cpu: 10m, memory: 16Mi}
|
||||
limits: {cpu: 100m, memory: 64Mi}
|
||||
readinessProbe:
|
||||
httpGet: {path: /lesavka-av-test/, port: http}
|
||||
httpGet: {path: /av-test/, port: http}
|
||||
livenessProbe:
|
||||
httpGet: {path: /lesavka-av-test/, port: http}
|
||||
httpGet: {path: /av-test/, port: http}
|
||||
initialDelaySeconds: 10
|
||||
volumeMounts:
|
||||
- {name: config, mountPath: /etc/nginx/observer, readOnly: true}
|
||||
- {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true}
|
||||
- {name: assets, mountPath: /usr/share/nginx/html/av-test, readOnly: true}
|
||||
- {name: tmp, mountPath: /tmp}
|
||||
- name: receipts
|
||||
image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6
|
||||
command: [python, -u, -B, /app/server.py]
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities: {drop: [ALL]}
|
||||
resources:
|
||||
requests: {cpu: 10m, memory: 16Mi}
|
||||
limits: {cpu: 100m, memory: 64Mi}
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: [python, -c, "import socket; socket.create_connection(('127.0.0.1', 8081), 2).close()"]
|
||||
periodSeconds: 10
|
||||
volumeMounts:
|
||||
- {name: receipts, mountPath: /app, readOnly: true}
|
||||
volumes:
|
||||
- name: config
|
||||
configMap: {name: lesavka-av-observer-nginx}
|
||||
@ -51,3 +67,5 @@ spec:
|
||||
configMap: {name: lesavka-av-observer-assets}
|
||||
- name: tmp
|
||||
emptyDir: {medium: Memory, sizeLimit: 8Mi}
|
||||
- name: receipts
|
||||
configMap: {name: lesavka-av-observer-receipts}
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'none'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
|
||||
<title>Lesavka — bundled A/V observer</title>
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
|
||||
<title>A/V Test</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<main><h1>Bundled A/V observer</h1>
|
||||
<p>No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.</p>
|
||||
<main><h1>A/V Test</h1>
|
||||
<p>Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.</p>
|
||||
<div><button id="devices">Choose devices…</button>
|
||||
<label>Video <select id="video-device"></select></label>
|
||||
<label>Microphone <select id="audio-device"></select></label></div>
|
||||
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
|
||||
<p id="status" role="status">Stopped</p>
|
||||
<p id="report-status" role="status">Reporting starts with the test.</p>
|
||||
<video id="preview" autoplay muted playsinline></video>
|
||||
<pre id="metrics">No observations.</pre>
|
||||
<p>Sync and capture-to-RCT freshness: <strong>not measured by this observer</strong>.
|
||||
<p>Audio/video sync and end-to-end delay: <strong>not measured by this check</strong>.
|
||||
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
|
||||
Use the qualified paired-marker/clock-bounded probe for release acceptance.</p>
|
||||
A separate paired-signal measurement is needed to verify synchronization.</p>
|
||||
</main><script type="module" src="observer.js"></script></html>
|
||||
|
||||
@ -15,6 +15,12 @@ spec:
|
||||
- host: bstein.dev
|
||||
http:
|
||||
paths:
|
||||
- path: /av-test
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: lesavka-av-observer
|
||||
port: {number: 80}
|
||||
- path: /lesavka-av-test
|
||||
pathType: Prefix
|
||||
backend:
|
||||
|
||||
@ -13,5 +13,11 @@ configMapGenerator:
|
||||
- index.html
|
||||
- observer.js
|
||||
- metrics.js
|
||||
- reporting.js
|
||||
- activity-worklet.js
|
||||
- style.css
|
||||
- name: lesavka-av-observer-receipts
|
||||
files:
|
||||
- report-service/server.py
|
||||
- report-service/receipts.py
|
||||
- report-service/validation.py
|
||||
|
||||
@ -23,7 +23,7 @@ export class VideoMetrics {
|
||||
}
|
||||
|
||||
export function receipt(video,audio,settings,elapsedMs,reason) {
|
||||
return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs,
|
||||
return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs,
|
||||
settings,video:video.summary(),audio,
|
||||
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
|
||||
releaseEligible:false,recordedMedia:false};
|
||||
|
||||
@ -7,7 +7,9 @@ http {
|
||||
default_type application/octet-stream;
|
||||
access_log off;
|
||||
server_tokens off;
|
||||
client_max_body_size 1k;
|
||||
client_max_body_size 4k;
|
||||
client_body_timeout 5s;
|
||||
client_header_timeout 5s;
|
||||
client_body_temp_path /tmp/client;
|
||||
proxy_temp_path /tmp/proxy;
|
||||
fastcgi_temp_path /tmp/fastcgi;
|
||||
@ -17,19 +19,37 @@ http {
|
||||
listen 8080;
|
||||
absolute_redirect off;
|
||||
root /usr/share/nginx/html;
|
||||
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "no-referrer" always;
|
||||
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
|
||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
||||
location = /lesavka-av-test { return 308 /lesavka-av-test/; }
|
||||
location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; }
|
||||
location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ {
|
||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
||||
location = /lesavka-av-test { return 308 /av-test/; }
|
||||
location = /lesavka-av-test/ { return 308 /av-test/; }
|
||||
location = /lesavka-av-test/index.html { return 308 /av-test/; }
|
||||
location = /av-test { return 308 /av-test/; }
|
||||
location = /av-test/ {
|
||||
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||
try_files /av-test/index.html =404;
|
||||
}
|
||||
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|style\.css)$ {
|
||||
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||
try_files $uri =404;
|
||||
}
|
||||
location ~ ^/av-test/api/(start|report)$ {
|
||||
if ($request_method != POST) { return 405; }
|
||||
proxy_pass http://127.0.0.1:8081;
|
||||
proxy_connect_timeout 2s;
|
||||
proxy_read_timeout 5s;
|
||||
proxy_send_timeout 5s;
|
||||
proxy_pass_request_headers off;
|
||||
proxy_set_header Content-Type $http_content_type;
|
||||
proxy_set_header Origin $http_origin;
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
proxy_set_header Host localhost;
|
||||
}
|
||||
location / { return 404; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {VideoMetrics,receipt} from './metrics.js';
|
||||
import {RunReporter} from './reporting.js';
|
||||
const el=id=>document.getElementById(id);
|
||||
let generation=0, current=null, last=null;
|
||||
let generation=0, current=null, last=null, reportingRun=null;
|
||||
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
|
||||
function release(run) {
|
||||
if(run.released)return;
|
||||
@ -18,6 +19,7 @@ function stop(reason='operator') {
|
||||
if(run) {
|
||||
release(run);
|
||||
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
|
||||
run.reporter.finish(last);
|
||||
el('metrics').textContent=JSON.stringify(last,null,2);
|
||||
el('export').disabled=false;
|
||||
}
|
||||
@ -40,11 +42,11 @@ el('devices').onclick=async()=>{
|
||||
if(generation!==ticket)return;
|
||||
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
|
||||
const select=el(id), saved=select.value;
|
||||
select.replaceChildren(new Option('Select Lesavka device',''));
|
||||
select.replaceChildren(new Option('Select a device',''));
|
||||
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
|
||||
select.value=saved;
|
||||
}
|
||||
el('status').textContent='Select both Lesavka devices, then Start.';
|
||||
el('status').textContent='Select a camera and microphone, then Start.';
|
||||
el('devices').disabled=false; el('stop').disabled=true;
|
||||
} catch { if(generation===ticket)stop('device permission unavailable'); }
|
||||
finally {clearTimeout(timer);}
|
||||
@ -61,6 +63,10 @@ el('start').onclick=async()=>{
|
||||
const ticket=++generation;
|
||||
const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
|
||||
current=run; last=null;
|
||||
reportingRun=run;
|
||||
run.reporter=new RunReporter(message=>{
|
||||
if(reportingRun===run)el('report-status').textContent=message;
|
||||
});
|
||||
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
|
||||
el('status').textContent='Starting…';
|
||||
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
|
||||
@ -84,7 +90,9 @@ el('start').onclick=async()=>{
|
||||
run.node.port.onmessage=({data})=>{
|
||||
if(current!==run)return;
|
||||
run.audio=data; run.node.port.postMessage('ack');
|
||||
el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2);
|
||||
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
|
||||
el('metrics').textContent=JSON.stringify(sample,null,2);
|
||||
if(run.observing)run.reporter.observe(sample);
|
||||
};
|
||||
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
|
||||
run.source.connect(run.node);run.node.connect(run.context.destination);
|
||||
@ -95,6 +103,7 @@ el('start').onclick=async()=>{
|
||||
if(generation!==ticket)return;
|
||||
clearTimeout(run.setupTimer);
|
||||
run.started=performance.now();
|
||||
run.observing=true;
|
||||
function frame(now,metadata) {
|
||||
if(current!==run)return;
|
||||
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
|
||||
@ -110,7 +119,7 @@ el('stop').onclick=()=>stop();
|
||||
el('export').onclick=()=>{
|
||||
if(!last)return;
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
|
||||
const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click();
|
||||
const link=document.createElement('a');link.href=url;link.download='av-test-metrics.json';link.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
};
|
||||
window.addEventListener('pagehide',()=>stop('page closed'));
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
"""Bounded ephemeral write capabilities; validated numeric receipts go to stdout.
|
||||
|
||||
These are unverified browser diagnostics, never release-gate attestations.
|
||||
No public read API, database, cookies, media, device identities or credentials.
|
||||
"""
|
||||
from collections import deque
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
|
||||
from validation import validate_report
|
||||
|
||||
|
||||
class Refused(Exception):
|
||||
def __init__(self, status):
|
||||
self.status = status
|
||||
|
||||
|
||||
class ReceiptStore:
|
||||
def __init__(self, emit, clock=time.monotonic, wall=time.time):
|
||||
self.emit, self.clock, self.wall = emit, clock, wall
|
||||
self.runs, self.starts = {}, deque()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
self.runs = {key: value for key, value in self.runs.items() if value["expires"] > now}
|
||||
while self.starts and now-self.starts[0] >= 60:
|
||||
self.starts.popleft()
|
||||
if len(self.starts) >= 12 or len(self.runs) >= 64:
|
||||
raise Refused(429)
|
||||
run_id, token = secrets.token_hex(16), secrets.token_urlsafe(32)
|
||||
self.runs[run_id] = {"token": hashlib.sha256(token.encode()).digest(), "expires": now+600,
|
||||
"sequence": 0, "digest": None, "terminal": False, "last_progress": None}
|
||||
self.starts.append(now)
|
||||
self.log({"run_id": run_id, "sequence": 0, "state": "starting", "metrics": None})
|
||||
return {"run_id": run_id, "write_token": token}
|
||||
|
||||
def report(self, body, token):
|
||||
data = validate_report(body)
|
||||
if type(token) is not str or len(token) != 43:
|
||||
raise Refused(401)
|
||||
with self.lock:
|
||||
now = self.clock()
|
||||
run = self.runs.get(data["run_id"])
|
||||
if not run or run["expires"] <= now or not secrets.compare_digest(
|
||||
run["token"], hashlib.sha256(token.encode()).digest()):
|
||||
raise Refused(401)
|
||||
digest = hashlib.sha256(json.dumps(data, sort_keys=True, allow_nan=False).encode()).digest()
|
||||
if data["sequence"] == run["sequence"] and run["digest"] == digest:
|
||||
return # Idempotent network retry, no duplicate log entry.
|
||||
if run["terminal"] or data["sequence"] <= run["sequence"]:
|
||||
raise Refused(409)
|
||||
if data["state"] == "running" and run["last_progress"] is not None and now-run["last_progress"] < 8:
|
||||
raise Refused(429)
|
||||
self.log(data)
|
||||
run.update(sequence=data["sequence"], digest=digest, terminal=data["state"] != "running")
|
||||
if data["state"] == "running":
|
||||
run["last_progress"] = now
|
||||
else:
|
||||
run["expires"] = now+60 # Retain briefly for idempotent final retry.
|
||||
|
||||
def log(self, data):
|
||||
self.emit({"schema": "av-test.receipt.v1", "kind": "unverified-browser-diagnostic",
|
||||
"received_unix_ms": int(self.wall()*1000), "release_eligible": False, **data})
|
||||
103
services/bstein-dev-home/av-observer/report-service/server.py
Normal file
103
services/bstein-dev-home/av-observer/report-service/server.py
Normal file
@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback-only aggregate receipt service behind the site's exact-path proxy."""
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
|
||||
from receipts import ReceiptStore, Refused
|
||||
from validation import no_duplicate_fields
|
||||
|
||||
|
||||
MAX_BODY = 4096
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass # Never log request paths, headers, tokens, bodies or errors.
|
||||
|
||||
def reply(self, status, data=None):
|
||||
body = json.dumps(data, allow_nan=False).encode() if data is not None else b""
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
self.reply(405)
|
||||
|
||||
do_HEAD = do_GET
|
||||
do_OPTIONS = do_GET
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
if self.path not in ("/av-test/api/start", "/av-test/api/report"):
|
||||
return self.reply(404)
|
||||
if any(len(self.headers.get_all(name, [])) > 1 for name in ("Origin", "Authorization", "Content-Type")):
|
||||
return self.reply(400)
|
||||
if self.headers.get("Origin") != self.server.origin:
|
||||
return self.reply(403)
|
||||
if self.headers.get("Content-Type") != "application/json" or self.headers.get("Transfer-Encoding"):
|
||||
return self.reply(415)
|
||||
sizes = self.headers.get_all("Content-Length", [])
|
||||
if len(sizes) != 1 or not sizes[0].isdigit() or not 0 < int(sizes[0]) <= MAX_BODY:
|
||||
return self.reply(413)
|
||||
raw = self.rfile.read(int(sizes[0]))
|
||||
if len(raw) != int(sizes[0]):
|
||||
return self.reply(400)
|
||||
data = json.loads(raw, object_pairs_hook=no_duplicate_fields)
|
||||
if self.path == "/av-test/api/start":
|
||||
if data != {}:
|
||||
return self.reply(400)
|
||||
return self.reply(201, self.server.store.start())
|
||||
authorization = self.headers.get("Authorization", "")
|
||||
token = authorization[7:] if authorization.startswith("Bearer ") else ""
|
||||
self.server.store.report(data, token)
|
||||
self.reply(204)
|
||||
except Refused as error:
|
||||
self.reply(error.status)
|
||||
except (ValueError, UnicodeError, RecursionError, TypeError):
|
||||
self.reply(400)
|
||||
except (OSError, TimeoutError):
|
||||
pass
|
||||
|
||||
|
||||
class Server(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
def __init__(self, address, origin, store):
|
||||
self.origin, self.store = origin, store
|
||||
self.workers = threading.BoundedSemaphore(4)
|
||||
super().__init__(address, Handler)
|
||||
|
||||
def get_request(self):
|
||||
connection, address = super().get_request()
|
||||
connection.settimeout(3)
|
||||
return connection, address
|
||||
|
||||
def process_request(self, request, client_address):
|
||||
if not self.workers.acquire(blocking=False):
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, client_address)
|
||||
except Exception:
|
||||
self.workers.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self, request, client_address):
|
||||
try:
|
||||
super().process_request_thread(request, client_address)
|
||||
finally:
|
||||
self.workers.release()
|
||||
|
||||
def handle_error(self, *_args):
|
||||
pass # No raw exception/request logging, including unexpected input.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
store = ReceiptStore(lambda entry: print(json.dumps(entry, allow_nan=False), flush=True))
|
||||
Server(("127.0.0.1", 8081), os.environ.get("OBSERVER_ORIGIN", "https://bstein.dev"), store).serve_forever()
|
||||
@ -0,0 +1,48 @@
|
||||
"""Strict aggregate-only receipt schema: unknown fields and text are refused."""
|
||||
import math
|
||||
|
||||
|
||||
STATES = {"running", "completed", "hidden", "closed", "device-change", "stopped", "failed"}
|
||||
BOUNDS = {
|
||||
"elapsed_ms": 180_000,
|
||||
"video_width": 8192, "video_height": 8192, "video_fps": 240,
|
||||
"audio_rate": 384_000, "audio_channels": 32, "audio_context_rate": 384_000,
|
||||
"video_callbacks": 100_000, "video_unobserved": 100_000, "video_resets": 100_000,
|
||||
"video_interval_p95_ms": 180_000, "video_gap_max_ms": 180_000,
|
||||
"audio_input_frames": 100_000_000, "audio_processed_frames": 100_000_000,
|
||||
"audio_zero_frames": 100_000_000, "audio_rms": 32, "audio_peak": 32,
|
||||
}
|
||||
|
||||
|
||||
def no_duplicate_fields(pairs):
|
||||
result = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate field")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def validate_report(data):
|
||||
"""Return a rebuilt, bounded numeric receipt, never caller-owned text."""
|
||||
if type(data) is not dict or set(data) != {"run_id", "sequence", "state", "metrics"}:
|
||||
raise ValueError("report shape")
|
||||
run_id, sequence, state = data["run_id"], data["sequence"], data["state"]
|
||||
if type(run_id) is not str or len(run_id) != 32 or any(c not in "0123456789abcdef" for c in run_id):
|
||||
raise ValueError("run id")
|
||||
if type(sequence) is not int or not 1 <= sequence <= 64:
|
||||
raise ValueError("sequence")
|
||||
if type(state) is not str or state not in STATES:
|
||||
raise ValueError("state")
|
||||
metrics = data["metrics"]
|
||||
if type(metrics) is not dict or metrics.keys() != BOUNDS.keys():
|
||||
raise ValueError("metric fields")
|
||||
clean = {}
|
||||
for name, limit in BOUNDS.items():
|
||||
value = metrics[name]
|
||||
if value is not None and (type(value) not in (int, float) or not 0 <= value <= limit or not math.isfinite(value)):
|
||||
raise ValueError("metric value")
|
||||
clean[name] = value
|
||||
if clean["elapsed_ms"] is None:
|
||||
raise ValueError("elapsed missing")
|
||||
return {"run_id": run_id, "sequence": sequence, "state": state, "metrics": clean}
|
||||
91
services/bstein-dev-home/av-observer/reporting.js
Normal file
91
services/bstein-dev-home/av-observer/reporting.js
Normal file
@ -0,0 +1,91 @@
|
||||
// Only the explicit numeric projection below can leave the browser. Never
|
||||
// pass a receipt, device object, error string, frame, PCM or DOM value to fetch.
|
||||
export function reportMetrics(receipt) {
|
||||
const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null;
|
||||
const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{};
|
||||
return Object.fromEntries(Object.entries({
|
||||
elapsed_ms:receipt.elapsedMs,
|
||||
video_width:s.video?.width,video_height:s.video?.height,video_fps:s.video?.frameRate,
|
||||
audio_rate:s.audio?.sampleRate,audio_channels:s.audio?.channelCount,
|
||||
video_callbacks:v.callbacks,video_unobserved:v.unobservedPresentedFrames,
|
||||
video_resets:v.clockOrCounterResets,video_interval_p95_ms:v.callbackIntervalP95Ms,
|
||||
video_gap_max_ms:v.callbackGapMaxMs,audio_input_frames:a.inputFrames,
|
||||
audio_processed_frames:a.processedFrames,audio_zero_frames:a.exactZeroFrames,
|
||||
audio_rms:a.rms,audio_peak:a.peak,audio_context_rate:a.sampleRate,
|
||||
}).map(([key,value])=>[key,finite(value)]));
|
||||
}
|
||||
|
||||
export function stopState(reason) {
|
||||
if(reason==='120 seconds complete')return 'completed';
|
||||
if(reason==='page hidden; timing invalid')return 'hidden';
|
||||
if(reason==='page closed')return 'closed';
|
||||
if(reason==='device ended'||reason==='device selection changed')return 'device-change';
|
||||
if(reason==='operator'||reason==='selecting devices')return 'stopped';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
export class RunReporter {
|
||||
constructor(status,request=globalThis.fetch.bind(globalThis)) {
|
||||
this.status=status;this.request=request;this.pending=null;this.busy=false;
|
||||
this.terminal=false;this.sequence=0;this.lastProgress=-Infinity;this.available=true;
|
||||
status('Reporting: connecting…');
|
||||
this.ready=this.open();
|
||||
}
|
||||
async open() {
|
||||
try {
|
||||
const r=await this.request('/av-test/api/start',{method:'POST',credentials:'omit',
|
||||
headers:{'Content-Type':'application/json'},body:'{}',signal:AbortSignal.timeout(5000)});
|
||||
if(!r.ok)throw new Error('start rejected');
|
||||
const data=await r.json();
|
||||
if(!/^[a-f0-9]{32}$/.test(data.run_id)||!/^[A-Za-z0-9_-]{43}$/.test(data.write_token))throw new Error('bad receipt');
|
||||
this.runId=data.run_id;this.token=data.write_token;
|
||||
this.status(`Reporting enabled · test ${this.runId.slice(0,8)}`);
|
||||
} catch {
|
||||
this.available=false;this.status('Reporting unavailable · Save metrics remains available');
|
||||
}
|
||||
}
|
||||
observe(receipt) {
|
||||
if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return;
|
||||
this.lastProgress=receipt.elapsedMs;this.queue('running',receipt);
|
||||
}
|
||||
finish(receipt) {
|
||||
if(this.terminal)return;
|
||||
this.terminal=true;this.queue(stopState(receipt.reason),receipt);
|
||||
}
|
||||
queue(state,receipt) {
|
||||
// One latest snapshot, not a growing queue. Terminal state supersedes
|
||||
// pending progress; a stopped run never becomes running again.
|
||||
this.pending={state,metrics:reportMetrics(receipt)};
|
||||
void this.flush();
|
||||
}
|
||||
async flush() {
|
||||
if(this.busy)return;
|
||||
this.busy=true;
|
||||
try {
|
||||
await this.ready;
|
||||
if(!this.available){this.pending=null;return;}
|
||||
while(this.pending) {
|
||||
const report=this.pending;this.pending=null;
|
||||
const payload={run_id:this.runId,sequence:++this.sequence,...report};
|
||||
let delivered=false;
|
||||
for(let attempt=0;attempt<2&&!delivered;attempt++) {
|
||||
try {
|
||||
const response=await this.request('/av-test/api/report',{
|
||||
method:'POST',credentials:'omit',keepalive:true,
|
||||
headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.token}`},
|
||||
body:JSON.stringify(payload),signal:AbortSignal.timeout(5000),
|
||||
});
|
||||
delivered=response.ok;
|
||||
if(response.status>=400&&response.status<500)break;
|
||||
} catch { /* No raw exception or endpoint response is displayed. */ }
|
||||
}
|
||||
if(report.state!=='running') {
|
||||
this.status(delivered ? `Result reported · test ${this.runId.slice(0,8)}` :
|
||||
'Result not reported · use Save metrics');
|
||||
} else if(!delivered) {
|
||||
this.status('Reporting interrupted · local test continues');
|
||||
}
|
||||
}
|
||||
} finally {this.busy=false;}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user