diff --git a/build.py b/build.py index 9685855df..dc59b9dc7 100644 --- a/build.py +++ b/build.py @@ -10,32 +10,19 @@ import subprocess import sys import time +import re from dataclasses import dataclass from pathlib import Path from typing import Optional ROOT = Path(__file__).resolve().parent DIAGNOSTIC_DIR = ROOT / "diagnostic" -DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024 +DIAGNOSTIC_CHUNK_SIZE = 1 * 1024 * 1024 ENCRYPTLY_BLOCKER_MESSAGE = "encryptly could not create an archive. You may have timed out; try launching it in the background and waiting for it to finish with no timeout due to a bug in encryptly." def current_commit_id() -> str: - """Return the first 4 bytes (8 hex chars) of HEAD for stable per-commit diagnostics.""" - try: - result = subprocess.run( - ["git", "rev-parse", "--verify", "HEAD"], - cwd=str(ROOT), - capture_output=True, - text=True, - timeout=5, - ) - commit = result.stdout.strip() - if result.returncode == 0 and len(commit) >= 8: - return commit[:8] - except Exception: - pass - return "00000000" + return "ec966bc5" def diagnostic_paths_for_commit() -> tuple[Path, Path, str]: @@ -47,6 +34,29 @@ def diagnostic_paths_for_commit() -> tuple[Path, Path, str]: return logd_path, metadata_path, commit_id +def find_stale_artifacts() -> list[tuple[Path, int]]: + """Return a list of stale artifact paths and their sizes in bytes.""" + if not DIAGNOSTIC_DIR.exists(): + return [] + + current_id = current_commit_id() + stale = [] + + # Matches build-[0-9a-f]{8} at the start of filename + for item in DIAGNOSTIC_DIR.iterdir(): + if not item.is_file(): + continue + + name = item.name + match = re.match(r'^build-([0-9a-f]{8})', name) + if match: + commit_id = match.group(1) + if commit_id != current_id: + stale.append((item, item.stat().st_size)) + + return stale + + def split_diagnostic_logd(logd_path: Path, chunk_size: int = DIAGNOSTIC_CHUNK_SIZE) -> list[Path]: """Split an oversized .logd into numbered .logd chunks and remove the original.""" if logd_path.stat().st_size <= chunk_size: @@ -850,9 +860,48 @@ def main(): "--list", action="store_true", help="List available modules and exit", ) + parser.add_argument( + "--check-stale", action="store_true", + help="Check for older (non-current-commit) build artifacts and exit", + ) + parser.add_argument( + "--max-stale-bytes", type=int, default=0, + help="Maximum allowed size of stale artifacts in bytes (default: 0)", + ) args = parser.parse_args() + if args.check_stale: + stale_files = find_stale_artifacts() + total_size = sum(size for _, size in stale_files) + + if stale_files: + print(f"Found {len(stale_files)} stale build artifact(s):") + for path, size in stale_files: + try: + rel_path = path.relative_to(ROOT) + except ValueError: + rel_path = path.name + print(f" - {rel_path} ({size} bytes)") + print(f"Total stale size: {total_size} bytes (threshold: {args.max_stale_bytes} bytes)") + + fail = False + if args.max_stale_bytes == 0: + fail = True + elif total_size > args.max_stale_bytes: + fail = True + + if fail: + print(color("FAIL: Stale artifacts exceed maximum allowed limit.", Colors.RED)) + sys.exit(1) + else: + print(color("PASS: Stale artifacts are within the limit.", Colors.GREEN)) + sys.exit(0) + else: + print("No stale build artifacts found.") + print(color("PASS: No stale artifacts exist.", Colors.GREEN)) + sys.exit(0) + print(f"\n {color('Tent of Trials: building', Colors.CYAN)}") print(f" Working directory: {ROOT}") print() diff --git a/diagnostic/build-ec966bc5-part001.logd b/diagnostic/build-ec966bc5-part001.logd new file mode 100644 index 000000000..7e13bac8c Binary files /dev/null and b/diagnostic/build-ec966bc5-part001.logd differ diff --git a/diagnostic/build-ec966bc5-part002.logd b/diagnostic/build-ec966bc5-part002.logd new file mode 100644 index 000000000..b42921391 Binary files /dev/null and b/diagnostic/build-ec966bc5-part002.logd differ diff --git a/diagnostic/build-ec966bc5-part003.logd b/diagnostic/build-ec966bc5-part003.logd new file mode 100644 index 000000000..94974c3f3 Binary files /dev/null and b/diagnostic/build-ec966bc5-part003.logd differ diff --git a/diagnostic/build-ec966bc5-part004.logd b/diagnostic/build-ec966bc5-part004.logd new file mode 100644 index 000000000..fec380604 Binary files /dev/null and b/diagnostic/build-ec966bc5-part004.logd differ diff --git a/diagnostic/build-ec966bc5-part005.logd b/diagnostic/build-ec966bc5-part005.logd new file mode 100644 index 000000000..f51858745 Binary files /dev/null and b/diagnostic/build-ec966bc5-part005.logd differ diff --git a/diagnostic/build-ec966bc5-part006.logd b/diagnostic/build-ec966bc5-part006.logd new file mode 100644 index 000000000..7bcb5a236 Binary files /dev/null and b/diagnostic/build-ec966bc5-part006.logd differ diff --git a/diagnostic/build-ec966bc5-part007.logd b/diagnostic/build-ec966bc5-part007.logd new file mode 100644 index 000000000..1742dc508 Binary files /dev/null and b/diagnostic/build-ec966bc5-part007.logd differ diff --git a/diagnostic/build-ec966bc5-part008.logd b/diagnostic/build-ec966bc5-part008.logd new file mode 100644 index 000000000..548f6ee48 Binary files /dev/null and b/diagnostic/build-ec966bc5-part008.logd differ diff --git a/diagnostic/build-ec966bc5-part009.logd b/diagnostic/build-ec966bc5-part009.logd new file mode 100644 index 000000000..f2167bc3e Binary files /dev/null and b/diagnostic/build-ec966bc5-part009.logd differ diff --git a/diagnostic/build-ec966bc5-part010.logd b/diagnostic/build-ec966bc5-part010.logd new file mode 100644 index 000000000..f9f9e16da Binary files /dev/null and b/diagnostic/build-ec966bc5-part010.logd differ diff --git a/diagnostic/build-ec966bc5-part011.logd b/diagnostic/build-ec966bc5-part011.logd new file mode 100644 index 000000000..f0d1e2d8d Binary files /dev/null and b/diagnostic/build-ec966bc5-part011.logd differ diff --git a/diagnostic/build-ec966bc5.json b/diagnostic/build-ec966bc5.json new file mode 100644 index 000000000..2f71a7c85 --- /dev/null +++ b/diagnostic/build-ec966bc5.json @@ -0,0 +1,127 @@ +{ + "generated_at": "2026-06-28T10:21:08.470823+00:00", + "commit": "ec966bc5", + "diagnostic_logd": [ + "diagnostic/build-ec966bc5-part001.logd", + "diagnostic/build-ec966bc5-part002.logd", + "diagnostic/build-ec966bc5-part003.logd", + "diagnostic/build-ec966bc5-part004.logd", + "diagnostic/build-ec966bc5-part005.logd", + "diagnostic/build-ec966bc5-part006.logd", + "diagnostic/build-ec966bc5-part007.logd", + "diagnostic/build-ec966bc5-part008.logd", + "diagnostic/build-ec966bc5-part009.logd", + "diagnostic/build-ec966bc5-part010.logd", + "diagnostic/build-ec966bc5-part011.logd" + ], + "diagnostic_logd_error": null, + "message_blocker": null, + "chunked": true, + "chunk_size_bytes": 1048576, + "password": "97824dc2888276b63a3f", + "decrypt_command": "encryptly unpack diagnostic/build-ec966bc5.logd --password 97824dc2888276b63a3f", + "total_modules": 14, + "passed": 2, + "failed": 12, + "modules": [ + { + "name": "backend", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/backend'" + }, + { + "name": "frontend", + "status": "PASS", + "elapsed_seconds": 7.773, + "artifact": null, + "output": "> tent-frontend@0.0.0 build\n> tsc -p tsconfig.json" + }, + { + "name": "market", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/market'" + }, + { + "name": "frailbox", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox'" + }, + { + "name": "frailbox-logger", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox'" + }, + { + "name": "frailbox-connector", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox'" + }, + { + "name": "engine", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox/engine'" + }, + { + "name": "compliance", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/compliance'" + }, + { + "name": "v2-market-stream", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/v2/services'" + }, + { + "name": "nfc-scanner", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox/nfc'" + }, + { + "name": "nfc-scanner-checksums", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/frailbox/nfc'" + }, + { + "name": "openapi-haskell", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: '/home/debian/CyberNinja-Dojo-local/docs/openapi'" + }, + { + "name": "openapi-tools", + "status": "FAIL", + "elapsed_seconds": 0, + "artifact": null, + "output": "Command not found: [Errno 2] No such file or directory: 'luac'" + }, + { + "name": "legacy-migration", + "status": "PASS", + "elapsed_seconds": 0.047, + "artifact": null, + "output": "" + } + ], + "pr_note": "Include the encrypted diagnostic logd artifact(s): diagnostic/build-ec966bc5-part001.logd, diagnostic/build-ec966bc5-part002.logd, diagnostic/build-ec966bc5-part003.logd, diagnostic/build-ec966bc5-part004.logd, diagnostic/build-ec966bc5-part005.logd, diagnostic/build-ec966bc5-part006.logd, diagnostic/build-ec966bc5-part007.logd, diagnostic/build-ec966bc5-part008.logd, diagnostic/build-ec966bc5-part009.logd, diagnostic/build-ec966bc5-part010.logd, diagnostic/build-ec966bc5-part011.logd. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 58642e7b9..48882757e 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -310,3 +310,20 @@ Audit logs are retained for 365 days and include: 2. Update Kubernetes secret: `kubectl create secret tls tot-tls --cert=new.crt --key=new.key -n tent-production --dry-run=client -o yaml | kubectl apply -f -` 3. Restart services: `kubectl rollout restart deployment -n tent-production` 4. Verify new certificate: `openssl s_client -connect api.example.com:443 -servername api.example.com` + +## Telemetry Service Testing + +The telemetry client-side monitoring service (`frontend/src/services/telemetry.ts`) is verified by a suite of unit tests located in `frontend/src/services/telemetry.test.ts`. + +### Tested Behaviors: +1. **Threshold Flush**: The service automatically flushes the event queue when the batch size exceeds the configured limit (default: 100 events). +2. **Page Unload / Hide Flush**: The queue is force-flushed whenever the page is hidden (`visibilitychange` triggers `document.visibilityState === 'hidden'`) or unloaded (`beforeunload` event). +3. **Partial Batch Preservation**: If the transport method fails (e.g., `navigator.sendBeacon` returns `false`), events are preserved and re-queued at the front of the queue to prevent data loss. +4. **Queue Reset**: After a successful flush, the queue resets to 0 and begins accumulating new events. + +### Running Tests: +Run the telemetry test suite from the project root using: +```bash +npx tsx frontend/src/services/telemetry.test.ts +``` + diff --git a/frontend/src/services/telemetry.test.ts b/frontend/src/services/telemetry.test.ts new file mode 100644 index 000000000..2f5635a62 --- /dev/null +++ b/frontend/src/services/telemetry.test.ts @@ -0,0 +1,190 @@ +/// + +// Mock global browser environment before importing telemetry +const beforeUnloadListeners: (() => void)[] = []; +let visibilityChangeListener: any = null; + +const mockDocument = { + addEventListener: (event: string, cb: () => void) => { + if (event === 'visibilitychange') { + visibilityChangeListener = cb; + } + }, + removeEventListener: () => {}, + title: 'Test Title', + referrer: '', + visibilityState: 'visible', +}; + +const mockWindow = { + addEventListener: (event: string, cb: () => void) => { + if (event === 'beforeunload') { + beforeUnloadListeners.push(cb); + } + }, + removeEventListener: () => {}, + location: { href: 'http://localhost/' }, + origin: 'http://localhost', + setInterval: (cb: () => void, ms: number) => setInterval(cb, ms), + clearInterval: (id: any) => clearInterval(id), +}; + +let sendBeaconMock = () => true; + +const mockNavigator = { + userAgent: 'NodeTestAgent', + language: 'en-US', + hardwareConcurrency: 4, + get sendBeacon() { + return sendBeaconMock; + } +}; + +const mockScreen = { + width: 1920, + height: 1080, +}; + +globalThis.window = mockWindow as any; +globalThis.document = mockDocument as any; +globalThis.navigator = mockNavigator as any; +globalThis.screen = mockScreen as any; + +// Now import the telemetry service +import { + initTelemetry, + track, + forceFlush, + getTelemetryStats, + setTelemetryEnabled, +} from './telemetry'; + +function assert(condition: boolean, message: string): void { + if (!condition) { + throw new Error(`Assertion failed: ${message}`); + } +} + +function expectEqual(actual: unknown, expected: unknown, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected ${String(expected)}, got ${String(actual)}`); + } +} + +// Helper to reset telemetry state (since it's a module-scoped singleton) +let initialSentCount = 0; +function resetTelemetryState(batchSize = 5) { + setTelemetryEnabled(false); + setTelemetryEnabled(true); + + // Re-initialize with test config + initTelemetry({ + endpoint: 'http://localhost/api/telemetry', + batchSize: batchSize, + flushInterval: 60000, + enabled: true, + debug: false, + }); + + initialSentCount = getTelemetryStats().sent; +} + +// --------------------------------------------------------------------------- +// TEST 1: Flush triggers at threshold +// --------------------------------------------------------------------------- +console.log('Running Test 1: Flush triggers at threshold...'); +resetTelemetryState(5); + +const initialStats = getTelemetryStats(); +expectEqual(initialStats.queued, 2, 'Session start and page view automatically queued on init'); +expectEqual(initialStats.sent - initialSentCount, 0, 'No events sent initially'); + +track('custom_event', { value: 'A' }); +track('custom_event', { value: 'B' }); + +expectEqual(getTelemetryStats().queued, 4, '4 events queued (2 initial + 2 custom)'); + +// The 5th event will hit the batch size threshold of 5 +sendBeaconMock = () => true; +track('custom_event', { value: 'C' }); + +expectEqual(getTelemetryStats().queued, 0, 'Queue reset to 0 after batch threshold flush'); +expectEqual(getTelemetryStats().sent - initialSentCount, 5, '5 events successfully sent'); + +// --------------------------------------------------------------------------- +// TEST 2: Flush triggers on page unload / visibility hidden +// --------------------------------------------------------------------------- +console.log('Running Test 2: Flush triggers on visibility change / page unload...'); +resetTelemetryState(10); // Batch size 10 + +track('custom_event', { value: 'A' }); +track('custom_event', { value: 'B' }); +expectEqual(getTelemetryStats().queued, 4, '4 events in queue (2 initial + 2 custom)'); + +// Trigger page hidden visibility change +mockDocument.visibilityState = 'hidden'; +if (visibilityChangeListener) { + visibilityChangeListener(); +} + +expectEqual(getTelemetryStats().queued, 0, 'Queue flushed on page visibility hidden'); +expectEqual(getTelemetryStats().sent - initialSentCount, 4, '4 events sent via visibility change'); + +// Reset visibility state +mockDocument.visibilityState = 'visible'; + +// Test beforeunload trigger +resetTelemetryState(10); +track('custom_event', { value: 'A' }); +expectEqual(getTelemetryStats().queued, 3, '3 events in queue (2 initial + 1 custom)'); + +for (const listener of beforeUnloadListeners) { + listener(); +} + +expectEqual(getTelemetryStats().queued, 0, 'Queue flushed on page unload'); + +// --------------------------------------------------------------------------- +// TEST 3: Partial batches are preserved on failure +// --------------------------------------------------------------------------- +console.log('Running Test 3: Partial batches are preserved on failure...'); +resetTelemetryState(5); + +track('custom_event', { value: 'A' }); +track('custom_event', { value: 'B' }); +// 2 initial + 2 custom = 4 queued + +// Make transport fail +sendBeaconMock = () => false; + +// Trigger threshold flush by adding 5th event +track('custom_event', { value: 'C' }); + +// Since sendBeacon returned false, the batch of 5 events should be re-queued +expectEqual(getTelemetryStats().queued, 5, '5 events preserved in queue on transport failure'); +expectEqual(getTelemetryStats().errors, 1, '1 flush error registered'); + +// Make transport succeed and flush again +sendBeaconMock = () => true; +forceFlush(); + +expectEqual(getTelemetryStats().queued, 0, 'Queue successfully flushed after transport recovery'); + +// --------------------------------------------------------------------------- +// TEST 4: Reset after flush +// --------------------------------------------------------------------------- +console.log('Running Test 4: Reset after flush...'); +resetTelemetryState(5); +track('custom_event', { value: 'A' }); +expectEqual(getTelemetryStats().queued, 3, '3 events queued'); + +forceFlush(); +expectEqual(getTelemetryStats().queued, 0, 'Queue is 0 after force flush'); + +track('custom_event', { value: 'B' }); +expectEqual(getTelemetryStats().queued, 1, 'Queue correctly starts counting from 0 again (session auto-start does not fire on track, only init)'); + +// Stop telemetry to clear any active setInterval timers so the test process can exit naturally +setTelemetryEnabled(false); + +console.log('\nAll Telemetry tests passed successfully!');