Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/analytics-policy.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions frontend/src/analytics.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>;
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<string, string | number>
): 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();
});
}