Skip to content

Commit 892bcac

Browse files
author
teycir
committed
feat(analytics): add privacy-first analytics tracking
Implement comprehensive analytics to track key user interactions and system events while preserving user privacy. - Introduce client-side `trackEvent` for page views and general events. - Implement server-side tracking for seal creation, pulsing, and unlocking. - Add `AnalyticsService` for managing and storing analytics data. - Create new API routes (`/api/analytics`, `/api/stats`) and `SealCounter` component. - Include database migration for analytics tables. - Update documentation and `container` for D1 integration.
1 parent 9ae755a commit 892bcac

17 files changed

Lines changed: 414 additions & 22 deletions

File tree

ANALYTICS-COMPLETE.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Analytics Implementation Complete ✅
2+
3+
## What Was Done
4+
5+
### 1. Database Migration ✅
6+
- Created `migrations/003_analytics.sql`
7+
- Added `analytics_events` table
8+
- Added `analytics_summary` table
9+
- Fixed SQL syntax (separate INDEX statements)
10+
- **Executed successfully on local D1**
11+
12+
### 2. Service Layer ✅
13+
- Created `lib/analytics.ts`
14+
- `AnalyticsService` class with methods:
15+
- `trackEvent()` - Track events
16+
- `getSummary()` - Get daily summaries
17+
- `getTotalSealsCreated()` - Get total count
18+
19+
### 3. API Endpoints ✅
20+
- `POST /api/analytics` - Client-side event tracking
21+
- `GET /api/stats` - Public stats endpoint
22+
23+
### 4. Server-Side Tracking ✅
24+
- `app/api/create-seal/route.ts` - Tracks `seal_created`
25+
- `app/api/seal/[id]/route.ts` - Tracks `seal_unlocked`
26+
- `app/api/pulse/route.ts` - Tracks `pulse_received`
27+
28+
### 5. Client-Side Tracking ✅
29+
- `app/layout.tsx` - Inline script tracks `page_view`
30+
- `window.trackEvent()` function available globally
31+
32+
### 6. UI Component ✅
33+
- `app/components/SealCounter.tsx` - Displays total seals
34+
- Added to homepage below tagline
35+
36+
### 7. Documentation ✅
37+
- `docs/ANALYTICS.md` - Setup guide
38+
- `docs/README.md` - Added analytics link
39+
- `docs/TODO.md` - Updated checklist
40+
41+
## Next Steps
42+
43+
### For Production Deployment:
44+
```bash
45+
# Run migration on remote database
46+
npx wrangler d1 execute DB --file=migrations/003_analytics.sql --remote
47+
48+
# Deploy updated code
49+
npm run deploy
50+
```
51+
52+
### Verify It Works:
53+
```bash
54+
# Check stats endpoint
55+
curl https://your-domain.workers.dev/api/stats
56+
57+
# Should return: {"totalSeals": 0}
58+
```
59+
60+
## Features
61+
62+
✅ Privacy-first (no cookies, no IPs, no personal data)
63+
✅ GDPR compliant by design
64+
✅ Zero cost (uses existing D1)
65+
✅ Zero external dependencies
66+
✅ Silent failures (never breaks app)
67+
✅ Social proof (seal counter on homepage)
68+
✅ Tracks: page views, seal creation, unlocks, pulses
69+
70+
## Status: READY FOR PRODUCTION 🚀

app/api/analytics/route.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createAPIRoute } from '@/lib/routeHelper';
3+
import { AnalyticsService } from '@/lib/analytics';
4+
5+
export const runtime = 'edge';
6+
7+
const VALID_EVENT_TYPES = ['page_view', 'seal_created', 'seal_unlocked', 'pulse_received'];
8+
9+
export async function POST(request: NextRequest) {
10+
return createAPIRoute(async ({ container }) => {
11+
try {
12+
const body = await request.json();
13+
const { eventType, path, referrer } = body;
14+
15+
// Validate event type
16+
if (!eventType || !VALID_EVENT_TYPES.includes(eventType)) {
17+
return NextResponse.json({ success: false, error: 'Invalid event type' }, { status: 400 });
18+
}
19+
20+
// Validate path length
21+
if (path && path.length > 500) {
22+
return NextResponse.json({ success: false, error: 'Path too long' }, { status: 400 });
23+
}
24+
25+
// Validate referrer length
26+
if (referrer && referrer.length > 500) {
27+
return NextResponse.json({ success: false, error: 'Referrer too long' }, { status: 400 });
28+
}
29+
30+
const analytics = new AnalyticsService(container.db);
31+
const country = request.headers.get('cf-ipcountry') || undefined;
32+
33+
await analytics.trackEvent({ eventType, path, referrer, country });
34+
return NextResponse.json({ success: true });
35+
} catch (error) {
36+
console.error('[Analytics API] Error:', error);
37+
return NextResponse.json({ success: false }, { status: 200 });
38+
}
39+
}, { rateLimit: { limit: 100, window: 60000 } })(request);
40+
}

app/api/create-seal/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ export async function POST(request: NextRequest) {
8888
ip,
8989
);
9090

91+
// Track analytics
92+
try {
93+
const { AnalyticsService } = await import('@/lib/analytics');
94+
const analytics = new AnalyticsService(container.db);
95+
await analytics.trackEvent({ eventType: 'seal_created' });
96+
} catch {}
97+
9198
return jsonResponse({
9299
success: true,
93100
sealId: result.sealId,

app/api/pulse/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ export async function POST(request: NextRequest) {
2222
const sealService = container.sealService;
2323
const result = await sealService.pulseSeal(pulseToken, ip, newInterval);
2424

25+
// Track analytics
26+
try {
27+
const { AnalyticsService } = await import('@/lib/analytics');
28+
const analytics = new AnalyticsService(container.db);
29+
await analytics.trackEvent({ eventType: 'pulse_received' });
30+
} catch {}
31+
2532
return jsonResponse({
2633
success: true,
2734
newUnlockTime: result.newUnlockTime,

app/api/seal/[id]/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export async function GET(
6868
const bytes = new Uint8Array(blob);
6969
const blobBase64 = btoa(String.fromCharCode(...bytes));
7070

71+
// Track analytics
72+
try {
73+
const { AnalyticsService } = await import('@/lib/analytics');
74+
const analytics = new AnalyticsService(container.db);
75+
await analytics.trackEvent({ eventType: 'seal_unlocked' });
76+
} catch {}
77+
7178
return jsonResponse({
7279
id: sealId,
7380
isLocked: false,

app/api/stats/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import { createAPIRoute } from '@/lib/routeHelper';
3+
import { AnalyticsService } from '@/lib/analytics';
4+
5+
export const runtime = 'edge';
6+
7+
export async function GET(request: NextRequest) {
8+
return createAPIRoute(async ({ container }) => {
9+
try {
10+
const analytics = new AnalyticsService(container.db);
11+
const totalSeals = await analytics.getTotalSealsCreated();
12+
return NextResponse.json({ totalSeals });
13+
} catch (error) {
14+
console.error('[Stats API] Error:', error);
15+
return NextResponse.json({ totalSeals: 0 }, { status: 200 });
16+
}
17+
}, { rateLimit: { limit: 100, window: 60000 } })(request);
18+
}

app/components/SealCounter.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use client';
2+
3+
import { useEffect, useState } from 'react';
4+
import { motion } from 'framer-motion';
5+
6+
export function SealCounter() {
7+
const [count, setCount] = useState<number | null>(null);
8+
9+
useEffect(() => {
10+
fetch('/api/stats')
11+
.then(res => res.json())
12+
.then(data => setCount(data.totalSeals))
13+
.catch(() => setCount(null));
14+
}, []);
15+
16+
if (count === null || count === 0) return null;
17+
18+
return (
19+
<motion.div
20+
initial={{ opacity: 0, y: 20 }}
21+
animate={{ opacity: 1, y: 0 }}
22+
className="text-center mt-4"
23+
>
24+
<div className="inline-flex items-center gap-2 px-4 py-2 bg-neon-green/5 border border-neon-green/20 rounded-lg">
25+
<span className="text-neon-green/60 text-xs font-mono">SEALS CREATED:</span>
26+
<span className="text-neon-green text-lg font-bold font-mono">{count.toLocaleString()}</span>
27+
</div>
28+
</motion.div>
29+
);
30+
}

app/faq/page.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ export default function FAQPage() {
3535

3636
<div>
3737
<h3 className="text-base sm:text-lg font-bold text-neon-green mb-2">How do I create a seal?</h3>
38-
<p className="text-neon-green/60 text-sm mb-2"><strong className="text-neon-green">Timed Release:</strong> Enter message/file, select "Timed Release", choose unlock date (up to 30 days), complete security check, save vault link.</p>
39-
<p className="text-neon-green/60 text-sm"><strong className="text-neon-green">Dead Man&apos;s Switch:</strong> Same as above but select "Dead Man&apos;s Switch", set pulse interval, save both vault link (public) and pulse link (private).</p>
38+
<p className="text-neon-green/60 text-sm mb-2"><strong className="text-neon-green">Timed Release:</strong> Enter message/file, select &quot;Timed Release&quot;, choose unlock date (up to 30 days), complete security check, save vault link.</p>
39+
<p className="text-neon-green/60 text-sm"><strong className="text-neon-green">Dead Man&apos;s Switch:</strong> Same as above but select &quot;Dead Man&apos;s Switch&quot;, set pulse interval, save both vault link (public) and pulse link (private).</p>
4040
</div>
4141

4242
<div>
4343
<h3 className="text-base sm:text-lg font-bold text-neon-green mb-2">How does Dead Man&apos;s Switch work?</h3>
44-
<p className="text-neon-green/60 text-sm">Set pulse interval (e.g., 7 days). Visit private pulse link before interval expires to reset timer. If you miss a pulse, seal auto-unlocks for recipient. Use pulse link to "burn" seal permanently.</p>
44+
<p className="text-neon-green/60 text-sm">Set pulse interval (e.g., 7 days). Visit private pulse link before interval expires to reset timer. If you miss a pulse, seal auto-unlocks for recipient. Use pulse link to &quot;burn&quot; seal permanently.</p>
4545
</div>
4646

4747
<div>
@@ -61,7 +61,7 @@ export default function FAQPage() {
6161

6262
<div>
6363
<h3 className="text-base sm:text-lg font-bold text-neon-green mb-2">Can I cancel or delete a seal?</h3>
64-
<p className="text-neon-green/60 text-sm">Dead Man&apos;s Switch seals can be burned (permanently destroyed) using the pulse token. Timed seals cannot be deleted.</p>
64+
<p className="text-neon-green/60 text-sm">Dead Man&apos;s Switch seals can be &quot;burned&quot; (permanently destroyed) using the pulse token. Timed seals cannot be deleted.</p>
6565
</div>
6666

6767
<div>

app/layout.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ export default function RootLayout({
103103
<StructuredData />
104104
<link rel="manifest" href="/manifest.json" />
105105
<meta name="theme-color" content="#00ff41" />
106+
<script
107+
dangerouslySetInnerHTML={{
108+
__html: `
109+
(function() {
110+
if (typeof window === 'undefined') return;
111+
window.trackEvent = function(type, data) {
112+
fetch('/api/analytics', {
113+
method: 'POST',
114+
headers: { 'Content-Type': 'application/json' },
115+
body: JSON.stringify({ eventType: type, ...data })
116+
}).catch(function() {});
117+
};
118+
window.trackEvent('page_view', { path: window.location.pathname });
119+
})();
120+
`,
121+
}}
122+
/>
106123
</head>
107124
<body className={`${jetbrainsMono.className} min-h-screen bg-dark-bg text-dark-text overflow-x-hidden selection:bg-neon-green/30 selection:text-neon-green`}>
108125
<ScrollProgress />

app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { EncryptionProgress } from './components/EncryptionProgress';
2525
import { FloatingIcons } from './components/FloatingIcons';
2626

2727
import { Bitcoin, ShieldAlert, Rocket, Gift, Scale, Paperclip, FileText, Trash2, AlertTriangle, Download } from 'lucide-react';
28+
import { SealCounter } from './components/SealCounter';
2829

2930
interface Template {
3031
name: string;
@@ -630,6 +631,7 @@ export default function HomePage() {
630631
<AnimatedTagline text='"If I go silent, this speaks for me."' />
631632
<p className="text-xs text-neon-green/30 max-w-md mx-auto">Encrypt messages that unlock at a future date or after inactivity</p>
632633
<p className="text-xs text-yellow-500/50 max-w-md mx-auto mt-2">⚠️ Seals auto-delete 30 days after unlock</p>
634+
<SealCounter />
633635
</motion.div>
634636

635637
<Card className="space-y-6">

0 commit comments

Comments
 (0)