From 44432353c1f66db4b1b6323363e19093cc00652d Mon Sep 17 00:00:00 2001 From: Lex0865 Date: Mon, 28 Sep 2026 08:10:59 -0600 Subject: [PATCH] Versioned Analytics Consent and Immediate Withdrawal Prompts users to review analytics consent when the policy changes, and stops telemetry promptly when consent is withdrawn. Adds storage failure handling, tests, and consent documentation. Closes #834 --- docs/ANALYTICS_CONSENT.md | 63 ++++++++ docs/MONITORING_CIRCUIT_BREAKER.md | 3 + pnpm-lock.yaml | 147 +++++++++++++++--- src/App.tsx | 19 ++- src/components/AnalyticsConsentPrompt.tsx | 143 +++++++++++++++++ src/components/dashboard/Contracts.tsx | 2 +- src/components/dashboard/Settings.tsx | 67 ++++---- .../dashboard/TransactionSigner.tsx | 1 - src/hooks/useSettings.ts | 31 +++- .../transactionBuilder.property.test.ts | 7 - src/lib/errorReporting.ts | 48 +++++- src/lib/logging/logger.ts | 17 +- src/lib/performance.ts | 61 ++++++-- src/lib/tests/contractInvoker.test.ts | 3 +- .../analytics.circuitBreaker.test.ts | 14 ++ src/utils/__tests__/analyticsConsent.test.ts | 131 ++++++++++++++++ .../__tests__/monitoring.consent.test.ts | 49 +++++- src/utils/analytics.ts | 77 +++++---- src/utils/analyticsConsent.ts | 85 ++++++++++ src/utils/logger.ts | 64 +------- src/utils/monitoring.ts | 99 +++++++++--- src/utils/preferences.ts | 43 ++++- 22 files changed, 969 insertions(+), 205 deletions(-) create mode 100644 docs/ANALYTICS_CONSENT.md create mode 100644 src/components/AnalyticsConsentPrompt.tsx create mode 100644 src/utils/__tests__/analyticsConsent.test.ts create mode 100644 src/utils/analyticsConsent.ts diff --git a/docs/ANALYTICS_CONSENT.md b/docs/ANALYTICS_CONSENT.md new file mode 100644 index 00000000..65e52557 --- /dev/null +++ b/docs/ANALYTICS_CONSENT.md @@ -0,0 +1,63 @@ +# Analytics and diagnostics consent + +Optional analytics and diagnostics require an explicit, versioned choice. The +consent prompt appears on first use and again when the policy version changes. +The Settings page lets a user withdraw consent later. + +## What the choice covers + +When consent is current, configured telemetry providers may receive page +activity, browser and device details, performance measurements, redacted crash +reports, and masked session replay data for errors. The configured retention +period is up to 30 days. Local dashboard calculations and user-requested data +exports are not controlled by this setting. + +Without current consent, the app does not send custom analytics, real user +monitoring (RUM), error reports, or Sentry events. A missing, malformed, stale, +or unreadable choice is treated as no consent. If browser storage cannot save a +grant, the grant is rejected and the app remains usable without telemetry. A +decline that cannot be saved remains effective for the current session and the +prompt explains that the choice should be retried before closing the app. + +## Policy changes and migration + +`ANALYTICS_POLICY_VERSION` in `src/utils/preferences.ts` identifies the policy +the user reviewed. Bump it whenever the collected data, purposes, providers, +retention, or controls materially change. The app disables telemetry as soon as +the saved version no longer matches and asks the user to review the updated +policy. + +Existing installations that only have the old `diagnosticsConsent` preference +are not silently migrated into analytics consent. They receive the prompt once +and can grant or decline under the current policy. The `diagnosticsConsent` +field remains as a compatibility alias for consumers of the older preference +shape; new code should use `analyticsConsent` and its policy version. + +## Withdrawal behavior and limits + +Withdrawal blocks new event capture, discards queued analytics and error data, +aborts active fetch requests, disconnects RUM observers, clears Sentry user +context, and closes the Sentry client without waiting for queued events to +flush. Storage events propagate choices between tabs. A request that the +browser already delivered before withdrawal cannot be recalled from a provider. + +Custom telemetry delivery requires `fetch` and `AbortController` so in-flight +requests can be canceled. If either is unavailable, custom analytics and RUM +delivery is skipped. Browser storage is also required to persist a choice; +when it is unavailable, consent defaults to off. + +## Tests + +The consent and monitoring behavior is covered by: + +- `src/utils/__tests__/analyticsConsent.test.ts` — initial choice, stale policy, + storage failure, queued-event withdrawal, and aborting an active request. +- `src/utils/__tests__/monitoring.consent.test.ts` — Sentry opt-in and opt-out. +- `src/utils/__tests__/analytics.circuitBreaker.test.ts` — analytics delivery + through the provider failure circuit. + +Run the focused suite with: + +```bash +pnpm exec vitest run --config vitest.config.js src/utils/__tests__/analyticsConsent.test.ts src/utils/__tests__/monitoring.consent.test.ts src/utils/__tests__/analytics.circuitBreaker.test.ts +``` diff --git a/docs/MONITORING_CIRCUIT_BREAKER.md b/docs/MONITORING_CIRCUIT_BREAKER.md index 05c26e43..78ff6b90 100644 --- a/docs/MONITORING_CIRCUIT_BREAKER.md +++ b/docs/MONITORING_CIRCUIT_BREAKER.md @@ -145,6 +145,9 @@ if (!result.delivered) { `fail-closed` and persist the queue in `localStorage`. - No changes are required for consumers that only call `trackEvent` / `trackPageView` / `trackPerformanceMetric`. +- Outbound analytics and monitoring also require the current, versioned user + consent. See [Analytics and diagnostics consent](./ANALYTICS_CONSENT.md) for + policy upgrades, legacy preference behavior, and withdrawal semantics. ## 9. Security notes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 750916a0..5d612545 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 8.54.0 version: 8.54.0(react@18.3.1) '@stellar/stellar-sdk': - specifier: ^12.3.0 - version: 12.3.0(bare-url@2.5.2) + specifier: ^17.1.0 + version: 17.1.0 '@tensorflow/tfjs': specifier: ^4.22.0 version: 4.22.0(seedrandom@3.0.5) @@ -192,7 +192,7 @@ importers: version: 17.11.0 jsdom: specifier: ^29.1.1 - version: 29.1.1 + version: 29.1.1(@noble/hashes@2.4.0) lint-staged: specifier: ^17.0.0 version: 17.3.0 @@ -216,7 +216,7 @@ importers: version: 5.4.21(@types/node@26.3.0)(terser@5.50.0) vitest: specifier: ^2.1.9 - version: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + version: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) mobile: dependencies: @@ -1983,6 +1983,13 @@ packages: '@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1': resolution: {integrity: sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==} + '@noble/ed25519@3.2.0': + resolution: {integrity: sha512-criDgRlnUA09hchYrTy/JUWPIEap5rZxQe6wDWzRx51oWWpDRcUpuNzlgPxDJaOK6AsW9c0wKcj3rKRv6t+bPQ==} + + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2436,6 +2443,10 @@ packages: '@stellar/js-xdr@3.1.2': resolution: {integrity: sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==} + '@stellar/js-xdr@5.0.0': + resolution: {integrity: sha512-HBDNKnxr+ecdaEmbZ0mcKkirOF8tXXEbWSw34P3wT40Tn3g+u+cCH17xAWZymzscq8cojHB8340pR8QNVaD32w==} + engines: {node: '>=22.0.0', pnpm: '>=10.0.0'} + '@stellar/stellar-base@12.1.1': resolution: {integrity: sha512-gOBSOFDepihslcInlqnxKZdIW9dMUO1tpOm3AtJR33K2OvpXG6SaVHCzAmCFArcCqI9zXTEiSoh70T48TmiHJA==} deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. @@ -2443,6 +2454,11 @@ packages: '@stellar/stellar-sdk@12.3.0': resolution: {integrity: sha512-F2DYFop/M5ffXF0lvV5Ezjk+VWNKg0QDX8gNhwehVU3y5LYA3WAY6VcCarMGPaG9Wdgoeh1IXXzOautpqpsltw==} + '@stellar/stellar-sdk@17.1.0': + resolution: {integrity: sha512-vf/d9KR2B7MkrJBKW+cN8GPgBbiLFrRfr2pkR41VmqI6is6EfYvzhyGztbuJ3XPmcotkCRCveweAAzw2cjDn1w==} + engines: {node: '>=22.12.0'} + hasBin: true + '@storybook/addon-a11y@8.6.18': resolution: {integrity: sha512-LFvudttdIfDTNWprA8/N1vbiWbJRrNscyt2OP9Qwi85E1d3LKLy+e8AWiqY08gpy2OUYujK7AjxfpKtNeddrxw==} peerDependencies: @@ -3216,6 +3232,9 @@ packages: axios@1.19.0: resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + b4a@1.8.1: resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: @@ -3372,6 +3391,9 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bignumber.js@11.1.5: + resolution: {integrity: sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==} + bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3608,6 +3630,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4258,10 +4284,18 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + eventsource@2.0.2: resolution: {integrity: sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==} engines: {node: '>=12.0.0'} + eventsource@4.1.1: + resolution: {integrity: sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==} + engines: {node: '>=20.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -4356,6 +4390,9 @@ packages: picomatch: optional: true + feaxios@0.0.23: + resolution: {integrity: sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -4906,6 +4943,10 @@ packages: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} + is-retry-allowed@3.0.0: + resolution: {integrity: sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==} + engines: {node: '>=12'} + is-set@2.0.3: resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} engines: {node: '>= 0.4'} @@ -6524,6 +6565,10 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + socks-proxy-agent@8.0.5: resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} engines: {node: '>= 14'} @@ -6972,6 +7017,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.6.0: + resolution: {integrity: sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -8667,7 +8716,9 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@exodus/bytes@1.15.1': {} + '@exodus/bytes@1.15.1(@noble/hashes@2.4.0)': + optionalDependencies: + '@noble/hashes': 2.4.0 '@firebase/analytics-compat@0.2.17(@firebase/app-compat@0.2.50)(@firebase/app@0.11.1)': dependencies: @@ -9497,6 +9548,10 @@ snapshots: dependencies: eslint-scope: 5.1.1 + '@noble/ed25519@3.2.0': {} + + '@noble/hashes@2.4.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -10026,6 +10081,8 @@ snapshots: '@stellar/js-xdr@3.1.2': {} + '@stellar/js-xdr@5.0.0': {} + '@stellar/stellar-base@12.1.1(bare-url@2.5.2)': dependencies: '@stellar/js-xdr': 3.1.2 @@ -10053,6 +10110,24 @@ snapshots: - debug - supports-color + '@stellar/stellar-sdk@17.1.0': + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) + '@noble/ed25519': 3.2.0 + '@noble/hashes': 2.4.0 + '@stellar/js-xdr': 5.0.0 + '@types/json-schema': 7.0.15 + axios: 1.20.0 + bignumber.js: 11.1.5 + commander: 14.0.3 + eventsource: 4.1.1 + feaxios: 0.0.23 + smol-toml: 1.9.0 + uint8array-extras: 1.6.0 + transitivePeerDependencies: + - debug + - supports-color + '@storybook/addon-a11y@8.6.18(storybook@8.6.18(prettier@3.9.6))': dependencies: '@storybook/addon-highlight': 8.6.18(storybook@8.6.18(prettier@3.9.6)) @@ -10244,7 +10319,7 @@ snapshots: '@stryker-mutator/core': 8.7.1 '@stryker-mutator/util': 8.7.1 tslib: 2.7.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) '@tensorflow/tfjs-backend-cpu@4.22.0(@tensorflow/tfjs-core@4.22.0)': dependencies: @@ -10777,7 +10852,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) transitivePeerDependencies: - supports-color @@ -10840,7 +10915,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) + vitest: 2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0) '@vitest/utils@2.0.5': dependencies: @@ -11063,6 +11138,16 @@ snapshots: - debug - supports-color + axios@1.20.0: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + b4a@1.8.1: {} babel-core@7.0.0-bridge.0(@babel/core@7.29.7): @@ -11239,6 +11324,8 @@ snapshots: dependencies: require-from-string: 2.0.2 + bignumber.js@11.1.5: {} + bignumber.js@9.3.1: {} body-parser@1.20.6: @@ -11506,6 +11593,8 @@ snapshots: commander@12.1.0: {} + commander@14.0.3: {} + commander@2.20.3: {} commondir@1.0.1: {} @@ -11711,10 +11800,10 @@ snapshots: data-uri-to-buffer@6.0.2: {} - data-urls@7.0.0: + data-urls@7.0.0(@noble/hashes@2.4.0): dependencies: whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -12273,8 +12362,14 @@ snapshots: events@3.3.0: optional: true + eventsource-parser@3.1.1: {} + eventsource@2.0.2: {} + eventsource@4.1.1: + dependencies: + eventsource-parser: 3.1.1 + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -12422,6 +12517,10 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + feaxios@0.0.23: + dependencies: + is-retry-allowed: 3.0.0 + fflate@0.8.3: {} figures@2.0.0: @@ -12779,9 +12878,9 @@ snapshots: dependencies: react-is: 16.13.1 - html-encoding-sniffer@6.0.0: + html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) transitivePeerDependencies: - '@noble/hashes' @@ -13054,6 +13153,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + is-retry-allowed@3.0.0: {} + is-set@2.0.3: {} is-shared-array-buffer@1.0.4: @@ -13547,17 +13648,17 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} - jsdom@29.1.1: + jsdom@29.1.1(@noble/hashes@2.4.0): dependencies: '@asamuzakjp/css-color': 5.1.11 '@asamuzakjp/dom-selector': 7.1.1 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.9(css-tree@3.2.1) - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) css-tree: 3.2.1 - data-urls: 7.0.0 + data-urls: 7.0.0(@noble/hashes@2.4.0) decimal.js: 10.6.0 - html-encoding-sniffer: 6.0.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.4.0) is-potential-custom-element-name: 1.0.1 lru-cache: 11.5.2 parse5: 8.0.1 @@ -13568,7 +13669,7 @@ snapshots: w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 - whatwg-url: 16.0.1 + whatwg-url: 16.0.1(@noble/hashes@2.4.0) xml-name-validator: 5.0.0 transitivePeerDependencies: - '@noble/hashes' @@ -15130,6 +15231,8 @@ snapshots: smart-buffer@4.2.0: {} + smol-toml@1.9.0: {} + socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 @@ -15606,6 +15709,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.6.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -15743,7 +15848,7 @@ snapshots: fsevents: 2.3.3 terser: 5.50.0 - vitest@2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1)(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0): + vitest@2.1.9(@types/node@26.3.0)(@vitest/ui@2.1.9)(jsdom@29.1.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(terser@5.50.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.15.0(@types/node@26.3.0)(typescript@5.9.3))(vite@5.4.21(@types/node@26.3.0)(terser@5.50.0)) @@ -15768,7 +15873,7 @@ snapshots: optionalDependencies: '@types/node': 26.3.0 '@vitest/ui': 2.1.9(vitest@2.1.9) - jsdom: 29.1.1 + jsdom: 29.1.1(@noble/hashes@2.4.0) transitivePeerDependencies: - less - lightningcss @@ -15816,9 +15921,9 @@ snapshots: whatwg-mimetype@5.0.0: {} - whatwg-url@16.0.1: + whatwg-url@16.0.1(@noble/hashes@2.4.0): dependencies: - '@exodus/bytes': 1.15.1 + '@exodus/bytes': 1.15.1(@noble/hashes@2.4.0) tr46: 6.0.0 webidl-conversions: 8.0.1 transitivePeerDependencies: diff --git a/src/App.tsx b/src/App.tsx index 43bcaeec..40d7f5cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,8 @@ import ChunkLoadErrorBoundary from './components/ChunkLoadErrorBoundary'; import { DeveloperTools } from './components/DeveloperTools'; import OnboardingFlow from './components/onboarding/OnboardingFlow'; import { TipProvider } from './components/ai/TipProvider'; +import AnalyticsConsentPrompt from './components/AnalyticsConsentPrompt'; +import { needsAnalyticsConsentReview } from './utils/analyticsConsent'; const DashboardLayout = lazy(() => import('./routes/DashboardLayout')); @@ -42,12 +44,22 @@ function AppLoadingFallback() { export default function App() { const [showOnboarding, setShowOnboarding] = React.useState(false); + const [showConsentPrompt, setShowConsentPrompt] = React.useState(() => needsAnalyticsConsentReview()); React.useEffect(() => { - const hasCompleted = localStorage.getItem('hasCompletedOnboarding'); - if (!hasCompleted) { - setShowOnboarding(true); + try { + const hasCompleted = localStorage.getItem('hasCompletedOnboarding'); + if (!hasCompleted) setShowOnboarding(true); + } catch { + // The app can still run when browser storage is unavailable. } + + const handleStorage = (event: StorageEvent) => { + if (event.key !== 'user-preferences' && event.key !== null) return; + setShowConsentPrompt(needsAnalyticsConsentReview()); + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); }, []); return ( @@ -56,6 +68,7 @@ export default function App() { {showOnboarding && setShowOnboarding(false)} />} + {showConsentPrompt && setShowConsentPrompt(false)} />} }> diff --git a/src/components/AnalyticsConsentPrompt.tsx b/src/components/AnalyticsConsentPrompt.tsx new file mode 100644 index 00000000..7d46c557 --- /dev/null +++ b/src/components/AnalyticsConsentPrompt.tsx @@ -0,0 +1,143 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { saveAnalyticsConsentDecision } from '../utils/analyticsConsent'; + +interface AnalyticsConsentPromptProps { + onDecision: () => void; +} + +export default function AnalyticsConsentPrompt({ onDecision }: AnalyticsConsentPromptProps) { + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const dialogRef = useRef(null); + const declineButtonRef = useRef(null); + + useEffect(() => { + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + declineButtonRef.current?.focus(); + return () => previousFocus?.focus(); + }, []); + + function decide(allowed: boolean) { + setSaving(true); + setError(null); + try { + saveAnalyticsConsentDecision(allowed); + onDecision(); + } catch { + setError(allowed + ? 'Your choice could not be saved. Analytics and diagnostics remain off; try again after checking your browser storage settings.' + : 'Your choice could not be saved. Analytics and diagnostics are off for this session. You can continue, but review this choice again before closing the app.'); + } finally { + setSaving(false); + } + } + + function keepFocusInsideDialog(event: React.KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + return; + } + if (event.key !== 'Tab' || !dialogRef.current) return; + + const buttons = Array.from(dialogRef.current.querySelectorAll('button:not(:disabled)')); + if (buttons.length === 0) return; + const first = buttons[0]; + const last = buttons[buttons.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + + return ( +
+
+

+ Privacy choice +

+ + + {error && ( +

+ {error} +

+ )} +
+ {error && ( + + )} + + +
+
+
+ ); +} + +const buttonStyle: React.CSSProperties = { + minHeight: 40, + padding: '9px 14px', + border: '1px solid var(--border-bright)', + borderRadius: 'var(--radius-sm)', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + fontSize: 12, + fontWeight: 600, + cursor: 'pointer', +}; diff --git a/src/components/dashboard/Contracts.tsx b/src/components/dashboard/Contracts.tsx index b28de569..eb11f872 100644 --- a/src/components/dashboard/Contracts.tsx +++ b/src/components/dashboard/Contracts.tsx @@ -594,6 +594,7 @@ export default function Contracts() {
+ )} {contractError && (
{contractError} @@ -634,7 +635,6 @@ export default function Contracts() {
)} - {contractData && ( diff --git a/src/components/dashboard/Settings.tsx b/src/components/dashboard/Settings.tsx index 8991b940..06b4c87e 100644 --- a/src/components/dashboard/Settings.tsx +++ b/src/components/dashboard/Settings.tsx @@ -17,7 +17,6 @@ import { type DashboardLayout, type LayoutHistoryEntry, } from "../../lib/dashboardLayouts"; -import { revokeSentryConsent } from "../../utils/monitoring"; const SESSION_API_KEY = 'stellar_custom_api_key'; @@ -173,9 +172,10 @@ export default function Settings() { const [installOutcome, setInstallOutcome] = useState(null); const [updateReady, setUpdateReady] = useState(false); const [offline, setOffline] = useState(false); - const [layoutModalTab, setLayoutModalTab] = useState<"export" | "import" | null>(null); - const [activeLayout, setActiveLayout] = useState(null); - const [layoutNotice, setLayoutNotice] = useState(null); + const [layoutModalTab, setLayoutModalTab] = useState<"export" | "import" | null>(null); + const [activeLayout, setActiveLayout] = useState(null); + const [layoutNotice, setLayoutNotice] = useState(null); + const [consentSaveError, setConsentSaveError] = useState(null); /** Load the layout the user is currently looking at, so Export has something to export. */ const openLayoutModal = useCallback(async (tab: "export" | "import") => { @@ -449,34 +449,39 @@ export default function Settings() {

Privacy & Diagnostics

-
-

Allow diagnostic data collection

-

- Share crash reports and performance data to help improve Stellar Dev Dashboard. -

-

- Data Retention Policy: Diagnostic data is anonymized and retained for a maximum of 30 days. It is used exclusively to improve application reliability. -

-
-
- { - const consent = e.target.checked; - setPreference('diagnosticsConsent', consent); - if (!consent) { - revokeSentryConsent(); - } - }} +
+

Allow analytics and diagnostic data collection

+

+ Share page activity, browser and device details, performance measurements, redacted crash reports, and masked session replay data for errors to help improve Stellar Dev Dashboard. Data is sent only to configured monitoring providers and retained for up to 30 days. +

+

+ This setting is optional. You can withdraw consent at any time; material policy changes require a new review. +

+
+
+ { + const consent = e.target.checked; + try { + setPreference('analyticsConsent', consent); + setConsentSaveError(null); + } catch { + setConsentSaveError(consent + ? 'Your choice could not be saved. Analytics remains off.' + : 'Consent was withdrawn for this session, but the choice could not be saved. Retry before closing the app.'); + } + }} style={{ width: '16px', height: '16px', cursor: 'pointer' }} - aria-label="Toggle diagnostics consent" - /> - {preferences.diagnosticsConsent ? 'Enabled' : 'Disabled'} -
-
-
-
+ aria-label="Toggle analytics and diagnostics consent" + /> + {preferences.analyticsConsent ? 'Enabled' : 'Disabled'} + + + {consentSaveError &&

{consentSaveError}

} + +

Extensions

diff --git a/src/components/dashboard/TransactionSigner.tsx b/src/components/dashboard/TransactionSigner.tsx index 75defe21..32689206 100644 --- a/src/components/dashboard/TransactionSigner.tsx +++ b/src/components/dashboard/TransactionSigner.tsx @@ -66,7 +66,6 @@ export default function TransactionSigner() { const bio = useBehavioralBiometrics(walletPublicKey); const [accountInfo, setAccountInfo] = useState(null); - const networkPassphrase = NETWORKS[network].passphrase; useEffect(() => { async function fetchPreferences() { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 8eb758df..014d5217 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { getActiveProfileName, loadConfigProfiles, @@ -12,6 +12,7 @@ import { savePreferences, updatePreference, } from "../utils/preferences"; +import { saveAnalyticsConsentDecision } from "../utils/analyticsConsent"; export interface ConfigProfile { name: string; @@ -24,6 +25,9 @@ export interface SettingsPreferences { autoRefreshDashboard: boolean; defaultSearchScope: string; diagnosticsConsent: boolean; + analyticsConsent: boolean; + analyticsConsentPolicyVersion: string | null; + analyticsConsentReviewedVersion: string | null; [key: string]: unknown; } @@ -44,6 +48,17 @@ export function useSettings(): UseSettingsReturn { const [activeProfileName, setActiveNameState] = useState(() => getActiveProfileName() as string); const [preferences, setPreferences] = useState(() => loadPreferences() as SettingsPreferences); + useEffect(() => { + if (typeof window === 'undefined') return; + const handleStorage = (event: StorageEvent) => { + if (event.key === 'user-preferences' || event.key === null) { + setPreferences(loadPreferences() as SettingsPreferences); + } + }; + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, []); + const activeProfile = useMemo(() => { return ( profiles.find((profile) => profile.name === activeProfileName) || { @@ -76,6 +91,20 @@ export function useSettings(): UseSettingsReturn { } function setPreference(key: string, value: unknown): void { + if (key === "analyticsConsent" || key === "diagnosticsConsent") { + const allowed = value === true; + try { + setPreferences(saveAnalyticsConsentDecision(allowed) as SettingsPreferences); + } catch (error) { + setPreferences({ + ...loadPreferences(), + analyticsConsent: false, + diagnosticsConsent: false, + } as SettingsPreferences); + throw error; + } + return; + } setPreferences(updatePreference(key, value) as SettingsPreferences); } diff --git a/src/lib/__tests__/transactionBuilder.property.test.ts b/src/lib/__tests__/transactionBuilder.property.test.ts index ff4c9939..2f4a0f57 100644 --- a/src/lib/__tests__/transactionBuilder.property.test.ts +++ b/src/lib/__tests__/transactionBuilder.property.test.ts @@ -4,7 +4,6 @@ import { describe, it, expect } from "vitest"; const NETWORK_PASSPHRASE = StellarSdk.Networks.TESTNET; const BASE_FEE = "100"; -const MIN_AMOUNT = 0.0000001; const MAX_SAFE_AMOUNT = 9000000000000000; function keypairArb() { @@ -35,8 +34,6 @@ function formatStellarAmount(stroops: bigint): string { function validAmountArb() { return fc - .bigInt({ min: 1n, max: 10000000000000000n }) - .map((stroops) => (Number(stroops) / 10000000).toFixed(7)); .bigInt({ min: 1n, max: 1_000_000_000n * 10_000_000n }) .map(formatStellarAmount); } @@ -200,10 +197,6 @@ describe("Property-based: Amount boundary rejection", () => { it("rejects zero, negative, and extreme amounts in payment operations", () => { fc.assert( fc.property(publicKeyArb(), extremeAmountArb(), (dest, amount) => { - const source = buildAccount(); - const numAmount = typeof amount === 'number' ? amount : parseFloat(String(amount)); - - if (numAmount < MIN_AMOUNT || !isFinite(numAmount) || isNaN(numAmount) || numAmount > MAX_SAFE_AMOUNT) { const num = Number(amount); const isInvalid = !/^\d+(\.\d{1,7})?$/.test(amount) || isNaN(num) || num <= 0 || !isFinite(num) || num > MAX_SAFE_AMOUNT; diff --git a/src/lib/errorReporting.ts b/src/lib/errorReporting.ts index cee420bb..8cabed7f 100644 --- a/src/lib/errorReporting.ts +++ b/src/lib/errorReporting.ts @@ -1,4 +1,6 @@ import { createLogger } from '../utils/logger'; +import { hasCurrentAnalyticsConsent, subscribeToAnalyticsConsent } from '../utils/analyticsConsent'; +import { guardProviderSend } from '../utils/providerCircuitBreaker'; import { redactError, redactString, redactUrl, redactValue } from './observability/redact'; const logger = createLogger('ErrorReporting'); @@ -82,6 +84,19 @@ export interface Breadcrumb { let errorQueue: ErrorReport[] = []; let errorCount = 0; let sessionId = generateSessionId(); +let errorReportingRequested = ERROR_REPORTING_CONFIG.enabled; +const activeErrorRequests = new Set(); +let errorListenersAttached = false; + +subscribeToAnalyticsConsent(allowed => { + ERROR_REPORTING_CONFIG.enabled = errorReportingRequested && allowed; + if (!allowed) { + errorQueue = []; + activeErrorRequests.forEach(controller => controller.abort()); + activeErrorRequests.clear(); + clearErrorData(); + } +}); function generateSessionId(): string { return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; @@ -180,7 +195,7 @@ function getNetworkInfo() { } export const reportError = (error: unknown, errorInfo: Record | null = null): void => { - if (!ERROR_REPORTING_CONFIG.enabled || errorCount >= ERROR_REPORTING_CONFIG.maxErrorsPerSession) { + if (!hasCurrentAnalyticsConsent() || !ERROR_REPORTING_CONFIG.enabled || errorCount >= ERROR_REPORTING_CONFIG.maxErrorsPerSession) { return; } @@ -218,7 +233,7 @@ export const reportError = (error: unknown, errorInfo: Record | category: errorReport.category, severity: errorReport.severity, url: userContext.url, - }, error instanceof Error ? error : new Error(String(error))); + }, undefined, error instanceof Error ? error : new Error(String(error))); errorQueue.push(errorReport); @@ -245,6 +260,7 @@ export const reportError = (error: unknown, errorInfo: Record | let breadcrumbs: Breadcrumb[] = []; export const addBreadcrumb = (message: string, category = 'info', data: Record = {}): void => { + if (!hasCurrentAnalyticsConsent()) return; breadcrumbs.push({ timestamp: new Date().toISOString(), message: redactString(message), @@ -262,12 +278,15 @@ function getBreadcrumbs(): Breadcrumb[] { } async function flushErrorQueue(): Promise { - if (errorQueue.length === 0) return; + if (!hasCurrentAnalyticsConsent() || !ERROR_REPORTING_CONFIG.enabled || errorQueue.length === 0) return; const errorsToSend = [...errorQueue]; errorQueue = []; if (ERROR_REPORTING_CONFIG.endpoint) { + if (typeof AbortController === 'undefined') return; + const controller = new AbortController(); + activeErrorRequests.add(controller); try { // Error reporting is delivery-oriented: `fail-closed` propagates failures // so the batch is re-queued below and retried after the breaker cools down. @@ -283,7 +302,8 @@ async function flushErrorQueue(): Promise { errors: errorsToSend, sessionId, timestamp: new Date().toISOString() - }) + }), + signal: controller.signal, }); if (!response.ok) { throw new Error(`Error reporting endpoint responded ${response.status}`); @@ -292,13 +312,20 @@ async function flushErrorQueue(): Promise { { failureThreshold: 5, successThreshold: 2, timeout: 60000 }, ); } catch (e) { - console.error('Failed to send errors to reporting service:', e); - errorQueue.unshift(...errorsToSend); + if (!controller.signal.aborted) { + console.error('Failed to send errors to reporting service:', e); + } + if (hasCurrentAnalyticsConsent() && ERROR_REPORTING_CONFIG.enabled) { + errorQueue.unshift(...errorsToSend); + } + } finally { + activeErrorRequests.delete(controller); } } } export const reportWarning = (message: string, data: Record | null = null, category = 'warning'): void => { + if (!hasCurrentAnalyticsConsent()) return; const safeMessage = redactString(message); const safeData = data ? redactValue>(data) : data; const warningReport = { @@ -316,6 +343,7 @@ export const reportWarning = (message: string, data: Record | n }; export const reportPerformance = (metric: string, value: number, context: Record = {}): void => { + if (!hasCurrentAnalyticsConsent()) return; const performanceReport = { id: `perf-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, level: 'info', @@ -332,7 +360,13 @@ export const reportPerformance = (metric: string, value: number, context: Record }; export const initializeErrorReporting = (config: Partial = {}): void => { - Object.assign(ERROR_REPORTING_CONFIG, config); + const { enabled, ...otherConfig } = config; + Object.assign(ERROR_REPORTING_CONFIG, otherConfig); + if (typeof enabled === 'boolean') errorReportingRequested = enabled; + ERROR_REPORTING_CONFIG.enabled = errorReportingRequested && hasCurrentAnalyticsConsent(); + + if (errorListenersAttached || typeof window === 'undefined') return; + errorListenersAttached = true; setInterval(flushErrorQueue, ERROR_REPORTING_CONFIG.flushInterval); window.addEventListener('beforeunload', flushErrorQueue); diff --git a/src/lib/logging/logger.ts b/src/lib/logging/logger.ts index 9b0c33ea..2b42a6e8 100644 --- a/src/lib/logging/logger.ts +++ b/src/lib/logging/logger.ts @@ -243,8 +243,17 @@ export class Logger { this.addLog(this.createLogEntry(LogLevel.INFO, message, context, tags)); } - warn(message: string, context?: Record, tags?: string[]) { - this.addLog(this.createLogEntry(LogLevel.WARN, message, context, tags)); + warn(message: string, context?: Record, tags?: string[], error?: Error) { + const entry = this.createLogEntry(LogLevel.WARN, message, context, tags); + if (error) { + entry.stack = error.stack; + entry.context = { + ...(entry.context || {}), + errorName: error.name, + errorMessage: redactSensitive(error.message), + }; + } + this.addLog(entry); } error(message: string, context?: Record, tags?: string[], error?: Error) { @@ -283,7 +292,9 @@ export class Logger { subscribe(callback: (entry: LogEntry) => void) { this.subscribers.add(callback); - return () => this.subscribers.delete(callback); + return () => { + this.subscribers.delete(callback); + }; } getLogs(filter?: LogFilter): LogEntry[] { diff --git a/src/lib/performance.ts b/src/lib/performance.ts index 93a86f99..8dd3444a 100644 --- a/src/lib/performance.ts +++ b/src/lib/performance.ts @@ -1,6 +1,9 @@ import { logger } from './logging'; -import { guardProviderSend } from '../utils/providerCircuitBreaker'; +import { + hasCurrentAnalyticsConsent, + subscribeToAnalyticsConsent, +} from '../utils/analyticsConsent'; type PerfConfig = { rumEndpoint?: string; // optional endpoint to send RUM events @@ -20,24 +23,36 @@ const defaultConfig: PerfConfig = { }, }; +const activeObservers: PerformanceObserver[] = []; +const activeRequests = new Set(); +let monitoringConfig: PerfConfig | null = null; +let consentSubscription: (() => void) | null = null; +let performanceMonitoringActive = false; + function sendEvent(endpoint: string | undefined, payload: any) { + if (!hasCurrentAnalyticsConsent()) return; if (!endpoint) { // Fallback: debug log for local dev logger.debug('[RUM]', payload); return; } - try { - navigator.sendBeacon - ? navigator.sendBeacon(endpoint, JSON.stringify(payload)) - : void fetch(endpoint, { method: 'POST', body: JSON.stringify(payload), keepalive: true }); - } catch (e) { - logger.warn('RUM send failed', { error: e }); - } + if (typeof fetch !== 'function' || typeof AbortController === 'undefined') return; + const controller = new AbortController(); + activeRequests.add(controller); + void fetch(endpoint, { + method: 'POST', + body: JSON.stringify(payload), + keepalive: true, + signal: controller.signal, + }).catch(e => { + if (!controller.signal.aborted) logger.warn('RUM send failed', { error: e }); + }).finally(() => activeRequests.delete(controller)); } -export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { - const cfg = { ...defaultConfig, ...userConfig }; +function startPerformanceMonitoring(cfg: PerfConfig) { + if (performanceMonitoringActive || !hasCurrentAnalyticsConsent() || typeof window === 'undefined') return; + performanceMonitoringActive = true; // Page load metrics (Navigation Timing / Paint) if ('performance' in window) { @@ -85,6 +100,7 @@ export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { sendEvent(cfg.rumEndpoint, { type: 'budget_violation', metric: 'lcp', value: lcp }); } }); + activeObservers.push(po); po.observe({ type: 'largest-contentful-paint', buffered: true }); } } catch (e) { @@ -109,6 +125,7 @@ export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { sendEvent(cfg.rumEndpoint, { type: 'budget_violation', metric: 'cls', value: cls }); } }); + activeObservers.push(poCLS); poCLS.observe({ type: 'layout-shift', buffered: true }); } } catch (e) { @@ -131,6 +148,7 @@ export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { } } }); + activeObservers.push(poFID); poFID.observe({ type: 'first-input', buffered: true }); } } catch (e) { @@ -174,6 +192,29 @@ export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { }; } +function stopPerformanceMonitoring() { + performanceMonitoringActive = false; + activeObservers.forEach(observer => observer.disconnect()); + activeObservers.length = 0; + activeRequests.forEach(controller => controller.abort()); + activeRequests.clear(); + if (typeof window !== 'undefined') delete (window as Window & { __perf?: unknown }).__perf; +} + +export function initPerformanceMonitoring(userConfig: PerfConfig = {}) { + monitoringConfig = { ...defaultConfig, ...userConfig }; + if (!consentSubscription) { + consentSubscription = subscribeToAnalyticsConsent(allowed => { + if (!allowed) { + stopPerformanceMonitoring(); + } else if (monitoringConfig) { + startPerformanceMonitoring(monitoringConfig); + } + }); + } + if (hasCurrentAnalyticsConsent()) startPerformanceMonitoring(monitoringConfig); +} + export default { initPerformanceMonitoring, }; diff --git a/src/lib/tests/contractInvoker.test.ts b/src/lib/tests/contractInvoker.test.ts index afa1ea61..239b1f83 100644 --- a/src/lib/tests/contractInvoker.test.ts +++ b/src/lib/tests/contractInvoker.test.ts @@ -253,7 +253,6 @@ describe('Contract Invoker Flows', () => { owner: MOCK_PUBKEY } }); - }); }); }); @@ -319,4 +318,4 @@ describe('Contract Invoker Flows', () => { expect(meta).toEqual({}); }); }); -}); \ No newline at end of file +}); diff --git a/src/utils/__tests__/analytics.circuitBreaker.test.ts b/src/utils/__tests__/analytics.circuitBreaker.test.ts index b13db209..1388688f 100644 --- a/src/utils/__tests__/analytics.circuitBreaker.test.ts +++ b/src/utils/__tests__/analytics.circuitBreaker.test.ts @@ -1,9 +1,21 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { trackEvent, flushEvents, setAnalyticsEndpoint } from '../analytics'; import { getProviderStats, resetProviderCircuitBreaker } from '../providerCircuitBreaker'; +import { ANALYTICS_POLICY_VERSION } from '../preferences'; +import { syncAnalyticsConsentFromStorage } from '../analyticsConsent'; + +vi.mock('../logger', () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); describe('analytics provider circuit breaker', () => { beforeEach(() => { + localStorage.setItem('user-preferences', JSON.stringify({ + analyticsConsent: true, + analyticsConsentPolicyVersion: ANALYTICS_POLICY_VERSION, + analyticsConsentReviewedVersion: ANALYTICS_POLICY_VERSION, + })); + syncAnalyticsConsentFromStorage(); resetProviderCircuitBreaker('analytics'); setAnalyticsEndpoint('https://analytics.example.test/collect'); }); @@ -13,6 +25,8 @@ describe('analytics provider circuit breaker', () => { vi.restoreAllMocks(); resetProviderCircuitBreaker('analytics'); setAnalyticsEndpoint(null); + localStorage.removeItem('user-preferences'); + syncAnalyticsConsentFromStorage(); }); it('delivers queued events through the breaker on success', async () => { diff --git a/src/utils/__tests__/analyticsConsent.test.ts b/src/utils/__tests__/analyticsConsent.test.ts new file mode 100644 index 00000000..86b7c17d --- /dev/null +++ b/src/utils/__tests__/analyticsConsent.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + hasCurrentAnalyticsConsent, + needsAnalyticsConsentReview, + saveAnalyticsConsentDecision, +} from '../analyticsConsent'; +import { ANALYTICS_POLICY_VERSION } from '../preferences'; +import { flushEvents, setAnalyticsEndpoint, trackEvent } from '../analytics'; +import { resetProviderCircuitBreaker } from '../providerCircuitBreaker'; + +vi.mock('../logger', () => ({ + createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +describe('versioned analytics consent', () => { + beforeEach(() => { + localStorage.clear(); + resetProviderCircuitBreaker('analytics'); + setAnalyticsEndpoint('https://analytics.example.test/collect'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + localStorage.clear(); + setAnalyticsEndpoint(null); + resetProviderCircuitBreaker('analytics'); + // Clearing saved preferences revokes the module's in-memory test override. + saveAnalyticsConsentDecision(false); + }); + + it('records an explicit choice against the reviewed policy version', () => { + expect(hasCurrentAnalyticsConsent()).toBe(false); + expect(needsAnalyticsConsentReview()).toBe(true); + + saveAnalyticsConsentDecision(true); + + expect(hasCurrentAnalyticsConsent()).toBe(true); + expect(needsAnalyticsConsentReview()).toBe(false); + expect(JSON.parse(localStorage.getItem('user-preferences') || '{}')).toMatchObject({ + analyticsConsent: true, + diagnosticsConsent: true, + analyticsConsentPolicyVersion: ANALYTICS_POLICY_VERSION, + analyticsConsentReviewedVersion: ANALYTICS_POLICY_VERSION, + }); + + saveAnalyticsConsentDecision(false); + expect(hasCurrentAnalyticsConsent()).toBe(false); + }); + + it('requires review and disables tracking when an accepted policy version is stale', () => { + localStorage.setItem('user-preferences', JSON.stringify({ + analyticsConsent: true, + diagnosticsConsent: true, + analyticsConsentPolicyVersion: 'older-policy', + analyticsConsentReviewedVersion: 'older-policy', + })); + + expect(hasCurrentAnalyticsConsent()).toBe(false); + expect(needsAnalyticsConsentReview()).toBe(true); + }); + + it('treats malformed stored preferences and invalid choices as no consent', () => { + localStorage.setItem('user-preferences', '{invalid json'); + + expect(hasCurrentAnalyticsConsent()).toBe(false); + expect(needsAnalyticsConsentReview()).toBe(true); + expect(() => saveAnalyticsConsentDecision('yes' as unknown as boolean)).toThrow(TypeError); + }); + + it('does not keep telemetry enabled when the accepted and reviewed versions disagree', () => { + localStorage.setItem('user-preferences', JSON.stringify({ + analyticsConsent: true, + analyticsConsentPolicyVersion: ANALYTICS_POLICY_VERSION, + analyticsConsentReviewedVersion: 'older-policy', + })); + + expect(hasCurrentAnalyticsConsent()).toBe(false); + expect(needsAnalyticsConsentReview()).toBe(true); + }); + + it('fails closed when browser storage cannot save an opt-in', () => { + vi.spyOn(localStorage, 'setItem').mockImplementation(() => { + throw new Error('storage unavailable'); + }); + + expect(() => saveAnalyticsConsentDecision(true)).toThrow('storage unavailable'); + expect(hasCurrentAnalyticsConsent()).toBe(false); + }); + + it('keeps telemetry off when local storage is unavailable', () => { + vi.stubGlobal('localStorage', undefined); + + expect(hasCurrentAnalyticsConsent()).toBe(false); + expect(needsAnalyticsConsentReview()).toBe(true); + expect(() => saveAnalyticsConsentDecision(true)).toThrow('Preferences cannot be saved'); + expect(hasCurrentAnalyticsConsent()).toBe(false); + }); + + it('drops queued analytics immediately when consent is withdrawn', async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 }); + vi.stubGlobal('fetch', fetchMock); + saveAnalyticsConsentDecision(true); + + trackEvent('before-withdrawal'); + saveAnalyticsConsentDecision(false); + await flushEvents(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('aborts an in-flight analytics request when consent is withdrawn', async () => { + let requestSignal: AbortSignal | undefined; + const fetchMock = vi.fn((_url: string, options: { signal?: AbortSignal } = {}) => { + requestSignal = options.signal as AbortSignal; + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }); + }); + vi.stubGlobal('fetch', fetchMock); + saveAnalyticsConsentDecision(true); + trackEvent('in-flight'); + + const flush = flushEvents(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + saveAnalyticsConsentDecision(false); + await flush; + + expect(requestSignal?.aborted).toBe(true); + }); +}); diff --git a/src/utils/__tests__/monitoring.consent.test.ts b/src/utils/__tests__/monitoring.consent.test.ts index e48910c3..95ae684d 100644 --- a/src/utils/__tests__/monitoring.consent.test.ts +++ b/src/utils/__tests__/monitoring.consent.test.ts @@ -21,9 +21,24 @@ vi.mock('@sentry/react', () => { }); vi.mock('../preferences', () => ({ + ANALYTICS_POLICY_VERSION: '1', loadPreferences: vi.fn(), + savePreferences: vi.fn((value) => value), })); +function consentPreferences(allowed: boolean) { + return { + compactMode: false, + showAdvancedPanels: true, + autoRefreshDashboard: true, + defaultSearchScope: 'all', + diagnosticsConsent: allowed, + analyticsConsent: allowed, + analyticsConsentPolicyVersion: allowed ? '1' : null, + analyticsConsentReviewedVersion: allowed ? '1' : null, + } as ReturnType; +} + vi.mock('../logger', () => ({ createLogger: vi.fn(() => ({ info: vi.fn(), @@ -40,10 +55,11 @@ describe('monitoring Sentry consent', () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); it('initializes Sentry when diagnosticsConsent is true', async () => { - vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true }); + vi.mocked(preferences.loadPreferences).mockReturnValue(consentPreferences(true)); const monitoring = await import('../monitoring'); monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' }); @@ -54,7 +70,7 @@ describe('monitoring Sentry consent', () => { }); it('does not initialize Sentry when diagnosticsConsent is false (defaults to no consent)', async () => { - vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: false }); + vi.mocked(preferences.loadPreferences).mockReturnValue(consentPreferences(false)); const monitoring = await import('../monitoring'); monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' }); @@ -63,7 +79,7 @@ describe('monitoring Sentry consent', () => { }); it('applies external provider circuit-breaker policies during init', async () => { - vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true }); + vi.mocked(preferences.loadPreferences).mockReturnValue(consentPreferences(true)); const monitoring = await import('../monitoring'); monitoring.initMonitoring({ @@ -76,7 +92,7 @@ describe('monitoring Sentry consent', () => { }); it('closes Sentry client when consent is revoked', async () => { - vi.mocked(preferences.loadPreferences).mockReturnValue({ diagnosticsConsent: true }); + vi.mocked(preferences.loadPreferences).mockReturnValue(consentPreferences(true)); const monitoring = await import('../monitoring'); monitoring.revokeSentryConsent(); @@ -85,6 +101,29 @@ describe('monitoring Sentry consent', () => { expect(getClient).toHaveBeenCalled(); const client = getClient(); - expect(client?.close).toHaveBeenCalledWith(2000); + expect(client?.close).toHaveBeenCalledWith(0); + }); + + it('disconnects performance observers immediately when consent is withdrawn', async () => { + vi.mocked(preferences.loadPreferences).mockReturnValue(consentPreferences(true)); + const disconnects: Array> = []; + class MockPerformanceObserver { + disconnect = vi.fn(); + observe = vi.fn(); + + constructor(_callback: unknown) { + disconnects.push(this.disconnect); + } + } + vi.stubGlobal('PerformanceObserver', MockPerformanceObserver); + + const monitoring = await import('../monitoring'); + const consent = await import('../analyticsConsent'); + monitoring.initMonitoring({ sentryDsn: 'http://test-dsn@sentry.io/1' }); + + expect(disconnects.length).toBeGreaterThan(0); + consent.saveAnalyticsConsentDecision(false); + + expect(disconnects.every(disconnect => disconnect.mock.calls.length === 1)).toBe(true); }); }); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 4718b87c..494fb132 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -8,6 +8,10 @@ */ import { guardProviderSend } from './providerCircuitBreaker'; +import { + hasCurrentAnalyticsConsent, + subscribeToAnalyticsConsent, +} from './analyticsConsent'; const analyticsConfig = { enabled: true, @@ -18,12 +22,20 @@ const analyticsConfig = { }; let eventQueue = []; +const activeFlushes = new Set(); + +subscribeToAnalyticsConsent(allowed => { + if (allowed) return; + eventQueue = []; + activeFlushes.forEach(controller => controller.abort()); + activeFlushes.clear(); +}); /** * Track a custom event */ export const trackEvent = (eventName, properties = {}) => { - if (!analyticsConfig.enabled) return; + if (!analyticsConfig.enabled || !hasCurrentAnalyticsConsent()) return; const event = { name: eventName, @@ -91,36 +103,45 @@ export const trackApiCall = (endpoint, method, duration, status) => { * Flush pending events to analytics endpoint */ export const flushEvents = async () => { - if (eventQueue.length === 0 || !analyticsConfig.endpoint) return; + if (!analyticsConfig.enabled || !hasCurrentAnalyticsConsent() || eventQueue.length === 0 || !analyticsConfig.endpoint) return; const eventsToSend = [...eventQueue]; eventQueue = []; - - const result = await guardProviderSend( - 'analytics', - async () => { - const response = await fetch(analyticsConfig.endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - events: eventsToSend, - sessionId: analyticsConfig.sessionId, - timestamp: new Date().toISOString(), - }), - }); - if (!response.ok) { - throw new Error(`Analytics endpoint responded ${response.status}`); - } - }, - { failureThreshold: 3, successThreshold: 1, timeout: 30000 }, - ); - - if (!result.delivered && !result.skipped) { - // Transient failure while the circuit is still closed — keep the batch. - // When the circuit is OPEN (`skipped`) the batch is intentionally dropped - // so the queue cannot grow without bound while the provider is down. - console.error('Analytics flush failed:', result.error); - eventQueue.unshift(...eventsToSend); + if (typeof AbortController === 'undefined') return; + const controller = new AbortController(); + activeFlushes.add(controller); + + try { + const result = await guardProviderSend( + 'analytics', + async () => { + if (!hasCurrentAnalyticsConsent()) return; + const response = await fetch(analyticsConfig.endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + events: eventsToSend, + sessionId: analyticsConfig.sessionId, + timestamp: new Date().toISOString(), + }), + signal: controller.signal, + }); + if (!response.ok) { + throw new Error(`Analytics endpoint responded ${response.status}`); + } + }, + { failureThreshold: 3, successThreshold: 1, timeout: 30000 }, + ); + + if (!result.delivered && !result.skipped && hasCurrentAnalyticsConsent()) { + // Transient failure while the circuit is still closed — keep the batch. + // When the circuit is OPEN (`skipped`) the batch is intentionally dropped + // so the queue cannot grow without bound while the provider is down. + console.error('Analytics flush failed:', result.error); + eventQueue.unshift(...eventsToSend); + } + } finally { + activeFlushes.delete(controller); } }; diff --git a/src/utils/analyticsConsent.ts b/src/utils/analyticsConsent.ts new file mode 100644 index 00000000..1f576f31 --- /dev/null +++ b/src/utils/analyticsConsent.ts @@ -0,0 +1,85 @@ +import { + ANALYTICS_POLICY_VERSION, + loadPreferences, + savePreferences, +} from './preferences'; + +export type AnalyticsConsentListener = (_allowed: boolean) => void; + +const PREFERENCES_KEY = 'user-preferences'; +const consentListeners = new Set(); +let sessionConsentOverride: boolean | null = null; + +function storedConsentIsCurrent(): boolean { + const preferences = loadPreferences(); + return preferences.analyticsConsent === true + && preferences.analyticsConsentPolicyVersion === ANALYTICS_POLICY_VERSION + && preferences.analyticsConsentReviewedVersion === ANALYTICS_POLICY_VERSION; +} + +/** Consent is opt-in and only applies to the policy version the user reviewed. */ +export function hasCurrentAnalyticsConsent(): boolean { + return sessionConsentOverride ?? storedConsentIsCurrent(); +} + +/** A new or materially changed policy must be reviewed once before tracking. */ +export function needsAnalyticsConsentReview(): boolean { + return loadPreferences().analyticsConsentReviewedVersion !== ANALYTICS_POLICY_VERSION; +} + +export function subscribeToAnalyticsConsent(listener: AnalyticsConsentListener): () => void { + consentListeners.add(listener); + return () => consentListeners.delete(listener); +} + +function notifyConsentChange(allowed: boolean): void { + consentListeners.forEach(listener => { + try { + listener(allowed); + } catch { + // A consent listener must not prevent the user's decision from taking effect. + } + }); +} + +/** + * Persist an explicit choice. Withdrawal is applied in memory before storage is + * touched; granting remains fail-closed until its versioned choice is saved. + */ +export function saveAnalyticsConsentDecision(allowed: boolean) { + if (typeof allowed !== 'boolean') { + throw new TypeError('Analytics consent must be a boolean.'); + } + + if (!allowed) { + sessionConsentOverride = false; + notifyConsentChange(false); + } + + const current = loadPreferences(); + const saved = savePreferences({ + ...current, + analyticsConsent: allowed, + diagnosticsConsent: allowed, + analyticsConsentPolicyVersion: ANALYTICS_POLICY_VERSION, + analyticsConsentReviewedVersion: ANALYTICS_POLICY_VERSION, + }); + + sessionConsentOverride = allowed; + if (allowed) notifyConsentChange(true); + return saved; +} + +/** Apply another tab's persisted choice without writing it again. */ +export function syncAnalyticsConsentFromStorage(): void { + sessionConsentOverride = storedConsentIsCurrent(); + notifyConsentChange(sessionConsentOverride); +} + +if (typeof window !== 'undefined') { + window.addEventListener('storage', event => { + if (event.key === PREFERENCES_KEY || event.key === null) { + syncAnalyticsConsentFromStorage(); + } + }); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts index a9dfebe3..57d7d0c9 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,5 +1,3 @@ -import { redactError, redactString, redactValue } from '../lib/observability/redact'; - export enum LogLevel { DEBUG = 0, INFO = 1, @@ -16,8 +14,6 @@ import { NamespaceLogger as StructuredNamespaceLogger, } from '../lib/logging/logger'; -export { LogLevel } from '../lib/logging/logger'; - export interface LogEntry { timestamp: string; level: string; @@ -75,66 +71,10 @@ export const addLogHandler = (handler: LogHandler): void => { }; export const removeLogHandler = (handler: LogHandler): void => { - logHandlers = logHandlers.filter(h => h !== handler); + const index = registeredHandlers.indexOf(handler); + if (index >= 0) registeredHandlers.splice(index, 1); }; -function formatLogEntry( - level: LogLevel, - message: string, - context: Record = {}, - error: Error | null = null -): LogEntry { - const timestamp = new Date().toISOString(); - const entry: LogEntry = { - timestamp, - level: LogLevelNames[level], - levelValue: level, - message: redactString(message), - context: redactValue>(context), - sessionId, - url: typeof window !== 'undefined' ? redactString(window.location.href) : null, - userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : null, - }; - - if (error) { - const info = redactError(error); - entry.error = { - name: info.name, - message: info.message, - stack: info.stack ?? undefined, - }; - } - - return entry; -} - -function log( - level: LogLevel, - message: string, - context: Record = {}, - error: Error | null = null -): void { - if (level < currentLogLevel) return; - - const entry = formatLogEntry(level, message, context, error); - - // Console output in development (entry is already redacted) - const consoleFn = (['debug', 'info', 'warn', 'error', 'error'] as const)[level]; - if (typeof console !== 'undefined' && console[consoleFn]) { - console[consoleFn](`[${entry.level}] ${entry.message}`, entry.context); - if (entry.error) console.error(entry.error); - } - - // Call registered handlers - logHandlers.forEach(handler => { - try { - handler(entry); - } catch (err) { - console.error('Log handler error:', err); - } - }); -} - export const debug = (message: string, context: Record = {}): void => { structuredLogger.debug(message, context); }; diff --git a/src/utils/monitoring.ts b/src/utils/monitoring.ts index b5427616..a04596ae 100644 --- a/src/utils/monitoring.ts +++ b/src/utils/monitoring.ts @@ -24,7 +24,10 @@ import { } from '../lib/errorReporting'; import { initPerformanceMonitoring } from '../lib/performance'; import { createLogger } from './logger'; -import { loadPreferences } from './preferences'; +import { + hasCurrentAnalyticsConsent, + subscribeToAnalyticsConsent, +} from './analyticsConsent'; import { setProviderPolicy, type ProviderFailurePolicy } from './providerCircuitBreaker'; const logger = createLogger('Monitoring'); @@ -63,19 +66,22 @@ const defaultConfig: MonitoringConfig = { }; let _initialised = false; +let sentryStarted = false; +let activeMonitoringConfig: MonitoringConfig | null = null; +let unsubscribeConsent: (() => void) | null = null; +const webVitalsObservers: PerformanceObserver[] = []; // ─── Sentry init ────────────────────────────────────────────────────────────── -function initialiseSentry(cfg: MonitoringConfig): void { - const prefs = loadPreferences(); - if (prefs.diagnosticsConsent !== true) { +function initialiseSentry(cfg: MonitoringConfig): boolean { + if (!hasCurrentAnalyticsConsent()) { logger.info('Sentry initialization skipped: user has not granted diagnostics consent.'); - return; + return false; } if (!cfg.sentryDsn) { logger.warn('Sentry DSN not set – error tracking disabled.', { env: cfg.environment }); - return; + return false; } Sentry.init({ @@ -108,6 +114,7 @@ function initialiseSentry(cfg: MonitoringConfig): void { // ── Scrubbing ──────────────────────────────────────────────────────────── // Strip PII / secrets from outgoing event payloads. beforeSend(event) { + if (!hasCurrentAnalyticsConsent()) return null; // Remove auth tokens from request headers recorded in the event if (event.request?.headers) { const h = event.request.headers as Record; @@ -129,6 +136,9 @@ function initialiseSentry(cfg: MonitoringConfig): void { } return event; }, + beforeSendTransaction(event) { + return hasCurrentAnalyticsConsent() ? event : null; + }, // Drop Sentry's own internal traffic and localhost noise denyUrls: [/localhost/, /127\.0\.0\.1/, /extensions\//i], @@ -139,6 +149,7 @@ function initialiseSentry(cfg: MonitoringConfig): void { release: cfg.release ?? 'unknown', tracesSampleRate: cfg.tracesSampleRate, }); + return true; } // ─── Global error listeners ─────────────────────────────────────────────────── @@ -149,6 +160,7 @@ function initialiseSentry(cfg: MonitoringConfig): void { */ function attachGlobalErrorHandlers(): void { window.addEventListener('error', (event: ErrorEvent) => { + if (!hasCurrentAnalyticsConsent()) return; const err = event.error instanceof Error ? event.error : new Error(event.message); // Sentry already captures window.onerror via its SDK, but we add extra @@ -172,6 +184,7 @@ function attachGlobalErrorHandlers(): void { }); window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => { + if (!hasCurrentAnalyticsConsent()) return; const reason = event.reason; const err = reason instanceof Error ? reason : new Error(String(reason ?? 'Unhandled rejection')); @@ -196,7 +209,8 @@ function attachGlobalErrorHandlers(): void { * and also emits a Sentry breadcrumb for quick triage. */ function attachWebVitalsBridge(): void { - if (typeof PerformanceObserver === 'undefined') return; + if (!hasCurrentAnalyticsConsent() || !sentryStarted || webVitalsObservers.length > 0 + || typeof PerformanceObserver === 'undefined') return; // LCP try { @@ -209,6 +223,7 @@ function attachWebVitalsBridge(): void { addBreadcrumb(`LCP: ${value}ms`, 'performance', { value }); }); lcpObs.observe({ type: 'largest-contentful-paint', buffered: true }); + webVitalsObservers.push(lcpObs); } catch { /* observer not supported */ } // CLS @@ -223,6 +238,7 @@ function attachWebVitalsBridge(): void { addBreadcrumb(`CLS: ${cls}`, 'performance', { value: cls }); }); clsObs.observe({ type: 'layout-shift', buffered: true }); + webVitalsObservers.push(clsObs); } catch { /* observer not supported */ } // FID / INP @@ -236,9 +252,15 @@ function attachWebVitalsBridge(): void { } }); inputObs.observe({ type: 'first-input', buffered: true }); + webVitalsObservers.push(inputObs); } catch { /* observer not supported */ } } +function detachWebVitalsBridge(): void { + webVitalsObservers.forEach(observer => observer.disconnect()); + webVitalsObservers.length = 0; +} + // ─── Public API ─────────────────────────────────────────────────────────────── /** @@ -251,6 +273,7 @@ export function initMonitoring(userConfig: Partial = {}): void _initialised = true; const cfg: MonitoringConfig = { ...defaultConfig, ...userConfig }; + activeMonitoringConfig = cfg; // 0. Circuit-breaker failure policies for external providers if (cfg.providerPolicies) { @@ -259,14 +282,37 @@ export function initMonitoring(userConfig: Partial = {}): void }); } - // 1. Sentry SDK - initialiseSentry(cfg); + // 1. Optional providers start only after the current policy has been accepted. + if (hasCurrentAnalyticsConsent()) { + try { + sentryStarted = initialiseSentry(cfg); + } catch (error) { + logger.warn('Sentry could not be initialized; monitoring remains unavailable.', { error }); + sentryStarted = false; + } + } + + unsubscribeConsent?.(); + unsubscribeConsent = subscribeToAnalyticsConsent(allowed => { + if (!allowed) { + revokeSentryConsent(); + return; + } + + if (!activeMonitoringConfig || sentryStarted) return; + try { + sentryStarted = initialiseSentry(activeMonitoringConfig); + } catch (error) { + logger.warn('Sentry could not be initialized after consent was granted.', { error }); + } + attachWebVitalsBridge(); + }); // 2. Global error capture (bridges into errorReporting + Sentry) - attachGlobalErrorHandlers(); + if (typeof window !== 'undefined') attachGlobalErrorHandlers(); // 3. Web Vitals → Sentry measurements + breadcrumbs - attachWebVitalsBridge(); + if (typeof window !== 'undefined') attachWebVitalsBridge(); // 4. Existing performance monitoring (LCP/CLS/FID budgets, RUM endpoint) initPerformanceMonitoring({ rumEndpoint: cfg.rumEndpoint }); @@ -278,10 +324,19 @@ export function initMonitoring(userConfig: Partial = {}): void } export function revokeSentryConsent(): void { - logger.info('User revoked diagnostics consent, closing Sentry client.'); - const client = Sentry.getClient(); - if (client) { - client.close(2000); // 2 second flush then close + logger.info('User revoked diagnostics consent, closing Sentry client without flushing queued events.'); + detachWebVitalsBridge(); + sentryStarted = false; + try { + Sentry.setUser(null); + const client = Sentry.getClient(); + if (client) { + void Promise.resolve(client.close(0)).catch(error => { + logger.warn('Sentry shutdown failed after consent was revoked.', { error }); + }); + } + } catch (error) { + logger.warn('Sentry could not be closed after consent was revoked.', { error }); } } @@ -295,7 +350,9 @@ export function setMonitoringUser( stellarAddress: string | null, extra?: Record, ): void { - if (stellarAddress) { + if (!hasCurrentAnalyticsConsent()) { + Sentry.setUser(null); + } else if (stellarAddress) { Sentry.setUser({ id: stellarAddress, ...extra }); } else { Sentry.setUser(null); @@ -315,6 +372,7 @@ export async function withSpan( fn: () => T | Promise, attributes?: Record, ): Promise { + if (!hasCurrentAnalyticsConsent()) return await fn(); return Sentry.startSpan({ name, attributes }, () => fn()); } @@ -326,6 +384,7 @@ export function captureError( err: unknown, context?: Record, ): void { + if (!hasCurrentAnalyticsConsent()) return; Sentry.withScope(scope => { if (context) { Object.entries(context).forEach(([k, v]) => scope.setExtra(k, v)); @@ -360,13 +419,14 @@ export interface HealthSnapshot { * Collect a synchronous snapshot of basic health metrics. */ export function collectHealthSnapshot(): HealthSnapshot { - const nav = navigator as Navigator & { deviceMemory?: number }; - const mem = (performance as Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number } }).memory; + const mem = typeof performance === 'undefined' + ? undefined + : (performance as Performance & { memory?: { usedJSHeapSize: number; totalJSHeapSize: number } }).memory; return { cpu: undefined, memory: mem ? mem.usedJSHeapSize / (mem.totalJSHeapSize || 1) : undefined, latency: 0, - uptime: performance.now(), + uptime: typeof performance === 'undefined' ? undefined : performance.now(), errors: [], }; } @@ -376,6 +436,7 @@ export function collectHealthSnapshot(): HealthSnapshot { */ export async function collectSystemHealthSnapshot(): Promise { const base = collectHealthSnapshot(); + if (typeof performance === 'undefined') return base; const entries = performance.getEntriesByType('navigation'); const nav = entries[0] as PerformanceNavigationTiming | undefined; return { diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index d1b5b361..338d8ca3 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -1,25 +1,60 @@ const PREFERENCES_KEY = "user-preferences"; +/** Bump this when the analytics or diagnostics policy changes materially. */ +export const ANALYTICS_POLICY_VERSION = '1'; + export const DEFAULT_PREFERENCES = { compactMode: false, showAdvancedPanels: true, autoRefreshDashboard: true, defaultSearchScope: "all", diagnosticsConsent: false, + analyticsConsent: false, + analyticsConsentPolicyVersion: null, + analyticsConsentReviewedVersion: null, }; +function isPreferencesRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizePreferences(value: unknown) { + const parsed = isPreferencesRecord(value) ? value : {}; + const consentIsCurrent = parsed.analyticsConsent === true + && parsed.analyticsConsentPolicyVersion === ANALYTICS_POLICY_VERSION + && parsed.analyticsConsentReviewedVersion === ANALYTICS_POLICY_VERSION; + + return { + ...DEFAULT_PREFERENCES, + ...parsed, + diagnosticsConsent: consentIsCurrent, + analyticsConsent: consentIsCurrent, + analyticsConsentPolicyVersion: typeof parsed.analyticsConsentPolicyVersion === 'string' + ? parsed.analyticsConsentPolicyVersion + : null, + analyticsConsentReviewedVersion: typeof parsed.analyticsConsentReviewedVersion === 'string' + ? parsed.analyticsConsentReviewedVersion + : null, + }; +} + export function loadPreferences() { try { + if (typeof localStorage === 'undefined') return normalizePreferences({}); const raw = localStorage.getItem(PREFERENCES_KEY); - const parsed = raw ? JSON.parse(raw) : {}; - return { ...DEFAULT_PREFERENCES, ...parsed }; + const parsed: unknown = raw ? JSON.parse(raw) : {}; + return normalizePreferences(parsed); } catch { - return { ...DEFAULT_PREFERENCES }; + return normalizePreferences({}); } } export function savePreferences(preferences) { - const next = { ...DEFAULT_PREFERENCES, ...(preferences || {}) }; + const current = loadPreferences(); + const next = normalizePreferences({ ...current, ...(isPreferencesRecord(preferences) ? preferences : {}) }); + if (typeof localStorage === 'undefined') { + throw new Error('Preferences cannot be saved in this environment.'); + } localStorage.setItem(PREFERENCES_KEY, JSON.stringify(next)); return next; }