diff --git a/docs/analytics-policy.md b/docs/analytics-policy.md new file mode 100644 index 0000000..4ba5c51 --- /dev/null +++ b/docs/analytics-policy.md @@ -0,0 +1,59 @@ +# Analytics & Privacy Policy + +## Overview + +CosmosVote includes an **opt-in**, privacy-preserving analytics module to understand how the application is used. Analytics are **disabled by default** and collect no personally identifiable information (PII). + +## What Is Collected + +| Field | Description | +|-------|-------------| +| `name` | Event name (e.g. `proposal_viewed`, `vote_cast`) | +| `ts` | Unix timestamp (milliseconds) of the event | +| `sessionId` | Anonymous UUID generated at page load; not persisted across sessions | +| `props` | Optional non-identifying metadata (e.g. proposal ID as a number) | + +### Tracked Events + +| Event | When fired | +|-------|-----------| +| `proposal_viewed` | User views a proposal detail page | +| `vote_cast` | User submits a vote | +| `wallet_connected` | User connects a wallet | +| `wallet_disconnected` | User disconnects a wallet | + +## What Is NOT Collected + +- Wallet addresses or public keys +- IP addresses +- Names, emails, or any personal identifiers +- Browser fingerprints +- Cross-session tracking identifiers + +## Data Retention + +Events are batched in memory and `localStorage` (as a write-ahead buffer) and flushed either every 30 seconds, when the batch reaches 10 events, or when the page is hidden. + +- **No persistent server storage is required.** Operators may choose to store aggregated, anonymized counts only. +- The `localStorage` key `_analytics_queue` is cleared after each successful flush. +- If no `VITE_ANALYTICS_ENDPOINT` is configured, events are only logged to the browser console in development mode and never leave the client. + +## How to Opt Out + +Analytics are **disabled by default**. To confirm they remain off, ensure your `.env` file does not set `VITE_ANALYTICS_ENABLED=true`: + +```env +# analytics disabled (default) +VITE_ANALYTICS_ENABLED=false +``` + +To enable analytics, set both variables: + +```env +VITE_ANALYTICS_ENABLED=true +VITE_ANALYTICS_ENDPOINT=https://your-analytics-endpoint.example.com/events +``` + +## Implementation + +See [`frontend/src/analytics.ts`](../frontend/src/analytics.ts). The module uses no third-party SDKs and makes no outbound requests unless `VITE_ANALYTICS_ENABLED=true` and `VITE_ANALYTICS_ENDPOINT` is set. diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts new file mode 100644 index 0000000..90da23d --- /dev/null +++ b/frontend/src/analytics.ts @@ -0,0 +1,79 @@ +/** + * Privacy-preserving analytics module. + * - No PII, no wallet addresses, no IP collection + * - Disabled by default (VITE_ANALYTICS_ENABLED must be "true" to enable) + * - Batches events and flushes to VITE_ANALYTICS_ENDPOINT if set, else logs in dev + */ + +const ENABLED = import.meta.env.VITE_ANALYTICS_ENABLED === "true"; +const ENDPOINT = import.meta.env.VITE_ANALYTICS_ENDPOINT as string | undefined; +const IS_DEV = import.meta.env.DEV; +const BATCH_SIZE = 10; +const FLUSH_INTERVAL_MS = 30_000; + +interface AnalyticsEvent { + name: string; + props?: Record; + sessionId: string; + ts: number; +} + +// Stable anonymous session ID (regenerated each page load, not persisted) +const SESSION_ID = crypto.randomUUID(); + +let queue: AnalyticsEvent[] = (() => { + try { + return JSON.parse(localStorage.getItem("_analytics_queue") ?? "[]"); + } catch { + return []; + } +})(); + +function persist() { + try { + localStorage.setItem("_analytics_queue", JSON.stringify(queue)); + } catch { + // storage unavailable — operate in-memory only + } +} + +async function flush() { + if (!queue.length) return; + const batch = queue.splice(0, queue.length); + persist(); + + if (ENDPOINT) { + try { + await fetch(ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(batch), + keepalive: true, + }); + } catch { + // Re-queue on failure + queue = [...batch, ...queue]; + persist(); + } + } else if (IS_DEV) { + console.debug("[analytics]", batch); + } +} + +export function trackEvent( + name: string, + props?: Record +): void { + if (!ENABLED) return; + queue.push({ name, props, sessionId: SESSION_ID, ts: Date.now() }); + persist(); + if (queue.length >= BATCH_SIZE) flush(); +} + +// Periodic flush +if (ENABLED) { + setInterval(flush, FLUSH_INTERVAL_MS); + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }); +}