From 25819f84866787e741ab4dcd039313ac094b451a Mon Sep 17 00:00:00 2001
From: myu ngot
Date: Mon, 19 Oct 2026 22:47:35 +0000
Subject: [PATCH 1/3] Improve measured mobile startup and operational
diagnostics
---
.github/workflows/operations-monitor.yml | 39 +++
IMPLEMENTATION_PLAN.md | 10 +-
OPERATIONS.md | 51 +++
PERFORMANCE.md | 33 ++
RELEASE_RUNBOOK.md | 6 +-
backend/app/api/routes/analytics.py | 14 +-
backend/app/api/routes/telemetry.py | 16 +
backend/app/core/logging.py | 39 ++-
backend/app/main.py | 60 +++-
backend/app/schemas/operations.py | 58 ++++
backend/app/services/events.py | 8 +-
backend/app/services/operations.py | 88 +++++
backend/check_migration_window.py | 96 ++++++
backend/monitor_operations.py | 151 +++++++++
backend/tests/test_operations.py | 301 ++++++++++++++++++
frontend/e2e/dashboard.spec.ts | 2 +-
frontend/e2e/helpers/routes.ts | 2 +
frontend/e2e/performance.spec.ts | 48 +++
frontend/scripts/measure-performance.mjs | 65 ++++
frontend/src/App.test.tsx | 1 +
frontend/src/App.tsx | 10 +-
frontend/src/components/ConnectAndAuth.tsx | 3 +-
.../src/components/DeferredSection.test.tsx | 41 +++
frontend/src/components/DeferredSection.tsx | 47 +++
frontend/src/components/Diagnostics.tsx | 30 ++
frontend/src/components/ErrorBoundary.tsx | 5 +-
.../src/components/IndexerStatusNotice.tsx | 3 +
.../src/components/PrivateAnalytics.test.tsx | 32 ++
frontend/src/components/PrivateAnalytics.tsx | 19 ++
.../src/components/ShipmentVolumeChart.tsx | 74 +++++
frontend/src/config/deferredWallets.ts | 36 +++
frontend/src/config/operations.test.ts | 131 ++++++++
frontend/src/config/sentry.ts | 94 +++++-
frontend/src/config/telemetry.ts | 159 +++++++++
frontend/src/config/wagmi.ts | 74 +++--
frontend/src/hooks/streamHealth.ts | 20 ++
frontend/src/hooks/useApi.ts | 7 +-
frontend/src/hooks/useExceptions.ts | 6 +-
frontend/src/hooks/useJourney.ts | 5 +-
frontend/src/hooks/useSSE.test.tsx | 141 +++++---
frontend/src/hooks/useSSE.ts | 140 +++++---
frontend/src/main.tsx | 37 ++-
frontend/src/pages/Dashboard.tsx | 101 +-----
frontend/src/pages/Privacy.tsx | 44 +--
frontend/src/types/api.ts | 158 +++++++++
frontend/src/vite-env.d.ts | 1 +
frontend/vite.config.test.ts | 86 ++---
frontend/vite.config.ts | 8 +-
openapi.json | 247 ++++++++++++++
performance/increment10.json | 275 ++++++++++++++++
50 files changed, 2788 insertions(+), 334 deletions(-)
create mode 100644 .github/workflows/operations-monitor.yml
create mode 100644 OPERATIONS.md
create mode 100644 PERFORMANCE.md
create mode 100644 backend/app/api/routes/telemetry.py
create mode 100644 backend/app/schemas/operations.py
create mode 100644 backend/app/services/operations.py
create mode 100644 backend/check_migration_window.py
create mode 100644 backend/monitor_operations.py
create mode 100644 backend/tests/test_operations.py
create mode 100644 frontend/e2e/performance.spec.ts
create mode 100644 frontend/scripts/measure-performance.mjs
create mode 100644 frontend/src/components/DeferredSection.test.tsx
create mode 100644 frontend/src/components/DeferredSection.tsx
create mode 100644 frontend/src/components/Diagnostics.tsx
create mode 100644 frontend/src/components/PrivateAnalytics.test.tsx
create mode 100644 frontend/src/components/PrivateAnalytics.tsx
create mode 100644 frontend/src/components/ShipmentVolumeChart.tsx
create mode 100644 frontend/src/config/deferredWallets.ts
create mode 100644 frontend/src/config/operations.test.ts
create mode 100644 frontend/src/config/telemetry.ts
create mode 100644 frontend/src/hooks/streamHealth.ts
create mode 100644 performance/increment10.json
diff --git a/.github/workflows/operations-monitor.yml b/.github/workflows/operations-monitor.yml
new file mode 100644
index 0000000..c9c90c6
--- /dev/null
+++ b/.github/workflows/operations-monitor.yml
@@ -0,0 +1,39 @@
+name: Operations Monitor
+on:
+ schedule:
+ # Four checks a day; avoid keeping free services continuously awake.
+ - cron: '15 */6 * * *'
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: operations-monitor
+ cancel-in-progress: false
+jobs:
+ observe:
+ name: Observe ${{ matrix.environment }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 7
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - environment: production
+ api: https://chainlens-4qvy.onrender.com
+ indexer: disabled
+ - environment: testing
+ api: https://veritras-api-testing.onrender.com
+ indexer: enabled
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+ - name: Check sustained readiness, API errors and SSE heartbeat
+ env:
+ API: ${{ matrix.api }}
+ ENVIRONMENT: ${{ matrix.environment }}
+ INDEXER: ${{ matrix.indexer }}
+ run: python backend/monitor_operations.py --url "$API" --environment "$ENVIRONMENT" --expect-indexer "$INDEXER"
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 9d9c6d8..c4cd87b 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -1,6 +1,6 @@
# Veritras implementation plan
-Originally prepared October 17, 2026 from [PROJECT_REVIEW.md](PROJECT_REVIEW.md). **Status updated October 19, 2026 for increment 9.** Integration recovery checks, typed contracts, protected-main merge controls and production admission gates are implemented. Final CI/deployment evidence is recorded in the increment 7 pull request; see [release controls](RELEASE_CONTROLS.md) and the [release/recovery runbook](RELEASE_RUNBOOK.md).
+Originally prepared October 17, 2026 from [PROJECT_REVIEW.md](PROJECT_REVIEW.md). **Status updated October 20, 2026 for increment 10.** Integration recovery checks, typed contracts, protected-main merge controls and production admission gates are implemented. Final CI/deployment evidence is recorded in the increment 7 pull request; see [release controls](RELEASE_CONTROLS.md) and the [release/recovery runbook](RELEASE_RUNBOOK.md).
## Status at a glance
@@ -22,7 +22,7 @@ Priority meanings: **P0 — release blocker** must be resolved before enabling p
| Integration and release controls (increment 7) | **Done** | **P1 addressed** | Real write-failure/restart, process-loss, reorg and backup/restore rehearsals; typed detail/verification; protected main and exact-commit production admission; see [runbook](RELEASE_RUNBOOK.md) |
| QR verification, shipment detail and guided demo (increment 8) | **Done** | **P2 addressed** | Direct public links and SVG QR labels, canonical paginated timeline, shipment detail with contract-checked transitions, isolated guided demo, mobile wallet/keyboard access and route metadata; see [guide](PRODUCT_JOURNEY.md) |
| Shipment exceptions (increment 9) | **Done** | **P2 addressed** | Configurable owner policy, canonical arrival/checkpoint/custody rules, durable deduplicated occurrences, acknowledgement/history, synchronized evaluation and public mobile list; see [guide](SHIPMENT_EXCEPTIONS.md) |
-| Performance and operations (increment 10) | **Pending** | P2 | Existing lazy routes/Lighthouse checks provide a baseline; measured optimization and operational completion remain |
+| Performance and operations (increment 10) | **In progress: acceptance** | P2 | Measured mobile LCP 11.33→5.22 s, deferred SDK/chart loading, private diagnostics, stream recovery, scheduled operations checks and migration-window safeguards; release acceptance in progress |
| Business pilot integrations | **Deferred** | Later | Organizations, evidence files, imports, standards integrations and repeated journeys |
### Urgent safeguards and next actions
@@ -296,7 +296,7 @@ Completed in increment 7: actual PostgreSQL checkpoint failure after a metadata
### PR 10: Performance and operations
-**Status: pending completion. Priority: P2.** Existing lazy routes, error reporting configuration and Lighthouse CI are a baseline. Measured optimization, environment-aware observability and readiness alerts remain. Increment 7 supplies the local backup/restore rehearsal and recovery runbook; hosted restore acceptance belongs to the separate production enablement gate.
+**Status: implemented; release acceptance in progress. Priority: P2.** Three controlled mobile samples per build show median LCP 11.33→5.22 seconds, transfer −60%, initial gzip JavaScript −69%, with unchanged CLS. See [performance evidence](PERFORMANCE.md) and [operations](OPERATIONS.md). Private error/route telemetry, replay disabled, API/SSE counters, heartbeat recovery, scheduled cold-start-aware monitoring and coordinated migration preflight are implemented. Valid production field percentiles require real traffic; no historical field comparison is claimed. Local backup/restore is rehearsed; hosted production restore acceptance remains in the separate enablement gate.
- Measure mobile loading and interaction before changing bundles; investigate wallet/provider startup, chart imports, and unnecessary SDK loading.
- Defer optional wallet/chart code where measurements justify it. Compare before/after Lighthouse and field metrics using the same scenario.
@@ -308,7 +308,7 @@ Completed in increment 7: actual PostgreSQL checkpoint failure after a metadata
## Migration, backfill, and release procedure
1. Inspect production deployment/schema state and record a restorable database checkpoint. Rehearse on an isolated database copy first.
-2. Apply additive migrations and deploy compatible backend code. Keep any old cursor/projection writer paused while reconciling identities or rebuilding event projections.
+2. After protected merge, exact-main CI and candidate image admission, confirm the prior API is already awake/ready and run the short-lived migration-window preflight. Coordinate additive DDL with immediate compatible runtime rollout; never apply production DDL while CI is pending. See [operations](OPERATIONS.md#coordinated-migration-window). Keep any old cursor/projection writer paused while reconciling identities or rebuilding event projections.
3. Run reconciliation in dry-run mode, inspect conflicts, then apply validated changes. Add uniqueness/non-null constraints only after conflicting data is resolved.
4. Load/replay confirmed canonical logs in bounded ranges from each configured deployment block, validating receipt identities and projection counts. Persist progress transactionally.
5. Confirm readiness, indexing progress, permissions, verification results, and analytics. Deploy the compatible frontend against the intended environment.
@@ -330,7 +330,7 @@ After the reliable demo is complete and a specific user workflow has been valida
## Next implementation session
-The next product increment is **9: shipment exception list**. Increments 5–8 are implemented, including truthful analytics, enforced release controls, public QR verification and the guided/mobile journey. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
+Increment **10: performance and operations** is undergoing release acceptance. Increments 5–9 are delivered. After increment 10, prioritize field observation and a defined business pilot; there is no automatically authorized production backfill. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
Preserve analytics definitions and sample/exclusion counts during subsequent work. Keep production indexing enablement and any change to release-gate configuration as explicit reviewed changes.
diff --git a/OPERATIONS.md b/OPERATIONS.md
new file mode 100644
index 0000000..cbc413d
--- /dev/null
+++ b/OPERATIONS.md
@@ -0,0 +1,51 @@
+# Operations
+
+## Signals and privacy
+
+`GET /health/live` is process liveness and remains the Render health path. `GET /health/ready` returns 503 for unavailable database or degraded indexing, with actionable bounded reasons, confirmed-target lag and freshness. Production deliberately expects indexing **disabled**; testing expects an enabled, current indexer. A live process is not proof of current chain projections.
+
+`GET /health/operations` returns readiness plus bounded process-local counters: the last 15 minute buckets of requests/business API 5xx, average header-response latency and cumulative ≤100/500/2000 ms buckets; stream active/open/closed/failure/resync/overflow totals; and bounded browser metric/route aggregates. SSE request duration measures response headers, not stream lifetime. Counters reset on restart, are per process, and are not a durable audit, distributed rate or percentile. No database migration, paid metrics service or extra worker is introduced.
+
+Structured application request logs contain environment, full release SHA when Render supplies it, generated request ID, method, route template, status, header duration and exception class. Query strings, concrete product/shipment IDs, IPs, tokens and request bodies are excluded. An unmatched route is labeled `unmatched`; some router versions report the leaf template. Arbitrary logging extras and exception messages are omitted. The request ID is returned as `X-Request-ID` for incident correlation. Review new logging call sites: do not put user input in the message itself.
+
+Browser telemetry accepts only enumerated metric and route names, finite bounded numbers, environment and validated release SHA. It rejects extra fields, never uses auth headers or cookies, is limited to 60 requests/minute per client, and deduplicates each metric/route for 30 seconds in the client. Demo sends no business/telemetry API calls. Browser counts are untrusted and do not trigger operational alerts. No input text, wallet address, signature, bearer token, product ID, shipment ID, query string or arbitrary error message is sent. No browser telemetry is persisted to Postgres.
+
+Vercel Analytics and Speed Insights strip URL parameters/fragments and replace dynamic IDs with route templates. Optional Sentry loads only when `VITE_SENTRY_DSN` is configured; events have release/environment, route, generic exception type and same-origin built asset frame locations. Requests, users, breadcrumbs, arbitrary context and exception text are dropped. Tracing and **all session replay are disabled**, so there is no recording of forms, wallet prompts, notes or DOM input. External Sentry delivery is not claimed when a DSN is absent. The generic browser error counter remains available without Sentry.
+
+## Scheduled checks
+
+[Operations Monitor](.github/workflows/operations-monitor.yml) runs four times a day at 00:15, 06:15, 12:15 and 18:15 UTC (08:15, 14:15, 20:15 and 02:15 Asia/Manila), and supports manual dispatch. It checks both APIs with a four-minute cold-start grace and requires three successive readiness samples, then checks aggregate API errors and a real SSE heartbeat. The stream client checks named heartbeats, reconnects with bounded backoff, pauses while hidden and displays a disconnected notice. A replay/resync refreshes committed queries after recovery.
+
+The workflow exits nonzero with named conditions and runbook actions for sustained readiness failure, three or more business API 5xx in the retained 15-minute window, malformed diagnostics, or missing SSE heartbeat. Existing readiness checks remain separate. Failures appear in GitHub Actions and its native notification settings; no new email, Slack, webhook or external recipient is configured. Owners can enable GitHub Actions failure notifications in their own GitHub notification preferences. Notification delivery was not tested.
+
+These checks are periodic, not a real-time paging SLA. Free-service cold starts are allowed; no continuous keep-alive is used. Errors between samples, on an earlier process, or outside the retained window can be missed. For a paid pilot with an uptime requirement, first define retention, on-call recipient and escalation preferences, then add durable metrics and an appropriate monitor through a reviewed change.
+
+## Incident response
+
+| Condition | Inspect | Recovery acceptance |
+| --- | --- | --- |
+| `readiness` | `/health/ready`: database, indexer reason, confirmed-target lag and successful poll age; Render logs for the exact release | Three ready samples; testing indexer current/fresh, or production disabled as configured. Investigate RPC/database/scope before restarting. Never advance a cursor to hide lag. |
+| `api_errors` | Request `request_id`, route template, status, exception class and release; deployment and DB availability | Fix cause through protected CI; verify business endpoint behavior and monitor a fresh error window. Browser reports are diagnostic, not authorization to mutate data. |
+| `event_stream` | `event_stream_unavailable`, stream failures/overflows, database connection and proxy behavior | Actual named heartbeat, browser reconnect/resync, and committed data refresh. Do not treat reconnect alone as proof of indexer health. |
+| `diagnostics` | Exact commit, schema admission, environment and operations response | Typed, correct-environment diagnostics and ready API. No fabricated counters or gate bypass. |
+| Schema/cold-start failure | Candidate exact-main CI, image admission, current schema and release | Follow coordinated migration window below. Do not restart an incompatible historical image against the new schema. |
+
+Avoid logging error payloads or sharing unreviewed HAR files, wallet storage, screenshots of secret settings, or temporary preview access links. Preserve safe request IDs, commit and provider deployment IDs in the release record.
+
+## Backup, restore and indexer ownership
+
+Follow [RELEASE_RUNBOOK.md](RELEASE_RUNBOOK.md) and [DURABLE_INDEXING.md](DURABLE_INDEXING.md). Before DDL, retain a restorable database checkpoint and rehearse on an isolated clone. The local rehearsal acquires the writer lock, dumps/restores and compares schema plus full business/journal/cursor/history fingerprints; its actual result is required alongside unit tests. Hosted production restore acceptance and production backfill remain a separate enablement gate. Do not copy production data into a public preview.
+
+Use one intended indexing owner per chain/registry/tracker scope. The PostgreSQL advisory writer lock and transactional cursor are authoritative; another process must report writer contention and retry rather than writing concurrently. Never delete the lock/cursor or import the obsolete file cursor. A restart must resume the last committed batch once; chain reorganizations rewind/replay transactionally. Production remains disabled until reconciliation, deployment anchor, isolated hosted restore and explicit enablement approval are complete.
+
+A separate paid worker is not justified by the current single-instance free-service demo. If uptime or horizontal API scaling becomes required, design a reviewed worker deployment with one intended owner, the same DB lock, explicit health/lag monitoring and tested shutdown/recovery. Do not enable multiple writers as a scaling shortcut.
+
+## Coordinated migration window
+
+Increment 10 has **no DDL**; schema remains `e93a107bd624`. Increment 9 exposed a restart window when DDL was applied before main CI completed while the old free API was asleep. The old artifact correctly refused the newer schema; the replacement later recovered without data loss. Future strict-head migrations must use this sequence:
+
+1. Rehearse additive SQL on an isolated restore, retain a backup and record current/candidate schema compatibility. Stage and test first. Do not apply production DDL while feature/main CI is pending.
+2. Merge through the protected PR, wait for every required exact-main push check, and inspect the candidate Render image build admission. Confirm the prior API is already awake and ready on its expected SHA. A cold, incompatible old image cannot be the handoff safety net.
+3. Run `backend/check_migration_window.py --candidate FULL_MAIN_SHA --current FULL_SERVING_API_SHA --url https://chainlens-4qvy.onrender.com --sql-file REVIEWED_SQL_PATH`. It checks exact-main admission first, then current liveness/readiness/identity, and hashes reviewed SQL. Its two-minute evidence is read-only and is not authorization to execute DDL. Recheck if delayed.
+4. Confirm the admitted replacement is ready for immediate rollout; coordinate reviewed DDL and runtime handoff in the same window. If the provider cannot guarantee the handoff, use an explicitly scheduled maintenance window or a separately reviewed compatibility release. Do not apply early and hope CI finishes. An awake old process reduces the cold-start risk but cannot guarantee uninterrupted service if it crashes.
+5. Verify new exact release, schema, `/health/ready`, frontend attestation and unchanged business fingerprints. Retain evidence. Use corrective migrations or a rehearsed isolated restore; never weaken schema admission or run destructive downgrades.
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
new file mode 100644
index 0000000..f792533
--- /dev/null
+++ b/PERFORMANCE.md
@@ -0,0 +1,33 @@
+# Performance measurements
+
+Increment 10 measured the anonymous dashboard before changing loading boundaries. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
+
+## Controlled mobile comparison
+
+Measured October 20, 2026 (Asia/Manila), Chromium 154.0.8037.57. Three cold-cache Lighthouse runs per build, same frozen API fixtures, gzip server, 390×844 viewport and Lighthouse simulated mobile CPU/network. External HTTPS was blocked equally in both builds. API latency, production CDN behavior, WalletConnect provider latency and real user devices are outside this comparison.
+
+| Median | Before | After | Change |
+| --- | ---: | ---: | ---: |
+| Lighthouse performance | 47 | 66 | +19 points |
+| First contentful paint | 6.50 s | 3.50 s | 46% lower |
+| Largest contentful paint | 11.33 s | 5.22 s | 54% lower |
+| Total blocking time | 463 ms | 318 ms | 31% lower |
+| Cumulative layout shift | 0 | 0 | unchanged |
+| Total transferred bytes | 1,671,523 | 660,648 | 60% lower |
+| Initial JavaScript, gzip | 1,226,486 | 382,948 | 69% lower |
+
+One supplementary menu interaction took 146 ms before and 104 ms after. It includes Playwright click overhead and two animation frames: **it is not field INP or a percentile**, and is not the basis of the improvement claim. LCP remains above the 2.5-second good threshold in this constrained lab.
+
+The previous manual Web3 group combined optional SDKs with eager imports. Removing it alone reduced transfer but worsened blocking time in an intermediate trial. The final change also defers remote connector setup until wallet intent, restores it immediately for stored wallet connections, defers the below-fold chart by visibility, and schedules optional diagnostic SDK loading during idle time. No dependency upgrade or weaker Lighthouse threshold was needed.
+
+## Reproduce and prevent regression
+
+From `frontend`, build with `CI=true VITE_API_URL=http://127.0.0.1:18010 VITE_WALLETCONNECT_PROJECT_ID=ci-test-project-id npm exec -- vite build --manifest`, then run `node scripts/measure-performance.mjs dist ../.testing/performance-comparison`. Use Node 24 for direct fixture TypeScript loading and set `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` if Chromium is elsewhere. Preserve the baseline artifact, use the same browser and machine, and avoid competing workloads. The output contains three Lighthouse JSON reports, a summary and mobile screenshot; keep them local unless reviewed for publication.
+
+The existing Lighthouse CI checks remain required. The Playwright startup regression additionally caps anonymous dashboard script bytes at **450,000 gzip bytes**, including idle diagnostics; verifies no chart or remote WalletConnect/Reown SDK starts before intent/visibility; scrolls the chart into view and opens the wallet picker. Real local integration checks cover signing, reconnect, switching wallets and contract writes.
+
+## Field measurements
+
+No valid production before/after field comparison was available for this release. The connected metrics query could not retrieve a valid dataset, and Vercel documents programmatic metric queries as requiring Observability Plus. No plan upgrade was requested or made. Lab measurements above must not be presented as production percentiles.
+
+Vercel Speed Insights remains enabled after idle loading and receives sanitized route URLs. Compare production mobile p75 LCP and INP over equal seven-day windows only after both windows have meaningful counts; record dates, release, device mix, route and count alongside each value. Do not combine preview/demo traffic with production or compare a single synthetic visit to field traffic. The public API diagnostics expose process-local browser LCP/CLS means and maximum-interaction samples; these are untrusted diagnostic samples, not durable RUM percentiles or field INP. See [operations](OPERATIONS.md) for retention and privacy.
diff --git a/RELEASE_RUNBOOK.md b/RELEASE_RUNBOOK.md
index ab522ff..d6b61a5 100644
--- a/RELEASE_RUNBOOK.md
+++ b/RELEASE_RUNBOOK.md
@@ -1,14 +1,14 @@
# Release, backfill and recovery
-This runbook accompanies [release controls](RELEASE_CONTROLS.md), [durable indexing](DURABLE_INDEXING.md), [canonical identity](CANONICAL_PRODUCT_IDENTITY.md), [health](INDEXER_HEALTH.md) and [analytics](ANALYTICS.md). Increment 7 adds no database migration: the current head is `c62a904df318`. **Ordinary releases keep production `INDEXER_ENABLED=false`.** Enabling or reconstructing production indexing requires the separate reviewed procedure below.
+This runbook accompanies [release controls](RELEASE_CONTROLS.md), [durable indexing](DURABLE_INDEXING.md), [canonical identity](CANONICAL_PRODUCT_IDENTITY.md), [health](INDEXER_HEALTH.md) and [analytics](ANALYTICS.md). Increment 10 adds no database migration: the current head remains `e93a107bd624`. See [operations](OPERATIONS.md) and [measured performance](PERFORMANCE.md). **Ordinary releases keep production `INDEXER_ENABLED=false`.** Enabling or reconstructing production indexing requires the separate reviewed procedure below.
## Release order and evidence
1. Start from current main on a feature branch. Keep API/schema changes additive and compatible with the running frontend. Generate OpenAPI from the actual application and regenerate `frontend/src/types/api.ts`; the drift check must pass. Never copy production secrets into CI or preview configuration.
2. Run the serial local acceptance sequence below, then stage the feature into the persistent `testing` branch with an ordinary merge. Use the stable testing Vercel alias so CORS and SIWE origins match. Verify the deployed testing commit, schema, separate database, chain 11155111, registry/tracker and deployment anchor against [HOSTED_TESTING.md](HOSTED_TESTING.md). Testing uses its own Sepolia contracts and wallets, with 12 confirmations; local Hardhat uses chain 31337 and zero confirmations. Sepolia ETH comes from a free faucet.
-3. Record a restorable database checkpoint before any future migration. Rehearse that migration on an isolated copy and compare business/journal counts and identities. Review generated SQL and ownership changes. Run `python manage_schema.py` read-only first, then `python manage_schema.py --apply` only against the explicitly selected database. The deployment entrypoint rejects an outdated schema; it never migrates production automatically. Stop for identity/history conflicts. Increment 7 needs no schema change.
+3. Record a restorable database checkpoint before any future migration. Rehearse that migration on an isolated copy and compare business/journal counts and identities. Review generated SQL and ownership changes. Run `python manage_schema.py` read-only first. Retain the rehearsal and reviewed SQL; defer production `--apply` until the coordinated window after exact-main CI and image admission in step 5. The deployment entrypoint rejects an outdated schema; it never migrates production automatically. Stop for identity/history conflicts. Increment 10 needs no schema change.
4. Open an up-to-date PR targeting protected main. All eight named GitHub Actions checks in [RELEASE_CONTROLS.md](RELEASE_CONTROLS.md) must succeed. Record the feature SHA, testing deployment SHA, check run URLs and environment. Skipped, cancelled, neutral and missing jobs do not meet production admission. Do not bypass the rule or weaken a failing check.
-5. Squash merge the green PR. The new main SHA gets its own push CI and integration runs. Render starts a Docker build on commit; the Dockerfile waits for those exact main-push runs and records a commit/policy/workflow admission proof in the image. `start_api.py` validates that proof locally, then checks schema and binds the port. Cold starts of an admitted image make no GitHub request. Its platform health path is `/health/live`. The previous running service remains available during replacement. Production readiness requires its database and the disabled-indexer policy. Inspect `/health/live` and `/health/ready`, including `release_commit`, environment and chain.
+5. Squash merge the green PR. The new main SHA gets its own push CI and integration runs. Render starts a Docker build on commit; the Dockerfile waits for those exact main-push runs and records a commit/policy/workflow admission proof in the image. `start_api.py` validates that proof locally, then checks schema and binds the port. Cold starts of an admitted image make no GitHub request. Its platform health path is `/health/live`. For a schema-changing release, first complete exact-main CI, confirm candidate image admission, and verify the old API is already awake and ready. Run `check_migration_window.py` and coordinate reviewed DDL with immediate rollout as described in [operations](OPERATIONS.md#coordinated-migration-window). An old artifact cannot cold-start against an unrecognized schema head; never migrate while CI is pending. An awake process reduces that restart risk but does not guarantee availability if it crashes. For a release without DDL the existing service follows the normal replacement path. Production readiness requires its database and the disabled-indexer policy. Inspect `/health/live` and `/health/ready`, including `release_commit`, environment and chain.
6. Vercel's production build waits for the same exact main-push checks and then a ready compatible production API before building. For a frontend-only release, the API may use the latest backend-changing ancestor commit. `/release.json` records the frontend SHA, checked workflow IDs/attempts and observed backend SHA; it contains no credentials. Inspect that attestation and the real frontend's API origin after publication.
7. Verify public product detail/verification, dashboard/analytics, filtered CSV/PDF exports, and a narrow viewport. Confirm production counts did not change from testing activity and production indexing remains disabled. Keep the final main SHA, provider deployment IDs and results in the PR/release record. Avoid continuous keep-alive polling on free Render services.
diff --git a/backend/app/api/routes/analytics.py b/backend/app/api/routes/analytics.py
index 04c12a1..5697f64 100644
--- a/backend/app/api/routes/analytics.py
+++ b/backend/app/api/routes/analytics.py
@@ -121,19 +121,31 @@ async def analytics_events():
queue = broadcaster.subscribe()
async def event_generator():
+ from app.services.operations import operations
+ import logging
+
try:
revision = await asyncio.to_thread(chain_revision)
+ operations.stream("resyncs")
yield "event: resync\ndata: {}\n\n"
while True:
try:
message = await asyncio.wait_for(queue.get(), timeout=5)
yield message
except asyncio.TimeoutError:
- yield ": heartbeat\n\n"
+ yield ": heartbeat\nevent: heartbeat\ndata: {}\n\n"
latest = await asyncio.to_thread(chain_revision)
if latest != revision:
revision = latest
yield "event: chain_updated\ndata: {}\n\n"
+ except asyncio.CancelledError:
+ raise
+ except Exception as error:
+ operations.stream("failures")
+ logging.getLogger("veritras.events").error(
+ "event_stream_unavailable", extra={"error_type": type(error).__name__}
+ )
+ yield "event: unavailable\ndata: {}\n\n"
finally:
broadcaster.unsubscribe(queue)
diff --git a/backend/app/api/routes/telemetry.py b/backend/app/api/routes/telemetry.py
new file mode 100644
index 0000000..ed9d1e4
--- /dev/null
+++ b/backend/app/api/routes/telemetry.py
@@ -0,0 +1,16 @@
+from fastapi import APIRouter, HTTPException, Request, Response
+from app.core.config import settings
+from app.core.limiter import limiter
+from app.schemas.operations import BrowserMetric
+from app.services.operations import operations
+
+router = APIRouter()
+
+
+@router.post("/browser", status_code=204)
+@limiter.limit("60/minute")
+def browser_metric(request: Request, metric: BrowserMetric):
+ if metric.environment != settings.environment:
+ raise HTTPException(422, "Telemetry environment does not match this API")
+ operations.browser_metric(metric.metric, metric.route, metric.value)
+ return Response(status_code=204)
diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py
index 6efcd66..0e4ff7f 100644
--- a/backend/app/core/logging.py
+++ b/backend/app/core/logging.py
@@ -2,21 +2,14 @@
import logging
import os
import sys
-
-# Fields present on every LogRecord by default — skip when merging extras.
-_RESERVED = frozenset({
- "name", "msg", "args", "levelname", "levelno", "pathname", "filename",
- "module", "exc_info", "exc_text", "stack_info", "lineno", "funcName",
- "created", "msecs", "relativeCreated", "thread", "threadName",
- "processName", "process", "message", "taskName",
-})
+import re
class JsonFormatter(logging.Formatter):
"""Emit one JSON object per log record to stdout.
- Any key passed via extra={} lands as a top-level JSON field, making log
- lines queryable by key in Render, Datadog, Loki, etc.
+ Only operational fields from an explicit allowlist become JSON fields.
+ Exception messages and arbitrary extras are excluded.
"""
def format(self, record: logging.LogRecord) -> str:
@@ -27,14 +20,36 @@ def format(self, record: logging.LogRecord) -> str:
"level": record.levelname,
"logger": record.name,
"message": record.message,
+ "environment": os.environ.get("ENVIRONMENT", "development"),
+ "release": (
+ os.environ.get("RENDER_GIT_COMMIT")
+ if re.fullmatch(
+ r"[0-9a-f]{40}", os.environ.get("RENDER_GIT_COMMIT", "")
+ )
+ else None
+ ),
}
for key, val in record.__dict__.items():
- if key not in _RESERVED and not key.startswith("_"):
+ if key in {
+ "method",
+ "path",
+ "status",
+ "duration_ms",
+ "request_id",
+ "error_type",
+ "reason",
+ "last_processed_block",
+ "target_head",
+ "lag_blocks",
+ "last_error",
+ }:
log_obj[key] = val
if record.exc_info:
- log_obj["exc_info"] = self.formatException(record.exc_info)
+ log_obj["exc_info"] = (
+ type(record.exc_info[1]).__name__ + " (details omitted)"
+ )
return json.dumps(log_obj, default=str)
diff --git a/backend/app/main.py b/backend/app/main.py
index 6834b5b..2b7564a 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -3,6 +3,7 @@
import re
import logging.config
import time
+import uuid
from contextlib import asynccontextmanager
from typing import Optional
@@ -20,7 +21,9 @@
from app.core.limiter import limiter
from app.core.logging import LOGGING_CONFIG
from app.core.security import SecurityHeadersMiddleware
-from app.api.routes import auth, products, shipments, analytics, exceptions
+from app.api.routes import auth, products, shipments, analytics, exceptions, telemetry
+from app.schemas.operations import OperationsResponse
+from app.services.operations import operations
from app.services.exceptions import ExceptionWorker
from app.services.indexer import EventIndexer
from app.services.health import DatabaseProbe
@@ -143,21 +146,43 @@ def parse_cors_origins(cors_origins: str) -> list[str]:
@app.middleware("http")
async def log_requests(request: Request, call_next):
- start_time = time.time()
- response = await call_next(request)
+ start_time = time.monotonic()
+ request_id = uuid.uuid4().hex
+ response = None
+ error_type = None
+ try:
+ response = await call_next(request)
+ except Exception as error:
+ error_type = type(error).__name__
+ raise
+ finally:
+ duration_ms = (time.monotonic() - start_time) * 1000
+ route = getattr(request.scope.get("route"), "path", "unmatched")
+ status = response.status_code if response is not None else 500
+ operations.request(
+ status=status,
+ duration_ms=duration_ms,
+ business=not route.startswith(("/health", "/telemetry")),
+ )
+ logger.log(
+ (
+ logging.ERROR
+ if status >= 500 and not route.startswith("/health")
+ else logging.INFO
+ ),
+ "request",
+ extra={
+ "method": request.method,
+ "path": route,
+ "status": status,
+ "duration_ms": round(duration_ms, 2),
+ "request_id": request_id,
+ "error_type": error_type,
+ },
+ )
+ response.headers["X-Request-ID"] = request_id
if request.url.path == "/health" or request.url.path.startswith("/health/"):
response.headers["Cache-Control"] = "no-store"
- duration_ms = (time.time() - start_time) * 1000
- logger.info(
- "request",
- extra={
- "method": request.method,
- "path": request.url.path,
- "status": response.status_code,
- "duration_ms": round(duration_ms, 2),
- "client_ip": request.client.host if request.client else None,
- },
- )
return response
@@ -166,6 +191,7 @@ async def log_requests(request: Request, call_next):
app.include_router(shipments.router, prefix="/shipments", tags=["shipments"])
app.include_router(analytics.router, prefix="/analytics", tags=["analytics"])
app.include_router(exceptions.router, prefix="/exceptions", tags=["exceptions"])
+app.include_router(telemetry.router, prefix="/telemetry", tags=["telemetry"])
def indexer_state():
@@ -249,6 +275,12 @@ def health_ready():
)
+@app.get("/health/operations", response_model=OperationsResponse)
+def health_operations():
+ """Bounded diagnostics; public aggregates contain no user input or identities."""
+ return {"health": readiness_state(), "counters": operations.snapshot()}
+
+
@app.get("/health")
def health_check():
"""Compatibility summary of readiness. Does not make a separate RPC call."""
diff --git a/backend/app/schemas/operations.py b/backend/app/schemas/operations.py
new file mode 100644
index 0000000..6f84d82
--- /dev/null
+++ b/backend/app/schemas/operations.py
@@ -0,0 +1,58 @@
+from typing import Literal
+from pydantic import BaseModel, ConfigDict, Field
+from app.schemas.health import ReadinessResponse
+
+
+class BrowserMetric(BaseModel):
+ model_config = ConfigDict(extra="forbid")
+ metric: Literal[
+ "LCP",
+ "CLS",
+ "interaction_max_ms",
+ "client_error",
+ "api_error",
+ "stream_disconnected",
+ "stream_recovered",
+ ]
+ route: Literal[
+ "dashboard",
+ "products",
+ "shipments",
+ "shipment_detail",
+ "analytics",
+ "exceptions",
+ "verify",
+ "demo",
+ "privacy",
+ "terms",
+ "not_found",
+ ]
+ value: float = Field(ge=0, le=600000, allow_inf_nan=False)
+ release: str | None = Field(default=None, pattern=r"^[0-9a-f]{40}$")
+ environment: Literal["development", "testing", "production"]
+
+
+class BrowserAggregate(BaseModel):
+ metric: str
+ route: str
+ count: int
+ mean: float
+
+
+class OperationalCounters(BaseModel):
+ window_seconds: int
+ uptime_seconds: int
+ requests: int
+ api_requests: int
+ api_5xx: int
+ mean_duration_ms: float | None
+ duration_buckets: dict[str, int]
+ streams: dict[str, int]
+ browser_metrics: list[BrowserAggregate]
+ storage: Literal["process_memory"]
+ reset_on_restart: bool
+
+
+class OperationsResponse(BaseModel):
+ health: ReadinessResponse
+ counters: OperationalCounters
diff --git a/backend/app/services/events.py b/backend/app/services/events.py
index d688ef6..87a14b1 100644
--- a/backend/app/services/events.py
+++ b/backend/app/services/events.py
@@ -3,6 +3,7 @@
import asyncio
import json
import threading
+from app.services.operations import operations
class EventBroadcaster:
@@ -14,17 +15,22 @@ def subscribe(self):
queue = asyncio.Queue(maxsize=64)
with self._lock:
self._queues[queue] = asyncio.get_running_loop()
+ operations.stream("opened")
return queue
def unsubscribe(self, queue):
with self._lock:
- self._queues.pop(queue, None)
+ removed = self._queues.pop(queue, None)
+ if removed is not None:
+ operations.stream("closed")
@staticmethod
def _put(queue, message):
if queue.full():
+ operations.stream("overflows")
queue.get_nowait()
message = "event: resync\ndata: {}\n\n"
+ operations.stream("resyncs")
queue.put_nowait(message)
def publish_from_thread(self, event_type, data):
diff --git a/backend/app/services/operations.py b/backend/app/services/operations.py
new file mode 100644
index 0000000..1e90464
--- /dev/null
+++ b/backend/app/services/operations.py
@@ -0,0 +1,88 @@
+"""Bounded process-local operational counters. No identities, payloads or tokens."""
+
+from collections import Counter, deque
+import threading
+import time
+
+
+class Operations:
+ def __init__(self, clock=time.monotonic):
+ self.clock = clock
+ self.started = clock()
+ self.lock = threading.Lock()
+ self.requests = deque(maxlen=15)
+ self.browser = Counter()
+ self.streams = Counter(
+ active=0, opened=0, closed=0, failures=0, resyncs=0, overflows=0
+ )
+
+ def request(self, *, status, duration_ms, business=True):
+ with self.lock:
+ minute = int(self.clock() // 60)
+ if not self.requests or self.requests[-1][0] != minute:
+ self.requests.append((minute, Counter()))
+ counts = self.requests[-1][1]
+ counts["requests"] += 1
+ counts["duration_ms"] += max(0, duration_ms)
+ if business:
+ counts["api_requests"] += 1
+ counts["api_5xx"] += int(status >= 500)
+ for limit in (100, 500, 2000):
+ counts[f"le_{limit}_ms"] += int(duration_ms <= limit)
+
+ def stream(self, event):
+ assert event in {"opened", "closed", "failures", "resyncs", "overflows"}
+ with self.lock:
+ self.streams[event] += 1
+ if event == "opened":
+ self.streams["active"] += 1
+ if event == "closed":
+ self.streams["active"] = max(0, self.streams["active"] - 1)
+
+ def browser_metric(self, metric, route, value):
+ # Both metric/route come from bounded enums validated by the API schema.
+ with self.lock:
+ key = (metric, route)
+ self.browser[key] += 1
+ self.browser[(metric, route, "sum")] += value
+
+ def snapshot(self):
+ with self.lock:
+ minimum = int(self.clock() // 60) - 14
+ counts = Counter()
+ for minute, values in self.requests:
+ if minute >= minimum:
+ counts.update(values)
+ browser = [
+ dict(
+ metric=key[0],
+ route=key[1],
+ count=value,
+ mean=round(self.browser[(*key, "sum")] / value, 2),
+ )
+ for key, value in self.browser.items()
+ if len(key) == 2 and value
+ ]
+ return dict(
+ window_seconds=900,
+ uptime_seconds=int(self.clock() - self.started),
+ requests=int(counts["requests"]),
+ api_requests=int(counts["api_requests"]),
+ api_5xx=int(counts["api_5xx"]),
+ mean_duration_ms=(
+ round(counts["duration_ms"] / counts["requests"], 2)
+ if counts["requests"]
+ else None
+ ),
+ duration_buckets={
+ f"le_{limit}_ms": int(counts[f"le_{limit}_ms"])
+ for limit in (100, 500, 2000)
+ },
+ streams=dict(self.streams),
+ browser_metrics=browser,
+ storage="process_memory",
+ reset_on_restart=True,
+ )
+
+
+operations = Operations()
diff --git a/backend/check_migration_window.py b/backend/check_migration_window.py
new file mode 100644
index 0000000..114615f
--- /dev/null
+++ b/backend/check_migration_window.py
@@ -0,0 +1,96 @@
+"""Read-only prerequisite for production DDL; does not apply any migration.
+
+Run after protected merge and successful exact-main CI, while coordinating the
+replacement image rollout. Rehearsal and a retained backup remain mandatory.
+"""
+
+import argparse
+from datetime import datetime, timedelta, timezone
+import hashlib
+import json
+from pathlib import Path
+import re
+from check_health import fetch_json, validate_url
+from release_gate import assess
+
+
+def preflight(
+ *,
+ candidate,
+ current,
+ origin,
+ sql_file,
+ fetch=fetch_json,
+ admission=assess,
+ now=None
+):
+ if any(
+ not re.fullmatch(r"[0-9a-f]{40}", value or "") for value in (candidate, current)
+ ):
+ raise ValueError("Full current and candidate commits are required")
+ if candidate == current:
+ raise ValueError("Candidate must differ from current deployed API")
+ proof = admission(candidate) # Exact main-push workflow + all required jobs.
+ if proof.get("commit") != candidate:
+ raise ValueError("Candidate admission mismatch")
+ live_code, live = fetch(origin + "/health/live", 15)
+ ready_code, ready = fetch(origin + "/health/ready", 15)
+ if (
+ live_code != 200
+ or ready_code != 200
+ or live.get("status") != "alive"
+ or ready.get("status") != "ready"
+ or ready.get("database") != "connected"
+ or any(
+ body.get("release_commit") != current
+ or body.get("environment") != "production"
+ for body in (live, ready)
+ )
+ ):
+ raise ValueError(
+ "The current production API must already be awake, ready and on the expected commit"
+ )
+ source = Path(sql_file).read_bytes()
+ if not source:
+ raise ValueError("Reviewed SQL file is empty")
+ now = now or datetime.now(timezone.utc)
+ return dict(
+ approved_for_coordination=True,
+ not_authorization_to_apply=True,
+ candidate=candidate,
+ current=current,
+ sql_sha256=hashlib.sha256(source).hexdigest(),
+ checked_at=now.isoformat(),
+ expires_at=(now + timedelta(minutes=2)).isoformat(),
+ workflow_evidence=proof["workflows"],
+ next_step="Confirm replacement build has passed admission; apply rehearsed additive DDL immediately with rollout. Recheck if delayed. Never bypass schema admission or downgrade.",
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--candidate", required=True)
+ parser.add_argument("--current", required=True)
+ parser.add_argument("--url", required=True, type=validate_url)
+ parser.add_argument("--sql-file", required=True)
+ args = parser.parse_args()
+ try:
+ print(
+ json.dumps(
+ preflight(
+ candidate=args.candidate,
+ current=args.current,
+ origin=args.url,
+ sql_file=args.sql_file,
+ ),
+ sort_keys=True,
+ )
+ )
+ except Exception as error:
+ # Provider errors may contain URLs or credentials. Type only.
+ print(
+ json.dumps(
+ {"approved_for_coordination": False, "reason": type(error).__name__}
+ )
+ )
+ raise SystemExit(1)
diff --git a/backend/monitor_operations.py b/backend/monitor_operations.py
new file mode 100644
index 0000000..1e32a4a
--- /dev/null
+++ b/backend/monitor_operations.py
@@ -0,0 +1,151 @@
+"""Read-only scheduled operations check. Native Actions failures provide alerts.
+
+No service restart, paid keep-alive, external recipients, business writes or secrets.
+"""
+
+import argparse
+import json
+import ssl
+import time
+from urllib.request import Request, urlopen
+from check_health import probe, validate_url, fetch_json
+
+ACTIONS = {
+ "readiness": "Inspect health/indexer reason and Render runtime logs; see OPERATIONS.md#incident-response.",
+ "api_errors": "Inspect structured request error_type/request_id and release; see OPERATIONS.md#incident-response.",
+ "event_stream": "Inspect event_stream_unavailable and database connectivity; see OPERATIONS.md#incident-response.",
+ "diagnostics": "Check deployment/schema and health/operations response; see OPERATIONS.md#incident-response.",
+}
+
+
+def stream_probe(origin, timeout=12):
+ start = time.monotonic()
+ with urlopen(
+ Request(origin + "/analytics/events", headers={"Accept": "text/event-stream"}),
+ timeout=timeout,
+ context=ssl.create_default_context(),
+ ) as response:
+ if response.status != 200 or not response.headers.get(
+ "Content-Type", ""
+ ).startswith("text/event-stream"):
+ return False
+ size = 0
+ while time.monotonic() - start < timeout:
+ line = response.readline(4097)
+ size += len(line)
+ if not line or len(line) > 4096 or size > 16384:
+ return False
+ if line.strip() in (b": heartbeat", b"event: heartbeat"):
+ return True
+ if line.strip() == b"event: unavailable":
+ return False
+ return False
+
+
+def assess_counters(body):
+ counters = body["counters"]
+ requests, errors = counters["api_requests"], counters["api_5xx"]
+ if (
+ any(type(v) is not int or v < 0 for v in (requests, errors))
+ or errors > requests
+ ):
+ return ["diagnostics"]
+ return ["api_errors"] if errors >= 3 else []
+
+
+def monitor(
+ origin,
+ *,
+ environment,
+ expect_indexer,
+ grace=240,
+ interval=15,
+ timeout=15,
+ readiness=probe,
+ fetch=fetch_json,
+ stream=stream_probe,
+ clock=time.monotonic,
+ sleep=time.sleep
+):
+ deadline = clock() + grace
+ consecutive = 0
+ observed = {"ready": False, "state": "unavailable"}
+ while clock() < deadline:
+ observed = readiness(
+ origin,
+ environment=environment,
+ expect_indexer=expect_indexer,
+ timeout=timeout,
+ )
+ consecutive = consecutive + 1 if observed["ready"] else 0
+ if consecutive >= 3:
+ break
+ sleep(min(interval, max(0, deadline - clock())))
+ failures = []
+ if consecutive < 3:
+ failures.append("readiness")
+ else:
+ try:
+ code, body = fetch(origin + "/health/operations", timeout)
+ if (
+ code != 200
+ or body["health"]["environment"] != environment
+ or body["health"]["indexer"]["enabled"] is not expect_indexer
+ ):
+ failures.append("diagnostics")
+ else:
+ failures.extend(assess_counters(body))
+ except Exception:
+ failures.append("diagnostics")
+ try:
+ if not stream(origin, timeout=12):
+ failures.append("event_stream")
+ except Exception:
+ failures.append("event_stream")
+ # Keep only allowlisted operational state; never echo arbitrary response text/URLs.
+ safe = {
+ key: observed[key]
+ for key in (
+ "ready",
+ "state",
+ "reason",
+ "lag_blocks",
+ "last_processed_block",
+ "target_head",
+ )
+ if key in observed
+ }
+ return dict(
+ healthy=not failures,
+ environment=environment,
+ expected_indexer=expect_indexer,
+ readiness=safe,
+ conditions=sorted(set(failures)),
+ actions=[ACTIONS[f] for f in sorted(set(failures))],
+ )
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--url", required=True, type=validate_url)
+ parser.add_argument(
+ "--environment", required=True, choices=["production", "testing"]
+ )
+ parser.add_argument(
+ "--expect-indexer", required=True, choices=["enabled", "disabled"]
+ )
+ parser.add_argument(
+ "--grace", type=int, default=240, choices=range(30, 301), metavar="30..300"
+ )
+ args = parser.parse_args()
+ result = monitor(
+ args.url,
+ environment=args.environment,
+ expect_indexer=args.expect_indexer == "enabled",
+ grace=args.grace,
+ )
+ print(json.dumps(result, sort_keys=True))
+ if not result["healthy"]:
+ for condition in result["conditions"]:
+ print("::error title=Veritras " + condition + "::" + ACTIONS[condition])
+ raise SystemExit(0 if result["healthy"] else 1)
diff --git a/backend/tests/test_operations.py b/backend/tests/test_operations.py
new file mode 100644
index 0000000..afc7fab
--- /dev/null
+++ b/backend/tests/test_operations.py
@@ -0,0 +1,301 @@
+import json
+import logging
+from datetime import datetime, timezone
+import pytest
+from app.services.operations import Operations
+from app.core.logging import JsonFormatter
+from monitor_operations import monitor, assess_counters
+from check_migration_window import preflight
+
+
+def test_counters_are_bounded_expire_and_reset():
+ now = [0.0]
+ metrics = Operations(lambda: now[0])
+ for i in range(30):
+ now[0] = i * 60
+ metrics.request(status=500, duration_ms=20)
+ state = metrics.snapshot()
+ assert len(metrics.requests) == 15 and state["api_5xx"] == 15
+ now[0] += 901
+ assert metrics.snapshot()["api_5xx"] == 0
+ assert state["storage"] == "process_memory" and state["reset_on_restart"]
+
+
+def test_health_requests_do_not_dilute_business_error_count():
+ metrics = Operations()
+ metrics.request(status=500, duration_ms=20)
+ metrics.request(status=503, duration_ms=10, business=False)
+ s = metrics.snapshot()
+ assert s["requests"] == 2 and s["api_5xx"] == 1 and s["api_requests"] == 1
+
+
+def test_telemetry_rejects_input_and_identity_fields(client_fixture):
+ valid = dict(
+ metric="LCP",
+ route="shipment_detail",
+ value=123,
+ environment="development",
+ release="a" * 40,
+ )
+ assert client_fixture.post("/telemetry/browser", json=valid).status_code == 204
+ for field in ("wallet", "token", "message", "url", "note", "signature", "user"):
+ assert (
+ client_fixture.post(
+ "/telemetry/browser", json=dict(valid, **{field: "secret"})
+ ).status_code
+ == 422
+ )
+ for field, value in [
+ ("environment", "production"),
+ ("route", "/shipments/SECRET"),
+ ("value", -1),
+ ("value", 600001),
+ ("release", "secret"),
+ ]:
+ assert (
+ client_fixture.post(
+ "/telemetry/browser", json=dict(valid, **{field: value})
+ ).status_code
+ == 422
+ )
+
+
+def test_public_operations_are_typed_aggregates(client_fixture):
+ response = client_fixture.get("/health/operations")
+ assert response.status_code == 200
+ body = response.json()
+ assert body["health"]["environment"] == "development"
+ assert body["counters"]["reset_on_restart"]
+ assert all(
+ "url" not in metric and "release" not in metric
+ for metric in body["counters"]["browser_metrics"]
+ )
+ assert response.headers["cache-control"] == "no-store"
+
+
+def test_request_logs_do_not_include_paths_input_ip_or_arbitrary_extras(
+ client_fixture, capfd, monkeypatch
+):
+ monkeypatch.setenv("RENDER_GIT_COMMIT", "a" * 40)
+ response = client_fixture.get("/products/SECRET-PRODUCT?token=SECRET-TOKEN")
+ records = [
+ json.loads(line)
+ for line in capfd.readouterr().out.splitlines()
+ if line.startswith("{")
+ ]
+ request = next(r for r in reversed(records) if r.get("message") == "request")
+ assert request["path"] == "/{product_id}"
+ assert request["release"] == "a" * 40 and "client_ip" not in request
+ assert request["request_id"] == response.headers["x-request-id"]
+ assert "SECRET" not in json.dumps(request)
+ record = logging.LogRecord(
+ "veritras.test", logging.ERROR, __file__, 1, "failure", (), None
+ )
+ record.token = "SECRET"
+ record.user = {"wallet": "SECRET"}
+ try:
+ raise ValueError("SECRET RPC https://credentials.invalid")
+ except ValueError:
+ import sys
+
+ record.exc_info = sys.exc_info()
+ output = JsonFormatter().format(record)
+ assert "SECRET" not in output and "credentials.invalid" not in output
+ assert "ValueError" in output
+
+
+def test_monitor_requires_sustained_readiness_and_checks_stream():
+ now = [0.0]
+ calls = []
+
+ def ready(*args, **kwargs):
+ calls.append(1)
+ return {
+ "ready": len(calls) > 1,
+ "state": "current" if len(calls) > 1 else "starting",
+ }
+
+ def sleep(seconds):
+ now[0] += seconds
+
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=90,
+ readiness=ready,
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {"environment": "testing", "indexer": {"enabled": True}},
+ "counters": {"api_requests": 10, "api_5xx": 0},
+ },
+ ),
+ stream=lambda *a, **k: True,
+ clock=lambda: now[0],
+ sleep=sleep,
+ )
+ assert result["healthy"] and len(calls) == 4
+
+
+def test_monitor_disabled_policy_is_expected_and_stream_failure_actionable():
+ now = [0.0]
+ result = monitor(
+ "https://example.invalid",
+ environment="production",
+ expect_indexer=False,
+ grace=90,
+ readiness=lambda *a, **k: {"ready": True, "state": "disabled"},
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {"environment": "production", "indexer": {"enabled": False}},
+ "counters": {"api_requests": 3, "api_5xx": 3},
+ },
+ ),
+ stream=lambda *a, **k: False,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert result["conditions"] == ["api_errors", "event_stream"]
+ assert all("OPERATIONS.md" in action for action in result["actions"])
+
+
+def test_monitor_stale_indexer_fails_without_business_or_stream_calls():
+ now = [0.0]
+
+ def fail(*args, **kwargs):
+ raise AssertionError("No downstream probes while unavailable")
+
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=30,
+ readiness=lambda *a, **k: {
+ "ready": False,
+ "state": "degraded",
+ "reason": "poll_stale",
+ },
+ fetch=fail,
+ stream=fail,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert (
+ result["conditions"] == ["readiness"]
+ and result["readiness"]["reason"] == "poll_stale"
+ )
+
+
+def test_invalid_counters_fail_closed():
+ assert assess_counters({"counters": {"api_requests": 1, "api_5xx": 2}}) == [
+ "diagnostics"
+ ]
+
+
+def test_migration_preflight_rejects_pending_ci_before_health(tmp_path):
+ sql = tmp_path / "migration.sql"
+ sql.write_text("CREATE TABLE example(id integer);")
+
+ def pending(commit):
+ raise RuntimeError("CI pending")
+
+ def no_health(*a):
+ raise AssertionError("Admission precedes warmup check")
+
+ with pytest.raises(RuntimeError):
+ preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=pending,
+ fetch=no_health,
+ )
+
+
+def test_migration_preflight_requires_awake_exact_prior_release(tmp_path):
+ sql = tmp_path / "migration.sql"
+ sql.write_text("CREATE TABLE example(id integer);")
+ proof = lambda commit: {"commit": commit, "workflows": []}
+ for code, commit in [(503, "b" * 40), (200, "c" * 40)]:
+ with pytest.raises(ValueError):
+ preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=proof,
+ fetch=lambda *a: (
+ code,
+ {
+ "status": "ready",
+ "release_commit": commit,
+ "environment": "production",
+ },
+ ),
+ )
+
+ def fetch(url, timeout):
+ return 200, dict(
+ status="alive" if url.endswith("live") else "ready",
+ database="connected",
+ release_commit="b" * 40,
+ environment="production",
+ )
+
+ result = preflight(
+ candidate="a" * 40,
+ current="b" * 40,
+ origin="https://example.invalid",
+ sql_file=sql,
+ admission=proof,
+ fetch=fetch,
+ now=datetime(2026, 1, 1, tzinfo=timezone.utc),
+ )
+ assert result["approved_for_coordination"] and result["not_authorization_to_apply"]
+ assert result["expires_at"] == "2026-01-01T00:02:00+00:00"
+
+
+@pytest.mark.asyncio
+async def test_stream_failure_is_private_observable_and_releases_subscription(
+ monkeypatch,
+):
+ from app.api.routes import analytics
+ from app.services.operations import operations
+
+ baseline = operations.snapshot()["streams"]
+
+ def broken():
+ raise RuntimeError("SECRET connection string")
+
+ monkeypatch.setattr(analytics, "chain_revision", broken)
+ response = await analytics.analytics_events()
+ chunks = [chunk async for chunk in response.body_iterator]
+ assert chunks == ["event: unavailable\ndata: {}\n\n"]
+ result = operations.snapshot()["streams"]
+ assert result["failures"] == baseline["failures"] + 1
+ assert result["active"] == baseline["active"]
+
+
+@pytest.mark.asyncio
+async def test_stream_heartbeat_is_named_and_cancel_releases_subscription(monkeypatch):
+ import asyncio
+ from app.api.routes import analytics
+ from app.services.operations import operations
+
+ monkeypatch.setattr(analytics, "chain_revision", lambda: 0)
+
+ async def timeout(awaitable, **kwargs):
+ awaitable.close()
+ raise asyncio.TimeoutError
+
+ monkeypatch.setattr(analytics.asyncio, "wait_for", timeout)
+ baseline = operations.snapshot()["streams"]["active"]
+ response = await analytics.analytics_events()
+ iterator = response.body_iterator
+ assert await anext(iterator) == "event: resync\ndata: {}\n\n"
+ assert "event: heartbeat" in await anext(iterator)
+ await iterator.aclose()
+ assert operations.snapshot()["streams"]["active"] == baseline
diff --git a/frontend/e2e/dashboard.spec.ts b/frontend/e2e/dashboard.spec.ts
index bc747e3..982178a 100644
--- a/frontend/e2e/dashboard.spec.ts
+++ b/frontend/e2e/dashboard.spec.ts
@@ -22,7 +22,7 @@ test('KPI cards render with mocked values', async ({ page }) => {
test('Shipment Volume chart section renders', async ({ page }) => {
await page.goto('/')
- await expect(page.getByText('Shipment Volume')).toBeVisible()
+ await expect(page.getByRole('heading', { name: 'Shipment Volume', exact: true })).toBeVisible()
})
test('Recent Products section renders', async ({ page }) => {
diff --git a/frontend/e2e/helpers/routes.ts b/frontend/e2e/helpers/routes.ts
index 05afa22..4882ad6 100644
--- a/frontend/e2e/helpers/routes.ts
+++ b/frontend/e2e/helpers/routes.ts
@@ -47,6 +47,8 @@ function apiPath(url: URL, pathname: string): boolean {
}
export async function setupRoutes(page: Page): Promise {
+ // Synthetic fixtures must not report synthetic failures into a hosted API.
+ await page.route(url => isApiOrigin(url) && url.pathname === "/telemetry/browser", route => route.fulfill({status:204}))
await page.route(url => isApiOrigin(url) && url.pathname.replace(/\/$/, '') === '/exceptions', route => route.fulfill({ json: { items: [], total: 0, evaluation: { paused: true, reason: 'indexing_disabled', evaluated_at: null, block_number: null, uncertain_shipments: 0 } } }))
await page.route(url => isApiOrigin(url) && /^\/shipments\/[^/]+$/.test(url.pathname), route => route.fulfill({ json: MOCK_SHIPMENTS.items[0] }))
await page.route(url => isApiOrigin(url) && /^\/products\/[^/]+\/timeline$/.test(url.pathname), route => route.fulfill({ json: { product_id: 'PROD-001', items: [], total: 0, skip: 0, limit: 100 } }))
diff --git a/frontend/e2e/performance.spec.ts b/frontend/e2e/performance.spec.ts
new file mode 100644
index 0000000..e8f49e0
--- /dev/null
+++ b/frontend/e2e/performance.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from "@playwright/test";
+import { readFileSync } from "node:fs";
+import { gzipSync } from "node:zlib";
+import { setupRoutes } from "./helpers/routes";
+
+test("anonymous mobile startup keeps remote SDKs and below-fold chart deferred within its measured budget", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await setupRoutes(page);
+ await page.goto("/");
+ await expect(
+ page.getByRole("heading", { name: "Track Beyond The Ordinary" }),
+ ).toBeVisible();
+ await page.waitForTimeout(3500); // Include idle diagnostics, not just the first paint.
+ const scripts = await page.evaluate(() =>
+ performance
+ .getEntriesByType("resource")
+ .map((e) => new URL(e.name).pathname)
+ .filter((p) => p.startsWith("/assets/") && p.endsWith(".js")),
+ );
+ expect(
+ scripts.some((p) =>
+ /ShipmentVolumeChart|reown|walletconnect|recharts|web3-vendor/.test(p),
+ ),
+ ).toBe(false);
+ const bytes = [...new Set(scripts)].reduce(
+ (sum, p) => sum + gzipSync(readFileSync(`dist${p}`)).length,
+ 0,
+ );
+ expect(bytes).toBeLessThan(450000);
+ await page.getByLabel("Shipment volume chart").scrollIntoViewIfNeeded();
+ await expect
+ .poll(async () =>
+ page.evaluate(() =>
+ performance
+ .getEntriesByType("resource")
+ .some((e) => e.name.includes("ShipmentVolumeChart")),
+ ),
+ )
+ .toBe(true);
+ await page.getByRole("button", { name: "Open navigation menu" }).click();
+ await page
+ .getByRole("button", { name: "Connect Wallet", exact: true })
+ .click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(page.getByRole("button", { name: /MetaMask/i })).toBeVisible();
+});
diff --git a/frontend/scripts/measure-performance.mjs b/frontend/scripts/measure-performance.mjs
new file mode 100644
index 0000000..a1c6490
--- /dev/null
+++ b/frontend/scripts/measure-performance.mjs
@@ -0,0 +1,65 @@
+// Controlled mobile lab: frozen API fixtures, identical browser/throttling,
+// external requests blocked. Reports are local artifacts, never field metrics.
+import assert from 'node:assert/strict'
+import http from 'node:http'
+import { readFileSync, existsSync, createReadStream, mkdirSync, writeFileSync } from 'node:fs'
+import { resolve, extname } from 'node:path'
+import { gzipSync } from 'node:zlib'
+import { chromium } from 'playwright'
+import lighthouse from 'lighthouse'
+import { MOCK_KPI, MOCK_PRODUCTS, MOCK_DAILY_VOLUME, MOCK_SHIPMENTS, MOCK_PRODUCT_DETAIL, MOCK_VERIFY_OK } from '../e2e/helpers/data.ts'
+const [distArg, outputArg] = process.argv.slice(2)
+assert.ok(distArg && outputArg, 'Usage: node scripts/measure-performance.mjs DIST OUTPUT_DIRECTORY')
+const dist=resolve(distArg), output=resolve(outputArg)
+mkdirSync(output,{recursive:true})
+const port=18010
+const types={'.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.webp':'image/webp','.png':'image/png','.woff2':'font/woff2','.json':'application/json','.html':'text/html'}
+const server=http.createServer((req,res)=>{
+ const url=new URL(req.url,'http://127.0.0.1:'+port), path=url.pathname.replace(/\/$/,'')
+ const json=data=>{res.setHeader('Content-Type','application/json');res.end(JSON.stringify(data))}
+ if(path==='/health/ready')return json({status:'ready',database:'connected',indexer:{status:'current',enabled:true,ready:true}})
+ if(path==='/analytics/events'){res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache'});res.write('event: resync\ndata: {}\n\n');const timer=setInterval(()=>res.write('event: heartbeat\ndata: {}\n\n'),5000);req.on('close',()=>clearInterval(timer));return}
+ if(path==='/analytics/kpis')return json(MOCK_KPI)
+ if(path==='/analytics/daily-volume')return json(MOCK_DAILY_VOLUME)
+ if(path==='/analytics/anomalies'||path==='/analytics/bottlenecks')return json([])
+ if(path==='/products')return json(MOCK_PRODUCTS)
+ if(path==='/shipments')return json(MOCK_SHIPMENTS)
+ if(path.endsWith('/verify'))return json(MOCK_VERIFY_OK)
+ if(path.endsWith('/timeline'))return json({items:[],total:0})
+ if(/^\/products\/[^/]+$/.test(path))return json(MOCK_PRODUCT_DETAIL)
+ if(path==='/telemetry/browser'){res.writeHead(204);return res.end()}
+ const filename=resolve(dist,'.'+url.pathname)
+ const file=filename.startsWith(dist+'/')&&existsSync(filename)&&extname(filename)?filename:resolve(dist,'index.html')
+ let body=readFileSync(file)
+ res.setHeader('Content-Type',types[extname(file)]||'application/octet-stream')
+ if(/gzip/.test(req.headers['accept-encoding']||'')){body=gzipSync(body);res.setHeader('Content-Encoding','gzip')}
+ res.end(body)
+})
+await new Promise(resolve=>server.listen(port,'127.0.0.1',resolve))
+const browser=await chromium.launch({executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH||'/usr/bin/chromium',args:['--remote-debugging-port=19222','--no-sandbox']})
+const samples=[]
+try{
+ for(let i=0;i<3;i++){
+ const result=process.env.MEASURE_REUSE === '1' ? {lhr:JSON.parse(readFileSync(resolve(output,`lighthouse-${i+1}.json`),'utf8'))} : await lighthouse(`http://127.0.0.1:${port}/`,{port:19222,output:'json',logLevel:'error',onlyCategories:['performance'],formFactor:'mobile',screenEmulation:{mobile:true,width:390,height:844,deviceScaleFactor:1,disabled:false},throttlingMethod:'simulate',blockedUrlPatterns:['https://*'],maxWaitForLoad:30000,maxWaitForFcp:30000})
+ const r=result.lhr
+ assert.ok(!r.runtimeError,JSON.stringify(r.runtimeError))
+ const sample={performance:r.categories.performance.score,fcp_ms:r.audits['first-contentful-paint'].numericValue,lcp_ms:r.audits['largest-contentful-paint'].numericValue,tbt_ms:r.audits['total-blocking-time'].numericValue,cls:r.audits['cumulative-layout-shift'].numericValue,transfer_bytes:r.audits['total-byte-weight'].numericValue}
+ samples.push(sample);writeFileSync(resolve(output,`lighthouse-${i+1}.json`),JSON.stringify(r))
+ console.log(JSON.stringify({sample:i+1,...sample}))
+ }
+ const context=await browser.newContext({viewport:{width:390,height:844},reducedMotion:'reduce'})
+ await context.route('https://**',route=>route.abort())
+ const page=await context.newPage();const scripts=new Set()
+ page.on('response',r=>{if(r.request().resourceType()==='script'&&r.url().startsWith(`http://127.0.0.1:${port}/assets/`))scripts.add(new URL(r.url()).pathname)})
+ await page.goto(`http://127.0.0.1:${port}`,{waitUntil:'domcontentloaded'})
+ await page.getByRole('button',{name:'Open navigation menu'}).waitFor()
+ await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))
+ const before=await page.evaluate(()=>performance.now())
+ await page.getByRole('button',{name:'Open navigation menu'}).click()
+ await page.getByRole('navigation',{name:'Mobile navigation'}).waitFor()
+ const interaction=await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(()=>resolve(performance.now())))))
+ await page.screenshot({path:resolve(output,'mobile.png'),fullPage:true})
+ const scriptFiles=[...scripts].map(url=>({path:url,bytes:readFileSync(resolve(dist,'.'+url)).length,gzip_bytes:gzipSync(readFileSync(resolve(dist,'.'+url))).length}))
+ const summary={method:'Controlled mobile lab; 3 cold-cache Lighthouse runs; 390x844; simulated mobile CPU/network; gzip; frozen API fixtures; external HTTPS blocked. Interaction includes Playwright click overhead and two animation frames, not field INP.',browser:browser.version(),samples,median:Object.fromEntries(Object.keys(samples[0]).map(key=>[key,samples.map(s=>s[key]).sort((a,b)=>a-b)[1]])),menu_interaction_ms:interaction-before,initial_script_bytes:scriptFiles.reduce((n,s)=>n+s.bytes,0),initial_script_gzip_bytes:scriptFiles.reduce((n,s)=>n+s.gzip_bytes,0),scripts:scriptFiles}
+ writeFileSync(resolve(output,'summary.json'),JSON.stringify(summary,null,2)+'\n');console.log(JSON.stringify(summary))
+}finally{await browser.close();server.closeAllConnections();await new Promise(resolve=>server.close(resolve))}
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index 5d46c24..d7be5f2 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -34,6 +34,7 @@ vi.mock('@vercel/speed-insights/react', () => ({
vi.mock('./config/wagmi', () => ({
rainbowKitConfig: {},
+ hasStoredWalletConnection: false,
}))
vi.mock('./components/EtherealBackground', () => ({
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9d929b2..6286958 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -5,10 +5,9 @@ import '@rainbow-me/rainbowkit/styles.css'
import { WagmiProvider } from 'wagmi'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
-import { Analytics } from '@vercel/analytics/react'
-import { SpeedInsights } from '@vercel/speed-insights/react'
+import Diagnostics from './components/Diagnostics'
import { Toaster } from 'sonner'
-import { rainbowKitConfig } from './config/wagmi'
+import { rainbowKitConfig, hasStoredWalletConnection } from './config/wagmi'
import EtherealBackground from './components/EtherealBackground'
import ErrorBoundary from './components/ErrorBoundary'
import Layout from './components/Layout'
@@ -47,13 +46,12 @@ function App() {
)
return (
-
+
-
-
+
diff --git a/frontend/src/components/ConnectAndAuth.tsx b/frontend/src/components/ConnectAndAuth.tsx
index 900f981..ed4f2d5 100644
--- a/frontend/src/components/ConnectAndAuth.tsx
+++ b/frontend/src/components/ConnectAndAuth.tsx
@@ -1,3 +1,4 @@
+import { activateWallets } from '@/config/wagmi'
import { useEffect } from 'react'
import { ConnectButton } from '@rainbow-me/rainbowkit'
import { useAccount } from 'wagmi'
@@ -27,7 +28,7 @@ export default function ConnectAndAuth() {
}
return (
-
+
vi.unstubAllGlobals());
+it("loads chart only as its reserved section approaches the viewport and disconnects", () => {
+ let callback: IntersectionObserverCallback = () => {};
+ const disconnect = vi.fn(),
+ observe = vi.fn();
+ vi.stubGlobal(
+ "IntersectionObserver",
+ class {
+ constructor(cb: IntersectionObserverCallback) {
+ callback = cb;
+ }
+ observe = observe;
+ disconnect = disconnect;
+ },
+ );
+ const { unmount } = render(
+
+ EXPENSIVE CHART
+ ,
+ );
+ expect(screen.queryByText("EXPENSIVE CHART")).not.toBeInTheDocument();
+ act(() =>
+ callback(
+ [{ isIntersecting: true }] as IntersectionObserverEntry[],
+ {} as IntersectionObserver,
+ ),
+ );
+ expect(screen.getByText("EXPENSIVE CHART")).toBeInTheDocument();
+ unmount();
+ expect(disconnect).toHaveBeenCalled();
+});
+it("loads without IntersectionObserver support", () => {
+ vi.stubGlobal("IntersectionObserver", undefined);
+ // Missing property, rather than an explicitly undefined browser constructor.
+ delete (window as unknown as Record).IntersectionObserver;
+ render(Accessible chart);
+ expect(screen.getByText("Accessible chart")).toBeInTheDocument();
+});
diff --git a/frontend/src/components/DeferredSection.tsx b/frontend/src/components/DeferredSection.tsx
new file mode 100644
index 0000000..c060e83
--- /dev/null
+++ b/frontend/src/components/DeferredSection.tsx
@@ -0,0 +1,47 @@
+import { Suspense, useEffect, useRef, useState, type ReactNode } from "react";
+
+/** Keep below-fold chart code out of startup; preserve its reserved layout. */
+export default function DeferredSection({
+ children,
+ label,
+}: {
+ children: ReactNode;
+ label: string;
+}) {
+ const ref = useRef(null);
+ const [visible, setVisible] = useState(false);
+ useEffect(() => {
+ if (!("IntersectionObserver" in window)) {
+ setVisible(true);
+ return;
+ }
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setVisible(true);
+ observer.disconnect();
+ }
+ },
+ { rootMargin: "100px" },
+ );
+ if (ref.current) observer.observe(ref.current);
+ return () => observer.disconnect();
+ }, []);
+ const placeholder = (
+
+ {label} loads when visible.
+
+ );
+ return (
+
+ {visible ? (
+ {children}
+ ) : (
+ placeholder
+ )}
+
+ );
+}
diff --git a/frontend/src/components/Diagnostics.tsx b/frontend/src/components/Diagnostics.tsx
new file mode 100644
index 0000000..f8d97bd
--- /dev/null
+++ b/frontend/src/components/Diagnostics.tsx
@@ -0,0 +1,30 @@
+import { lazy, Suspense, useEffect, useState } from "react";
+import { observeVitals, reportMetric } from "@/config/telemetry";
+const Analytics = lazy(() => import("./PrivateAnalytics"));
+export default function Diagnostics() {
+ const [ready, setReady] = useState(false);
+ useEffect(() => {
+ const stop = observeVitals();
+ const reportError = () => reportMetric("client_error", 1);
+ window.addEventListener("error", reportError);
+ window.addEventListener("unhandledrejection", reportError);
+ const idle =
+ "requestIdleCallback" in window
+ ? window.requestIdleCallback(() => setReady(true), { timeout: 3000 })
+ : undefined;
+ const timer =
+ idle === undefined ? setTimeout(() => setReady(true), 2000) : undefined;
+ return () => {
+ stop();
+ window.removeEventListener("error", reportError);
+ window.removeEventListener("unhandledrejection", reportError);
+ if (idle !== undefined) window.cancelIdleCallback(idle);
+ if (timer !== undefined) clearTimeout(timer);
+ };
+ }, []);
+ return ready ? (
+
+
+
+ ) : null;
+}
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx
index c14da19..8c05531 100644
--- a/frontend/src/components/ErrorBoundary.tsx
+++ b/frontend/src/components/ErrorBoundary.tsx
@@ -1,3 +1,4 @@
+import { captureClientError } from '@/config/sentry'
import { Component, type ErrorInfo, type ReactNode } from 'react'
interface Props {
@@ -18,8 +19,8 @@ export default class ErrorBoundary extends Component {
return { hasError: true }
}
- componentDidCatch(error: Error, errorInfo: ErrorInfo) {
- console.error('ErrorBoundary caught an error:', error, errorInfo)
+ componentDidCatch(error: Error, _errorInfo: ErrorInfo) {
+ captureClientError(error)
}
render() {
diff --git a/frontend/src/components/IndexerStatusNotice.tsx b/frontend/src/components/IndexerStatusNotice.tsx
index 9c021d6..d4da523 100644
--- a/frontend/src/components/IndexerStatusNotice.tsx
+++ b/frontend/src/components/IndexerStatusNotice.tsx
@@ -1,6 +1,8 @@
+import { useStreamHealth } from '@/hooks/streamHealth'
import { useIndexerHealth } from '@/hooks/useIndexerHealth'
export default function IndexerStatusNotice() {
+ const stream = useStreamHealth()
const { data, isError } = useIndexerHealth()
let message: string | undefined
if (isError)
@@ -20,6 +22,7 @@ export default function IndexerStatusNotice() {
else if (data && data.indexer.status !== 'current')
message =
'Blockchain updates are delayed. Some recent activity may be missing.'
+ if (!message && (stream === 'reconnecting' || stream === 'stale')) message = 'Live updates are disconnected. Showing previously fetched data while the connection retries.'
if (!message) return null
return (
({ analytics: vi.fn(), speed: vi.fn() }));
+vi.mock("@vercel/analytics/react", () => ({
+ Analytics: (props: unknown) => {
+ hooks.analytics(props);
+ return null;
+ },
+}));
+vi.mock("@vercel/speed-insights/react", () => ({
+ SpeedInsights: (props: unknown) => {
+ hooks.speed(props);
+ return null;
+ },
+}));
+import PrivateAnalytics from "./PrivateAnalytics";
+it("removes identifiers from analytics URLs and speed insight routes", () => {
+ render(
);
+ const event = {
+ type: "vital",
+ url: "/shipments/SECRET?token=SECRET#SECRET",
+ route: "/shipments/SECRET",
+ };
+ const speed = hooks.speed.mock.calls[0][0].beforeSend(event);
+ expect(JSON.stringify(speed)).not.toContain("SECRET");
+ expect(speed.route).toBe("/shipments/:shipmentId");
+ const analytics = hooks.analytics.mock.calls[0][0].beforeSend({
+ type: "pageview",
+ url: "/verify/SECRET?signature=SECRET",
+ });
+ expect(JSON.stringify(analytics)).not.toContain("SECRET");
+});
diff --git a/frontend/src/components/PrivateAnalytics.tsx b/frontend/src/components/PrivateAnalytics.tsx
new file mode 100644
index 0000000..e5e20c5
--- /dev/null
+++ b/frontend/src/components/PrivateAnalytics.tsx
@@ -0,0 +1,19 @@
+import { Analytics } from "@vercel/analytics/react";
+import { SpeedInsights } from "@vercel/speed-insights/react";
+import { safeAnalyticsUrl } from "@/config/telemetry";
+export default function PrivateAnalytics() {
+ return (
+ <>
+
({ ...event, url: safeAnalyticsUrl(event.url) })}
+ />
+ ({
+ ...event,
+ url: safeAnalyticsUrl(event.url),
+ route: new URL(safeAnalyticsUrl(event.url)).pathname,
+ })}
+ />
+ >
+ );
+}
diff --git a/frontend/src/components/ShipmentVolumeChart.tsx b/frontend/src/components/ShipmentVolumeChart.tsx
new file mode 100644
index 0000000..9af4f59
--- /dev/null
+++ b/frontend/src/components/ShipmentVolumeChart.tsx
@@ -0,0 +1,74 @@
+import {
+ AreaChart,
+ Area,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+} from "recharts";
+export default function ShipmentVolumeChart({
+ data,
+}: {
+ data: { name: string; shipments: number; products: number }[];
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/config/deferredWallets.ts b/frontend/src/config/deferredWallets.ts
new file mode 100644
index 0000000..86f9da5
--- /dev/null
+++ b/frontend/src/config/deferredWallets.ts
@@ -0,0 +1,36 @@
+import type { CreateConnectorFn } from "wagmi";
+
+export function storedWalletConnection(
+ storage: () => Pick | undefined,
+): boolean {
+ try {
+ return !!JSON.parse(storage()?.getItem("wagmi.store") ?? "null")?.state
+ ?.current;
+ } catch {
+ return false;
+ }
+}
+
+/** Wagmi eagerly calls setup, which loads remote SDKs even for anonymous readers. */
+export function deferConnector(
+ factory: CreateConnectorFn,
+ enabled: () => boolean,
+): CreateConnectorFn {
+ return (config) => {
+ const connector = factory(config);
+ let initialized = false;
+ return {
+ ...connector,
+ async setup() {
+ if (initialized || (connector.type !== "injected" && !enabled()))
+ return;
+ initialized = true;
+ try {
+ await connector.setup?.call(this);
+ } catch {
+ initialized = false;
+ }
+ },
+ };
+ };
+}
diff --git a/frontend/src/config/operations.test.ts b/frontend/src/config/operations.test.ts
new file mode 100644
index 0000000..7ee4c07
--- /dev/null
+++ b/frontend/src/config/operations.test.ts
@@ -0,0 +1,131 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { sanitizeEvent } from "./sentry";
+import { reportMetric, routeLabel, safeAnalyticsUrl } from "./telemetry";
+import { deferConnector, storedWalletConnection } from "./deferredWallets";
+import type { CreateConnectorFn } from "wagmi";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ window.history.replaceState({}, "", "/");
+});
+describe("private diagnostics", () => {
+ it("uses route templates and drops identifiers, credentials, query and fragment", () => {
+ expect(routeLabel("/shipments/SECRET")).toBe("shipment_detail");
+ expect(
+ safeAnalyticsUrl(
+ "https://user:SECRET@private.example/verify/SECRET?signature=SECRET#SECRET",
+ ),
+ ).toBe(`${window.location.origin}/verify/:productId`);
+ expect(safeAnalyticsUrl("/unknown-SECRET?token=SECRET")).toBe(
+ `${window.location.origin}/not-found`,
+ );
+ });
+ it("allowlists errors without wallet, token, note, request or arbitrary context", () => {
+ const safe = sanitizeEvent({
+ message: "SECRET",
+ user: { id: "SECRET" },
+ request: { url: "SECRET" },
+ extra: { note: "SECRET" },
+ breadcrumbs: [{ message: "SECRET" }],
+ exception: {
+ values: [
+ {
+ type: "SECRET",
+ value: "SECRET",
+ stacktrace: {
+ frames: [
+ {
+ filename: "https://private.example/SECRET.js",
+ vars: { token: "SECRET" },
+ },
+ {
+ filename: `${window.location.origin}/assets/index-abc.js?token=SECRET`,
+ lineno: 12,
+ },
+ ],
+ },
+ },
+ ],
+ },
+ });
+ expect(JSON.stringify(safe)).not.toContain("SECRET");
+ expect(safe.exception?.values?.[0].stacktrace?.frames?.[1].filename).toBe(
+ "/assets/index-abc.js",
+ );
+ expect(safe).not.toHaveProperty("user");
+ expect(safe).not.toHaveProperty("breadcrumbs");
+ });
+ it("sends bounded aggregate values without authentication and suppresses demo", () => {
+ const fetch = vi.fn().mockResolvedValue({});
+ vi.stubGlobal("fetch", fetch);
+ window.history.replaceState({}, "", "/shipments/PRIVATE-ID?token=PRIVATE");
+ reportMetric("api_error", 1);
+ reportMetric("api_error", 1);
+ reportMetric("LCP", NaN);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ const options = fetch.mock.calls[0][1];
+ expect(options.credentials).toBe("omit");
+ expect(options.headers).toEqual({ "Content-Type": "application/json" });
+ expect(options.body).not.toContain("PRIVATE");
+ expect(JSON.parse(options.body).route).toBe("shipment_detail");
+ window.history.replaceState({}, "", "/demo");
+ reportMetric("client_error", 1);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
+describe("wallet startup boundaries", () => {
+ it("handles missing, corrupt and inaccessible storage and restores existing connections", () => {
+ expect(storedWalletConnection(() => undefined)).toBe(false);
+ expect(
+ storedWalletConnection(() => {
+ throw Error("blocked");
+ }),
+ ).toBe(false);
+ expect(storedWalletConnection(() => ({ getItem: () => "{bad" }))).toBe(
+ false,
+ );
+ expect(
+ storedWalletConnection(() => ({
+ getItem: () => JSON.stringify({ state: { current: "connection" } }),
+ })),
+ ).toBe(true);
+ });
+ it("defers remote SDK setup until intent, runs once, and initializes injected wallets immediately", async () => {
+ let enabled = false;
+ const setup = vi.fn();
+ const factory = (() => ({
+ type: "walletConnect",
+ setup,
+ })) as unknown as CreateConnectorFn;
+ const connector = deferConnector(factory, () => enabled)({} as never);
+ await connector.setup?.();
+ expect(setup).not.toHaveBeenCalled();
+ enabled = true;
+ await connector.setup?.();
+ await connector.setup?.();
+ expect(setup).toHaveBeenCalledOnce();
+ const injected = deferConnector(
+ (() => ({ type: "injected", setup })) as unknown as CreateConnectorFn,
+ () => false,
+ )({} as never);
+ await injected.setup?.();
+ expect(setup).toHaveBeenCalledTimes(2);
+ });
+ it("permits retry after a failed setup", async () => {
+ const setup = vi
+ .fn()
+ .mockRejectedValueOnce(Error("offline"))
+ .mockResolvedValue(undefined);
+ const connector = deferConnector(
+ (() => ({
+ type: "walletConnect",
+ setup,
+ })) as unknown as CreateConnectorFn,
+ () => true,
+ )({} as never);
+ await connector.setup?.();
+ await connector.setup?.();
+ expect(setup).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/frontend/src/config/sentry.ts b/frontend/src/config/sentry.ts
index 60ef177..fb6f3a3 100644
--- a/frontend/src/config/sentry.ts
+++ b/frontend/src/config/sentry.ts
@@ -1,19 +1,81 @@
-import * as Sentry from '@sentry/react'
+import { appEnvironment } from "./runtime";
+import { release, routeLabel, reportMetric } from "./telemetry";
+import type { ErrorEvent } from "@sentry/react";
-export function initSentry() {
- if (import.meta.env.VITE_SENTRY_DSN) {
- Sentry.init({
- dsn: import.meta.env.VITE_SENTRY_DSN,
- integrations: [
- Sentry.browserTracingIntegration(),
- Sentry.replayIntegration({
- maskAllText: false,
- blockAllMedia: false,
- }),
- ],
- tracesSampleRate: 0.1,
- replaysSessionSampleRate: 0.1,
- replaysOnErrorSampleRate: 1.0,
- })
+/** Allowlist the diagnostic shape, never try to redact arbitrary business text. */
+export function sanitizeEvent(event: ErrorEvent): ErrorEvent {
+ return {
+ type: undefined,
+ event_id: /^[0-9a-f]{32}$/.test(event.event_id ?? "")
+ ? event.event_id
+ : undefined,
+ timestamp: event.timestamp,
+ level: "error",
+ platform: "javascript",
+ message: "Unexpected application error (details omitted)",
+ environment: appEnvironment.name,
+ release: release ?? undefined,
+ tags: { route: routeLabel() },
+ exception: {
+ values: (event.exception?.values ?? []).slice(0, 1).map((value) => ({
+ type: [
+ "Error",
+ "TypeError",
+ "RangeError",
+ "ReferenceError",
+ "SyntaxError",
+ ].includes(value.type ?? "")
+ ? value.type
+ : "Error",
+ value: "Details omitted to protect wallet and shipment input",
+ stacktrace: {
+ frames: (value.stacktrace?.frames ?? []).slice(-20).map((frame) => ({
+ filename: safeFrame(frame.filename),
+ lineno: frame.lineno,
+ colno: frame.colno,
+ })),
+ },
+ })),
+ },
+ };
+}
+function safeFrame(value?: string) {
+ try {
+ const url = new URL(value ?? "", window.location.origin);
+ return url.origin === window.location.origin &&
+ /^\/assets\/[\w.-]+\.js$/.test(url.pathname)
+ ? url.pathname
+ : undefined;
+ } catch {
+ return undefined;
}
}
+let capture: ((error: unknown) => void) | undefined;
+export function captureClientError(error: unknown) {
+ reportMetric("client_error", 1);
+ capture?.(error);
+}
+export async function initSentry() {
+ const dsn = import.meta.env.VITE_SENTRY_DSN;
+ if (!dsn) return;
+ // Optional SDK is not part of startup or the build when no DSN is configured.
+ const Sentry = await import("@sentry/react");
+ Sentry.init({
+ dsn,
+ environment: appEnvironment.name,
+ release: release ?? undefined,
+ sendDefaultPii: false,
+ defaultIntegrations: false,
+ integrations: [
+ Sentry.globalHandlersIntegration(),
+ Sentry.browserApiErrorsIntegration(),
+ Sentry.dedupeIntegration(),
+ ],
+ tracesSampleRate: 0,
+ replaysSessionSampleRate: 0,
+ replaysOnErrorSampleRate: 0,
+ beforeBreadcrumb: () => null,
+ beforeSend: sanitizeEvent,
+ });
+ capture = (error) => Sentry.captureException(error);
+}
diff --git a/frontend/src/config/telemetry.ts b/frontend/src/config/telemetry.ts
new file mode 100644
index 0000000..86e3dd9
--- /dev/null
+++ b/frontend/src/config/telemetry.ts
@@ -0,0 +1,159 @@
+import { appEnvironment } from "./runtime";
+
+export const release = /^[0-9a-f]{40}$/.test(
+ import.meta.env.VITE_RELEASE_COMMIT ?? "",
+)
+ ? import.meta.env.VITE_RELEASE_COMMIT
+ : null;
+export function routeLabel(path = window.location.pathname) {
+ if (path === "/") return "dashboard";
+ if (/^\/shipments\/[^/]+\/?$/.test(path)) return "shipment_detail";
+ if (/^\/verify(?:\/[^/]+)?\/?$/.test(path)) return "verify";
+ const label = path.replace(/^\//, "").replace(/\/$/, "");
+ return [
+ "products",
+ "shipments",
+ "analytics",
+ "exceptions",
+ "demo",
+ "privacy",
+ "terms",
+ ].includes(label)
+ ? label
+ : "not_found";
+}
+export function safeAnalyticsUrl(value: string) {
+ const url = new URL(value, window.location.origin);
+ const route = routeLabel(url.pathname);
+ const paths: Record = {
+ dashboard: "/",
+ shipment_detail: "/shipments/:shipmentId",
+ verify: "/verify/:productId",
+ not_found: "/not-found",
+ };
+ url.pathname = paths[route] ?? `/${route}`;
+ url.search = "";
+ url.hash = "";
+ url.protocol = window.location.protocol;
+ url.host = window.location.host;
+ url.username = "";
+ url.password = "";
+ return url.toString();
+}
+export type Metric =
+ | "LCP"
+ | "CLS"
+ | "interaction_max_ms"
+ | "client_error"
+ | "api_error"
+ | "stream_disconnected"
+ | "stream_recovered";
+const lastSent = new Map();
+export function reportMetric(
+ metric: Metric,
+ value: number,
+ path = window.location.pathname,
+) {
+ if (!Number.isFinite(value) || value < 0 || value > 600000) return;
+ const route = routeLabel(path),
+ key = `${metric}:${route}`,
+ now = Date.now();
+ // The guided demo remains free of all live business/telemetry API calls.
+ if (
+ routeLabel() === "demo" ||
+ route === "demo" ||
+ now - (lastSent.get(key) ?? -Infinity) < 30000
+ )
+ return;
+ lastSent.set(key, now);
+ const body = JSON.stringify({
+ metric,
+ route,
+ value,
+ environment: appEnvironment.name,
+ release,
+ });
+ // No wallet/session headers, arbitrary error text, URL, identifiers or input.
+ void fetch(`${appEnvironment.apiUrl}/telemetry/browser`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body,
+ credentials: "omit",
+ keepalive: true,
+ }).catch(() => {});
+}
+export function observeVitals() {
+ if (!("PerformanceObserver" in window)) return () => {};
+ const observers: PerformanceObserver[] = [];
+ const initialPath = window.location.pathname;
+ let lcp = 0,
+ cls = 0,
+ maxInteraction = 0,
+ sessionValue = 0,
+ sessionStart = 0,
+ lastShift = 0;
+ const observe = (
+ type: string,
+ callback: (entries: PerformanceEntry[]) => void,
+ extra = {},
+ ) => {
+ try {
+ const observer = new PerformanceObserver((list) =>
+ callback(list.getEntries()),
+ );
+ observer.observe({ type, buffered: true, ...extra });
+ observers.push(observer);
+ } catch {
+ /* Unsupported metric is omitted. */
+ }
+ };
+ observe("largest-contentful-paint", (entries) => {
+ lcp = entries[entries.length - 1]?.startTime ?? lcp;
+ });
+ observe("layout-shift", (entries) => {
+ for (const entry of entries) {
+ const shift = entry as PerformanceEntry & {
+ hadRecentInput: boolean;
+ value: number;
+ };
+ if (shift.hadRecentInput) continue;
+ if (
+ shift.startTime - lastShift > 1000 ||
+ shift.startTime - sessionStart > 5000
+ ) {
+ sessionValue = 0;
+ sessionStart = shift.startTime;
+ }
+ sessionValue += shift.value;
+ lastShift = shift.startTime;
+ cls = Math.max(cls, sessionValue);
+ }
+ });
+ observe(
+ "event",
+ (entries) => {
+ for (const entry of entries) {
+ const event = entry as PerformanceEntry & { interactionId?: number };
+ if (event.interactionId)
+ maxInteraction = Math.max(maxInteraction, event.duration);
+ }
+ },
+ { durationThreshold: 40 },
+ );
+ const flush = () => {
+ if (lcp) reportMetric("LCP", lcp, initialPath);
+ reportMetric("CLS", cls, initialPath);
+ if (maxInteraction)
+ reportMetric("interaction_max_ms", maxInteraction, initialPath);
+ };
+ const hidden = () => {
+ if (document.visibilityState === "hidden") flush();
+ };
+ document.addEventListener("visibilitychange", hidden);
+ window.addEventListener("pagehide", flush);
+ return () => {
+ observers.forEach((o) => o.disconnect());
+ document.removeEventListener("visibilitychange", hidden);
+ window.removeEventListener("pagehide", flush);
+ };
+}
diff --git a/frontend/src/config/wagmi.ts b/frontend/src/config/wagmi.ts
index 0eda45c..e5efef1 100644
--- a/frontend/src/config/wagmi.ts
+++ b/frontend/src/config/wagmi.ts
@@ -1,28 +1,64 @@
-import { getDefaultConfig } from '@rainbow-me/rainbowkit'
-import { injectedWallet } from '@rainbow-me/rainbowkit/wallets'
-import { sepolia, hardhat } from 'wagmi/chains'
-import { http } from 'wagmi'
-import { appEnvironment } from './runtime'
+import { connectorsForWallets } from "@rainbow-me/rainbowkit";
+import {
+ injectedWallet,
+ safeWallet,
+ rainbowWallet,
+ base,
+ metaMaskWallet,
+ walletConnectWallet,
+} from "@rainbow-me/rainbowkit/wallets";
+import { sepolia, hardhat } from "wagmi/chains";
+import { createConfig, http } from "wagmi";
+import { deferConnector, storedWalletConnection } from "./deferredWallets";
+import { appEnvironment } from "./runtime";
-const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID
+const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID;
-const local = appEnvironment.chainId === 31337
+const local = appEnvironment.chainId === 31337;
if (import.meta.env.PROD && !local && !projectId && !import.meta.env.CI) {
throw new Error(
- '[Veritras] VITE_WALLETCONNECT_PROJECT_ID is required in production. ' +
- 'Get a free Project ID at https://cloud.walletconnect.com',
- )
+ "[Veritras] VITE_WALLETCONNECT_PROJECT_ID is required in production. " +
+ "Get a free Project ID at https://cloud.walletconnect.com",
+ );
}
-const chains = local ? ([hardhat] as const) : ([sepolia] as const)
+const chains = local ? ([hardhat] as const) : ([sepolia] as const);
-export const rainbowKitConfig = getDefaultConfig({
- appName: 'Veritras',
- projectId: projectId || 'local-testing',
+export const hasStoredWalletConnection = storedWalletConnection(() =>
+ typeof window === "undefined" ? undefined : window.localStorage,
+);
+let walletsEnabled = hasStoredWalletConnection;
+const connectors = connectorsForWallets(
+ [
+ {
+ groupName: local ? "Local testing" : "Popular",
+ wallets: local
+ ? [injectedWallet]
+ : [
+ safeWallet,
+ rainbowWallet,
+ base,
+ metaMaskWallet,
+ walletConnectWallet,
+ ],
+ },
+ ],
+ { appName: "Veritras", projectId: projectId || "local-testing" },
+);
+export const rainbowKitConfig = createConfig({
chains,
- transports: { [appEnvironment.chainId]: http(appEnvironment.rpcUrl) },
- ...(local
- ? { wallets: [{ groupName: 'Local testing', wallets: [injectedWallet] }] }
- : {}),
+ connectors: connectors.map((connector) =>
+ deferConnector(connector, () => walletsEnabled),
+ ),
+ transports: {
+ [hardhat.id]: http(appEnvironment.rpcUrl),
+ [sepolia.id]: http(appEnvironment.rpcUrl),
+ },
ssr: false,
-})
+});
+
+export function activateWallets() {
+ walletsEnabled = true;
+ // Connector methods are public Wagmi interfaces; no private config mutation.
+ for (const connector of rainbowKitConfig.connectors) void connector.setup?.();
+}
diff --git a/frontend/src/hooks/streamHealth.ts b/frontend/src/hooks/streamHealth.ts
new file mode 100644
index 0000000..29f1e51
--- /dev/null
+++ b/frontend/src/hooks/streamHealth.ts
@@ -0,0 +1,20 @@
+import { useSyncExternalStore } from "react";
+export type StreamState =
+ "idle" | "connecting" | "connected" | "reconnecting" | "stale";
+let state: StreamState = "idle";
+const listeners = new Set<() => void>();
+export function setStreamHealth(value: StreamState) {
+ if (state === value) return;
+ state = value;
+ listeners.forEach((listener) => listener());
+}
+export function useStreamHealth() {
+ return useSyncExternalStore(
+ (listener) => {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ },
+ () => state,
+ () => "idle" as StreamState,
+ );
+}
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index eb545a0..87d893e 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -1,3 +1,4 @@
+import { reportMetric } from '@/config/telemetry'
import { appEnvironment } from '@/config/runtime'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import type { Product, ProductDetail, Shipment } from '@/types'
@@ -16,16 +17,18 @@ export async function fetchApi(endpoint: string, options?: RequestInit) {
headers['Authorization'] = `Bearer ${token}`
}
- const response = await fetch(`${API_URL}${endpoint}`, {
+ let response: Response
+ try { response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
- })
+ }) } catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) reportMetric('api_error', 1); throw error }
if (response.status === 401) {
clearAuthToken()
}
if (!response.ok) {
+ if (response.status >= 500) reportMetric('api_error', 1)
const errorText = await response.text().catch(() => response.statusText)
throw new Error(
`API Error (${response.status}): ${errorText || response.statusText}`
diff --git a/frontend/src/hooks/useExceptions.ts b/frontend/src/hooks/useExceptions.ts
index 9220a9f..4a3769f 100644
--- a/frontend/src/hooks/useExceptions.ts
+++ b/frontend/src/hooks/useExceptions.ts
@@ -22,7 +22,7 @@ export function useExceptions(
if (shipmentId) query.set("shipment_id", shipmentId);
return useQuery({
queryKey: ["exceptions", state, page, shipmentId],
- queryFn: () => fetchApi(`/exceptions?${query}`),
+ queryFn: ({ signal }) => fetchApi(`/exceptions?${query}`, { signal }),
refetchInterval: 30_000,
refetchIntervalInBackground: false,
});
@@ -31,8 +31,8 @@ export function useExceptions(
export function useExceptionPolicy(shipmentId: string) {
return useQuery({
queryKey: ["exception-policy", shipmentId],
- queryFn: () =>
- fetchApi(`/exceptions/policies/${encodeURIComponent(shipmentId)}`),
+ queryFn: ({ signal }) =>
+ fetchApi(`/exceptions/policies/${encodeURIComponent(shipmentId)}`, { signal }),
enabled: !!shipmentId,
});
}
diff --git a/frontend/src/hooks/useJourney.ts b/frontend/src/hooks/useJourney.ts
index 16de7f9..b20fb91 100644
--- a/frontend/src/hooks/useJourney.ts
+++ b/frontend/src/hooks/useJourney.ts
@@ -6,7 +6,7 @@ import type { components } from "@/types/api";
export function useShipment(id: string) {
return useQuery({
queryKey: ["shipment", id],
- queryFn: () => fetchApi(`/shipments/${encodeURIComponent(id)}`),
+ queryFn: ({ signal }) => fetchApi(`/shipments/${encodeURIComponent(id)}`, { signal }),
enabled: !!id,
refetchInterval: 15_000,
refetchIntervalInBackground: false,
@@ -16,9 +16,10 @@ export function useShipment(id: string) {
export function useTimeline(id: string, page: number) {
return useQuery({
queryKey: ["timeline", id, page],
- queryFn: () =>
+ queryFn: ({ signal }) =>
fetchApi(
`/products/${encodeURIComponent(id)}/timeline?skip=${page * 100}&limit=100`,
+ { signal },
),
enabled: !!id,
refetchInterval: 15_000,
diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx
index f74c66d..c8850b1 100644
--- a/frontend/src/hooks/useSSE.test.tsx
+++ b/frontend/src/hooks/useSSE.test.tsx
@@ -1,45 +1,108 @@
-import { act, renderHook } from '@testing-library/react'
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
-import type { ReactNode } from 'react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { useChainEvents } from './useSSE'
+import { act, renderHook } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useAnalyticsEvents, useChainEvents } from "./useSSE";
+import { useStreamHealth } from "./streamHealth";
+vi.mock("@/config/telemetry", () => ({ reportMetric: vi.fn() }));
class Stream {
- static instances: Stream[] = []
- listeners = new Map void>()
- onerror: (() => void) | null = null
- close = vi.fn()
- constructor(public url: string) { Stream.instances.push(this) }
- addEventListener(type: string, callback: (event: MessageEvent) => void) { this.listeners.set(type, callback) }
- send(type: string) { this.listeners.get(type)?.({ data: '{}' } as MessageEvent) }
+ static instances: Stream[] = [];
+ listeners = new Map void>();
+ onerror: (() => void) | null = null;
+ close = vi.fn();
+ constructor(public url: string) {
+ Stream.instances.push(this);
+ }
+ addEventListener(type: string, callback: (event: MessageEvent) => void) {
+ this.listeners.set(type, callback);
+ }
+ send(type: string) {
+ this.listeners.get(type)?.({ data: "{}" } as MessageEvent);
+ }
}
-afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); Stream.instances = [] })
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.useRealTimers();
+ Stream.instances = [];
+});
-describe('committed chain events', () => {
- it('refreshes evidence/history/shipments and cleans up reconnects', () => {
- vi.useFakeTimers()
- vi.stubGlobal('EventSource', Stream)
- const client = new QueryClient()
- const invalidate = vi.spyOn(client, 'invalidateQueries')
- const wrapper = ({ children }: { children: ReactNode }) => {children}
- const { unmount } = renderHook(() => useChainEvents(), { wrapper })
- const first = Stream.instances[0]
- expect(first.url).toContain('/analytics/events')
- act(() => first.send('chain_updated'))
- for (const key of ['products', 'product-history', 'product-transfers', 'shipments', 'verify', 'registration', 'metadata-history', 'kpis']) {
- expect(invalidate).toHaveBeenCalledWith({ queryKey: [key] })
+describe("committed chain events", () => {
+ it("refreshes evidence/history/shipments and cleans up reconnects", () => {
+ vi.useFakeTimers();
+ vi.stubGlobal("EventSource", Stream);
+ const client = new QueryClient();
+ const invalidate = vi.spyOn(client, "invalidateQueries");
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ const { unmount } = renderHook(() => useChainEvents(), { wrapper });
+ const first = Stream.instances[0];
+ expect(first.url).toContain("/analytics/events");
+ act(() => first.send("chain_updated"));
+ for (const key of [
+ "products",
+ "product-history",
+ "product-transfers",
+ "shipments",
+ "verify",
+ "registration",
+ "metadata-history",
+ "kpis",
+ ]) {
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: [key] });
}
- act(() => first.onerror?.())
- expect(first.close).toHaveBeenCalledOnce()
- act(() => vi.advanceTimersByTime(5000))
- expect(Stream.instances).toHaveLength(2)
- invalidate.mockClear()
- act(() => Stream.instances[1].send('resync'))
- expect(invalidate).toHaveBeenCalled()
- unmount()
- expect(Stream.instances[1].close).toHaveBeenCalledOnce()
- act(() => vi.advanceTimersByTime(10000))
- expect(Stream.instances).toHaveLength(2)
- })
-})
+ act(() => first.onerror?.());
+ expect(first.close).toHaveBeenCalledOnce();
+ act(() => vi.advanceTimersByTime(5000));
+ expect(Stream.instances).toHaveLength(2);
+ invalidate.mockClear();
+ act(() => Stream.instances[1].send("resync"));
+ expect(invalidate).toHaveBeenCalled();
+ unmount();
+ expect(Stream.instances[1].close).toHaveBeenCalledOnce();
+ act(() => vi.advanceTimersByTime(10000));
+ expect(Stream.instances).toHaveLength(2);
+ });
+});
+
+it("detects silence, backs off, reports recovery and stops while hidden", () => {
+ vi.useFakeTimers();
+ vi.stubGlobal("EventSource", Stream);
+ const callback = vi.fn();
+ const { result, unmount } = renderHook(() => {
+ useAnalyticsEvents(callback);
+ return useStreamHealth();
+ });
+ act(() => Stream.instances[0].send("open"));
+ expect(result.current).toBe("connected");
+ act(() => vi.advanceTimersByTime(30000));
+ act(() => Stream.instances[0].send("heartbeat"));
+ act(() => vi.advanceTimersByTime(30000));
+ expect(result.current).toBe("connected");
+ act(() => vi.advanceTimersByTime(10000));
+ expect(result.current).toBe("stale");
+ act(() => vi.advanceTimersByTime(5000));
+ expect(Stream.instances).toHaveLength(2);
+ act(() => Stream.instances[1].onerror?.());
+ act(() => vi.advanceTimersByTime(9999));
+ expect(Stream.instances).toHaveLength(2);
+ act(() => vi.advanceTimersByTime(1));
+ expect(Stream.instances).toHaveLength(3);
+ act(() => Stream.instances[2].send("heartbeat"));
+ expect(result.current).toBe("connected");
+ const visibility = vi
+ .spyOn(document, "visibilityState", "get")
+ .mockReturnValue("hidden");
+ act(() => document.dispatchEvent(new Event("visibilitychange")));
+ expect(result.current).toBe("idle");
+ act(() => vi.advanceTimersByTime(90000));
+ expect(Stream.instances).toHaveLength(3);
+ visibility.mockReturnValue("visible");
+ act(() => document.dispatchEvent(new Event("visibilitychange")));
+ expect(Stream.instances).toHaveLength(4);
+ unmount();
+ visibility.mockRestore();
+ expect(Stream.instances[3].close).toHaveBeenCalledOnce();
+});
diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts
index ff13935..0c8a23a 100644
--- a/frontend/src/hooks/useSSE.ts
+++ b/frontend/src/hooks/useSSE.ts
@@ -1,54 +1,122 @@
-import { appEnvironment } from '@/config/runtime'
-import { useEffect, useRef } from 'react'
-import { useQueryClient } from '@tanstack/react-query'
+import { appEnvironment } from "@/config/runtime";
+import { useEffect, useRef } from "react";
+import { reportMetric } from "@/config/telemetry";
+import { setStreamHealth } from "./streamHealth";
+import { useQueryClient } from "@tanstack/react-query";
-const API_URL = appEnvironment.apiUrl
+const API_URL = appEnvironment.apiUrl;
-export function useAnalyticsEvents(onEvent: (type: string, data: unknown) => void, enabled = true) {
- const onEventRef = useRef(onEvent)
- onEventRef.current = onEvent
+export function useAnalyticsEvents(
+ onEvent: (type: string, data: unknown) => void,
+ enabled = true,
+) {
+ const onEventRef = useRef(onEvent);
+ onEventRef.current = onEvent;
useEffect(() => {
- if (!enabled) return
- let eventSource: EventSource | null = null
- let reconnectTimer: ReturnType | null = null
-
+ if (!enabled) return;
+ let eventSource: EventSource | null = null;
+ let reconnectTimer: ReturnType | null = null;
+ let watchdog: ReturnType | null = null;
+ let attempt = 0,
+ lastSeen = Date.now(),
+ recovering = false,
+ stopped = false;
+ const clear = () => {
+ eventSource?.close();
+ eventSource = null;
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ if (watchdog) clearInterval(watchdog);
+ reconnectTimer = null;
+ watchdog = null;
+ };
+ const fail = (stale = false) => {
+ if (stopped || document.visibilityState === "hidden") return;
+ clear();
+ setStreamHealth(stale ? "stale" : "reconnecting");
+ if (!recovering) reportMetric("stream_disconnected", 1);
+ recovering = true;
+ reconnectTimer = setTimeout(
+ connect,
+ Math.min(30000, 5000 * 2 ** Math.min(attempt++, 3)),
+ );
+ };
+ const touch = () => {
+ lastSeen = Date.now();
+ attempt = 0;
+ setStreamHealth("connected");
+ if (recovering) {
+ reportMetric("stream_recovered", 1);
+ recovering = false;
+ }
+ };
const connect = () => {
- eventSource = new EventSource(`${API_URL}/analytics/events`)
-
- for (const type of ['product_created', 'shipment_created', 'chain_updated', 'resync']) {
+ if (stopped || document.visibilityState === "hidden") return;
+ clear();
+ lastSeen = Date.now();
+ setStreamHealth(recovering ? "reconnecting" : "connecting");
+ eventSource = new EventSource(`${API_URL}/analytics/events`);
+ eventSource.addEventListener("open", touch);
+ eventSource.addEventListener("heartbeat", touch);
+ eventSource.addEventListener("unavailable", () => fail());
+ for (const type of [
+ "product_created",
+ "shipment_created",
+ "chain_updated",
+ "resync",
+ ]) {
eventSource.addEventListener(type, (event) => {
+ touch();
try {
- onEventRef.current(type, JSON.parse((event as MessageEvent).data))
+ onEventRef.current(type, JSON.parse((event as MessageEvent).data));
} catch {
- onEventRef.current(type, {})
+ onEventRef.current(type, {});
}
- })
- }
-
- eventSource.onerror = () => {
- eventSource?.close()
- eventSource = null
- reconnectTimer = setTimeout(connect, 5000)
+ });
}
- }
-
- connect()
-
+ eventSource.onerror = () => fail();
+ watchdog = setInterval(() => {
+ if (Date.now() - lastSeen > 35000) fail(true);
+ }, 10000);
+ };
+ const visibility = () => {
+ clear();
+ if (document.visibilityState === "hidden") setStreamHealth("idle");
+ else connect();
+ };
+ document.addEventListener("visibilitychange", visibility);
+ connect();
return () => {
- if (reconnectTimer) clearTimeout(reconnectTimer)
- eventSource?.close()
- }
- }, [enabled])
+ stopped = true;
+ clear();
+ setStreamHealth("idle");
+ document.removeEventListener("visibilitychange", visibility);
+ };
+ }, [enabled]);
}
/** One stream for every page; a reconnect/resync refreshes committed state. */
export function useChainEvents(enabled = true) {
- const queryClient = useQueryClient()
+ const queryClient = useQueryClient();
useAnalyticsEvents(() => {
- for (const key of ['products', 'product', 'product-history', 'product-transfers',
- 'shipments', 'shipment', 'timeline', 'verify', 'registration', 'metadata-history', 'exceptions', 'kpis', 'anomalies', 'bottlenecks', 'daily-volume']) {
- void queryClient.invalidateQueries({ queryKey: [key] })
+ for (const key of [
+ "products",
+ "product",
+ "product-history",
+ "product-transfers",
+ "shipments",
+ "shipment",
+ "timeline",
+ "verify",
+ "registration",
+ "metadata-history",
+ "exceptions",
+ "kpis",
+ "anomalies",
+ "bottlenecks",
+ "daily-volume",
+ ]) {
+ void queryClient.invalidateQueries({ queryKey: [key] });
}
- }, enabled)
+ }, enabled);
}
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 319fbaf..20fc066 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -1,18 +1,25 @@
-import React from 'react'
-import ReactDOM from 'react-dom/client'
-import * as Sentry from '@sentry/react'
-import App from './App'
-import { validateEnv } from './config/env'
-import { initSentry } from './config/sentry'
-import './index.css'
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import { validateEnv } from "./config/env";
+import { initSentry } from "./config/sentry";
+import "./index.css";
-validateEnv()
-initSentry()
+validateEnv();
+if ("requestIdleCallback" in window)
+ window.requestIdleCallback(
+ () => {
+ void initSentry().catch(() => {});
+ },
+ { timeout: 3000 },
+ );
+else
+ setTimeout(() => {
+ void initSentry().catch(() => {});
+ }, 2000);
-ReactDOM.createRoot(document.getElementById('root')!).render(
+ReactDOM.createRoot(document.getElementById("root")!).render(
- An error has occurred}>
-
-
-
-)
+
+ ,
+);
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index 8880f2b..73450bf 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -1,4 +1,5 @@
-import { useState } from 'react'
+import { useState, lazy } from 'react'
+import DeferredSection from '@/components/DeferredSection'
import { motion } from 'framer-motion'
import LoadingSpinner from '@/components/ui/LoadingSpinner'
import PremiumHero from '@/components/PremiumHero'
@@ -14,15 +15,7 @@ import {
ArrowUpRight,
Shield,
} from 'lucide-react'
-import {
- AreaChart,
- Area,
- XAxis,
- YAxis,
- CartesianGrid,
- Tooltip,
- ResponsiveContainer,
-} from 'recharts'
+const ShipmentVolumeChart = lazy(() => import('@/components/ShipmentVolumeChart'))
import type { Product } from '@/types'
const containerVariants = {
@@ -312,91 +305,9 @@ export default function Dashboard() {
Volume data unavailable.
) : (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
)}
diff --git a/frontend/src/pages/Privacy.tsx b/frontend/src/pages/Privacy.tsx
index 36c0948..492f399 100644
--- a/frontend/src/pages/Privacy.tsx
+++ b/frontend/src/pages/Privacy.tsx
@@ -13,9 +13,10 @@ export default function Privacy() {
Privacy Policy
- How Veritras handles your data on the blockchain supply-chain platform.
+ How Veritras handles your data on the blockchain supply-chain
+ platform.
-
Last Updated: July 31, 2026
+
Last Updated: October 20, 2026
{/* ─── 1. Overview ─────────────────────────────────────────── */}
@@ -24,8 +25,9 @@ export default function Privacy() {
Overview
- Veritras is a blockchain-based supply chain transparency platform. This
- policy explains what data we collect, how we use it, and your rights.
+ Veritras is a blockchain-based supply chain transparency platform.
+ This policy explains what data we collect, how we use it, and your
+ rights.
@@ -36,24 +38,28 @@ export default function Privacy() {
-
- Wallet addresses{' '}
+ Wallet addresses{" "}
(Ethereum / Sepolia) — collected via RainbowKit for authentication.
-
IP addresses and browser metadata
- {' '}
- — collected automatically by our hosting providers Vercel and Render.
+ {" "}
+ — collected automatically by our hosting providers Vercel and
+ Render.
-
Product and shipment data you submit
- {' '}
+ {" "}
— stored on our backend and referenced on-chain.
-
- Usage analytics{' '}
- — Vercel Analytics and Speed Insights for performance monitoring.
+ Usage analytics —
+ Vercel Analytics and Speed Insights for performance monitoring, with
+ dynamic identifiers and URL parameters removed. Aggregate error and
+ connection-health diagnostics exclude wallet addresses, signatures,
+ tokens and form input. Session replay is disabled.
@@ -80,8 +86,8 @@ export default function Privacy() {
Transactions on Ethereum / Sepolia are publicly visible on the
- blockchain by nature. Wallet addresses and transaction data are part of
- the public ledger and cannot be deleted.
+ blockchain by nature. Wallet addresses and transaction data are part
+ of the public ledger and cannot be deleted.
@@ -92,8 +98,9 @@ export default function Privacy() {
We do not sell your data. Data is shared only with hosting providers
- (Vercel, Render) under their respective privacy policies, and the
- blockchain network (public by design).
+ (Vercel, Render), an optional configured error-reporting provider,
+ under their respective privacy policies, and the blockchain network
+ (public by design).
@@ -104,9 +111,7 @@ export default function Privacy() {
- )
+ );
}
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 889497c..1783996 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -455,6 +455,23 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/telemetry/browser": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /** Browser Metric */
+ post: operations["browser_metric_telemetry_browser_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/health/indexer": {
parameters: {
query?: never;
@@ -509,6 +526,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/health/operations": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Health Operations
+ * @description Bounded diagnostics; public aggregates contain no user input or identities.
+ */
+ get: operations["health_operations_health_operations_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/health": {
parameters: {
query?: never;
@@ -601,6 +638,39 @@ export interface components {
*/
sample_size: number;
};
+ /** BrowserAggregate */
+ BrowserAggregate: {
+ /** Metric */
+ metric: string;
+ /** Route */
+ route: string;
+ /** Count */
+ count: number;
+ /** Mean */
+ mean: number;
+ };
+ /** BrowserMetric */
+ BrowserMetric: {
+ /**
+ * Metric
+ * @enum {string}
+ */
+ metric: "LCP" | "CLS" | "interaction_max_ms" | "client_error" | "api_error" | "stream_disconnected" | "stream_recovered";
+ /**
+ * Route
+ * @enum {string}
+ */
+ route: "dashboard" | "products" | "shipments" | "shipment_detail" | "analytics" | "exceptions" | "verify" | "demo" | "privacy" | "terms" | "not_found";
+ /** Value */
+ value: number;
+ /** Release */
+ release?: string | null;
+ /**
+ * Environment
+ * @enum {string}
+ */
+ environment: "development" | "testing" | "production";
+ };
/** ChallengeRequest */
ChallengeRequest: {
/** Address */
@@ -989,6 +1059,43 @@ export interface components {
*/
expires_at: string;
};
+ /** OperationalCounters */
+ OperationalCounters: {
+ /** Window Seconds */
+ window_seconds: number;
+ /** Uptime Seconds */
+ uptime_seconds: number;
+ /** Requests */
+ requests: number;
+ /** Api Requests */
+ api_requests: number;
+ /** Api 5Xx */
+ api_5xx: number;
+ /** Mean Duration Ms */
+ mean_duration_ms: number | null;
+ /** Duration Buckets */
+ duration_buckets: {
+ [key: string]: number;
+ };
+ /** Streams */
+ streams: {
+ [key: string]: number;
+ };
+ /** Browser Metrics */
+ browser_metrics: components["schemas"]["BrowserAggregate"][];
+ /**
+ * Storage
+ * @constant
+ */
+ storage: "process_memory";
+ /** Reset On Restart */
+ reset_on_restart: boolean;
+ };
+ /** OperationsResponse */
+ OperationsResponse: {
+ health: components["schemas"]["ReadinessResponse"];
+ counters: components["schemas"]["OperationalCounters"];
+ };
/** PolicyResponse */
PolicyResponse: {
/**
@@ -2484,6 +2591,37 @@ export interface operations {
};
};
};
+ browser_metric_telemetry_browser_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["BrowserMetric"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 204: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
health_indexer_health_indexer_get: {
parameters: {
query?: never;
@@ -2562,6 +2700,26 @@ export interface operations {
};
};
};
+ health_operations_health_operations_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["OperationsResponse"];
+ };
+ };
+ };
+ };
health_check_health_get: {
parameters: {
query?: never;
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index d53ee56..92bd389 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -1,6 +1,7 @@
///
interface ImportMetaEnv {
+ readonly VITE_RELEASE_COMMIT: string
readonly VITE_APP_ENV: string
readonly VITE_CHAIN_ID: string
readonly VITE_RPC_URL: string
diff --git a/frontend/vite.config.test.ts b/frontend/vite.config.test.ts
index 555b0ae..407e067 100644
--- a/frontend/vite.config.test.ts
+++ b/frontend/vite.config.test.ts
@@ -1,48 +1,62 @@
// @vitest-environment node
-import { describe, it, expect, vi } from 'vitest'
-import config from './vite.config'
+import { describe, it, expect, vi } from "vitest";
+import config from "./vite.config";
-describe('vite.config.ts', () => {
- vi.stubEnv('CI', 'true')
+describe("vite.config.ts", () => {
+ vi.stubEnv("CI", "true");
const resolved =
- typeof config === 'function'
- ? config({ command: 'build', mode: 'production' })
- : config
+ typeof config === "function"
+ ? config({ command: "build", mode: "production" })
+ : config;
- type Group = { name: string; test: RegExp }
+ type Group = { name: string; test: RegExp };
const groups = (): Group[] =>
((resolved.build as any)?.rolldownOptions?.output?.codeSplitting?.groups ??
- []) as Group[]
+ []) as Group[];
- it('configures codeSplitting groups for expected vendor bundles (regression: FCP 11.46s)', () => {
- expect(resolved.build).toBeDefined()
- expect(groups().map(g => g.name)).toEqual([
- 'react-vendor',
- 'ui-vendor',
- 'web3-vendor',
- 'charts-vendor',
- 'query-vendor',
- ])
+ it("keeps shared UI groups without merging optional SDKs into eager imports", () => {
+ expect(resolved.build).toBeDefined();
+ expect(groups().map((g) => g.name)).toEqual([
+ "react-vendor",
+ "ui-vendor",
+ "query-vendor",
+ ]);
- const reactVendor = groups().find(g => g.name === 'react-vendor')!
- expect(reactVendor.test.test('/repo/node_modules/react/index.js')).toBe(true)
- expect(reactVendor.test.test('/repo/node_modules/react-dom/client.js')).toBe(true)
+ const reactVendor = groups().find((g) => g.name === "react-vendor")!;
+ expect(reactVendor.test.test("/repo/node_modules/react/index.js")).toBe(
+ true,
+ );
expect(
- reactVendor.test.test('/repo/node_modules/react-router-dom/dist/index.js')
- ).toBe(true)
- })
+ reactVendor.test.test("/repo/node_modules/react-dom/client.js"),
+ ).toBe(true);
+ expect(
+ reactVendor.test.test(
+ "/repo/node_modules/react-router-dom/dist/index.js",
+ ),
+ ).toBe(true);
+ });
- it('extracts heavy libraries out of the initial chunk', () => {
- const matches = (id: string) => groups().some(g => g.test.test(id))
+ it("preserves dynamic import boundaries for wallet SDKs and charts", () => {
+ const matches = (id: string) => groups().some((g) => g.test.test(id));
- expect(matches('/repo/node_modules/wagmi/dist/esm/index.js')).toBe(true)
- expect(matches('/repo/node_modules/viem/_esm/index.js')).toBe(true)
- expect(matches('/repo/node_modules/@rainbow-me/rainbowkit/dist/index.js')).toBe(true)
- expect(matches('/repo/node_modules/recharts/es6/index.js')).toBe(true)
- expect(matches('/repo/node_modules/@tanstack/react-query/build/index.js')).toBe(true)
- expect(matches('/repo/node_modules/@tanstack/query-core/build/index.js')).toBe(true)
- expect(matches('/repo/node_modules/framer-motion/dist/es/index.mjs')).toBe(true)
- expect(matches('/repo/node_modules/lucide-react/dist/esm/index.js')).toBe(true)
- })
-})
+ expect(matches("/repo/node_modules/wagmi/dist/esm/index.js")).toBe(false);
+ expect(matches("/repo/node_modules/viem/_esm/index.js")).toBe(false);
+ expect(
+ matches("/repo/node_modules/@rainbow-me/rainbowkit/dist/index.js"),
+ ).toBe(false);
+ expect(matches("/repo/node_modules/recharts/es6/index.js")).toBe(false);
+ expect(
+ matches("/repo/node_modules/@tanstack/react-query/build/index.js"),
+ ).toBe(true);
+ expect(
+ matches("/repo/node_modules/@tanstack/query-core/build/index.js"),
+ ).toBe(true);
+ expect(matches("/repo/node_modules/framer-motion/dist/es/index.mjs")).toBe(
+ true,
+ );
+ expect(matches("/repo/node_modules/lucide-react/dist/esm/index.js")).toBe(
+ true,
+ );
+ });
+});
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index a4946f3..da10e1b 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -14,6 +14,7 @@ export default defineConfig(({ mode, command }) => {
})
return {
define: {
+ 'import.meta.env.VITE_RELEASE_COMMIT': JSON.stringify(raw.VERCEL_GIT_COMMIT_SHA || raw.RENDER_GIT_COMMIT || raw.VITE_RELEASE_COMMIT || ''),
'import.meta.env.VITE_VERCEL_ENV': JSON.stringify(raw.VERCEL_ENV || ''),
'import.meta.env.CI': JSON.stringify(raw.CI || ''),
},
@@ -51,7 +52,7 @@ export default defineConfig(({ mode, command }) => {
outDir: 'dist',
sourcemap: mode === 'development',
// Vite 8 (Rolldown): object-form output.manualChunks was removed, so the
- // five vendor bundles are expressed as codeSplitting groups instead.
+ // vendor groups keep optional wallet connectors dynamically split.
rolldownOptions: {
output: {
codeSplitting: {
@@ -64,11 +65,6 @@ export default defineConfig(({ mode, command }) => {
name: 'ui-vendor',
test: /node_modules\/(?:framer-motion|lucide-react)\//,
},
- {
- name: 'web3-vendor',
- test: /node_modules\/(?:wagmi|viem|@rainbow-me\/rainbowkit)\//,
- },
- { name: 'charts-vendor', test: /node_modules\/recharts\// },
{
name: 'query-vendor',
test: /node_modules\/@tanstack\/(?:react-query|query-core)\//,
diff --git a/openapi.json b/openapi.json
index 2d337a6..bf286b3 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1862,6 +1862,40 @@
}
}
},
+ "/telemetry/browser": {
+ "post": {
+ "tags": [
+ "telemetry"
+ ],
+ "summary": "Browser Metric",
+ "operationId": "browser_metric_telemetry_browser_post",
+ "requestBody": {
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/BrowserMetric"
+ }
+ }
+ },
+ "required": true
+ },
+ "responses": {
+ "204": {
+ "description": "Successful Response"
+ },
+ "422": {
+ "description": "Validation Error",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/HTTPValidationError"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/health/indexer": {
"get": {
"summary": "Health Indexer",
@@ -1937,6 +1971,25 @@
}
}
},
+ "/health/operations": {
+ "get": {
+ "summary": "Health Operations",
+ "description": "Bounded diagnostics; public aggregates contain no user input or identities.",
+ "operationId": "health_operations_health_operations_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OperationsResponse"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/health": {
"get": {
"summary": "Health Check",
@@ -2107,6 +2160,104 @@
],
"title": "AnomalyResponse"
},
+ "BrowserAggregate": {
+ "properties": {
+ "metric": {
+ "type": "string",
+ "title": "Metric"
+ },
+ "route": {
+ "type": "string",
+ "title": "Route"
+ },
+ "count": {
+ "type": "integer",
+ "title": "Count"
+ },
+ "mean": {
+ "type": "number",
+ "title": "Mean"
+ }
+ },
+ "type": "object",
+ "required": [
+ "metric",
+ "route",
+ "count",
+ "mean"
+ ],
+ "title": "BrowserAggregate"
+ },
+ "BrowserMetric": {
+ "properties": {
+ "metric": {
+ "type": "string",
+ "enum": [
+ "LCP",
+ "CLS",
+ "interaction_max_ms",
+ "client_error",
+ "api_error",
+ "stream_disconnected",
+ "stream_recovered"
+ ],
+ "title": "Metric"
+ },
+ "route": {
+ "type": "string",
+ "enum": [
+ "dashboard",
+ "products",
+ "shipments",
+ "shipment_detail",
+ "analytics",
+ "exceptions",
+ "verify",
+ "demo",
+ "privacy",
+ "terms",
+ "not_found"
+ ],
+ "title": "Route"
+ },
+ "value": {
+ "type": "number",
+ "maximum": 600000.0,
+ "minimum": 0.0,
+ "title": "Value"
+ },
+ "release": {
+ "anyOf": [
+ {
+ "type": "string",
+ "pattern": "^[0-9a-f]{40}$"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Release"
+ },
+ "environment": {
+ "type": "string",
+ "enum": [
+ "development",
+ "testing",
+ "production"
+ ],
+ "title": "Environment"
+ }
+ },
+ "additionalProperties": false,
+ "type": "object",
+ "required": [
+ "metric",
+ "route",
+ "value",
+ "environment"
+ ],
+ "title": "BrowserMetric"
+ },
"ChallengeRequest": {
"properties": {
"address": {
@@ -3261,6 +3412,102 @@
],
"title": "NonceResponse"
},
+ "OperationalCounters": {
+ "properties": {
+ "window_seconds": {
+ "type": "integer",
+ "title": "Window Seconds"
+ },
+ "uptime_seconds": {
+ "type": "integer",
+ "title": "Uptime Seconds"
+ },
+ "requests": {
+ "type": "integer",
+ "title": "Requests"
+ },
+ "api_requests": {
+ "type": "integer",
+ "title": "Api Requests"
+ },
+ "api_5xx": {
+ "type": "integer",
+ "title": "Api 5Xx"
+ },
+ "mean_duration_ms": {
+ "anyOf": [
+ {
+ "type": "number"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "title": "Mean Duration Ms"
+ },
+ "duration_buckets": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "type": "object",
+ "title": "Duration Buckets"
+ },
+ "streams": {
+ "additionalProperties": {
+ "type": "integer"
+ },
+ "type": "object",
+ "title": "Streams"
+ },
+ "browser_metrics": {
+ "items": {
+ "$ref": "#/components/schemas/BrowserAggregate"
+ },
+ "type": "array",
+ "title": "Browser Metrics"
+ },
+ "storage": {
+ "type": "string",
+ "const": "process_memory",
+ "title": "Storage"
+ },
+ "reset_on_restart": {
+ "type": "boolean",
+ "title": "Reset On Restart"
+ }
+ },
+ "type": "object",
+ "required": [
+ "window_seconds",
+ "uptime_seconds",
+ "requests",
+ "api_requests",
+ "api_5xx",
+ "mean_duration_ms",
+ "duration_buckets",
+ "streams",
+ "browser_metrics",
+ "storage",
+ "reset_on_restart"
+ ],
+ "title": "OperationalCounters"
+ },
+ "OperationsResponse": {
+ "properties": {
+ "health": {
+ "$ref": "#/components/schemas/ReadinessResponse"
+ },
+ "counters": {
+ "$ref": "#/components/schemas/OperationalCounters"
+ }
+ },
+ "type": "object",
+ "required": [
+ "health",
+ "counters"
+ ],
+ "title": "OperationsResponse"
+ },
"PolicyResponse": {
"properties": {
"arrival_enabled": {
diff --git a/performance/increment10.json b/performance/increment10.json
new file mode 100644
index 0000000..c123a0d
--- /dev/null
+++ b/performance/increment10.json
@@ -0,0 +1,275 @@
+{
+ "baseline": {
+ "method": "Controlled mobile lab; 3 cold-cache Lighthouse runs; 390x844; simulated mobile CPU/network; gzip; frozen API fixtures; external HTTPS blocked. Interaction includes Playwright click overhead and two animation frames, not field INP.",
+ "browser": "154.0.8037.57",
+ "samples": [
+ {
+ "performance": 0.47,
+ "fcp_ms": 6465.762075000001,
+ "lcp_ms": 11292.51725,
+ "tbt_ms": 463,
+ "cls": 0,
+ "transfer_bytes": 1671523
+ },
+ {
+ "performance": 0.47,
+ "fcp_ms": 6531.2059,
+ "lcp_ms": 11326.437,
+ "tbt_ms": 482.2563,
+ "cls": 0,
+ "transfer_bytes": 1671556
+ },
+ {
+ "performance": 0.49,
+ "fcp_ms": 6502.119675,
+ "lcp_ms": 11331.885250000001,
+ "tbt_ms": 425,
+ "cls": 0,
+ "transfer_bytes": 1671523
+ }
+ ],
+ "median": {
+ "performance": 0.47,
+ "fcp_ms": 6502.119675,
+ "lcp_ms": 11326.437,
+ "tbt_ms": 463,
+ "cls": 0,
+ "transfer_bytes": 1671523
+ },
+ "menu_interaction_ms": 146.20000000001164,
+ "initial_script_bytes": 5004840,
+ "initial_script_gzip_bytes": 1226486,
+ "scripts": [
+ {
+ "path": "/assets/rolldown-runtime-BcKkbAw3.js",
+ "bytes": 1348,
+ "gzip_bytes": 728
+ },
+ {
+ "path": "/assets/query-vendor-DJgo8a-R.js",
+ "bytes": 43051,
+ "gzip_bytes": 12912
+ },
+ {
+ "path": "/assets/index-CloMir2O.js",
+ "bytes": 169958,
+ "gzip_bytes": 52985
+ },
+ {
+ "path": "/assets/react-vendor-3SPKybA3.js",
+ "bytes": 210630,
+ "gzip_bytes": 64998
+ },
+ {
+ "path": "/assets/ui-vendor-B5Tjc55t.js",
+ "bytes": 136665,
+ "gzip_bytes": 44801
+ },
+ {
+ "path": "/assets/charts-vendor-lDg_OUPr.js",
+ "bytes": 373609,
+ "gzip_bytes": 106138
+ },
+ {
+ "path": "/assets/web3-vendor-MseeSP9p.js",
+ "bytes": 2941433,
+ "gzip_bytes": 597750
+ },
+ {
+ "path": "/assets/dist-CoS6E-3x.js",
+ "bytes": 933,
+ "gzip_bytes": 450
+ },
+ {
+ "path": "/assets/events-CMzhc0tf.js",
+ "bytes": 6062,
+ "gzip_bytes": 2066
+ },
+ {
+ "path": "/assets/index.es-DZ7_0AsB.js",
+ "bytes": 393442,
+ "gzip_bytes": 110017
+ },
+ {
+ "path": "/assets/dist-DzuSKP3H.js",
+ "bytes": 256487,
+ "gzip_bytes": 105416
+ },
+ {
+ "path": "/assets/lit-BUxpUABm.js",
+ "bytes": 15003,
+ "gzip_bytes": 5803
+ },
+ {
+ "path": "/assets/ConstantsUtil-B2pB0X6-.js",
+ "bytes": 121260,
+ "gzip_bytes": 32163
+ },
+ {
+ "path": "/assets/core-D3tlAAzk.js",
+ "bytes": 334959,
+ "gzip_bytes": 90259
+ }
+ ]
+ },
+ "after": {
+ "method": "Controlled mobile lab; 3 cold-cache Lighthouse runs; 390x844; simulated mobile CPU/network; gzip; frozen API fixtures; external HTTPS blocked. Interaction includes Playwright click overhead and two animation frames, not field INP.",
+ "browser": "154.0.8037.57",
+ "samples": [
+ {
+ "performance": 0.66,
+ "fcp_ms": 3496.1353750000003,
+ "lcp_ms": 5142.4166000000005,
+ "tbt_ms": 318,
+ "cls": 0,
+ "transfer_bytes": 660648
+ },
+ {
+ "performance": 0.62,
+ "fcp_ms": 3543.559875,
+ "lcp_ms": 5223.0958,
+ "tbt_ms": 447.50000000000045,
+ "cls": 0,
+ "transfer_bytes": 660648
+ },
+ {
+ "performance": 0.66,
+ "fcp_ms": 3469.43075,
+ "lcp_ms": 5272.861499999999,
+ "tbt_ms": 313.5,
+ "cls": 0,
+ "transfer_bytes": 662008
+ }
+ ],
+ "median": {
+ "performance": 0.66,
+ "fcp_ms": 3496.1353750000003,
+ "lcp_ms": 5223.0958,
+ "tbt_ms": 318,
+ "cls": 0,
+ "transfer_bytes": 660648
+ },
+ "menu_interaction_ms": 103.90000000002328,
+ "initial_script_bytes": 1237300,
+ "initial_script_gzip_bytes": 382948,
+ "scripts": [
+ {
+ "path": "/assets/rolldown-runtime-BcKkbAw3.js",
+ "bytes": 1348,
+ "gzip_bytes": 728
+ },
+ {
+ "path": "/assets/query-vendor-DJgo8a-R.js",
+ "bytes": 43051,
+ "gzip_bytes": 12912
+ },
+ {
+ "path": "/assets/ui-vendor-B5Tjc55t.js",
+ "bytes": 136665,
+ "gzip_bytes": 44801
+ },
+ {
+ "path": "/assets/stringify-Bx8_8276.js",
+ "bytes": 7321,
+ "gzip_bytes": 2722
+ },
+ {
+ "path": "/assets/encodeFunctionData-5YZEskHs.js",
+ "bytes": 19575,
+ "gzip_bytes": 6513
+ },
+ {
+ "path": "/assets/utils-BXiNjgTj.js",
+ "bytes": 3193,
+ "gzip_bytes": 1368
+ },
+ {
+ "path": "/assets/Value-CyfJx_yS.js",
+ "bytes": 664,
+ "gzip_bytes": 445
+ },
+ {
+ "path": "/assets/createBatchScheduler-aFWtjeYp.js",
+ "bytes": 7929,
+ "gzip_bytes": 2285
+ },
+ {
+ "path": "/assets/preload-helper-BZ1Pz5am.js",
+ "bytes": 1218,
+ "gzip_bytes": 688
+ },
+ {
+ "path": "/assets/call-BPDGbFOU.js",
+ "bytes": 72617,
+ "gzip_bytes": 20491
+ },
+ {
+ "path": "/assets/sepolia-Buht6mwa.js",
+ "bytes": 738,
+ "gzip_bytes": 459
+ },
+ {
+ "path": "/assets/with-selector-CozBkNk_.js",
+ "bytes": 1671,
+ "gzip_bytes": 749
+ },
+ {
+ "path": "/assets/clsx-DB0hHKMi.js",
+ "bytes": 369,
+ "gzip_bytes": 233
+ },
+ {
+ "path": "/assets/react-vendor-3SPKybA3.js",
+ "bytes": 210630,
+ "gzip_bytes": 64998
+ },
+ {
+ "path": "/assets/index-knZQ4hSZ.js",
+ "bytes": 677742,
+ "gzip_bytes": 211107
+ },
+ {
+ "path": "/assets/safeWallet-5MNKTR5Z-Ba8u5lpn.js",
+ "bytes": 1818,
+ "gzip_bytes": 706
+ },
+ {
+ "path": "/assets/rainbowWallet-O26YNBMX-CBGCSxd0.js",
+ "bytes": 5119,
+ "gzip_bytes": 953
+ },
+ {
+ "path": "/assets/base-QS6CYWIN-Dirz7KkB.js",
+ "bytes": 901,
+ "gzip_bytes": 390
+ },
+ {
+ "path": "/assets/metaMaskWallet-EI6MED72-BfqZnyky.js",
+ "bytes": 2202,
+ "gzip_bytes": 885
+ },
+ {
+ "path": "/assets/walletConnectWallet-YHWKVTDY-DvZwMEVq.js",
+ "bytes": 1876,
+ "gzip_bytes": 700
+ },
+ {
+ "path": "/assets/assets-Q6ZU7ZJ5-D-Bo-EQf.js",
+ "bytes": 23618,
+ "gzip_bytes": 4097
+ },
+ {
+ "path": "/assets/login-UP3DZBGS-CaoZ1sv9.js",
+ "bytes": 11994,
+ "gzip_bytes": 2973
+ },
+ {
+ "path": "/assets/PrivateAnalytics-DpG6cLRZ.js",
+ "bytes": 5041,
+ "gzip_bytes": 1745
+ }
+ ]
+ },
+ "baseline_commit": "7adb990863aa399d2dd323d80e1bfa090794716f",
+ "measurement_date": "2026-10-20 Asia/Manila"
+}
From 98ae5a77e86cde76e95d501a4233cc57d905665d Mon Sep 17 00:00:00 2001
From: myu ngot
Date: Mon, 19 Oct 2026 22:54:43 +0000
Subject: [PATCH 2/3] Record final performance evidence and complete operations
acceptance
---
IMPLEMENTATION_PLAN.md | 16 ++--
PERFORMANCE.md | 18 ++--
backend/monitor_operations.py | 2 +
backend/tests/test_operations.py | 42 ++++++++-
frontend/src/components/Diagnostics.test.tsx | 37 ++++++++
performance/increment10.json | 97 ++++++++++----------
6 files changed, 146 insertions(+), 66 deletions(-)
create mode 100644 frontend/src/components/Diagnostics.test.tsx
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index c4cd87b..13d9710 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -22,7 +22,7 @@ Priority meanings: **P0 — release blocker** must be resolved before enabling p
| Integration and release controls (increment 7) | **Done** | **P1 addressed** | Real write-failure/restart, process-loss, reorg and backup/restore rehearsals; typed detail/verification; protected main and exact-commit production admission; see [runbook](RELEASE_RUNBOOK.md) |
| QR verification, shipment detail and guided demo (increment 8) | **Done** | **P2 addressed** | Direct public links and SVG QR labels, canonical paginated timeline, shipment detail with contract-checked transitions, isolated guided demo, mobile wallet/keyboard access and route metadata; see [guide](PRODUCT_JOURNEY.md) |
| Shipment exceptions (increment 9) | **Done** | **P2 addressed** | Configurable owner policy, canonical arrival/checkpoint/custody rules, durable deduplicated occurrences, acknowledgement/history, synchronized evaluation and public mobile list; see [guide](SHIPMENT_EXCEPTIONS.md) |
-| Performance and operations (increment 10) | **In progress: acceptance** | P2 | Measured mobile LCP 11.33→5.22 s, deferred SDK/chart loading, private diagnostics, stream recovery, scheduled operations checks and migration-window safeguards; release acceptance in progress |
+| Performance and operations (increment 10) | **Done** | **P2 addressed** | Measured mobile LCP 11.33→5.37 s, deferred SDK/chart loading, private diagnostics, stream recovery, scheduled operations checks and migration-window safeguards; validation and release evidence in [PR #28](https://github.com/Oionos/Veritras/pull/28) |
| Business pilot integrations | **Deferred** | Later | Organizations, evidence files, imports, standards integrations and repeated journeys |
### Urgent safeguards and next actions
@@ -33,6 +33,8 @@ Priority meanings: **P0 — release blocker** must be resolved before enabling p
4. **Maintain release enforcement.** Main reports protected and the owner confirmed all eight required checks, up-to-date PRs and no bypasses/force pushes/deletions. Production Docker and frontend builds require successful exact-main-commit CI; API startup validates its built admission proof without GitHub access; frontend also waits for a compatible ready API. API rule-detail readback remains forbidden to this integration. See [RELEASE_CONTROLS.md](RELEASE_CONTROLS.md).
5. **Preserve testing isolation and free-tier limits.** Use the stable `testing` preview for full-stack checks. Production and testing Render services share the workspace's 750 free instance hours/month; avoid continuous keep-alive pings. See [HOSTED_TESTING.md](HOSTED_TESTING.md).
+6. **Maintain operations and collect field evidence.** Increment 10 adds private diagnostics, mobile budgets and four scheduled checks per day. Field percentiles and personal failure-notification delivery remain unvalidated; see [operations](OPERATIONS.md) and [measurements](PERFORMANCE.md). Use the coordinated migration preflight for future DDL; never migrate production while exact-main CI is pending.
+
### Completed releases and evidence
| Release | Main commit | Completed work |
@@ -85,9 +87,9 @@ The numbers below are **plan increment IDs**, not actual GitHub pull request num
| 5 | **Done / P1 addressed** | `codex/registration-lifecycle-evidence` | Maintain durable transaction recovery, checked evidence and metadata history; release tracked in PR #21 | 2–4 |
| 6 | **Done / P1 addressed** | `codex/truthful-analytics-and-exports` | Maintain canonical cohorts, SLA exclusions and safe matching exports; release tracked in PR #22 | 3–5 delivered |
| 7 | **Done / P1 addressed** | `codex/integration-release-controls` | Maintain real recovery drills, protected-main checks and production admission; see [runbook](RELEASE_RUNBOOK.md) | 2–6 delivered |
-| 8 | Pending / P2 | `feat/qr-verification-and-demo` | QR links, unified shipment detail, mobile and guided demo | 5 and 7 |
-| 9 | Pending / P2 | `feat/shipment-exceptions` | Expected arrival, overdue and dwell exception list | 6–8 |
-| 10 | Pending / P2 | `perf/frontend-and-operations` | Measured loading improvements and operational cleanup | Stable reliable-demo milestone |
+| 8 | **Done / P2 addressed** | Released in #25 | Maintain QR links, shipment detail, mobile and guided demo; see [guide](PRODUCT_JOURNEY.md) | 5 and 7 |
+| 9 | **Done / P2 addressed** | Released in #27 | Maintain canonical exception rules, history and acknowledgement; see [guide](SHIPMENT_EXCEPTIONS.md) | 6–8 |
+| 10 | **Done / P2 addressed** | `codex/performance-and-operations`, PR #28 | Maintain loading budgets, private diagnostics, periodic health checks and recovery procedures; field comparison awaits real traffic | 5–9 delivered |
Tests and migration checks belong in each implementation PR. PR 7 assembles the complete flow and release gates; it is not the first testing work.
@@ -296,14 +298,14 @@ Completed in increment 7: actual PostgreSQL checkpoint failure after a metadata
### PR 10: Performance and operations
-**Status: implemented; release acceptance in progress. Priority: P2.** Three controlled mobile samples per build show median LCP 11.33→5.22 seconds, transfer −60%, initial gzip JavaScript −69%, with unchanged CLS. See [performance evidence](PERFORMANCE.md) and [operations](OPERATIONS.md). Private error/route telemetry, replay disabled, API/SSE counters, heartbeat recovery, scheduled cold-start-aware monitoring and coordinated migration preflight are implemented. Valid production field percentiles require real traffic; no historical field comparison is claimed. Local backup/restore is rehearsed; hosted production restore acceptance remains in the separate enablement gate.
+**Status: done for implementation and acceptance scope. Priority: P2 addressed.** Three controlled mobile samples per build show median LCP 11.33→5.37 seconds, transfer −60%, initial gzip JavaScript −69%, with unchanged CLS. See [performance evidence](PERFORMANCE.md) and [operations](OPERATIONS.md). Private error/route telemetry, replay disabled, API/SSE counters, heartbeat recovery, scheduled cold-start-aware monitoring and coordinated migration preflight are implemented. Valid production field percentiles require real traffic; no historical field comparison is claimed. Local backup/restore is rehearsed; hosted production restore acceptance remains in the separate enablement gate.
- Measure mobile loading and interaction before changing bundles; investigate wallet/provider startup, chart imports, and unnecessary SDK loading.
- Defer optional wallet/chart code where measurements justify it. Compare before/after Lighthouse and field metrics using the same scenario.
- Configure release/environment labels for error tracking, appropriate replay masking, and monitoring for indexer lag, API failures, and event-stream health.
- Document backup/restore and single-indexer ownership. Split the indexer into a worker only if uptime/scaling requirements justify the additional service.
-**Acceptance:** performance improvements have measured evidence; error reporting excludes sensitive input; indexing and API degradation are actionable and observable.
+**Acceptance completed:** three-run before/after mobile evidence; regression-tested privacy allowlists and replay disabled; real wallet/reconnect and recovery/restore journeys; hosted testing mobile/bundle/privacy/SSE checks; sustained testing readiness with zero lag and an actual heartbeat. Exact-main CI, provider IDs and final production checks are retained in [PR #28](https://github.com/Oionos/Veritras/pull/28). Ongoing field comparison requires real production samples; native notification delivery and hosted production restore/backfill are explicit separate limits.
## Migration, backfill, and release procedure
@@ -330,7 +332,7 @@ After the reliable demo is complete and a specific user workflow has been valida
## Next implementation session
-Increment **10: performance and operations** is undergoing release acceptance. Increments 5–9 are delivered. After increment 10, prioritize field observation and a defined business pilot; there is no automatically authorized production backfill. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
+Increment **10: performance and operations** is delivered; final protected-main and provider evidence is recorded in [PR #28](https://github.com/Oionos/Veritras/pull/28). Increments 5–9 are delivered. After increment 10, prioritize field observation and a defined business pilot; there is no automatically authorized production backfill. **Production indexing enablement validation remains important and pending:** review the deployment anchor, rehearse identity/history reconciliation and hosted restore, then obtain explicit enablement before backfill. Keep that gate separate from ordinary application releases.
Preserve analytics definitions and sample/exclusion counts during subsequent work. Keep production indexing enablement and any change to release-gate configuration as explicit reviewed changes.
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
index f792533..811c84f 100644
--- a/PERFORMANCE.md
+++ b/PERFORMANCE.md
@@ -1,6 +1,6 @@
# Performance measurements
-Increment 10 measured the anonymous dashboard before changing loading boundaries. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
+Increment 10 measured the anonymous dashboard before changing loading boundaries. The measured final frontend source is feature commit `25819f84866787e741ab4dcd039313ac094b451a`; subsequent monitoring/test/documentation changes do not alter that runtime. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
## Controlled mobile comparison
@@ -8,17 +8,17 @@ Measured October 20, 2026 (Asia/Manila), Chromium 154.0.8037.57. Three cold-cach
| Median | Before | After | Change |
| --- | ---: | ---: | ---: |
-| Lighthouse performance | 47 | 66 | +19 points |
-| First contentful paint | 6.50 s | 3.50 s | 46% lower |
-| Largest contentful paint | 11.33 s | 5.22 s | 54% lower |
-| Total blocking time | 463 ms | 318 ms | 31% lower |
+| Lighthouse performance | 47 | 61 | +14 points |
+| First contentful paint | 6.50 s | 3.52 s | 46% lower |
+| Largest contentful paint | 11.33 s | 5.37 s | 53% lower |
+| Total blocking time | 463 ms | 448 ms | 3% lower |
| Cumulative layout shift | 0 | 0 | unchanged |
-| Total transferred bytes | 1,671,523 | 660,648 | 60% lower |
-| Initial JavaScript, gzip | 1,226,486 | 382,948 | 69% lower |
+| Total transferred bytes | 1,671,523 | 660,866 | 60% lower |
+| Initial JavaScript, gzip | 1,226,486 | 383,058 | 69% lower |
-One supplementary menu interaction took 146 ms before and 104 ms after. It includes Playwright click overhead and two animation frames: **it is not field INP or a percentile**, and is not the basis of the improvement claim. LCP remains above the 2.5-second good threshold in this constrained lab.
+One supplementary menu interaction took 146 ms before and 106 ms after. It includes Playwright click overhead and two animation frames: **it is not field INP or a percentile**, and is not the basis of the improvement claim. LCP remains above the 2.5-second good threshold in this constrained lab.
-The previous manual Web3 group combined optional SDKs with eager imports. Removing it alone reduced transfer but worsened blocking time in an intermediate trial. The final change also defers remote connector setup until wallet intent, restores it immediately for stored wallet connections, defers the below-fold chart by visibility, and schedules optional diagnostic SDK loading during idle time. No dependency upgrade or weaker Lighthouse threshold was needed.
+A repeat on the final build retained the loading/transfer gains; blocking time varied from the initial trial, so the table uses the final measurements. The previous manual Web3 group combined optional SDKs with eager imports. Removing it alone reduced transfer but worsened blocking time in an intermediate trial. The final change also defers remote connector setup until wallet intent, restores it immediately for stored wallet connections, defers the below-fold chart by visibility, and schedules optional diagnostic SDK loading during idle time. No dependency upgrade or weaker Lighthouse threshold was needed.
## Reproduce and prevent regression
diff --git a/backend/monitor_operations.py b/backend/monitor_operations.py
index 1e32a4a..d523679 100644
--- a/backend/monitor_operations.py
+++ b/backend/monitor_operations.py
@@ -89,6 +89,8 @@ def monitor(
code, body = fetch(origin + "/health/operations", timeout)
if (
code != 200
+ or body["health"]["status"] != "ready"
+ or body["health"]["database"] != "connected"
or body["health"]["environment"] != environment
or body["health"]["indexer"]["enabled"] is not expect_indexer
):
diff --git a/backend/tests/test_operations.py b/backend/tests/test_operations.py
index afc7fab..1aacf74 100644
--- a/backend/tests/test_operations.py
+++ b/backend/tests/test_operations.py
@@ -127,7 +127,12 @@ def sleep(seconds):
fetch=lambda *a: (
200,
{
- "health": {"environment": "testing", "indexer": {"enabled": True}},
+ "health": {
+ "status": "ready",
+ "database": "connected",
+ "environment": "testing",
+ "indexer": {"enabled": True},
+ },
"counters": {"api_requests": 10, "api_5xx": 0},
},
),
@@ -149,7 +154,12 @@ def test_monitor_disabled_policy_is_expected_and_stream_failure_actionable():
fetch=lambda *a: (
200,
{
- "health": {"environment": "production", "indexer": {"enabled": False}},
+ "health": {
+ "status": "ready",
+ "database": "connected",
+ "environment": "production",
+ "indexer": {"enabled": False},
+ },
"counters": {"api_requests": 3, "api_5xx": 3},
},
),
@@ -232,6 +242,7 @@ def test_migration_preflight_requires_awake_exact_prior_release(tmp_path):
{
"status": "ready",
"release_commit": commit,
+ "database": "connected",
"environment": "production",
},
),
@@ -299,3 +310,30 @@ async def timeout(awaitable, **kwargs):
assert "event: heartbeat" in await anext(iterator)
await iterator.aclose()
assert operations.snapshot()["streams"]["active"] == baseline
+
+
+def test_monitor_rechecks_readiness_in_the_operations_snapshot():
+ now = [0.0]
+ result = monitor(
+ "https://example.invalid",
+ environment="testing",
+ expect_indexer=True,
+ grace=60,
+ readiness=lambda *a, **k: {"ready": True, "state": "current"},
+ fetch=lambda *a: (
+ 200,
+ {
+ "health": {
+ "status": "not_ready",
+ "database": "connected",
+ "environment": "testing",
+ "indexer": {"enabled": True},
+ },
+ "counters": {"api_requests": 1, "api_5xx": 0},
+ },
+ ),
+ stream=lambda *a, **k: True,
+ clock=lambda: now[0],
+ sleep=lambda t: now.__setitem__(0, now[0] + t),
+ )
+ assert result["healthy"] is False and result["conditions"] == ["diagnostics"]
diff --git a/frontend/src/components/Diagnostics.test.tsx b/frontend/src/components/Diagnostics.test.tsx
new file mode 100644
index 0000000..3ff9ec0
--- /dev/null
+++ b/frontend/src/components/Diagnostics.test.tsx
@@ -0,0 +1,37 @@
+import { render } from "@testing-library/react";
+import { afterEach, expect, it, vi } from "vitest";
+const metrics = vi.hoisted(() => ({
+ stop: vi.fn(),
+ observeVitals: vi.fn(),
+ reportMetric: vi.fn(),
+}));
+vi.mock("@/config/telemetry", () => metrics);
+vi.mock("./PrivateAnalytics", () => ({ default: () => null }));
+import Diagnostics from "./Diagnostics";
+afterEach(() => vi.useRealTimers());
+it("reports only generic errors without a configured SDK and removes listeners on unmount", () => {
+ vi.useFakeTimers();
+ metrics.observeVitals.mockReturnValue(metrics.stop);
+ const add = vi.spyOn(window, "addEventListener"),
+ remove = vi.spyOn(window, "removeEventListener");
+ const { unmount } = render();
+ const handler = add.mock.calls.find(
+ ([type]) => type === "error",
+ )![1] as EventListener;
+ handler(
+ new ErrorEvent("error", {
+ message: "SECRET signature",
+ error: new Error("SECRET wallet"),
+ }),
+ );
+ expect(metrics.reportMetric).toHaveBeenCalledWith("client_error", 1);
+ expect(JSON.stringify(metrics.reportMetric.mock.calls)).not.toContain(
+ "SECRET",
+ );
+ unmount();
+ expect(metrics.stop).toHaveBeenCalledOnce();
+ expect(remove).toHaveBeenCalledWith("error", handler);
+ expect(remove).toHaveBeenCalledWith("unhandledrejection", handler);
+ add.mockRestore();
+ remove.mockRestore();
+});
diff --git a/performance/increment10.json b/performance/increment10.json
index c123a0d..e60c03a 100644
--- a/performance/increment10.json
+++ b/performance/increment10.json
@@ -117,41 +117,41 @@
"browser": "154.0.8037.57",
"samples": [
{
- "performance": 0.66,
- "fcp_ms": 3496.1353750000003,
- "lcp_ms": 5142.4166000000005,
- "tbt_ms": 318,
+ "performance": 0.61,
+ "fcp_ms": 3524.43825,
+ "lcp_ms": 5338.8765,
+ "tbt_ms": 464.5,
"cls": 0,
- "transfer_bytes": 660648
+ "transfer_bytes": 660866
},
{
- "performance": 0.62,
- "fcp_ms": 3543.559875,
- "lcp_ms": 5223.0958,
- "tbt_ms": 447.50000000000045,
+ "performance": 0.61,
+ "fcp_ms": 3532.873125,
+ "lcp_ms": 5367.74625,
+ "tbt_ms": 448,
"cls": 0,
- "transfer_bytes": 660648
+ "transfer_bytes": 660866
},
{
- "performance": 0.66,
- "fcp_ms": 3469.43075,
- "lcp_ms": 5272.861499999999,
- "tbt_ms": 313.5,
+ "performance": 0.62,
+ "fcp_ms": 3520.9522500000003,
+ "lcp_ms": 5485.685399999999,
+ "tbt_ms": 398.5617999999995,
"cls": 0,
- "transfer_bytes": 662008
+ "transfer_bytes": 662226
}
],
"median": {
- "performance": 0.66,
- "fcp_ms": 3496.1353750000003,
- "lcp_ms": 5223.0958,
- "tbt_ms": 318,
+ "performance": 0.61,
+ "fcp_ms": 3524.43825,
+ "lcp_ms": 5367.74625,
+ "tbt_ms": 448,
"cls": 0,
- "transfer_bytes": 660648
+ "transfer_bytes": 660866
},
- "menu_interaction_ms": 103.90000000002328,
- "initial_script_bytes": 1237300,
- "initial_script_gzip_bytes": 382948,
+ "menu_interaction_ms": 106.39999999990687,
+ "initial_script_bytes": 1237708,
+ "initial_script_gzip_bytes": 383058,
"scripts": [
{
"path": "/assets/rolldown-runtime-BcKkbAw3.js",
@@ -173,26 +173,41 @@
"bytes": 7321,
"gzip_bytes": 2722
},
+ {
+ "path": "/assets/utils-BXiNjgTj.js",
+ "bytes": 3193,
+ "gzip_bytes": 1368
+ },
{
"path": "/assets/encodeFunctionData-5YZEskHs.js",
"bytes": 19575,
"gzip_bytes": 6513
},
{
- "path": "/assets/utils-BXiNjgTj.js",
- "bytes": 3193,
- "gzip_bytes": 1368
+ "path": "/assets/react-vendor-3SPKybA3.js",
+ "bytes": 210630,
+ "gzip_bytes": 64998
},
{
"path": "/assets/Value-CyfJx_yS.js",
"bytes": 664,
"gzip_bytes": 445
},
+ {
+ "path": "/assets/index-DbS2JhRP.js",
+ "bytes": 678117,
+ "gzip_bytes": 211200
+ },
{
"path": "/assets/createBatchScheduler-aFWtjeYp.js",
"bytes": 7929,
"gzip_bytes": 2285
},
+ {
+ "path": "/assets/sepolia-Buht6mwa.js",
+ "bytes": 738,
+ "gzip_bytes": 459
+ },
{
"path": "/assets/preload-helper-BZ1Pz5am.js",
"bytes": 1218,
@@ -203,30 +218,15 @@
"bytes": 72617,
"gzip_bytes": 20491
},
- {
- "path": "/assets/sepolia-Buht6mwa.js",
- "bytes": 738,
- "gzip_bytes": 459
- },
- {
- "path": "/assets/with-selector-CozBkNk_.js",
- "bytes": 1671,
- "gzip_bytes": 749
- },
{
"path": "/assets/clsx-DB0hHKMi.js",
"bytes": 369,
"gzip_bytes": 233
},
{
- "path": "/assets/react-vendor-3SPKybA3.js",
- "bytes": 210630,
- "gzip_bytes": 64998
- },
- {
- "path": "/assets/index-knZQ4hSZ.js",
- "bytes": 677742,
- "gzip_bytes": 211107
+ "path": "/assets/with-selector-CozBkNk_.js",
+ "bytes": 1671,
+ "gzip_bytes": 749
},
{
"path": "/assets/safeWallet-5MNKTR5Z-Ba8u5lpn.js",
@@ -264,12 +264,13 @@
"gzip_bytes": 2973
},
{
- "path": "/assets/PrivateAnalytics-DpG6cLRZ.js",
- "bytes": 5041,
- "gzip_bytes": 1745
+ "path": "/assets/PrivateAnalytics-CHvA_vTu.js",
+ "bytes": 5074,
+ "gzip_bytes": 1762
}
]
},
"baseline_commit": "7adb990863aa399d2dd323d80e1bfa090794716f",
- "measurement_date": "2026-10-20 Asia/Manila"
+ "measurement_date": "2026-10-20 Asia/Manila",
+ "measured_feature_commit": "25819f84866787e741ab4dcd039313ac094b451a"
}
From e1879d197a7e2681fdb97a56228df93e1166ac69 Mon Sep 17 00:00:00 2001
From: myu ngot
Date: Mon, 19 Oct 2026 22:56:37 +0000
Subject: [PATCH 3/3] Construct telemetry URLs only from trusted route
templates
---
PERFORMANCE.md | 2 +-
frontend/src/config/operations.test.ts | 3 +++
frontend/src/config/telemetry.ts | 9 +--------
3 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/PERFORMANCE.md b/PERFORMANCE.md
index 811c84f..40c2580 100644
--- a/PERFORMANCE.md
+++ b/PERFORMANCE.md
@@ -1,6 +1,6 @@
# Performance measurements
-Increment 10 measured the anonymous dashboard before changing loading boundaries. The measured final frontend source is feature commit `25819f84866787e741ab4dcd039313ac094b451a`; subsequent monitoring/test/documentation changes do not alter that runtime. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
+Increment 10 measured the anonymous dashboard before changing loading boundaries. The measured frontend source is feature commit `25819f84866787e741ab4dcd039313ac094b451a`; subsequent changes harden URL allowlisting and monitoring without changing loading boundaries. The baseline is main `7adb990863aa399d2dd323d80e1bfa090794716f`. Results and individual sample values are retained in [performance/increment10.json](performance/increment10.json).
## Controlled mobile comparison
diff --git a/frontend/src/config/operations.test.ts b/frontend/src/config/operations.test.ts
index 7ee4c07..f1e49d2 100644
--- a/frontend/src/config/operations.test.ts
+++ b/frontend/src/config/operations.test.ts
@@ -12,6 +12,9 @@ afterEach(() => {
describe("private diagnostics", () => {
it("uses route templates and drops identifiers, credentials, query and fragment", () => {
expect(routeLabel("/shipments/SECRET")).toBe("shipment_detail");
+ expect(safeAnalyticsUrl("javascript:SECRET")).toBe(
+ `${window.location.origin}/not-found`,
+ );
expect(
safeAnalyticsUrl(
"https://user:SECRET@private.example/verify/SECRET?signature=SECRET#SECRET",
diff --git a/frontend/src/config/telemetry.ts b/frontend/src/config/telemetry.ts
index 86e3dd9..14f2c7f 100644
--- a/frontend/src/config/telemetry.ts
+++ b/frontend/src/config/telemetry.ts
@@ -31,14 +31,7 @@ export function safeAnalyticsUrl(value: string) {
verify: "/verify/:productId",
not_found: "/not-found",
};
- url.pathname = paths[route] ?? `/${route}`;
- url.search = "";
- url.hash = "";
- url.protocol = window.location.protocol;
- url.host = window.location.host;
- url.username = "";
- url.password = "";
- return url.toString();
+ return new URL(paths[route] ?? `/${route}`, window.location.origin).toString();
}
export type Metric =
| "LCP"