diff --git a/.env.example b/.env.example index eef3509..20c0cdc 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,50 @@ VITE_SUPPORT_API_BASE_URL= +# ----------------------------------------------------------------------------- +# Sentry — error monitoring (see docs/sentry-setup.md) +# One project, one DSN; VITE_SENTRY_ENVIRONMENT separates dev/prod in the dashboard. +# ----------------------------------------------------------------------------- + +# Runtime (browser) — required in every deployed environment +# Leave empty locally to disable Sentry. +# Example: https://@o.ingest.sentry.io/ +VITE_SENTRY_DSN= +# Environment label shown in Sentry: local | dev | production +VITE_SENTRY_ENVIRONMENT=local +# Set to the git SHA at build time (injected automatically by the deploy workflow). +VITE_SENTRY_RELEASE= + +# Build-time only — used by @sentry/vite-plugin to upload source maps. +# Not embedded in the browser bundle. Leave empty to skip source map upload locally. +# Example: / / +SENTRY_AUTH_TOKEN= +SENTRY_ORG= +SENTRY_PROJECT= + +# ----------------------------------------------------------------------------- +# Analytics (GA4 + GTM) — see docs/analytics-setup.md +# +# Local dev: leave both empty to disable tracking (recommended). +# To test against the dev stack locally, set VITE_GTM_CONTAINER_ID to your dev GTM container. +# +# Deployed builds use GitHub secrets that map to these same Vite vars at build time: +# trustvc.io → VITE_GTM_CONTAINER_ID_PRODUCTION / VITE_GA4_TAG_ID_PRODUCTION +# dev.trustvc.io → VITE_GTM_CONTAINER_ID_DEVELOPMENT / VITE_GA4_TAG_ID_DEVELOPMENT +# +# GTM-only is recommended: set VITE_GTM_CONTAINER_ID, leave VITE_GA4_TAG_ID empty. +# Setting both to the same GA4 property double-counts events. +VITE_GTM_CONTAINER_ID= +VITE_GA4_TAG_ID= + # reCAPTCHA: site key (frontend). # Must match API secret key server-side. # In production, this must be set; do not allow verification bypass when missing. VITE_RECAPTCHA_SITE_KEY= -# Optional: platform/environment for Jira (e.g. dev, staging, prod). Default: dev -VITE_PLATFORM=dev +# Platform label sent on every analytics event (environment dimension). +# Deployed builds: set automatically by deploy-prod.yml (production) / deploy-dev.yml (dev). +# Local dev only — use local (default when unset): +VITE_PLATFORM=local VITE_MAGIC_API_KEY= VITE_NETWORK_TYPE=mainnet diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec8988a..853dc5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,7 @@ jobs: env: VITE_RPC_URL_1: ${{ secrets.VITE_RPC_URL_1 }} VITE_RPC_URL_101010: ${{ secrets.VITE_RPC_URL_101010 }} + # Analytics disabled in CI — matches local .env.example defaults + VITE_GTM_CONTAINER_ID: '' + VITE_GA4_TAG_ID: '' + VITE_PLATFORM: local diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 08aeb58..c3b7b5c 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -39,6 +39,12 @@ jobs: AWS_REGION: ap-southeast-1 S3_BUCKET: ${{ secrets.S3_BUCKET_DEV }} ENV_FILE: ${{ secrets.TRUSTVC_WEB_ENV_DEVELOPMENT }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: dev + VITE_SENTRY_RELEASE: ${{ github.sha }} + VITE_GTM_CONTAINER_ID: ${{ secrets.VITE_GTM_CONTAINER_ID_DEVELOPMENT }} + VITE_GA4_TAG_ID: ${{ secrets.VITE_GA4_TAG_ID_DEVELOPMENT }} + VITE_PLATFORM: dev steps: - name: Checkout code @@ -54,13 +60,30 @@ jobs: cache-dependency-path: package-lock.json - name: Create .env for build - run: printf '%s' "$ENV_FILE" > .env + run: | + printf '%s\n' "$ENV_FILE" \ + | grep -v '^VITE_SENTRY_DSN=' \ + | grep -v '^VITE_SENTRY_ENVIRONMENT=' \ + | grep -v '^VITE_SENTRY_RELEASE=' \ + | grep -v '^VITE_GTM_CONTAINER_ID=' \ + | grep -v '^VITE_GA4_TAG_ID=' \ + | grep -v '^VITE_PLATFORM=' > .env + echo "VITE_GTM_CONTAINER_ID=${VITE_GTM_CONTAINER_ID}" >> .env + echo "VITE_GA4_TAG_ID=${VITE_GA4_TAG_ID}" >> .env + echo "VITE_PLATFORM=${VITE_PLATFORM}" >> .env + echo "VITE_SENTRY_DSN=${VITE_SENTRY_DSN}" >> .env + echo "VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT}" >> .env + echo "VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE}" >> .env - name: Install dependencies run: npm ci - name: Build run: npm run build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Upload build artifact (Dev) uses: actions/upload-artifact@v4 diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 658d687..e675293 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -18,6 +18,12 @@ jobs: S3_BUCKET: ${{ secrets.S3_BUCKET_PROD }} CF_DISTRIBUTION: ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID_PROD }} ENV_FILE: ${{ secrets.TRUSTVC_WEB_ENV_PRODUCTION }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: production + VITE_SENTRY_RELEASE: ${{ github.sha }} + VITE_GTM_CONTAINER_ID: ${{ secrets.VITE_GTM_CONTAINER_ID_PRODUCTION }} + VITE_GA4_TAG_ID: ${{ secrets.VITE_GA4_TAG_ID_PRODUCTION }} + VITE_PLATFORM: production steps: - name: Checkout code @@ -33,7 +39,20 @@ jobs: cache-dependency-path: package-lock.json - name: Create .env for build - run: printf '%s\n' "$ENV_FILE" > .env + run: | + printf '%s\n' "$ENV_FILE" \ + | grep -v '^VITE_SENTRY_DSN=' \ + | grep -v '^VITE_SENTRY_ENVIRONMENT=' \ + | grep -v '^VITE_SENTRY_RELEASE=' \ + | grep -v '^VITE_GTM_CONTAINER_ID=' \ + | grep -v '^VITE_GA4_TAG_ID=' \ + | grep -v '^VITE_PLATFORM=' > .env + echo "VITE_GTM_CONTAINER_ID=${VITE_GTM_CONTAINER_ID}" >> .env + echo "VITE_GA4_TAG_ID=${VITE_GA4_TAG_ID}" >> .env + echo "VITE_PLATFORM=${VITE_PLATFORM}" >> .env + echo "VITE_SENTRY_DSN=${VITE_SENTRY_DSN}" >> .env + echo "VITE_SENTRY_ENVIRONMENT=${VITE_SENTRY_ENVIRONMENT}" >> .env + echo "VITE_SENTRY_RELEASE=${VITE_SENTRY_RELEASE}" >> .env - name: Install dependencies run: npm ci @@ -43,6 +62,10 @@ jobs: - name: Build run: npm run build + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 diff --git a/.github/workflows/e2e-verify.yml b/.github/workflows/e2e-verify.yml index 33c2083..b52e9e3 100644 --- a/.github/workflows/e2e-verify.yml +++ b/.github/workflows/e2e-verify.yml @@ -50,6 +50,10 @@ jobs: VITE_APP_NETWORK: local VITE_NETWORK_TYPE: testnet VITE_RPC_URL_1337: http://127.0.0.1:8545 + # Analytics disabled in E2E — no events sent to GA4/GTM during test runs + VITE_GTM_CONTAINER_ID: '' + VITE_GA4_TAG_ID: '' + VITE_PLATFORM: local - name: Wait for dev server run: npx wait-on http://localhost:5173 --timeout 180000 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index a6ea1cc..64bf0ca 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -48,6 +48,10 @@ jobs: VITE_RPC_URL_1337: http://127.0.0.1:8545 VITE_RPC_URL_80002: https://rpc-amoy.polygon.technology/ VITE_RPC_URL_137: https://rpc.ankr.com/polygon + # Analytics disabled in E2E — no events sent to GA4/GTM during test runs + VITE_GTM_CONTAINER_ID: '' + VITE_GA4_TAG_ID: '' + VITE_PLATFORM: local - name: Wait for dev server run: npx wait-on http://localhost:5173 --timeout 180000 diff --git a/index.html b/index.html index df0c1f1..239b828 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,15 @@ TrustVC + +
diff --git a/package-lock.json b/package-lock.json index 0bfa5ac..b8f2485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@sanity/client": "^7.20.0", "@sanity/image-url": "^2.1.1", + "@sentry/react": "^10.62.0", "@trustvc/decentralized-renderer-react-components": "^1.0.3", "@trustvc/trustvc": "^2.14.1", "@types/lodash": "^4.17.24", @@ -21,12 +22,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-feather": "^2.0.10", + "react-ga4": "^3.0.1", "react-router-dom": "^7.12.0", "react-tooltip": "^5.30.0", "swiper": "^12.0.3" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@sentry/vite-plugin": "^5.3.0", "@synthetixio/synpress": "4.0.5", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", @@ -5253,6 +5256,366 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", + "integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.62.0.tgz", + "integrity": "sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.62.0", + "@sentry/core": "10.62.0", + "@sentry/feedback": "10.62.0", + "@sentry/replay": "10.62.0", + "@sentry/replay-canvas": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.62.0.tgz", + "integrity": "sha512-mS9HVVuWIdye9o0xUGFmzNOBqktF4n5kugrF8NCOYYDrr5ZV8Cx7BlquHQn5UpCeViVhZtcDlEm4iOK7++Px7A==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz", + "integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.3.0", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "dev": true, + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@sentry/core": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", @@ -5277,6 +5640,27 @@ "dev": true, "license": "0BSD" }, + "node_modules/@sentry/feedback": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.62.0.tgz", + "integrity": "sha512-d0BVjJVny6qpBgGJgWL0fbcoQHjtD3z3R8EK/KzTS3RO92JX5n3A536n5D/rh0gZFgcIwiUzBXegmyPOSQn9ng==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@sentry/hub": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", @@ -5386,6 +5770,97 @@ "dev": true, "license": "0BSD" }, + "node_modules/@sentry/react": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.62.0.tgz", + "integrity": "sha512-PChimVpY0wzs3H/hJqyl87/ITTHwIZWTSY68QoENZyLnp7DvLcFiZYub/gFws1pzDPhtIQXVLU72fbmUjT5PSg==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.62.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.62.0.tgz", + "integrity": "sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.62.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.62.0.tgz", + "integrity": "sha512-CzPAxmpe5US/ABGA1TzpjFKOFZN5uqlzrRh/uM9/daVuzLVKIAQ0XRNxo/PPEXvlDm/PoMdI5L0qIODuIKnyyw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0", + "@sentry/replay": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay/node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/rollup-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz", + "integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@sentry/tracing": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/tracing/-/tracing-5.30.0.tgz", @@ -5441,6 +5916,20 @@ "dev": true, "license": "0BSD" }, + "node_modules/@sentry/vite-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz", + "integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0", + "@sentry/rollup-plugin": "5.3.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -7512,15 +8001,6 @@ "resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz", "integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==" }, - "node_modules/@trustvc/w3c-issuer/node_modules/web-did-resolver": { - "version": "2.0.32", - "resolved": "https://registry.npmjs.org/web-did-resolver/-/web-did-resolver-2.0.32.tgz", - "integrity": "sha512-L91/ApTmDjgzS0UDstTKn3kN/1hlQBnVcUN8K29e3xhVBpPktHYC6uvVAQ8ohbIg9D6wrrbaBQvfRArDxgJG2g==", - "dependencies": { - "cross-fetch": "^4.1.0", - "did-resolver": "^4.1.0" - } - }, "node_modules/@trustvc/w3c-issuer/node_modules/web-did-resolver": { "version": "2.0.32", "resolved": "https://registry.npmjs.org/web-did-resolver/-/web-did-resolver-2.0.32.tgz", @@ -11263,6 +11743,19 @@ "url": "https://bevry.me/fund" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -16495,9 +16988,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -18195,6 +18689,12 @@ "react": ">=16.8.6" } }, + "node_modules/react-ga4": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-3.0.1.tgz", + "integrity": "sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA==", + "license": "MIT" + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/package.json b/package.json index 3e0c84f..45ee537 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,9 @@ "type": "module", "scripts": { "dev": "vite --mode development --open", - "build:dev": "vite build --mode development", - "build:prod": "vite build --mode production", - "build": "vite build --mode production", + "build:dev": "NODE_OPTIONS=--max-old-space-size=6144 vite build --mode development", + "build:prod": "NODE_OPTIONS=--max-old-space-size=6144 vite build --mode production", + "build": "NODE_OPTIONS=--max-old-space-size=6144 vite build --mode production", "preview": "npm run build && vite preview", "preview:dev": "npm run build:dev && vite preview --mode development", "preview:prod": "npm run build:prod && vite preview --mode production", @@ -32,6 +32,7 @@ "dependencies": { "@sanity/client": "^7.20.0", "@sanity/image-url": "^2.1.1", + "@sentry/react": "^10.62.0", "@trustvc/decentralized-renderer-react-components": "^1.0.3", "@trustvc/trustvc": "^2.14.1", "@types/lodash": "^4.17.24", @@ -43,12 +44,14 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-feather": "^2.0.10", + "react-ga4": "^3.0.1", "react-router-dom": "^7.12.0", "react-tooltip": "^5.30.0", "swiper": "^12.0.3" }, "devDependencies": { "@eslint/js": "^9.17.0", + "@sentry/vite-plugin": "^5.3.0", "@synthetixio/synpress": "4.0.5", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.1.0", diff --git a/src/App.tsx b/src/App.tsx index 57c27e6..9f24ff7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,12 @@ import { useState, useEffect } from 'react' import { matchPath, useLocation } from 'react-router-dom' import Navbar from './components/common/Navbar' import AppRouter from './routes' +import { GoogleTagManager } from './components/common/GoogleTagManager' + +const GTM_CONTAINER_ID = import.meta.env.VITE_GTM_CONTAINER_ID as + | string + | undefined +const GA4_TAG_ID = import.meta.env.VITE_GA4_TAG_ID as string | undefined type BackgroundRouteRule = { paths: string[] @@ -74,10 +80,16 @@ function App() { const appShellClassName = `app-shell ${backgroundClassName}` return ( -
- - -
+ <> + +
+ + +
+ ) } diff --git a/src/components/AssetManagementPanel/AssetManagementApplication/index.tsx b/src/components/AssetManagementPanel/AssetManagementApplication/index.tsx index f5a6e33..6220503 100644 --- a/src/components/AssetManagementPanel/AssetManagementApplication/index.tsx +++ b/src/components/AssetManagementPanel/AssetManagementApplication/index.tsx @@ -3,6 +3,7 @@ import React, { FunctionComponent, useCallback, useEffect, + useRef, useState, } from 'react' import { useProviderContext } from '../../common/contexts/providerContext' @@ -13,6 +14,11 @@ import { AssetManagementActions } from '../AssetManagementActions' import { AssetManagementForm } from '../AssetManagementForm' import { Tag } from '../../common/Tag' import { useTokenRegistryVersion } from '../../../hooks/useTokenRegistryVersion' +import { + trackAssetActionInitiated, + trackAssetActionCompleted, + trackAssetActionFailed, +} from '../../../utils/analytics' import { TokenRegistryVersions } from '../../../constants' interface AssetManagementIsTransferableDocumentProps { @@ -140,10 +146,85 @@ export const AssetManagementApplication: FunctionComponent< (assetManagementActions: AssetManagementActions) => { resetProviders() setAssetManagementAction(assetManagementActions) + if (assetManagementActions !== AssetManagementActions.None) { + trackAssetActionInitiated( + assetManagementActions, + chainId, + tokenRegistryVersion ?? undefined + ) + } }, - [setAssetManagementAction, resetProviders] + [setAssetManagementAction, resetProviders, chainId, tokenRegistryVersion] ) + // Track on-chain outcome for the active asset action. + // outcomeRef prevents double-firing; resets to null when the state cycles back + // through UNINITIALIZED (which resetProviders() triggers on each new attempt). + const outcomeRef = useRef(null) + useEffect(() => { + if (assetManagementAction === AssetManagementActions.None) return + const txState = (() => { + switch (assetManagementAction) { + case AssetManagementActions.NominateBeneficiary: + return nominateState + case AssetManagementActions.TransferHolder: + return changeHolderState + case AssetManagementActions.EndorseBeneficiary: + return endorseBeneficiaryState + case AssetManagementActions.TransferOwner: + return transferOwnerHoldersState + case AssetManagementActions.TransferOwnerHolder: + return transferOwnerHoldersState + case AssetManagementActions.ReturnToIssuer: + return returnToIssuerState + case AssetManagementActions.AcceptReturnToIssuer: + return destroyTokenState + case AssetManagementActions.RejectReturnToIssuer: + return restoreTokenState + case AssetManagementActions.RejectTransferOwner: + return rejectTransferOwnerState + case AssetManagementActions.RejectTransferHolder: + return rejectTransferHolderState + case AssetManagementActions.RejectTransferOwnerHolder: + return rejectTransferOwnerHolderState + default: + return undefined + } + })() + if (!txState) return + if (txState === 'UNINITIALIZED' || txState === 'INITIALIZED') { + outcomeRef.current = null + return + } + if (txState !== 'CONFIRMED' && txState !== 'ERROR') return + const key = `${assetManagementAction}:${txState}` + if (outcomeRef.current === key) return + outcomeRef.current = key + if (txState === 'CONFIRMED') { + trackAssetActionCompleted(assetManagementAction, chainId) + } else { + trackAssetActionFailed( + assetManagementAction, + errorMessage ?? 'TRANSACTION_ERROR', + chainId + ) + } + }, [ + assetManagementAction, + chainId, + errorMessage, + nominateState, + changeHolderState, + endorseBeneficiaryState, + transferOwnerHoldersState, + returnToIssuerState, + destroyTokenState, + restoreTokenState, + rejectTransferOwnerState, + rejectTransferHolderState, + rejectTransferOwnerHolderState, + ]) + // Initialize the token information context with tokenId, tokenRegistryAddress and chainId useEffect(() => { if (tokenId && tokenRegistryAddress) { diff --git a/src/components/ConnectToMagicLink/index.tsx b/src/components/ConnectToMagicLink/index.tsx index cb7c965..2305375 100644 --- a/src/components/ConnectToMagicLink/index.tsx +++ b/src/components/ConnectToMagicLink/index.tsx @@ -1,5 +1,10 @@ import React, { useState } from 'react' import { toErrorMessage } from '../../utils/helper' +import { + trackWalletConnected, + trackWalletConnectFailed, + trackWalletDisconnected, +} from '../../utils/analytics' import { getMagicLinkIconSrc } from '../../utils/magicWallet' import { Button, ButtonSize } from '../common/Button' import Connected from '../ConnectToBlockchain/Connected' @@ -19,10 +24,13 @@ export const ConnectToMagicLinkModelComponent = ({ }: ConnectToMagicLinkModelProps) => { const { providerType, account, disconnectWallet } = useProviderContext() - const handleDisconnect = () => { - void disconnectWallet().catch(() => { + const handleDisconnect = async () => { + try { + await disconnectWallet() + trackWalletDisconnected('magic_link') + } catch { // Optional: surface a toast/error state - }) + } } return ( @@ -106,7 +114,9 @@ const ConnectToMagicLink: React.FC = () => { setIsConnecting(true) try { await upgradeToMagicSigner() + trackWalletConnected('magic_link') } catch (error: unknown) { + trackWalletConnectFailed('magic_link', getWalletErrorMessage(error)) setErrorMessage(getWalletErrorMessage(error)) } finally { setIsConnecting(false) diff --git a/src/components/ConnectToMetamask/index.tsx b/src/components/ConnectToMetamask/index.tsx index cd9fe2d..6c39c79 100644 --- a/src/components/ConnectToMetamask/index.tsx +++ b/src/components/ConnectToMetamask/index.tsx @@ -4,6 +4,11 @@ import Connected from '../ConnectToBlockchain/Connected' // import { NetworkContent } from '../NetworkSection/NetworkContent' // Warning icon - use inline SVG or import from public folder import { toErrorMessage } from '../../utils/helper' +import { + trackWalletConnected, + trackWalletConnectFailed, + trackWalletDisconnected, +} from '../../utils/analytics' import { SIGNER_TYPE, useProviderContext, @@ -24,8 +29,13 @@ export const ConnectToMetamaskModelComponent = ({ }: ConnectToMetamaskModelProps) => { const { providerType, account, disconnectWallet } = useProviderContext() - const handleDisconnect = () => { - disconnectWallet() + const handleDisconnect = async () => { + try { + await disconnectWallet() + trackWalletDisconnected('metamask') + } catch { + // Optional: surface a toast/error state + } } return (
@@ -121,7 +131,9 @@ const ConnectToMetamask: React.FC = ({ setErrorMessage('') try { await upgradeToMetaMaskSigner() + trackWalletConnected('metamask') } catch (error: unknown) { + trackWalletConnectFailed('metamask', getWalletErrorMessage(error)) console.error('Error in handleConnectWallet:', error) handleMetamaskError(error) } diff --git a/src/components/common/GoogleTagManager/index.tsx b/src/components/common/GoogleTagManager/index.tsx new file mode 100644 index 0000000..578a06b --- /dev/null +++ b/src/components/common/GoogleTagManager/index.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react' +import { initGA4 } from '../../../utils/analytics' + +interface GoogleTagManagerProps { + /** GTM container ID — e.g. GTM-XXXXXXX */ + gtmContainerId?: string + /** GA4 measurement ID — e.g. G-XXXXXXXXXX */ + ga4TagId?: string +} + +/** + * Bootstraps analytics on mount: + * - Loads the GTM container script into (if gtmContainerId is set) + * - Initialises GA4 direct tracking via react-ga4 (if ga4TagId is set) + * + * Renders nothing. The GTM noscript fallback lives in index.html so it works + * before JavaScript runs. + */ +export const GoogleTagManager = ({ + gtmContainerId, + ga4TagId, +}: GoogleTagManagerProps) => { + useEffect(() => { + // GA4 direct tracking + if (ga4TagId) { + initGA4(ga4TagId) + } + + // GTM container + if (gtmContainerId && !document.getElementById('gtm-script')) { + window.dataLayer = window.dataLayer || [] + window.dataLayer.push({ + 'gtm.start': new Date().getTime(), + event: 'gtm.js', + }) + + const script = document.createElement('script') + script.id = 'gtm-script' + script.async = true + script.src = `https://www.googletagmanager.com/gtm.js?id=${gtmContainerId}` + document.head.appendChild(script) + } + }, [gtmContainerId, ga4TagId]) + + return null +} diff --git a/src/components/home/VerifySection/useVerify.analytics.test.ts b/src/components/home/VerifySection/useVerify.analytics.test.ts new file mode 100644 index 0000000..bb1e3f3 --- /dev/null +++ b/src/components/home/VerifySection/useVerify.analytics.test.ts @@ -0,0 +1,475 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { useVerify } from './useVerify' +import { DocumentProvider } from '../../common/contexts/DocumentContext' + +// ─── Mock sentry — prevents @sentry/react from patching global timers ──────── + +vi.mock('../../../lib/sentry', () => ({ + captureVerificationBreadcrumb: vi.fn(), + captureVerificationException: vi.fn(), + captureVerificationInvalid: vi.fn(), + isSentryEnabled: vi.fn(() => false), + initSentry: vi.fn(), +})) + +// ─── Mock analytics ─────────────────────────────────────────────────────────── + +vi.mock('../../../utils/analytics', () => ({ + trackDocumentDropped: vi.fn(), + trackDocumentVerified: vi.fn(), + trackDocumentVerifyError: vi.fn(), + trackNetworkSelectionShown: vi.fn(), + trackNetworkSelected: vi.fn(), + trackNetworkSelectionCancelled: vi.fn(), + trackVerificationReset: vi.fn(), +})) + +// ─── Mock @trustvc/trustvc ──────────────────────────────────────────────────── + +Object.defineProperty(import.meta, 'env', { + value: { + VITE_RPC_URL_1: 'https://eth-mainnet.example.com', + VITE_RPC_URL_137: 'https://polygon.example.com', + }, + writable: true, + configurable: true, +}) + +if (!File.prototype.text) { + File.prototype.text = function () { + return new Promise(resolve => { + const reader = new FileReader() + reader.onload = () => resolve(reader.result as string) + reader.readAsText(this) + }) + } +} + +vi.mock('@trustvc/trustvc', async importOriginal => { + const actual = await importOriginal() + const mkChain = ( + id: string, + name: string, + rpcUrl: string, + explorerUrl: string + ) => ({ + id, + name, + label: name, + rpcUrl, + explorerUrl, + type: 'production' as const, + currency: 'ETH', + nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 }, + }) + return { + ...actual, + verifyDocument: vi.fn(), + getChainId: vi.fn(), + isTransferableRecord: vi.fn().mockReturnValue(false), + isDocumentRevokable: vi.fn(), + SUPPORTED_CHAINS: { + '1': mkChain( + '1', + 'homestead', + 'https://eth-mainnet.example.com', + 'https://etherscan.io' + ), + '137': mkChain( + '137', + 'matic', + 'https://polygon.example.com', + 'https://polygonscan.com' + ), + '50': mkChain( + '50', + 'xdc', + 'https://xdc-rpc.com', + 'https://xdc-explorer.io' + ), + '101010': mkChain( + '101010', + 'stability', + 'https://stability-rpc.com', + 'https://stability-explorer.io' + ), + '1338': mkChain( + '1338', + 'astron', + 'https://astron-rpc.com', + 'https://astron-explorer.io' + ), + '11155111': mkChain( + '11155111', + 'sepolia', + 'https://sepolia-rpc.com', + 'https://sepolia-explorer.io' + ), + '80002': mkChain( + '80002', + 'amoy', + 'https://amoy-rpc.com', + 'https://amoy-explorer.io' + ), + '51': mkChain( + '51', + 'xdcapothem', + 'https://apothem-rpc.com', + 'https://apothem-explorer.io' + ), + '20180427': mkChain( + '20180427', + 'stabilitytestnet', + 'https://stability-test-rpc.com', + 'https://stability-test-explorer.io' + ), + '21002': mkChain( + '21002', + 'astrontestnet', + 'https://astron-test-rpc.com', + 'https://astron-test-explorer.io' + ), + }, + isWrappedV2Document: vi.fn().mockReturnValue(false), + isWrappedV3Document: vi.fn().mockReturnValue(false), + isRawV2Document: vi.fn().mockReturnValue(false), + isSignedWrappedV2Document: vi.fn().mockReturnValue(false), + isRawV3Document: vi.fn().mockReturnValue(false), + isSignedWrappedV3Document: vi.fn().mockReturnValue(false), + isTitleEscrowVersion: vi.fn().mockResolvedValue(false), + TitleEscrowInterface: { V4: 'V4', V5: 'V5' }, + getTokenRegistryAddress: vi.fn().mockReturnValue(undefined), + getTokenId: vi.fn().mockReturnValue(undefined), + getDocumentData: vi.fn().mockReturnValue({ id: 'test-key-id' }), + utils: {}, + v2: {}, + v3: {}, + vc: { + isSignedDocument: vi.fn().mockReturnValue(false), + isRawDocument: vi.fn().mockReturnValue(false), + isSignedDocumentV2_0: vi.fn().mockReturnValue(false), + }, + } +}) + +// ─── Import after mocks ─────────────────────────────────────────────────────── + +import * as analytics from '../../../utils/analytics' +import { + verifyDocument, + getChainId, + isTransferableRecord, + CHAIN_ID, +} from '@trustvc/trustvc' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(DocumentProvider, null, children) + +const makeFile = (content: object, name = 'doc.json') => + new File([JSON.stringify(content)], name, { type: 'application/json' }) + +const validFragments = [ + { + name: 'OpenAttestationHash', + type: 'DOCUMENT_INTEGRITY', + status: 'VALID', + data: {}, + }, + { + name: 'OpenAttestationDnsTxtIdentityProof', + type: 'ISSUER_IDENTITY', + status: 'VALID', + data: { identifier: 'example.com', location: 'example.com' }, + }, + { + name: 'OpenAttestationEthereumDocumentStoreStatus', + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: {}, + }, +] + +const makeDragEvent = (file: File): React.DragEvent => + ({ + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + type: 'drop', + dataTransfer: { files: [file] as unknown as FileList }, + }) as unknown as React.DragEvent + +const makeInputEvent = (file: File): React.ChangeEvent => + ({ + target: { files: [file] as unknown as FileList, value: '' }, + }) as unknown as React.ChangeEvent + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(getChainId).mockReturnValue(CHAIN_ID.mainnet) + vi.mocked(isTransferableRecord).mockReturnValue(false) + vi.mocked(verifyDocument).mockResolvedValue(validFragments as any) +}) + +// ─── DOCUMENT_DROPPED source tracking ──────────────────────────────────────── + +describe('trackDocumentDropped', () => { + it('fires with source="drop" on handleDrop', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + const file = makeFile({ + version: 'https://schema.openattestation.com/2.0/schema.json', + }) + await act(async () => { + result.current.handleDrop(makeDragEvent(file)) + await new Promise(r => setTimeout(r, 0)) + }) + + expect(analytics.trackDocumentDropped).toHaveBeenCalledWith( + file.name, + 'drop' + ) + }) + + it('fires with source="file_picker" on handleFileInput', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + const file = makeFile({ data: 'test' }, 'upload.json') + await act(async () => { + result.current.handleFileInput(makeInputEvent(file)) + await new Promise(r => setTimeout(r, 0)) + }) + + expect(analytics.trackDocumentDropped).toHaveBeenCalledWith( + 'upload.json', + 'file_picker' + ) + }) + + it('fires with source="url" on loadDocument by default', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + await act(async () => { + await result.current.loadDocument({ data: {} }, '1', 'sample.json') + }) + + expect(analytics.trackDocumentDropped).toHaveBeenCalledWith( + 'sample.json', + 'url' + ) + }) + + it('fires with source="demo" when explicitly passed to loadDocument', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + await act(async () => { + await result.current.loadDocument({ data: {} }, '1', 'demo.json', 'demo') + }) + + expect(analytics.trackDocumentDropped).toHaveBeenCalledWith( + 'demo.json', + 'demo' + ) + }) +}) + +// ─── trackDocumentVerified ──────────────────────────────────────────────────── + +describe('trackDocumentVerified', () => { + it('fires after successful verification with extras', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + const doc = { data: 'valid' } + await act(async () => { + await result.current.loadDocument(doc, '1', 'verified.json') + }) + + await waitFor(() => { + expect(analytics.trackDocumentVerified).toHaveBeenCalled() + }) + + const [, , isValid, , , extras] = vi.mocked(analytics.trackDocumentVerified) + .mock.calls[0] + expect(isValid).toBe(true) + expect(extras).toHaveProperty('isExpired') + expect(extras).toHaveProperty('isTransferable') + expect(extras).toHaveProperty('tokenRegistryVersion') + expect(extras).toHaveProperty('chainId', '1') + }) + + it('fires with invalid result when fragments show error', async () => { + vi.mocked(verifyDocument).mockResolvedValue([ + { + name: 'OpenAttestationHash', + type: 'DOCUMENT_INTEGRITY', + status: 'INVALID', + data: {}, + }, + { + name: 'OpenAttestationDnsTxtIdentityProof', + type: 'ISSUER_IDENTITY', + status: 'VALID', + data: {}, + }, + { + name: 'OpenAttestationEthereumDocumentStoreStatus', + type: 'DOCUMENT_STATUS', + status: 'VALID', + data: {}, + }, + ] as any) + + const { result } = renderHook(() => useVerify(), { wrapper }) + + await act(async () => { + await result.current.loadDocument({}, '1', 'invalid.json') + }) + + await waitFor(() => { + expect(analytics.trackDocumentVerified).toHaveBeenCalled() + }) + + const [, , isValid] = vi.mocked(analytics.trackDocumentVerified).mock + .calls[0] + expect(isValid).toBe(false) + }) +}) + +// ─── trackDocumentVerifyError ───────────────────────────────────────────────── + +describe('trackDocumentVerifyError', () => { + it('fires when file is not valid JSON', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + const badFile = new File(['not valid json {{{'], 'bad.json', { + type: 'application/json', + }) + await act(async () => { + result.current.handleFileInput(makeInputEvent(badFile)) + await new Promise(r => setTimeout(r, 50)) + }) + + await waitFor(() => { + expect(analytics.trackDocumentVerifyError).toHaveBeenCalled() + }) + }) + + it('fires when verifyDocument throws', async () => { + vi.mocked(verifyDocument).mockRejectedValue(new Error('Network failure')) + + const { result } = renderHook(() => useVerify(), { wrapper }) + + await act(async () => { + await result.current.loadDocument({}, '1', 'error.json') + }) + + await waitFor(() => { + expect(analytics.trackDocumentVerifyError).toHaveBeenCalled() + }) + }) +}) + +// ─── trackNetworkSelectionShown ─────────────────────────────────────────────── + +describe('trackNetworkSelectionShown', () => { + it('fires when document requires network selection', async () => { + vi.mocked(getChainId).mockReturnValue(null as any) + vi.mocked(isTransferableRecord).mockReturnValue(true) + + const { result } = renderHook(() => useVerify(), { wrapper }) + + const file = makeFile({ + version: 'https://schema.openattestation.com/2.0/schema.json', + }) + await act(async () => { + result.current.handleFileInput(makeInputEvent(file)) + await new Promise(r => setTimeout(r, 0)) + }) + + await waitFor(() => { + expect(analytics.trackNetworkSelectionShown).toHaveBeenCalled() + }) + + expect(result.current.verifyStatus).toBe('network-select') + }) +}) + +// ─── trackNetworkSelected ───────────────────────────────────────────────────── + +describe('trackNetworkSelected', () => { + it('fires with the chosen chain when handleNetworkConfirm is called', async () => { + vi.mocked(getChainId).mockReturnValue(null as any) + vi.mocked(isTransferableRecord).mockReturnValue(true) + + const { result } = renderHook(() => useVerify(), { wrapper }) + + const file = makeFile({ + version: 'https://schema.openattestation.com/2.0/schema.json', + }) + await act(async () => { + result.current.handleFileInput(makeInputEvent(file)) + await new Promise(r => setTimeout(r, 0)) + }) + + await waitFor(() => + expect(result.current.verifyStatus).toBe('network-select') + ) + + await act(async () => { + await result.current.handleNetworkConfirm('137') + }) + + expect(analytics.trackNetworkSelected).toHaveBeenCalledWith('137') + }) +}) + +// ─── trackNetworkSelectionCancelled ────────────────────────────────────────── + +describe('trackNetworkSelectionCancelled', () => { + it('fires when handleNetworkCancel is called', async () => { + vi.mocked(getChainId).mockReturnValue(null as any) + vi.mocked(isTransferableRecord).mockReturnValue(true) + + const { result } = renderHook(() => useVerify(), { wrapper }) + + const file = makeFile({ + version: 'https://schema.openattestation.com/2.0/schema.json', + }) + await act(async () => { + result.current.handleFileInput(makeInputEvent(file)) + await new Promise(r => setTimeout(r, 0)) + }) + + await waitFor(() => + expect(result.current.verifyStatus).toBe('network-select') + ) + + act(() => { + result.current.handleNetworkCancel() + }) + + expect(analytics.trackNetworkSelectionCancelled).toHaveBeenCalled() + expect(result.current.verifyStatus).toBe('idle') + }) +}) + +// ─── trackVerificationReset ─────────────────────────────────────────────────── + +describe('trackVerificationReset', () => { + it('fires when handleReset is called', async () => { + const { result } = renderHook(() => useVerify(), { wrapper }) + + await act(async () => { + await result.current.loadDocument({}, '1', 'doc.json') + }) + + act(() => { + result.current.handleReset() + }) + + expect(analytics.trackVerificationReset).toHaveBeenCalled() + expect(result.current.verifyStatus).toBe('idle') + }) +}) diff --git a/src/components/home/VerifySection/useVerify.test.ts b/src/components/home/VerifySection/useVerify.test.ts index 3c846de..b85014d 100644 --- a/src/components/home/VerifySection/useVerify.test.ts +++ b/src/components/home/VerifySection/useVerify.test.ts @@ -12,6 +12,24 @@ import { DocumentProvider } from '../../common/contexts/DocumentContext' // ─── Mocks ──────────────────────────────────────────────────────────────────── +vi.mock('../../../lib/sentry', () => ({ + captureVerificationBreadcrumb: vi.fn(), + captureVerificationException: vi.fn(), + captureVerificationInvalid: vi.fn(), + isSentryEnabled: vi.fn(() => false), + initSentry: vi.fn(), +})) + +vi.mock('../../../utils/analytics', () => ({ + trackDocumentDropped: vi.fn(), + trackDocumentVerified: vi.fn(), + trackDocumentVerifyError: vi.fn(), + trackNetworkSelectionShown: vi.fn(), + trackNetworkSelected: vi.fn(), + trackNetworkSelectionCancelled: vi.fn(), + trackVerificationReset: vi.fn(), +})) + // Mock import.meta.env Object.defineProperty(import.meta, 'env', { value: { diff --git a/src/components/home/VerifySection/useVerify.ts b/src/components/home/VerifySection/useVerify.ts index 39698a8..c6864c9 100644 --- a/src/components/home/VerifySection/useVerify.ts +++ b/src/components/home/VerifySection/useVerify.ts @@ -22,6 +22,21 @@ import { import { getRpcUrl, getIsExpired } from '../../../utils/helper' import { useDocumentContext } from '../../common/contexts/DocumentContext' import { type VerifyErrorType, getErrorTypeFromError } from './verifyErrorUtils' +import { + captureVerificationBreadcrumb, + captureVerificationException, + captureVerificationInvalid, +} from '../../../lib/sentry' +import { + trackDocumentDropped, + trackDocumentVerified, + trackDocumentVerifyError, + trackNetworkSelectionShown, + trackNetworkSelected, + trackNetworkSelectionCancelled, + trackVerificationReset, + type DocumentDroppedSource, +} from '../../../utils/analytics' export type VerifyStatus = | 'idle' @@ -78,7 +93,8 @@ export interface UseVerifyReturn { loadDocument: ( _doc: unknown, _chainId: string | null | undefined, - _name: string + _name: string, + _source?: DocumentDroppedSource ) => Promise } @@ -496,7 +512,8 @@ export const useVerify = (): UseVerifyReturn => { const runVerification = async ( doc: unknown, chainId: string | null | undefined, - currentId: number + currentId: number, + verificationFileName?: string ) => { const isStale = () => currentId !== verificationIdRef.current @@ -518,9 +535,19 @@ export const useVerify = (): UseVerifyReturn => { const hasAtLeastOneValid = groupStatuses.some(s => s === 'VALID') const hasNoInvalid = groupStatuses.every(s => s !== 'INVALID') const isValid = hasAtLeastOneValid && hasNoInvalid + const errorType = !isValid ? getErrorTypeFromFragments(results) : undefined if (!isValid) { - setErrorType(getErrorTypeFromFragments(results)) - setErrorMessage(getErrorMessageFromFragments(results)) + const errorMessage = getErrorMessageFromFragments(results) + setErrorType(errorType!) + setErrorMessage(errorMessage) + captureVerificationInvalid({ + doc, + fileName: (verificationFileName ?? fileName) || undefined, + chainId, + errorType: errorType!, + errorMessage, + fragments: results, + }) } // Compute issuer name @@ -538,7 +565,8 @@ export const useVerify = (): UseVerifyReturn => { setTokenRegistryAddress(registryAddress) setTokenRegistryAddressContext(registryAddress || null) - setIsExpired(getIsExpired(doc)) + const isExpired = getIsExpired(doc) + setIsExpired(isExpired) //add code to fetch TokenId , keyId from the document const _keyId = getDocumentData(doc as any)?.id @@ -579,6 +607,22 @@ export const useVerify = (): UseVerifyReturn => { setRawDocument(doc) setVerifiedChainId(chainId ?? '') setVerifyStatus(isValid ? 'valid' : 'invalid') + + captureVerificationBreadcrumb( + isValid + ? 'Verification completed (valid)' + : 'Verification completed (invalid)', + { + chainId: chainId ?? undefined, + fileName: (verificationFileName ?? fileName) || undefined, + } + ) + trackDocumentVerified(doc, results, isValid, issuer, errorType, { + isExpired, + isTransferable: transferable, + tokenRegistryVersion: trVersion, + chainId: chainId ?? null, + }) } const clearVerificationMetadata = () => { @@ -598,7 +642,10 @@ export const useVerify = (): UseVerifyReturn => { setKeyIdContext(null) } - const processFile = async (file: File) => { + const processFile = async ( + file: File, + source: DocumentDroppedSource = 'file_picker' + ) => { const currentId = ++verificationIdRef.current setFileName(file.name) setVerifyStatus('verifying') @@ -606,26 +653,41 @@ export const useVerify = (): UseVerifyReturn => { setPendingDoc(null) clearVerificationMetadata() + trackDocumentDropped(file.name, source) + + let parsedDoc: any try { const text = await file.text() - const doc = JSON.parse(text) + parsedDoc = JSON.parse(text) // Prefer the document's own chain; fall back to its embedded network field // (getChainId ignores that for DNS-DID/DID docs, which can still use a // REVOCATION_STORE on that chain) before asking the user to pick one. - const chainId = getChainId(doc) ?? getEmbeddedChainId(doc) + const chainId = getChainId(parsedDoc) ?? getEmbeddedChainId(parsedDoc) - if (!chainId && requiresNetworkSelection(doc)) { + if (!chainId && requiresNetworkSelection(parsedDoc)) { // Needs blockchain verification but has no chain anywhere — ask the user - setPendingDoc(doc) + setPendingDoc(parsedDoc) setVerifyStatus('network-select') + trackNetworkSelectionShown(parsedDoc) return } - await runVerification(doc, chainId, currentId) + captureVerificationBreadcrumb('Verification started', { + fileName: file.name, + source: 'file', + }) + await runVerification(parsedDoc, chainId, currentId, file.name) } catch (err) { + if (currentId !== verificationIdRef.current) return + const errType = getErrorTypeFromError(err) clearVerificationMetadata() - setErrorType(getErrorTypeFromError(err)) + setErrorType(errType) setVerifyStatus('error') + captureVerificationException(err, { + stage: 'processFile', + fileName: file.name, + }) + trackDocumentVerifyError(parsedDoc, errType) } } @@ -633,18 +695,33 @@ export const useVerify = (): UseVerifyReturn => { if (!pendingDoc) return const currentId = ++verificationIdRef.current setVerifyStatus('verifying') + const docRef = pendingDoc + trackNetworkSelected(chainId) try { - await runVerification(pendingDoc, chainId, currentId) + captureVerificationBreadcrumb('Verification started', { + fileName, + source: 'file', + }) + await runVerification(pendingDoc, chainId, currentId, fileName) } catch (err) { + if (currentId !== verificationIdRef.current) return + const errType = getErrorTypeFromError(err) clearVerificationMetadata() - setErrorType(getErrorTypeFromError(err)) + setErrorType(errType) setVerifyStatus('error') + captureVerificationException(err, { + stage: 'handleNetworkConfirm', + fileName, + chainId, + }) + trackDocumentVerifyError(docRef, errType) } finally { setPendingDoc(null) } } const handleNetworkCancel = () => { + trackNetworkSelectionCancelled() setVerifyStatus('idle') setFileName('') setPendingDoc(null) @@ -666,13 +743,13 @@ export const useVerify = (): UseVerifyReturn => { e.stopPropagation() setDragActive(false) if (e.dataTransfer.files && e.dataTransfer.files[0]) { - processFile(e.dataTransfer.files[0]) + processFile(e.dataTransfer.files[0], 'drop') } } const handleFileInput = (e: React.ChangeEvent) => { if (e.target.files && e.target.files[0]) { - processFile(e.target.files[0]) + processFile(e.target.files[0], 'file_picker') e.target.value = '' } } @@ -680,7 +757,8 @@ export const useVerify = (): UseVerifyReturn => { const loadDocument = async ( doc: unknown, chainId: string | null | undefined, - name: string + name: string, + source: DocumentDroppedSource = 'url' ) => { const currentId = ++verificationIdRef.current setFileName(name) @@ -689,16 +767,31 @@ export const useVerify = (): UseVerifyReturn => { setPendingDoc(null) clearVerificationMetadata() + captureVerificationBreadcrumb('Verification started', { + fileName: name, + source: 'url', + }) + trackDocumentDropped(name, source) + try { - await runVerification(doc, chainId, currentId) + await runVerification(doc, chainId, currentId, name) } catch (err) { + if (currentId !== verificationIdRef.current) return + const errType = getErrorTypeFromError(err) clearVerificationMetadata() - setErrorType(getErrorTypeFromError(err)) + setErrorType(errType) setVerifyStatus('error') + captureVerificationException(err, { + stage: 'loadDocument', + fileName: name, + chainId, + }) + trackDocumentVerifyError(doc, errType) } } const handleReset = () => { + trackVerificationReset() setVerifyStatus('idle') setFragments([]) setFileName('') diff --git a/src/constants/analyticsEvents.ts b/src/constants/analyticsEvents.ts new file mode 100644 index 0000000..20dd954 --- /dev/null +++ b/src/constants/analyticsEvents.ts @@ -0,0 +1,30 @@ +export const ANALYTICS_EVENTS = { + /** Fired when a user drops or selects a document file for verification. */ + DOCUMENT_DROPPED: 'DOCUMENT_DROPPED', + /** Fired when document verification completes — valid or invalid. */ + DOCUMENT_VERIFICATION_COMPLETED: 'DOCUMENT_VERIFICATION_COMPLETED', + /** Fired when the network selection dialog appears (document has no embedded chain). */ + NETWORK_SELECTION_SHOWN: 'NETWORK_SELECTION_SHOWN', + /** Fired when the user confirms a network in the selection dialog. */ + NETWORK_SELECTED: 'NETWORK_SELECTED', + /** Fired when the user dismisses the network selection dialog without selecting. */ + NETWORK_SELECTION_CANCELLED: 'NETWORK_SELECTION_CANCELLED', + /** Fired when the user resets the verifier back to idle state. */ + VERIFICATION_RESET: 'VERIFICATION_RESET', + /** Fired when a wallet is successfully connected. */ + WALLET_CONNECTED: 'WALLET_CONNECTED', + /** Fired when a connected wallet is explicitly disconnected. */ + WALLET_DISCONNECTED: 'WALLET_DISCONNECTED', + /** Fired when a wallet connection attempt fails. */ + WALLET_CONNECT_FAILED: 'WALLET_CONNECT_FAILED', + /** Fired when the user initiates a transferable record management action. */ + ASSET_ACTION_INITIATED: 'ASSET_ACTION_INITIATED', + /** Fired when a transferable record management action is confirmed on-chain. */ + ASSET_ACTION_COMPLETED: 'ASSET_ACTION_COMPLETED', + /** Fired when a transferable record management action fails. */ + ASSET_ACTION_FAILED: 'ASSET_ACTION_FAILED', + /** Fired when a support form is successfully submitted. */ + SUPPORT_FORM_SUBMITTED: 'SUPPORT_FORM_SUBMITTED', + /** Fired when a support form submission fails. */ + SUPPORT_FORM_FAILED: 'SUPPORT_FORM_FAILED', +} as const diff --git a/src/hooks/useContactForm.ts b/src/hooks/useContactForm.ts index b04c514..15b6f88 100644 --- a/src/hooks/useContactForm.ts +++ b/src/hooks/useContactForm.ts @@ -1,4 +1,8 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + trackSupportFormSubmitted, + trackSupportFormFailed, +} from '../utils/analytics' import { getPresignedUrls, uploadToPresignedUrl, @@ -457,6 +461,7 @@ export const useContactForm = (options: UseContactFormOptions) => { attachmentKeys, recaptchaToken, }) + trackSupportFormSubmitted(typeOfEnquiry || 'Unknown') setSubmitSuccess( "Request submitted successfully. We'll get back to you soon." ) @@ -470,6 +475,7 @@ export const useContactForm = (options: UseContactFormOptions) => { ?.message const msg = rawMessage && rawMessage !== 'Failed to fetch' ? rawMessage : fallback + trackSupportFormFailed(typeOfEnquiry || 'Unknown', msg) setSubmitError(msg) } finally { setIsSubmitting(false) diff --git a/src/lib/sanity/news.ts b/src/lib/sanity/news.ts index 2beaedd..3d0a738 100644 --- a/src/lib/sanity/news.ts +++ b/src/lib/sanity/news.ts @@ -1,4 +1,5 @@ import type { NewsArticle } from '../../types/news' +import { captureSanityError } from '../sentry' import { isSanityConfigured, sanityClient } from './client' const NEWS_LIST_QUERY = `*[_type == "post"] | order(publishedAt desc){ @@ -75,6 +76,7 @@ export const fetchNewsArticles = async () => { return sortFeaturedFirst(normalized) } catch (err) { console.error('[Sanity] fetchNewsArticles failed', err) + captureSanityError(err, { operation: 'fetchNewsArticles' }) return [] } } @@ -88,6 +90,7 @@ export const fetchLatestFeaturedNewsArticle = async () => { return normalized.find(isFeaturedPost) ?? null } catch (err) { console.error('[Sanity] fetchLatestFeaturedNewsArticle failed', err) + captureSanityError(err, { operation: 'fetchLatestFeaturedNewsArticle' }) return null } } @@ -100,6 +103,7 @@ export const fetchNewsArticleCount = async () => { return typeof count === 'number' ? count : 0 } catch (err) { console.error('[Sanity] fetchNewsArticleCount failed', err) + captureSanityError(err, { operation: 'fetchNewsArticleCount' }) return 0 } } @@ -121,6 +125,7 @@ export const fetchNewsArticlesPage = async (offset: number, limit: number) => { return (data ?? []).map(normalizeFeatured) } catch (err) { console.error('[Sanity] fetchNewsArticlesPage failed', err) + captureSanityError(err, { operation: 'fetchNewsArticlesPage' }) return [] } } @@ -138,6 +143,7 @@ export const fetchNewsArticleBySlug = async (slug: string) => { return data ? normalizeFeatured(data) : null } catch (err) { console.error('[Sanity] fetchNewsArticleBySlug failed', err) + captureSanityError(err, { operation: 'fetchNewsArticleBySlug' }) return null } } diff --git a/src/lib/sentry/SentryErrorBoundary.tsx b/src/lib/sentry/SentryErrorBoundary.tsx new file mode 100644 index 0000000..ad7ebc9 --- /dev/null +++ b/src/lib/sentry/SentryErrorBoundary.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from 'react' +import { Sentry } from './init' + +type SentryErrorBoundaryProps = { + children: ReactNode +} + +export const SentryErrorBoundary = ({ children }: SentryErrorBoundaryProps) => ( + +

Something went wrong

+

+ An unexpected error occurred. Please refresh the page or try again + later. +

+
+ } + beforeCapture={scope => { + scope.setTag('error.source', 'app') + scope.setTag('error.boundary', 'root') + }} + > + {children} + +) diff --git a/src/lib/sentry/capture.test.ts b/src/lib/sentry/capture.test.ts new file mode 100644 index 0000000..0ac5a94 --- /dev/null +++ b/src/lib/sentry/capture.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +const mockScope = vi.hoisted(() => ({ + setTag: vi.fn(), + setContext: vi.fn(), +})) + +vi.mock('@sentry/react', () => ({ + init: vi.fn(), + captureException: vi.fn(), + captureMessage: vi.fn(), + addBreadcrumb: vi.fn(), + withScope: vi.fn((fn: (scope: typeof mockScope) => void) => { + fn(mockScope) + }), + browserTracingIntegration: vi.fn(() => ({})), + ErrorBoundary: ({ children }: { children: unknown }) => children, +})) + +vi.mock('./init', () => ({ + isSentryEnabled: vi.fn(() => true), +})) + +import * as Sentry from '@sentry/react' +import { + captureSanityError, + captureVerificationException, + captureVerificationInvalid, + triggerSentryTestError, +} from './capture' + +describe('captureSanityError', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('captures exception with sanity-cms source tag', () => { + const error = new Error('Sanity timeout') + captureSanityError(error, { operation: 'fetchNewsArticles' }) + + expect(Sentry.captureException).toHaveBeenCalledWith(error) + expect(Sentry.withScope).toHaveBeenCalled() + }) +}) + +describe('captureVerificationException', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends file extension metadata instead of raw filename', () => { + captureVerificationException(new Error('parse failed'), { + stage: 'processFile', + fileName: 'private-doc.json', + }) + + expect(mockScope.setContext).toHaveBeenCalledWith('details', { + fileExtension: '.json', + }) + }) +}) + +describe('captureVerificationInvalid', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('captures warning message without document payload', () => { + captureVerificationInvalid({ + doc: { proofValue: 'should-not-be-sent' }, + fileName: 'test.json', + chainId: '1', + errorType: 'VERIFICATION_ERROR', + errorMessage: 'Hash mismatch', + fragments: [ + { + name: 'OpenAttestationHash', + status: 'INVALID', + type: 'DOCUMENT_INTEGRITY', + }, + ], + }) + + expect(Sentry.captureMessage).toHaveBeenCalledWith( + 'Document verification failed', + 'warning' + ) + expect(Sentry.captureException).not.toHaveBeenCalled() + }) + + it('attaches context via scope, not global Sentry.setContext', () => { + captureVerificationInvalid({ + doc: {}, + fileName: 'test.json', + chainId: '137', + errorType: 'VERIFICATION_ERROR', + fragments: [], + }) + + expect(mockScope.setContext).toHaveBeenCalledWith( + 'verification', + expect.objectContaining({ fileExtension: '.json', fragmentSummary: [] }) + ) + }) +}) + +describe('triggerSentryTestError', () => { + it('blocks test errors in production environment', () => { + vi.stubEnv('VITE_SENTRY_ENVIRONMENT', 'production') + vi.stubEnv('VITE_SENTRY_DSN', 'https://example@o0.ingest.sentry.io/0') + try { + expect(() => triggerSentryTestError()).toThrow( + 'Sentry test errors are disabled in production' + ) + } finally { + vi.unstubAllEnvs() + } + }) +}) diff --git a/src/lib/sentry/capture.ts b/src/lib/sentry/capture.ts new file mode 100644 index 0000000..64c5a21 --- /dev/null +++ b/src/lib/sentry/capture.ts @@ -0,0 +1,195 @@ +import * as Sentry from '@sentry/react' +import type { VerifyErrorType } from '../../components/home/VerifySection/verifyErrorUtils' +import type { VerificationFragment } from '../../components/home/VerifySection/useVerify' +import { getDocumentSchemaLabel } from './documentSchema' +import { isSentryEnabled } from './init' + +export type ErrorSource = + | 'verification' + | 'support-api' + | 'sanity-cms' + | 'wallet' + | 'app' + +export type VerificationStage = + | 'processFile' + | 'handleNetworkConfirm' + | 'loadDocument' + | 'runVerification' + +const safeFileMetadata = ( + fileName?: string +): Record | undefined => { + if (!fileName) return undefined + + const dotIndex = fileName.lastIndexOf('.') + if (dotIndex > 0 && dotIndex < fileName.length - 1) { + return { fileExtension: fileName.slice(dotIndex) } + } + + return { hasFileName: 'true' } +} + +interface SentryScope { + setTag(key: string, value: string): void + setContext(key: string, context: Record | null): void +} + +const withScope = ( + source: ErrorSource, + tags: Record, + fn: (scope: SentryScope) => void +): void => { + if (!isSentryEnabled()) return + + Sentry.withScope(scope => { + scope.setTag('error.source', source) + for (const [key, value] of Object.entries(tags)) { + if (value != null && value !== '') { + scope.setTag(key, value) + } + } + fn(scope) + }) +} + +export const addBreadcrumb = ( + message: string, + category: string, + data?: Record +): void => { + if (!isSentryEnabled()) return + + Sentry.addBreadcrumb({ + category, + message, + level: 'info', + data: data as Record | undefined, + }) +} + +export const captureAppException = ( + error: unknown, + context?: { + source?: ErrorSource + tags?: Record + extra?: Record + } +): void => { + withScope(context?.source ?? 'app', context?.tags ?? {}, scope => { + if (context?.extra) { + scope.setContext('details', context.extra) + } + Sentry.captureException(error) + }) +} + +export const captureVerificationBreadcrumb = ( + message: string, + data?: Record +): void => { + addBreadcrumb(message, 'verification', data) +} + +export const captureVerificationException = ( + error: unknown, + context: { + stage: VerificationStage + fileName?: string + chainId?: string | null + } +): void => { + captureAppException(error, { + source: 'verification', + tags: { + 'verification.stage': context.stage, + 'verification.chain_id': context.chainId ?? undefined, + }, + extra: safeFileMetadata(context.fileName), + }) +} + +export const captureVerificationInvalid = (context: { + doc: unknown + fileName?: string + chainId?: string | null + errorType?: VerifyErrorType + errorMessage?: string + fragments?: VerificationFragment[] +}): void => { + if (!isSentryEnabled()) return + + const schema = getDocumentSchemaLabel(context.doc) + + withScope( + 'verification', + { + 'verification.result': 'invalid', + 'verification.schema': schema, + 'verification.error_type': context.errorType, + 'verification.chain_id': context.chainId ?? undefined, + }, + scope => { + const fragmentSummary = (context.fragments ?? []).map(fragment => ({ + name: fragment.name, + status: fragment.status, + type: fragment.type, + })) + scope.setContext('verification', { + ...safeFileMetadata(context.fileName), + errorMessage: context.errorMessage, + fragmentSummary, + }) + Sentry.captureMessage('Document verification failed', 'warning') + } + ) +} + +export const captureSanityError = ( + error: unknown, + context: { operation: string } +): void => { + captureAppException(error, { + source: 'sanity-cms', + tags: { + 'sanity.operation': context.operation, + }, + }) +} + +export const captureFetchError = ( + error: unknown, + context: { + service: 'support-api' | 'app' + path: string + method?: string + status?: number + } +): void => { + captureAppException(error, { + source: context.service, + tags: { + 'http.path': context.path, + 'http.method': context.method ?? 'GET', + 'http.status': context.status?.toString(), + }, + }) +} + +/** Dev-only helper for end-to-end Sentry verification. */ +export const triggerSentryTestError = (): void => { + if (!isSentryEnabled()) { + throw new Error('Sentry is not enabled (missing VITE_SENTRY_DSN)') + } + + const environment = + (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ?? + (import.meta.env.VITE_PLATFORM as string | undefined) ?? + 'local' + + if (environment === 'production') { + throw new Error('Sentry test errors are disabled in production') + } + + throw new Error('TrustVC Sentry test error — safe to ignore') +} diff --git a/src/lib/sentry/documentSchema.ts b/src/lib/sentry/documentSchema.ts new file mode 100644 index 0000000..bf3b31f --- /dev/null +++ b/src/lib/sentry/documentSchema.ts @@ -0,0 +1,40 @@ +import { + isWrappedV2Document, + isWrappedV3Document, + isRawV2Document, + isRawV3Document, + isSignedWrappedV2Document, + isSignedWrappedV3Document, + vc, +} from '@trustvc/trustvc' + +export type DocumentSchemaLabel = + | 'OA v2' + | 'OA v3' + | 'W3C VC V1.1' + | 'W3C VC V2.0' + | 'Unknown' + +export const getDocumentSchemaLabel = (doc: unknown): DocumentSchemaLabel => { + const d = doc as Record + if ( + isWrappedV2Document(d) || + isRawV2Document(d) || + isSignedWrappedV2Document(d) + ) { + return 'OA v2' + } + if ( + isWrappedV3Document(d) || + isRawV3Document(d) || + isSignedWrappedV3Document(d) + ) { + return 'OA v3' + } + if (vc.isSignedDocument(d) || vc.isRawDocument(d)) { + return vc.isSignedDocumentV2_0(d) || vc.isRawDocumentV2_0(d) + ? 'W3C VC V2.0' + : 'W3C VC V1.1' + } + return 'Unknown' +} diff --git a/src/lib/sentry/index.ts b/src/lib/sentry/index.ts new file mode 100644 index 0000000..786808c --- /dev/null +++ b/src/lib/sentry/index.ts @@ -0,0 +1,13 @@ +export { initSentry, isSentryEnabled, Sentry } from './init' +export { + addBreadcrumb, + captureAppException, + captureFetchError, + captureSanityError, + captureVerificationBreadcrumb, + captureVerificationException, + captureVerificationInvalid, + triggerSentryTestError, +} from './capture' +export { scrubEvent, scrubObject, scrubValue } from './scrub' +export { SentryErrorBoundary } from './SentryErrorBoundary' diff --git a/src/lib/sentry/init.ts b/src/lib/sentry/init.ts new file mode 100644 index 0000000..696c78e --- /dev/null +++ b/src/lib/sentry/init.ts @@ -0,0 +1,53 @@ +import * as Sentry from '@sentry/react' +import type { Event, EventHint } from '@sentry/react' +import { scrubBreadcrumb, scrubEvent } from './scrub' + +const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined + +export const isSentryEnabled = (): boolean => Boolean(dsn) + +let initialized = false + +export const initSentry = (): void => { + if (initialized || !dsn) return + + const environment = + (import.meta.env.VITE_SENTRY_ENVIRONMENT as string | undefined) ?? + (import.meta.env.VITE_PLATFORM as string | undefined) ?? + 'local' + + const release = import.meta.env.VITE_SENTRY_RELEASE as string | undefined + + Sentry.init({ + dsn, + environment, + release, + enabled: true, + // Session replay disabled — the verify UI may display credential content. + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + beforeSend: (event, hint) => + scrubEvent(event as Event, hint as EventHint) as typeof event | null, + beforeBreadcrumb: scrubBreadcrumb, + ignoreErrors: [ + 'ResizeObserver loop limit exceeded', + 'ResizeObserver loop completed with undelivered notifications', + ], + }) + + initialized = true + + if (typeof window !== 'undefined' && environment !== 'production') { + ;(window as TrustVCSentryTestWindow).__trustvcSentryTest = () => { + import('./capture').then(({ triggerSentryTestError }) => { + triggerSentryTestError() + }) + } + } +} + +interface TrustVCSentryTestWindow extends Window { + __trustvcSentryTest?: () => void +} + +export { Sentry } diff --git a/src/lib/sentry/scrub.test.ts b/src/lib/sentry/scrub.test.ts new file mode 100644 index 0000000..9f7f86f --- /dev/null +++ b/src/lib/sentry/scrub.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest' +import { scrubBreadcrumb, scrubEvent, scrubObject, scrubValue } from './scrub' + +describe('scrubValue', () => { + it('redacts sensitive keys in objects', () => { + const result = scrubObject({ + fileName: 'doc.json', + proofValue: 'long-sensitive-proof', + credential: { id: 'abc' }, + }) + + expect(result.fileName).toBe('doc.json') + expect(result.proofValue).toBe('[Redacted]') + expect(result.credential).toBe('[Redacted]') + }) + + it('redacts long credential-like strings', () => { + const payload = JSON.stringify({ + proofValue: 'x'.repeat(600), + merkleRoot: 'abc', + }) + + const result = scrubValue(payload) + expect(result).toMatch(/^\[Redacted string len=\d+\]$/) + }) + + it('preserves short non-sensitive strings', () => { + expect(scrubValue('DNS-TXT')).toBe('DNS-TXT') + }) + + it('strips hash, q, and sensitive query params from URLs', () => { + const url = + 'https://trustvc.io/verify?token=abc&apiKey=xyz&safe=keep#{"key":"secret"}' + const result = scrubValue(url) + expect(result).toBe('https://trustvc.io/verify?safe=keep') + }) +}) + +describe('scrubObject', () => { + it('redacts nested sensitive keys', () => { + const result = scrubObject({ + verification: { + issuer_identity: 'example.com', + proof: { value: 'secret' }, + }, + }) + + expect(result.verification).toEqual({ + issuer_identity: 'example.com', + proof: '[Redacted]', + }) + }) + + it('redacts auth-related keys', () => { + const result = scrubObject({ + Authorization: 'Bearer secret', + Cookie: 'session=abc', + apiKey: 'key-123', + }) + + expect(result.Authorization).toBe('[Redacted]') + expect(result.Cookie).toBe('[Redacted]') + expect(result.apiKey).toBe('[Redacted]') + }) + + it('redacts camelCase sensitive keys regardless of casing', () => { + const result = scrubObject({ + targetHash: 'abc123', + rawDocument: { data: 'x' }, + fileContent: 'raw bytes', + fileText: 'plain text', + }) + + expect(result.targetHash).toBe('[Redacted]') + expect(result.rawDocument).toBe('[Redacted]') + expect(result.fileContent).toBe('[Redacted]') + expect(result.fileText).toBe('[Redacted]') + }) +}) + +describe('scrubEvent', () => { + it('scrubs string message fields and request bodies', () => { + const payload = JSON.stringify({ + proofValue: 'x'.repeat(600), + }) + + const event = scrubEvent({ + message: payload, + logentry: { message: payload, formatted: payload } as { + message: string + formatted: string + }, + exception: { + values: [{ type: 'Error', value: payload }], + }, + request: { data: payload }, + }) + + expect(event?.message).toMatch(/^\[Redacted string len=\d+\]$/) + expect(event?.logentry?.message).toMatch(/^\[Redacted string len=\d+\]$/) + expect( + (event?.logentry as { formatted?: string } | undefined)?.formatted + ).toMatch(/^\[Redacted string len=\d+\]$/) + expect(event?.exception?.values?.[0]?.value).toMatch( + /^\[Redacted string len=\d+\]$/ + ) + expect(event?.request?.data).toMatch(/^\[Redacted string len=\d+\]$/) + }) + + it('strips hash and q param from request.url', () => { + const shareUrl = + 'https://trustvc.io/?q=%7B%22type%22%3A%22DOCUMENT%22%7D#{"key":"secret"}' + const event = scrubEvent({ request: { url: shareUrl } }) + + expect(event?.request?.url).not.toContain('#') + expect(event?.request?.url).not.toContain('?q=') + expect(event?.request?.url).toBe('https://trustvc.io/') + }) + + it('leaves request.url unchanged when no sensitive params present', () => { + const event = scrubEvent({ request: { url: 'https://trustvc.io/' } }) + expect(event?.request?.url).toBe('https://trustvc.io/') + }) +}) + +describe('scrubBreadcrumb', () => { + it('scrubs breadcrumb message and data', () => { + const payload = JSON.stringify({ proofValue: 'x'.repeat(600) }) + const breadcrumb = scrubBreadcrumb({ + message: payload, + data: { authorization: 'Bearer secret' }, + }) + + expect(breadcrumb?.message).toMatch(/^\[Redacted string len=\d+\]$/) + expect(breadcrumb?.data).toEqual({ authorization: '[Redacted]' }) + }) + + it('strips hash and q param from navigation breadcrumb from/to URLs', () => { + const sensitiveUrl = + 'https://trustvc.io/?q=%7B%22type%22%3A%22DOCUMENT%22%7D#{"key":"secret"}' + const breadcrumb = scrubBreadcrumb({ + type: 'navigation', + category: 'navigation', + data: { from: sensitiveUrl, to: 'https://trustvc.io/' }, + }) + + const from = breadcrumb?.data?.from as string + expect(from).not.toContain('#') + expect(from).not.toContain('?q=') + expect(from).toBe('https://trustvc.io/') + expect(breadcrumb?.data?.to).toBe('https://trustvc.io/') + }) +}) diff --git a/src/lib/sentry/scrub.ts b/src/lib/sentry/scrub.ts new file mode 100644 index 0000000..80e2ba6 --- /dev/null +++ b/src/lib/sentry/scrub.ts @@ -0,0 +1,206 @@ +import type { Breadcrumb, Event, EventHint } from '@sentry/react' + +const REDACTED = '[Redacted]' + +/** Keys that must never leave the browser in Sentry payloads. + * Only list keys NOT already caught by SENSITIVE_KEY_PATTERN below. */ +const SENSITIVE_KEYS = new Set([ + 'targethash', + 'document', + 'rawdocument', + 'doc', + 'email', + 'phone', + 'filecontent', + 'filetext', + 'body', +]) + +const SENSITIVE_KEY_PATTERN = + /proof|credential|merkle|signature|payload|attachment|private|secret|password|token|auth|cookie|apikey|api[._-]key|bearer/i + +const CREDENTIAL_JSON_PATTERN = + /"(?:proofValue|merkleRoot|targetHash|verifiableCredential|credentialSubject)"/i + +const MAX_STRING_LENGTH = 500 +const MAX_DEPTH = 8 + +const isSensitiveKey = (key: string): boolean => + SENSITIVE_KEYS.has(key.toLowerCase()) || SENSITIVE_KEY_PATTERN.test(key) + +const looksLikeCredentialPayload = (value: string): boolean => + value.length > MAX_STRING_LENGTH || CREDENTIAL_JSON_PATTERN.test(value) + +// Strip the hash fragment (decryption key), the `q` query param (document URI), +// and any param whose name is sensitive before the URL leaves the browser in Sentry payloads. +const scrubUrl = (value: string): string => { + try { + const parsed = new URL(value) + parsed.hash = '' + for (const key of [...parsed.searchParams.keys()]) { + if (key === 'q' || isSensitiveKey(key)) { + parsed.searchParams.delete(key) + } + } + return parsed.toString() + } catch { + return value + } +} + +const scrubString = (value: string): string => { + if (value.startsWith('https://') || value.startsWith('http://')) { + return scrubUrl(value) + } + if (looksLikeCredentialPayload(value)) { + return `[Redacted string len=${value.length}]` + } + return value +} + +export const scrubValue = (value: unknown, depth = 0): unknown => { + if (depth > MAX_DEPTH) return '[Truncated]' + + if ( + value == null || + typeof value === 'boolean' || + typeof value === 'number' + ) { + return value + } + + if (typeof value === 'string') { + return scrubString(value) + } + + if (Array.isArray(value)) { + return value.map(item => scrubValue(item, depth + 1)) + } + + if (typeof value === 'object') { + return scrubObject(value as Record, depth + 1) + } + + return value +} + +export const scrubObject = ( + obj: Record, + depth = 0 +): Record => { + const result: Record = {} + + for (const [key, val] of Object.entries(obj)) { + if (isSensitiveKey(key)) { + result[key] = REDACTED + continue + } + result[key] = scrubValue(val, depth + 1) + } + + return result +} + +export const scrubBreadcrumb = (breadcrumb: Breadcrumb): Breadcrumb | null => { + const scrubbed: Breadcrumb = { ...breadcrumb } + + if (typeof scrubbed.message === 'string') { + scrubbed.message = scrubString(scrubbed.message) + } + + if (scrubbed.data) { + scrubbed.data = scrubObject(scrubbed.data as Record) + } + + return scrubbed +} + +export const scrubEvent = (event: Event, _hint?: EventHint): Event | null => { + if (typeof event.message === 'string') { + event.message = scrubString(event.message) + } + + if (event.logentry) { + const logentry = { + ...event.logentry, + ...(typeof event.logentry.message === 'string' + ? { message: scrubString(event.logentry.message) } + : {}), + } as Event['logentry'] & { formatted?: string } + + if (typeof logentry.formatted === 'string') { + logentry.formatted = scrubString(logentry.formatted) + } + + event.logentry = logentry + } + + if (event.exception?.values) { + event.exception = { + ...event.exception, + values: event.exception.values.map(value => ({ + ...value, + value: + typeof value.value === 'string' + ? scrubString(value.value) + : value.value, + })), + } + } + + if (event.extra) { + event.extra = scrubObject(event.extra as Record) + } + + if (event.contexts) { + const contexts: Record = {} + for (const [key, ctx] of Object.entries(event.contexts)) { + contexts[key] = + ctx && typeof ctx === 'object' + ? scrubObject(ctx as Record) + : ctx + } + event.contexts = contexts as Event['contexts'] + } + + if (event.tags) { + event.tags = Object.fromEntries( + Object.entries(event.tags).map(([key, value]) => [ + key, + isSensitiveKey(key) + ? REDACTED + : typeof value === 'string' + ? scrubString(value) + : value, + ]) + ) as Event['tags'] + } + + if (event.breadcrumbs) { + event.breadcrumbs = event.breadcrumbs + .map(crumb => scrubBreadcrumb(crumb)) + .filter((crumb): crumb is Breadcrumb => crumb != null) + } + + if (event.request?.url && typeof event.request.url === 'string') { + event.request.url = scrubUrl(event.request.url) + } + + if (event.request?.headers && typeof event.request.headers === 'object') { + event.request.headers = scrubObject( + event.request.headers as Record + ) as Record + } + + if (event.request?.data) { + if (typeof event.request.data === 'string') { + event.request.data = scrubString(event.request.data) + } else if (typeof event.request.data === 'object') { + event.request.data = scrubObject( + event.request.data as Record + ) + } + } + + return event +} diff --git a/src/main.tsx b/src/main.tsx index a9bfe4f..d807318 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' import './index.css' -import App from './App' import { BrowserRouter } from 'react-router-dom' import { OverlayProvider } from './components/common/contexts/OverlayContext' import { ProviderContextProvider } from './components/common/contexts/providerContext' @@ -12,6 +11,10 @@ import { getSupportedChainInfo, } from './utils/chain-utils' import { DocumentProvider } from './components/common/contexts/DocumentContext' +import { initSentry, SentryErrorBoundary } from './lib/sentry' +import App from './App' + +initSentry() const rootElement = document.getElementById('root') @@ -23,19 +26,21 @@ const defaultChainId = getChainInfoFromNetworkName(NETWORK_NAME).id ReactDOM.createRoot(rootElement).render( - - - - - - - - - - - + + + + + + + + + + + + + ) diff --git a/src/utils/analytics.test.ts b/src/utils/analytics.test.ts new file mode 100644 index 0000000..06d619f --- /dev/null +++ b/src/utils/analytics.test.ts @@ -0,0 +1,640 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import ReactGA from 'react-ga4' + +// ─── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('react-ga4', () => ({ + default: { + initialize: vi.fn(), + event: vi.fn(), + }, +})) + +vi.mock('@trustvc/trustvc', async () => { + return { + isWrappedV2Document: vi.fn(), + isWrappedV3Document: vi.fn(), + isRawV2Document: vi.fn(), + isRawV3Document: vi.fn(), + isSignedWrappedV2Document: vi.fn(), + isSignedWrappedV3Document: vi.fn(), + vc: { + isSignedDocument: vi.fn(), + isRawDocument: vi.fn(), + isSignedDocumentV2_0: vi.fn(), + }, + } +}) + +// ─── Imports ────────────────────────────────────────────────────────────────── +// GTM_CONFIGURED and ENVIRONMENT are computed at analytics.ts module load. +// .env.test sets VITE_GTM_CONTAINER_ID= and VITE_PLATFORM=test so Vitest +// injects the correct values before any module evaluates. + +import * as trustvc from '@trustvc/trustvc' +import type { VerificationFragmentType } from '../components/home/VerifySection/useVerify' +import { + getDocumentSchema, + getIssuerMethod, + getSigningAlgorithm, + pushGTMEvent, + initGA4, + buildDroppedEvent, + buildVerificationEvent, + trackDocumentDropped, + trackDocumentVerified, + trackDocumentVerifyError, + trackNetworkSelectionShown, + trackNetworkSelected, + trackNetworkSelectionCancelled, + trackVerificationReset, + trackWalletConnected, + trackWalletDisconnected, + trackWalletConnectFailed, + trackAssetActionInitiated, + trackAssetActionCompleted, + trackAssetActionFailed, + trackSupportFormSubmitted, + trackSupportFormFailed, +} from './analytics' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const mockAllFalse = () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(false) + vi.mocked(trustvc.isWrappedV3Document).mockReturnValue(false) + vi.mocked(trustvc.isRawV2Document).mockReturnValue(false) + vi.mocked(trustvc.isRawV3Document).mockReturnValue(false) + vi.mocked(trustvc.isSignedWrappedV2Document).mockReturnValue(false) + vi.mocked(trustvc.isSignedWrappedV3Document).mockReturnValue(false) + vi.mocked(trustvc.vc.isSignedDocument).mockReturnValue(false) + vi.mocked(trustvc.vc.isRawDocument).mockReturnValue(false) + vi.mocked(trustvc.vc.isSignedDocumentV2_0).mockReturnValue(false) +} + +const makeFragment = ( + name: string, + type: VerificationFragmentType, + status: 'VALID' | 'INVALID' | 'SKIPPED' | 'ERROR' = 'VALID' +) => ({ name, type, status, data: {} }) + +// All verification-related fields that GTM carries between events +const VERIFICATION_RESET_FIELDS = [ + 'verification_result', + 'issuer_identity', + 'document_schema', + 'document_type', + 'issuer_method', + 'signing_algorithm', + 'error_code', + 'is_expired', + 'is_transferable', + 'token_registry_version', + 'chain_id', +] + +const DOCUMENT_RESET_FIELDS = [ + 'file_name', + 'source', + ...VERIFICATION_RESET_FIELDS, +] + +beforeEach(() => { + window.dataLayer = [] + mockAllFalse() + vi.clearAllMocks() +}) + +afterEach(() => { + vi.clearAllMocks() +}) + +// ─── getDocumentSchema ──────────────────────────────────────────────────────── + +describe('getDocumentSchema', () => { + it('returns OA v2 for wrapped v2 document', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('OA v2') + }) + + it('returns OA v2 for raw v2 document', () => { + vi.mocked(trustvc.isRawV2Document).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('OA v2') + }) + + it('returns OA v2 for signed wrapped v2 document', () => { + vi.mocked(trustvc.isSignedWrappedV2Document).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('OA v2') + }) + + it('returns OA v3 for wrapped v3 document', () => { + vi.mocked(trustvc.isWrappedV3Document).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('OA v3') + }) + + it('returns OA v3 for raw v3 document', () => { + vi.mocked(trustvc.isRawV3Document).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('OA v3') + }) + + it('returns W3C VC V2.0 for signed VC v2', () => { + vi.mocked(trustvc.vc.isSignedDocument).mockReturnValue(true) + vi.mocked(trustvc.vc.isSignedDocumentV2_0).mockReturnValue(true) + expect(getDocumentSchema({})).toBe('W3C VC V2.0') + }) + + it('returns W3C VC V1.1 for signed VC v1', () => { + vi.mocked(trustvc.vc.isSignedDocument).mockReturnValue(true) + vi.mocked(trustvc.vc.isSignedDocumentV2_0).mockReturnValue(false) + expect(getDocumentSchema({})).toBe('W3C VC V1.1') + }) + + it('returns W3C VC V1.1 for raw VC', () => { + vi.mocked(trustvc.vc.isRawDocument).mockReturnValue(true) + vi.mocked(trustvc.vc.isSignedDocumentV2_0).mockReturnValue(false) + expect(getDocumentSchema({})).toBe('W3C VC V1.1') + }) + + it('returns Unknown for unrecognised document', () => { + expect(getDocumentSchema({})).toBe('Unknown') + }) +}) + +// ─── getIssuerMethod ────────────────────────────────────────────────────────── + +describe('getIssuerMethod', () => { + it('returns DNS-TXT from DNS-TXT identity fragment', () => { + const frags = [ + makeFragment('OpenAttestationDnsTxtIdentityProof', 'ISSUER_IDENTITY'), + ] + expect(getIssuerMethod({}, frags)).toBe('DNS-TXT') + }) + + it('returns DNS-DID from DNS-DID identity fragment', () => { + const frags = [ + makeFragment('OpenAttestationDnsDidIdentityProof', 'ISSUER_IDENTITY'), + ] + expect(getIssuerMethod({}, frags)).toBe('DNS-DID') + }) + + it('returns DID:WEB from DID identity fragment', () => { + const frags = [ + makeFragment('OpenAttestationDidIdentityProof', 'ISSUER_IDENTITY'), + ] + expect(getIssuerMethod({}, frags)).toBe('DID:WEB') + }) + + it('ignores SKIPPED identity fragments', () => { + const frags = [ + makeFragment( + 'OpenAttestationDnsTxtIdentityProof', + 'ISSUER_IDENTITY', + 'SKIPPED' + ), + ] + expect(getIssuerMethod({}, frags)).toBe('Unknown') + }) + + it('returns DID:WEB for W3C VC with did: issuer', () => { + vi.mocked(trustvc.vc.isSignedDocument).mockReturnValue(true) + const doc = { issuer: 'did:web:example.com' } + expect(getIssuerMethod(doc, [])).toBe('DID:WEB') + }) + + it('returns Unknown when no fragments match', () => { + expect(getIssuerMethod({}, [])).toBe('Unknown') + }) +}) + +// ─── getSigningAlgorithm ────────────────────────────────────────────────────── + +describe('getSigningAlgorithm', () => { + it('returns merkleroot2018 for OA v2 documents', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + expect(getSigningAlgorithm({}, [])).toBe('merkleroot2018') + }) + + it('returns merkleroot2018 for OA v3 documents', () => { + vi.mocked(trustvc.isWrappedV3Document).mockReturnValue(true) + expect(getSigningAlgorithm({}, [])).toBe('merkleroot2018') + }) + + it('returns BBS2023 from BBS integrity fragment', () => { + const frags = [ + makeFragment('Bbs2023W3CSignatureIntegrity', 'DOCUMENT_INTEGRITY'), + ] + expect(getSigningAlgorithm({}, frags)).toBe('BBS2023') + }) + + it('returns ECDSA2023 from ECDSA integrity fragment', () => { + const frags = [ + makeFragment('EcdsaW3CSignatureIntegrity', 'DOCUMENT_INTEGRITY'), + ] + expect(getSigningAlgorithm({}, frags)).toBe('ECDSA2023') + }) + + it('ignores SKIPPED integrity fragments', () => { + const frags = [ + makeFragment( + 'Bbs2023W3CSignatureIntegrity', + 'DOCUMENT_INTEGRITY', + 'SKIPPED' + ), + ] + expect(getSigningAlgorithm({}, frags)).toBe('Unknown') + }) + + it('returns Unknown for unrecognised document with no fragments', () => { + expect(getSigningAlgorithm({}, [])).toBe('Unknown') + }) +}) + +// ─── pushGTMEvent ───────────────────────────────────────────────────────────── + +describe('pushGTMEvent', () => { + it('pushes event to window.dataLayer', () => { + pushGTMEvent({ event: 'TEST_EVENT', foo: 'bar' }) + expect(window.dataLayer).toHaveLength(1) + expect(window.dataLayer[0]).toEqual({ event: 'TEST_EVENT', foo: 'bar' }) + }) + + it('initialises dataLayer if absent', () => { + delete (window as any).dataLayer + pushGTMEvent({ event: 'INIT_TEST' }) + expect(window.dataLayer).toBeDefined() + expect(window.dataLayer[0].event).toBe('INIT_TEST') + }) + + it('does not throw when window is undefined (SSR-like)', () => { + const originalWindow = global.window + // @ts-expect-error simulating SSR + delete global.window + expect(() => pushGTMEvent({ event: 'SSR' })).not.toThrow() + global.window = originalWindow + }) +}) + +// ─── initGA4 ───────────────────────────────────────────────────────────────── + +describe('initGA4', () => { + it('calls ReactGA.initialize with the tag id', () => { + initGA4('G-TEST123') + expect(vi.mocked(ReactGA.initialize)).toHaveBeenCalledWith('G-TEST123') + }) + + it('is safe to call multiple times (idempotent)', () => { + expect(() => { + initGA4('G-TEST123') + initGA4('G-TEST123') + }).not.toThrow() + }) + + it('does nothing for empty tag id', () => { + initGA4('') + expect(vi.mocked(ReactGA.initialize)).not.toHaveBeenCalled() + }) +}) + +// ─── buildDroppedEvent ──────────────────────────────────────────────────────── + +describe('buildDroppedEvent', () => { + it('includes file_name and default source', () => { + const evt = buildDroppedEvent('test.json') + expect(evt.event).toBe('DOCUMENT_DROPPED') + expect(evt.file_name).toBe('test.json') + expect(evt.source).toBe('file_picker') + }) + + it('uses the provided source', () => { + expect(buildDroppedEvent('a.json', 'drop').source).toBe('drop') + expect(buildDroppedEvent('b.json', 'url').source).toBe('url') + expect(buildDroppedEvent('c.json', 'demo').source).toBe('demo') + }) + + it('includes environment', () => { + const evt = buildDroppedEvent('x.json', 'file_picker') + expect(evt.environment).toBeDefined() + }) + + it('explicitly resets all verification fields to undefined to clear GTM state', () => { + const evt = buildDroppedEvent('test.json') as Record + for (const field of VERIFICATION_RESET_FIELDS) { + expect(evt[field]).toBeUndefined() + // The key must be present (explicit undefined) so GTM clears stale values + expect(Object.prototype.hasOwnProperty.call(evt, field)).toBe(true) + } + }) +}) + +// ─── buildVerificationEvent ─────────────────────────────────────────────────── + +describe('buildVerificationEvent', () => { + it('builds a valid event payload', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + const frags = [ + makeFragment('OpenAttestationDnsTxtIdentityProof', 'ISSUER_IDENTITY'), + ] + const evt = buildVerificationEvent({}, frags, true, 'dns-name.com') + expect(evt.event).toBe('DOCUMENT_VERIFICATION_COMPLETED') + expect(evt.verification_result).toBe('valid') + expect(evt.document_schema).toBe('OA v2') + expect(evt.issuer_method).toBe('DNS-TXT') + expect(evt.issuer_identity).toBe('dns-name.com') + expect(evt.signing_algorithm).toBe('merkleroot2018') + expect(evt.error_code).toBeUndefined() + }) + + it('sets verification_result to invalid', () => { + const evt = buildVerificationEvent({}, [], false, 'id.com', 'HASH_ERROR') + expect(evt.verification_result).toBe('invalid') + expect(evt.error_code).toBe('HASH_ERROR') + }) + + it('includes extras when provided', () => { + const evt = buildVerificationEvent({}, [], true, 'id.com', undefined, { + isExpired: true, + isTransferable: false, + tokenRegistryVersion: 'V5', + chainId: '1', + }) + expect(evt.is_expired).toBe(true) + expect(evt.is_transferable).toBe(false) + expect(evt.token_registry_version).toBe('V5') + expect(evt.chain_id).toBe('1') + }) + + it('uses Unknown for empty issuer identity', () => { + const evt = buildVerificationEvent({}, [], true, '') + expect(evt.issuer_identity).toBe('Unknown') + }) +}) + +// ─── trackDocumentDropped ───────────────────────────────────────────────────── + +describe('trackDocumentDropped', () => { + it('pushes DOCUMENT_DROPPED to dataLayer with correct source', () => { + trackDocumentDropped('doc.json', 'drop') + expect(window.dataLayer).toHaveLength(1) + expect(window.dataLayer[0].event).toBe('DOCUMENT_DROPPED') + expect(window.dataLayer[0].file_name).toBe('doc.json') + expect(window.dataLayer[0].source).toBe('drop') + }) + + it('defaults source to file_picker', () => { + trackDocumentDropped('doc.json') + expect(window.dataLayer[0].source).toBe('file_picker') + }) + + it('resets all verification fields to undefined in the dataLayer push', () => { + trackDocumentDropped('doc.json', 'drop') + const evt = window.dataLayer[0] as Record + for (const field of VERIFICATION_RESET_FIELDS) { + expect(Object.prototype.hasOwnProperty.call(evt, field)).toBe(true) + expect(evt[field]).toBeUndefined() + } + }) + + it('never throws', () => { + expect(() => trackDocumentDropped(null as any)).not.toThrow() + }) +}) + +// ─── trackDocumentVerified ──────────────────────────────────────────────────── + +describe('trackDocumentVerified', () => { + it('pushes DOCUMENT_VERIFICATION_COMPLETED with valid result', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + const frags = [ + makeFragment('OpenAttestationDnsTxtIdentityProof', 'ISSUER_IDENTITY'), + ] + trackDocumentVerified({}, frags, true, 'issuer.com') + expect(window.dataLayer[0].event).toBe('DOCUMENT_VERIFICATION_COMPLETED') + expect(window.dataLayer[0].verification_result).toBe('valid') + }) + + it('passes extras to the event', () => { + trackDocumentVerified({}, [], true, 'id.com', undefined, { + isExpired: false, + isTransferable: true, + tokenRegistryVersion: 'V4', + chainId: '137', + }) + expect(window.dataLayer[0].is_transferable).toBe(true) + expect(window.dataLayer[0].chain_id).toBe('137') + expect(window.dataLayer[0].token_registry_version).toBe('V4') + }) + + it('never throws', () => { + expect(() => trackDocumentVerified(null as any, [], true, '')).not.toThrow() + }) +}) + +// ─── trackDocumentVerifyError ───────────────────────────────────────────────── + +describe('trackDocumentVerifyError', () => { + it('pushes invalid DOCUMENT_VERIFICATION_COMPLETED with error_code', () => { + trackDocumentVerifyError(undefined, 'PARSE_ERROR') + const evt = window.dataLayer[0] + expect(evt.event).toBe('DOCUMENT_VERIFICATION_COMPLETED') + expect(evt.verification_result).toBe('invalid') + expect(evt.error_code).toBe('PARSE_ERROR') + expect(evt.document_schema).toBe('Unknown') + }) + + it('detects schema when doc is provided', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + trackDocumentVerifyError({}, 'NETWORK_ERROR') + expect(window.dataLayer[0].document_schema).toBe('OA v2') + }) + + it('never throws', () => { + expect(() => trackDocumentVerifyError(null as any, 'ERR')).not.toThrow() + }) +}) + +// ─── trackNetworkSelectionShown ─────────────────────────────────────────────── + +describe('trackNetworkSelectionShown', () => { + it('pushes NETWORK_SELECTION_SHOWN with document_schema', () => { + vi.mocked(trustvc.isWrappedV2Document).mockReturnValue(true) + trackNetworkSelectionShown({}) + expect(window.dataLayer[0].event).toBe('NETWORK_SELECTION_SHOWN') + expect(window.dataLayer[0].document_schema).toBe('OA v2') + }) + + it('never throws', () => { + expect(() => trackNetworkSelectionShown(null as any)).not.toThrow() + }) +}) + +// ─── trackNetworkSelected ───────────────────────────────────────────────────── + +describe('trackNetworkSelected', () => { + it('pushes NETWORK_SELECTED with chain_id', () => { + trackNetworkSelected('137') + expect(window.dataLayer[0].event).toBe('NETWORK_SELECTED') + expect(window.dataLayer[0].chain_id).toBe('137') + }) + + it('never throws', () => { + expect(() => trackNetworkSelected('')).not.toThrow() + }) +}) + +// ─── trackNetworkSelectionCancelled ────────────────────────────────────────── + +describe('trackNetworkSelectionCancelled', () => { + it('pushes NETWORK_SELECTION_CANCELLED', () => { + trackNetworkSelectionCancelled() + expect(window.dataLayer[0].event).toBe('NETWORK_SELECTION_CANCELLED') + }) +}) + +// ─── trackVerificationReset ─────────────────────────────────────────────────── + +describe('trackVerificationReset', () => { + it('pushes VERIFICATION_RESET', () => { + trackVerificationReset() + expect(window.dataLayer[0].event).toBe('VERIFICATION_RESET') + }) +}) + +// ─── trackWalletConnected ───────────────────────────────────────────────────── + +describe('trackWalletConnected', () => { + it('pushes WALLET_CONNECTED with metamask wallet_type', () => { + trackWalletConnected('metamask') + expect(window.dataLayer[0].event).toBe('WALLET_CONNECTED') + expect(window.dataLayer[0].wallet_type).toBe('metamask') + }) + + it('pushes WALLET_CONNECTED with magic_link wallet_type', () => { + trackWalletConnected('magic_link') + expect(window.dataLayer[0].wallet_type).toBe('magic_link') + }) +}) + +// ─── trackWalletDisconnected ────────────────────────────────────────────────── + +describe('trackWalletDisconnected', () => { + it('pushes WALLET_DISCONNECTED with correct wallet_type', () => { + trackWalletDisconnected('metamask') + expect(window.dataLayer[0].event).toBe('WALLET_DISCONNECTED') + expect(window.dataLayer[0].wallet_type).toBe('metamask') + }) +}) + +// ─── trackWalletConnectFailed ───────────────────────────────────────────────── + +describe('trackWalletConnectFailed', () => { + it('pushes WALLET_CONNECT_FAILED with wallet_type and error_code', () => { + trackWalletConnectFailed('metamask', 'User Rejected Transaction') + const evt = window.dataLayer[0] + expect(evt.event).toBe('WALLET_CONNECT_FAILED') + expect(evt.wallet_type).toBe('metamask') + expect(evt.error_code).toBe('User Rejected Transaction') + }) +}) + +// ─── trackAssetActionInitiated ──────────────────────────────────────────────── + +describe('trackAssetActionInitiated', () => { + it('pushes ASSET_ACTION_INITIATED with action, chain_id, and version', () => { + trackAssetActionInitiated('TransferHolder', '137', 'V5') + const evt = window.dataLayer[0] + expect(evt.event).toBe('ASSET_ACTION_INITIATED') + expect(evt.action).toBe('TransferHolder') + expect(evt.chain_id).toBe('137') + expect(evt.token_registry_version).toBe('V5') + }) + + it('works without optional params', () => { + trackAssetActionInitiated('ReturnToIssuer') + expect(window.dataLayer[0].action).toBe('ReturnToIssuer') + expect(window.dataLayer[0].chain_id).toBeUndefined() + }) +}) + +// ─── trackAssetActionCompleted ─────────────────────────────────────────────── + +describe('trackAssetActionCompleted', () => { + it('pushes ASSET_ACTION_COMPLETED with action and chain_id', () => { + trackAssetActionCompleted('TransferHolder', '137') + const evt = window.dataLayer[0] + expect(evt.event).toBe('ASSET_ACTION_COMPLETED') + expect(evt.action).toBe('TransferHolder') + expect(evt.chain_id).toBe('137') + }) + + it('works without optional chainId', () => { + trackAssetActionCompleted('ReturnToIssuer') + expect(window.dataLayer[0].action).toBe('ReturnToIssuer') + expect(window.dataLayer[0].chain_id).toBeUndefined() + }) +}) + +// ─── trackAssetActionFailed ─────────────────────────────────────────────────── + +describe('trackAssetActionFailed', () => { + it('pushes ASSET_ACTION_FAILED with action, error_code, and chain_id', () => { + trackAssetActionFailed( + 'NominateBeneficiary', + 'User Rejected Transaction', + '1' + ) + const evt = window.dataLayer[0] + expect(evt.event).toBe('ASSET_ACTION_FAILED') + expect(evt.action).toBe('NominateBeneficiary') + expect(evt.error_code).toBe('User Rejected Transaction') + expect(evt.chain_id).toBe('1') + }) + + it('works without optional chainId', () => { + trackAssetActionFailed('ReturnToIssuer', 'TRANSACTION_ERROR') + expect(window.dataLayer[0].action).toBe('ReturnToIssuer') + expect(window.dataLayer[0].chain_id).toBeUndefined() + }) +}) + +// ─── trackSupportFormSubmitted ──────────────────────────────────────────────── + +describe('trackSupportFormSubmitted', () => { + it('pushes SUPPORT_FORM_SUBMITTED with enquiry_type', () => { + trackSupportFormSubmitted('TradeTrust') + expect(window.dataLayer[0].event).toBe('SUPPORT_FORM_SUBMITTED') + expect(window.dataLayer[0].enquiry_type).toBe('TradeTrust') + }) + + it('resets all document and verification fields to clear GTM state', () => { + trackSupportFormSubmitted('General_Enquiry') + const evt = window.dataLayer[0] as Record + for (const field of DOCUMENT_RESET_FIELDS) { + expect(Object.prototype.hasOwnProperty.call(evt, field)).toBe(true) + expect(evt[field]).toBeUndefined() + } + }) +}) + +// ─── trackSupportFormFailed ─────────────────────────────────────────────────── + +describe('trackSupportFormFailed', () => { + it('pushes SUPPORT_FORM_FAILED with enquiry_type and error_code', () => { + trackSupportFormFailed('General_Enquiry', 'Server Error') + const evt = window.dataLayer[0] + expect(evt.event).toBe('SUPPORT_FORM_FAILED') + expect(evt.enquiry_type).toBe('General_Enquiry') + expect(evt.error_code).toBe('Server Error') + }) + + it('resets document and file fields (excluding its own error_code) to clear GTM state', () => { + trackSupportFormFailed('General_Enquiry', 'Server Error') + const evt = window.dataLayer[0] as Record + const docFields = DOCUMENT_RESET_FIELDS.filter(f => f !== 'error_code') + for (const field of docFields) { + expect(Object.prototype.hasOwnProperty.call(evt, field)).toBe(true) + expect(evt[field]).toBeUndefined() + } + // error_code belongs to this event itself — must not be cleared + expect(evt.error_code).toBe('Server Error') + }) +}) diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts new file mode 100644 index 0000000..47bb114 --- /dev/null +++ b/src/utils/analytics.ts @@ -0,0 +1,563 @@ +import ReactGA from 'react-ga4' +import { + isWrappedV2Document, + isWrappedV3Document, + isRawV2Document, + isRawV3Document, + isSignedWrappedV2Document, + isSignedWrappedV3Document, + vc, +} from '@trustvc/trustvc' +import type { VerificationFragment } from '../components/home/VerifySection/useVerify' +import { ANALYTICS_EVENTS } from '../constants/analyticsEvents' + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type DocumentSchema = + | 'OA v2' + | 'OA v3' + | 'W3C VC V1.1' + | 'W3C VC V2.0' + | 'Unknown' + +export type IssuerMethod = 'DNS-TXT' | 'DNS-DID' | 'DID:WEB' | 'Unknown' + +export type SigningAlgorithm = + | 'merkleroot2018' + | 'BBS2023' + | 'ECDSA2023' + | 'Unknown' + +export type VerificationResult = 'valid' | 'invalid' + +// ─── GTM dataLayer types ────────────────────────────────────────────────────── + +export interface GTMEvent { + event: string + [key: string]: unknown +} + +declare global { + interface Window { + dataLayer: GTMEvent[] + } +} + +export type DocumentDroppedSource = 'drop' | 'file_picker' | 'url' | 'demo' + +export type WalletType = 'metamask' | 'magic_link' + +export interface DocumentDroppedEvent extends GTMEvent { + event: typeof ANALYTICS_EVENTS.DOCUMENT_DROPPED + environment: string + file_name: string + source: DocumentDroppedSource +} + +export interface DocumentVerificationEvent extends GTMEvent { + event: typeof ANALYTICS_EVENTS.DOCUMENT_VERIFICATION_COMPLETED + environment: string + document_schema: DocumentSchema + document_type: DocumentSchema + issuer_method: IssuerMethod + issuer_identity: string + signing_algorithm: SigningAlgorithm + verification_result: VerificationResult + error_code: string | undefined + is_expired: boolean | undefined + is_transferable: boolean | undefined + token_registry_version: string | null | undefined + chain_id: string | null | undefined +} + +// ─── Fragment → label maps ──────────────────────────────────────────────────── + +/** + * Maps DOCUMENT_INTEGRITY fragment names to signing algorithm labels. + * Extend this map to support future signing suites without touching call sites. + * Sourced from @trustvc/trustvc verifier implementations. + */ +const SIGNING_ALGORITHM_FRAGMENT_MAP: Readonly< + Record +> = { + OpenAttestationHash: 'merkleroot2018', + Bbs2023W3CSignatureIntegrity: 'BBS2023', + EcdsaW3CSignatureIntegrity: 'ECDSA2023', +} + +/** Maps ISSUER_IDENTITY fragment names to issuer method labels. */ +const ISSUER_METHOD_FRAGMENT_MAP: Readonly> = { + OpenAttestationDnsTxtIdentityProof: 'DNS-TXT', + OpenAttestationDnsDidIdentityProof: 'DNS-DID', + OpenAttestationDidIdentityProof: 'DID:WEB', +} + +// ─── Detection helpers ──────────────────────────────────────────────────────── + +export const getDocumentSchema = (doc: unknown): DocumentSchema => { + const d = doc as any + if ( + isWrappedV2Document(d) || + isRawV2Document(d) || + isSignedWrappedV2Document(d) + ) + return 'OA v2' + if ( + isWrappedV3Document(d) || + isRawV3Document(d) || + isSignedWrappedV3Document(d) + ) + return 'OA v3' + if (vc.isSignedDocument(d) || vc.isRawDocument(d)) + return vc.isSignedDocumentV2_0(d) ? 'W3C VC V2.0' : 'W3C VC V1.1' + return 'Unknown' +} + +export const getIssuerMethod = ( + doc: unknown, + frags: VerificationFragment[] +): IssuerMethod => { + // OA: derive from the identity proof fragment that actually ran (not skipped) + const identityFrag = frags.find( + f => f.type === 'ISSUER_IDENTITY' && f.status !== 'SKIPPED' + ) + if (identityFrag) { + const method = ISSUER_METHOD_FRAGMENT_MAP[identityFrag.name] + if (method) return method + } + // W3C VC: infer from the issuer DID method + const d = doc as any + if (vc.isSignedDocument(d) || vc.isRawDocument(d)) { + const issuer = typeof d?.issuer === 'string' ? d.issuer : d?.issuer?.id + if (typeof issuer === 'string' && issuer.startsWith('did:')) + return 'DID:WEB' + } + return 'Unknown' +} + +export const getSigningAlgorithm = ( + doc: unknown, + frags: VerificationFragment[] +): SigningAlgorithm => { + // OA v2/v3 always use SHA3 Merkle proof — no need to inspect fragments + const d = doc as any + if ( + isWrappedV2Document(d) || + isSignedWrappedV2Document(d) || + isRawV2Document(d) + ) + return 'merkleroot2018' + if ( + isWrappedV3Document(d) || + isSignedWrappedV3Document(d) || + isRawV3Document(d) + ) + return 'merkleroot2018' + + // W3C VC: identify from the active DOCUMENT_INTEGRITY verifier fragment + const activeFragment = frags.find( + f => + f.type === 'DOCUMENT_INTEGRITY' && + f.status !== 'SKIPPED' && + f.name in SIGNING_ALGORITHM_FRAGMENT_MAP + ) + return activeFragment + ? SIGNING_ALGORITHM_FRAGMENT_MAP[activeFragment.name] + : 'Unknown' +} + +// ─── GTM ───────────────────────────────────────────────────────────────────── + +/** + * Pushes an event to window.dataLayer only — no external calls are made here. + * GTM (if loaded) reads from dataLayer and forwards events per its own config. + * Events pushed before GTM loads are queued and replayed when GTM initialises. + * Safe to call from any context: silently no-ops in SSR, never throws. + */ +export const pushGTMEvent = (eventData: GTMEvent): void => { + try { + if (typeof window === 'undefined') return + window.dataLayer = window.dataLayer ?? [] + window.dataLayer.push(eventData) + } catch { + // Analytics failures must never affect the application + } +} + +// ─── GA4 ───────────────────────────────────────────────────────────────────── + +// When GTM is configured it forwards events to GA4 via its own tags — sending +// directly to GA4 as well would double-count every event, including the +// automatic page_view fired by ReactGA.initialize. Only use the GA4 direct +// channel when GTM is absent. +const GTM_CONFIGURED = Boolean(import.meta.env.VITE_GTM_CONTAINER_ID) + +let ga4Initialized = false + +export const initGA4 = (tagId: string): void => { + if (!tagId || ga4Initialized || GTM_CONFIGURED) return + try { + ReactGA.initialize(tagId) + ga4Initialized = true + } catch { + // Analytics failures must never affect the application + } +} + +const pushGA4Event = ( + eventName: string, + params: Record +): void => { + if (!ga4Initialized) return + try { + ReactGA.event(eventName, params as any) + } catch { + // Analytics failures must never affect the application + } +} + +// ─── Internal: fire to both channels ───────────────────────────────────────── + +const trackEvent = (payload: GTMEvent): void => { + pushGTMEvent(payload) + if (!GTM_CONFIGURED) { + const { event: eventName, ...params } = payload + pushGA4Event(eventName, params) + } +} + +// ─── Environment ───────────────────────────────────────────────────────────── + +const ENVIRONMENT = + (import.meta.env.VITE_PLATFORM as string | undefined) ?? 'local' + +// ─── Event builders ─────────────────────────────────────────────────────────── + +export const buildDroppedEvent = ( + fileName: string, + source: DocumentDroppedSource = 'file_picker' +): DocumentDroppedEvent => ({ + event: ANALYTICS_EVENTS.DOCUMENT_DROPPED, + environment: ENVIRONMENT, + file_name: fileName, + source, + // Explicitly clear verification fields so GTM doesn't carry stale values + // from the previous document_verification_completed event into this one. + verification_result: undefined, + issuer_identity: undefined, + document_schema: undefined, + document_type: undefined, + issuer_method: undefined, + signing_algorithm: undefined, + error_code: undefined, + is_expired: undefined, + is_transferable: undefined, + token_registry_version: undefined, + chain_id: undefined, +}) + +export const buildVerificationEvent = ( + doc: unknown, + frags: VerificationFragment[], + isValid: boolean, + issuerIdentity: string, + errorCode?: string, + extras?: { + isExpired?: boolean + isTransferable?: boolean + tokenRegistryVersion?: string | null + chainId?: string | null + } +): DocumentVerificationEvent => { + const schema = getDocumentSchema(doc) + return { + event: ANALYTICS_EVENTS.DOCUMENT_VERIFICATION_COMPLETED, + environment: ENVIRONMENT, + document_schema: schema, + document_type: schema, + issuer_method: getIssuerMethod(doc, frags), + issuer_identity: issuerIdentity || 'Unknown', + signing_algorithm: getSigningAlgorithm(doc, frags), + verification_result: isValid ? 'valid' : 'invalid', + error_code: errorCode, + is_expired: extras?.isExpired, + is_transferable: extras?.isTransferable, + token_registry_version: extras?.tokenRegistryVersion, + chain_id: extras?.chainId, + } +} + +// ─── Public tracking API ────────────────────────────────────────────────────── + +/** + * Fired when a user drops or selects a file for verification. + * Captures drop intent — before parsing or verification begins. + */ +export const trackDocumentDropped = ( + fileName: string, + source: DocumentDroppedSource = 'file_picker' +): void => { + try { + trackEvent(buildDroppedEvent(fileName, source)) + } catch { + // Analytics failures must never affect the application + } +} + +/** + * Fired when verification completes (valid or invalid). + * Captures schema, issuer method, identity, signing algorithm, result, and error code. + */ +export const trackDocumentVerified = ( + doc: unknown, + frags: VerificationFragment[], + isValid: boolean, + issuerIdentity: string, + errorCode?: string, + extras?: { + isExpired?: boolean + isTransferable?: boolean + tokenRegistryVersion?: string | null + chainId?: string | null + } +): void => { + try { + trackEvent( + buildVerificationEvent( + doc, + frags, + isValid, + issuerIdentity, + errorCode, + extras + ) + ) + } catch { + // Analytics failures must never affect the application + } +} + +/** + * Fired when an exception is thrown before fragments are available + * (parse error, network error, etc.). `doc` may be undefined when the + * file could not be parsed as JSON. + */ +export const trackDocumentVerifyError = ( + doc: unknown | undefined, + errorCode: string +): void => { + try { + const schema = doc != null ? getDocumentSchema(doc) : 'Unknown' + const algo = doc != null ? getSigningAlgorithm(doc, []) : 'Unknown' + trackEvent({ + event: ANALYTICS_EVENTS.DOCUMENT_VERIFICATION_COMPLETED, + environment: ENVIRONMENT, + document_schema: schema, + document_type: schema, + issuer_method: 'Unknown', + issuer_identity: 'Unknown', + signing_algorithm: algo, + verification_result: 'invalid', + error_code: errorCode, + is_expired: undefined, + is_transferable: undefined, + token_registry_version: undefined, + chain_id: undefined, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackNetworkSelectionShown = (doc: unknown): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.NETWORK_SELECTION_SHOWN, + environment: ENVIRONMENT, + document_schema: getDocumentSchema(doc), + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackNetworkSelected = (chainId: string): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.NETWORK_SELECTED, + environment: ENVIRONMENT, + chain_id: chainId, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackNetworkSelectionCancelled = (): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.NETWORK_SELECTION_CANCELLED, + environment: ENVIRONMENT, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackVerificationReset = (): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.VERIFICATION_RESET, + environment: ENVIRONMENT, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackWalletConnected = (walletType: WalletType): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.WALLET_CONNECTED, + environment: ENVIRONMENT, + wallet_type: walletType, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackWalletDisconnected = (walletType: WalletType): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.WALLET_DISCONNECTED, + environment: ENVIRONMENT, + wallet_type: walletType, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackWalletConnectFailed = ( + walletType: WalletType, + errorCode: string +): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.WALLET_CONNECT_FAILED, + environment: ENVIRONMENT, + wallet_type: walletType, + error_code: errorCode, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackAssetActionInitiated = ( + action: string, + chainId?: string, + tokenRegistryVersion?: string +): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.ASSET_ACTION_INITIATED, + environment: ENVIRONMENT, + action, + chain_id: chainId, + token_registry_version: tokenRegistryVersion, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackAssetActionCompleted = ( + action: string, + chainId?: string +): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.ASSET_ACTION_COMPLETED, + environment: ENVIRONMENT, + action, + chain_id: chainId, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackAssetActionFailed = ( + action: string, + errorCode: string, + chainId?: string +): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.ASSET_ACTION_FAILED, + environment: ENVIRONMENT, + action, + error_code: errorCode, + chain_id: chainId, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackSupportFormSubmitted = (enquiryType: string): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.SUPPORT_FORM_SUBMITTED, + environment: ENVIRONMENT, + enquiry_type: enquiryType, + // Clear document fields that persist in GTM's dataLayer from prior events + file_name: undefined, + source: undefined, + verification_result: undefined, + issuer_identity: undefined, + document_schema: undefined, + document_type: undefined, + issuer_method: undefined, + signing_algorithm: undefined, + error_code: undefined, + is_expired: undefined, + is_transferable: undefined, + token_registry_version: undefined, + chain_id: undefined, + }) + } catch { + // Analytics failures must never affect the application + } +} + +export const trackSupportFormFailed = ( + enquiryType: string, + errorCode: string +): void => { + try { + trackEvent({ + event: ANALYTICS_EVENTS.SUPPORT_FORM_FAILED, + environment: ENVIRONMENT, + enquiry_type: enquiryType, + error_code: errorCode, + // Clear document fields that persist in GTM's dataLayer from prior events + file_name: undefined, + source: undefined, + verification_result: undefined, + issuer_identity: undefined, + document_schema: undefined, + document_type: undefined, + issuer_method: undefined, + signing_algorithm: undefined, + is_expired: undefined, + is_transferable: undefined, + token_registry_version: undefined, + chain_id: undefined, + }) + } catch { + // Analytics failures must never affect the application + } +} diff --git a/src/utils/fetchClient.test.ts b/src/utils/fetchClient.test.ts index c0ddb56..5b8cd9e 100644 --- a/src/utils/fetchClient.test.ts +++ b/src/utils/fetchClient.test.ts @@ -1,6 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createFetchClient, FetchClientError } from './fetchClient' +vi.mock('../lib/sentry', () => ({ + captureFetchError: vi.fn(), +})) + +import { captureFetchError } from '../lib/sentry' + describe('fetchClient', () => { beforeEach(() => { vi.clearAllMocks() @@ -122,6 +128,71 @@ describe('fetchClient', () => { ) } }) + + it('does not report caller aborts to Sentry', async () => { + const abortError = new DOMException( + 'The user aborted a request.', + 'AbortError' + ) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)) + + const client = createFetchClient({ baseUrl: 'https://api.example.com' }) + const controller = new AbortController() + controller.abort() + + await expect( + client.request('/aborted', { signal: controller.signal }) + ).rejects.toThrow() + + expect(captureFetchError).not.toHaveBeenCalled() + }) + + it('reports timeout aborts to Sentry', async () => { + // Fire the timeout callback synchronously so timedOut=true before fetch rejects + vi.stubGlobal( + 'setTimeout', + vi.fn().mockImplementation((fn: () => void) => { + fn() + return 0 + }) + ) + vi.stubGlobal('clearTimeout', vi.fn()) + + const abortError = new DOMException( + 'The user aborted a request.', + 'AbortError' + ) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)) + + const client = createFetchClient({ baseUrl: 'https://api.example.com' }) + await expect(client.request('/slow')).rejects.toThrow() + + expect(captureFetchError).toHaveBeenCalledOnce() + }) + + it('removes caller abort listener after request completes', async () => { + const removeEventListener = vi.fn() + const signal = { + aborted: false, + addEventListener: vi.fn(), + removeEventListener, + } as unknown as AbortSignal + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => Promise.resolve({ success: true }), + }) + ) + + const client = createFetchClient({ baseUrl: 'https://api.example.com' }) + await client.request('/ok', { signal }) + + expect(signal.addEventListener).toHaveBeenCalled() + expect(removeEventListener).toHaveBeenCalled() + }) }) describe('FetchClientError', () => { diff --git a/src/utils/fetchClient.ts b/src/utils/fetchClient.ts index 9b5479e..ac7973e 100644 --- a/src/utils/fetchClient.ts +++ b/src/utils/fetchClient.ts @@ -1,6 +1,9 @@ +import { captureFetchError } from '../lib/sentry' + type FetchClientOptions = { baseUrl: string timeoutMs?: number + service?: 'support-api' | 'app' } interface ApiErrorBody { @@ -27,9 +30,15 @@ export class FetchClientError extends Error { } } +const isAbortError = (error: unknown): boolean => + error instanceof DOMException + ? error.name === 'AbortError' + : error instanceof Error && error.name === 'AbortError' + export const createFetchClient = ({ baseUrl, timeoutMs, + service = 'app', }: FetchClientOptions) => { const normalizedBaseUrl = baseUrl.replace(/\/$/, '') const defaultTimeoutMs = timeoutMs ?? 15000 @@ -37,42 +46,75 @@ export const createFetchClient = ({ const request = async (path: string, init?: RequestInit): Promise => { const controller = new AbortController() const timeoutMs = defaultTimeoutMs - const timeoutId = globalThis.setTimeout(() => controller.abort(), timeoutMs) + let timedOut = false + const timeoutId = globalThis.setTimeout(() => { + timedOut = true + controller.abort() + }, timeoutMs) const signal = init?.signal + const method = init?.method ?? 'GET' + let onCallerAbort: (() => void) | undefined if (signal) { if (signal.aborted) controller.abort() - signal.addEventListener('abort', () => controller.abort(), { once: true }) + onCallerAbort = () => controller.abort() + signal.addEventListener('abort', onCallerAbort, { once: true }) } - const response = await fetch(`${normalizedBaseUrl}${path}`, { - ...init, - signal: controller.signal, - }).finally(() => { - globalThis.clearTimeout(timeoutId) - }) + try { + const response = await fetch(`${normalizedBaseUrl}${path}`, { + ...init, + signal: controller.signal, + }) - const contentType = response.headers.get('content-type') || '' - let data: unknown = null - if (contentType.includes('application/json')) { - try { - data = await response.json() - } catch { - data = null + const contentType = response.headers.get('content-type') || '' + let data: unknown = null + if (contentType.includes('application/json')) { + try { + data = await response.json() + } catch { + data = null + } } - } - const errorBody = - typeof data === 'object' && data !== null ? (data as ApiErrorBody) : null - if (!response.ok || (errorBody && errorBody.success === false)) { - const message = - errorBody?.error?.message || - errorBody?.message || - `Request failed with status ${response.status}` - throw new FetchClientError({ status: response.status, message, data }) - } + const errorBody = + typeof data === 'object' && data !== null + ? (data as ApiErrorBody) + : null + if (!response.ok || (errorBody && errorBody.success === false)) { + const message = + errorBody?.error?.message || + errorBody?.message || + `Request failed with status ${response.status}` + const error = new FetchClientError({ + status: response.status, + message, + data, + }) + captureFetchError(error, { + service, + path, + method, + status: response.status, + }) + throw error + } - return data as T + return data as T + } catch (error) { + if (!(error instanceof FetchClientError)) { + const callerAborted = isAbortError(error) && !timedOut + if (!callerAborted) { + captureFetchError(error, { service, path, method }) + } + } + throw error + } finally { + globalThis.clearTimeout(timeoutId) + if (signal && onCallerAbort) { + signal.removeEventListener('abort', onCallerAbort) + } + } } return { request } @@ -81,4 +123,5 @@ export const createFetchClient = ({ export const fetchClientSupport = createFetchClient({ baseUrl: (import.meta.env?.VITE_SUPPORT_API_BASE_URL as string | undefined) || '', + service: 'support-api', }) diff --git a/vite.config.js b/vite.config.js index c2f9719..372dc44 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,7 @@ import { fileURLToPath, URL } from 'node:url' import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react' +import { sentryVitePlugin } from '@sentry/vite-plugin' import path from 'path' import { nodePolyfills } from 'vite-plugin-node-polyfills' @@ -10,6 +11,38 @@ export default defineConfig(({ mode }) => { const isTest = mode === 'test' const env = loadEnv(mode, process.cwd(), '') + const sentryAuthToken = + env.SENTRY_AUTH_TOKEN ?? process.env.SENTRY_AUTH_TOKEN + const sentryOrg = env.SENTRY_ORG ?? process.env.SENTRY_ORG + const sentryProject = env.SENTRY_PROJECT ?? process.env.SENTRY_PROJECT + const sentryRelease = + env.VITE_SENTRY_RELEASE ?? process.env.VITE_SENTRY_RELEASE + + const sentryBuildVars = { + SENTRY_AUTH_TOKEN: sentryAuthToken, + SENTRY_ORG: sentryOrg, + SENTRY_PROJECT: sentryProject, + VITE_SENTRY_RELEASE: sentryRelease, + } + const sentryBuildValues = Object.values(sentryBuildVars) + const allSentryBuildVarsEmpty = sentryBuildValues.every( + value => value === undefined || value === '' + ) + const allSentryBuildVarsSet = sentryBuildValues.every( + value => value !== undefined && value !== '' + ) + + if (!isTest && !allSentryBuildVarsEmpty && !allSentryBuildVarsSet) { + const missing = Object.entries(sentryBuildVars) + .filter(([, value]) => value === undefined || value === '') + .map(([key]) => key) + throw new Error( + `Incomplete Sentry build configuration. Set all of SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT, and VITE_SENTRY_RELEASE, or leave them all empty to skip source map upload. Missing: ${missing.join(', ')}` + ) + } + + const enableSentrySourceMaps = !isTest && allSentryBuildVarsSet + return { define: { 'process.env': { @@ -20,11 +53,28 @@ export default defineConfig(({ mode }) => { ...(isTest ? {} : { 'process.version': JSON.stringify('v16.0.0') }) }, build: { + sourcemap: enableSentrySourceMaps ? 'hidden' : false, commonjsOptions: { // crypto-browserify → randomfill uses `exports.*`; without this, // some CJS can leak into ESM chunks and throw "exports is not defined". transformMixedEsModules: true, }, + rollupOptions: { + output: { + manualChunks(id) { + if ( + id.includes('/node_modules/ethers/') || + id.includes('/node_modules/@tradetrust-tt/') || + id.includes('/node_modules/@trustvc/') + ) return 'vendor-web3' + if ( + id.includes('/node_modules/react/') || + id.includes('/node_modules/react-dom/') || + id.includes('/node_modules/react-router') + ) return 'vendor-react' + }, + }, + }, }, plugins: [ react(), @@ -32,6 +82,19 @@ export default defineConfig(({ mode }) => { globals: { Buffer: true, global: true, process: true }, protocolImports: true, }), + ...(enableSentrySourceMaps + ? [ + sentryVitePlugin({ + org: sentryOrg, + project: sentryProject, + authToken: sentryAuthToken, + release: { name: sentryRelease }, + sourcemaps: { + filesToDeleteAfterUpload: ['./dist/**/*.map'], + }, + }), + ] + : []), ], resolve: { alias: {