Most web analytics tools track individual users—session IDs, IP addresses, browsing paths, referrers. They answer questions like "What did user X do?" and "Where did user Y come from?"
EF-Map takes a different approach: We track aggregate feature usage (counters, sums) without ever identifying individual users. No sessions, no cookies, no PII. Just anonymous statistics that help us improve the tool.
This post explains our privacy-first analytics architecture, the metrics we collect (and don't collect), and how we balance learning from usage with respecting user privacy.
Why Privacy-First?
The Traditional Analytics Problem
Standard web analytics (Google Analytics, Mixpanel, Amplitude, etc.) collect:
- User IDs: Persistent identifiers across sessions
 - Session IDs: Track individual browsing sessions
 - IP addresses: Geolocation tracking
 - Referrers: Where users came from
 - Browsing paths: Page-by-page navigation history
 - Timestamps: When each action occurred
 - Device fingerprints: Browser, OS, screen size, etc.
 
Use case: "Show me all actions by user X" or "What path did user Y take before converting?"
Privacy concerns:
- Re-identification: Combining data points can identify individuals
 - Tracking across sites: Third-party cookies enable cross-site tracking
 - Data breaches: Centralized PII databases are high-value targets
 - GDPR/CCPA compliance: Requires consent banners, opt-outs, data deletion requests
 
Our philosophy: For an open-source mapping tool, we don't need to know who you are—we just need to know what features are used and how often.
What We Track (Aggregate Only)
Event Categories
We collect three types of metrics:
#### 1. Counters (Increment-Only)
Example: cinematic_enter
When fired: User toggles cinematic mode on.
Data stored:
{
  "cinematic_enter": 1247
}
What it tells us: Cinematic mode has been activated 1,247 times (total, all users, all time).
What it DOESN'T tell us:
- Who activated it
 - When they activated it
 - How many times the same user activated it
 
Use case: "Is cinematic mode popular? Should we invest in more visual features?"
#### 2. Session Counters (First-in-Session Tracking)
Example: cinematic_first + cinematic_sessions
When fired: User activates cinematic mode for the first time in a session.
Data stored:
{
  "cinematic_first": 312,
  "cinematic_sessions": 312
}
What it tells us: 312 sessions included at least one cinematic mode activation.
Why separate from cinematic_enter?
User session:
  cinematic_enter (1st time) → cinematic_first++, cinematic_sessions++, cinematic_enter++
  cinematic_enter (2nd time) → cinematic_enter++
  cinematic_enter (3rd time) → cinematic_enter++
Result:
  cinematic_enter: 3
  cinematic_first: 1
  cinematic_sessions: 1
Use case: "What % of sessions use cinematic mode?" → cinematic_sessions / total_sessions
#### 3. Time Sums (Duration Tracking)
Example: cinematic_time
When fired: User deactivates cinematic mode (sends total duration).
Data stored:
{
  "cinematic_time": {
    "sum": 482100,
    "count": 312
  }
}
What it tells us:
- Total time: 482,100 seconds (134 hours) across all users
 - Average time: 482,100 / 312 = 1,545 seconds (~26 minutes per session)
 
What it DOESN'T tell us:
- Who spent the most time
 - When they used it
 - Which systems they viewed
 
Use case: "Is cinematic mode a quick glance feature or a long-form exploration tool?"
Full Event Catalog
Feature usage events:
cinematic_enter,cinematic_first,cinematic_timerouting_calculate,routing_firstsearch_execute,search_firstshare_create,share_firsttribe_marks_view,tribe_marks_firsthelper_bridge_connect,helper_bridge_first
Discovery events:
route_fuel_optimize,route_jumps_optimizeexplore_mode_enable,explore_mode_firstscout_optimizer_run,scout_optimizer_first
Session buckets (engagement depth):
session_bucket_0: <1 minute (bounce)session_bucket_1: 1-5 minutessession_bucket_2: 5-15 minutessession_bucket_3: 15-30 minutessession_bucket_4: 30+ minutes
Total event types: ~45 (see Worker code for full list)
What We DON'T Track
Explicitly Prohibited
- User IDs: No persistent identifiers
 - Session IDs: No cross-request tracking
 - IP addresses: Never logged or stored
 - Geolocation: No country/city data
 - Referrers: Don't track where users came from
 - Browsing paths: Don't track page-by-page navigation
 - Timestamps: Don't store when events occurred (only aggregate counts)
 - Device fingerprints: No browser/OS/screen data
 - Personal data: No names, emails, or any PII
 
Why No Timestamps?
Timestamps enable re-identification:
User A:
  08:32:15 - Search for "Jita"
  08:32:18 - Calculate route Jita → Amarr
  08:32:45 - Create share link
User B:
  09:14:02 - Search for "Perimeter"
  09:14:05 - Calculate route Perimeter → Dodixie
With timestamps, we can:
- Cluster events by time proximity → identify individual sessions
 - Correlate actions → build user profiles
 - Cross-reference with other data sources (e.g., game logs)
 
Solution: Only store aggregate counts (no timestamps):
{
  "search_execute": 2,
  "routing_calculate": 2,
  "share_create": 1
}
Result: Impossible to reconstruct individual sessions or user journeys.
Architecture: Serverless + Client-Side Batching
Client: Batching and Debouncing
Code (src/utils/usage.ts):
const eventQueue: UsageEvent[] = [];
const MAX_BATCH = 12;
let flushTimer: number | null = null;
export function track(eventType: string, metadata?: Record<string, any>) {
  eventQueue.push({ type: eventType, ...metadata });
  
  // Auto-flush if batch full
  if (eventQueue.length >= MAX_BATCH) {
    flush();
  } else {
    // Debounced flush (5 seconds)
    if (flushTimer) clearTimeout(flushTimer);
    flushTimer = setTimeout(flush, 5000);
  }
}
async function flush() {
  if (eventQueue.length === 0) return;
  
  const batch = [...eventQueue];
  eventQueue.length = 0;
  
  try {
    await fetch('/api/usage-event', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ events: batch })
    });
  } catch (err) {
    console.warn('Analytics failed (non-critical):', err);
  }
}
Batching benefits:
- Fewer requests: 12 events → 1 HTTP call
 - Lower latency: No blocking on every action
 - Reduced load: Backend handles 10 requests/min instead of 120
 
Server: Cloudflare Worker + KV Storage
Endpoint: /api/usage-event (Cloudflare Worker)
Code (_worker.js):
const EVENT_MAP = {
  cinematic_enter: { type: 'counter' },
  cinematic_first: { type: 'counter' },
  cinematic_sessions: { type: 'counter' },
  cinematic_time: { type: 'sum', unit: 'ms' },
  routing_calculate: { type: 'counter' },
  // ... 40 more events
};
async function handleUsageEvent(req, env) {
  const { events } = await req.json();
  
  // Fetch current snapshot from KV
  const snapshot = await env.EF_STATS.get('usage_snapshot', { type: 'json' }) || {};
  
  // Update counters
  for (const event of events) {
    const config = EVENT_MAP[event.type];
    if (!config) continue; // Ignore unknown events
    
    if (config.type === 'counter') {
      snapshot[event.type] = (snapshot[event.type] || 0) + 1;
    } else if (config.type === 'sum') {
      if (!snapshot[event.type]) {
        snapshot[event.type] = { sum: 0, count: 0 };
      }
      snapshot[event.type].sum += event.ms;
      snapshot[event.type].count += 1;
    }
  }
  
  // Write back to KV
  await env.EF_STATS.put('usage_snapshot', JSON.stringify(snapshot));
  
  return new Response(JSON.stringify({ ok: true }), {
    headers: { 'Content-Type': 'application/json' }
  });
}
KV storage:
- Key: 
usage_snapshot - Value: JSON document with all counters
 
Example snapshot:
{
  "cinematic_enter": 1247,
  "cinematic_first": 312,
  "cinematic_sessions": 312,
  "cinematic_time": { "sum": 482100, "count": 312 },
  "routing_calculate": 3421,
  "search_execute": 8192
}
Persistence: KV is globally distributed, eventually consistent. Updates propagate within 60 seconds.
Public Stats API
Endpoint: /api/stats (Cloudflare Worker)
Code:
async function handleStats(req, env) {
  const snapshot = await env.EF_STATS.get('usage_snapshot', { type: 'json' }) || {};
  
  return new Response(JSON.stringify(snapshot), {
    headers: {
      'Content-Type': 'application/json',
      'Access-Control-Allow-Origin': '*', // Public API
      'Cache-Control': 'public, max-age=300' // Cache for 5 minutes
    }
  });
}
Usage:
curl https://ef-map.com/api/stats | jq .
Output:
{
  "cinematic_enter": 1247,
  "cinematic_first": 312,
  "cinematic_time": { "sum": 482100, "count": 312 },
  "routing_calculate": 3421,
  "search_execute": 8192
}
Why public?
- Transparency: Users can audit what we track
 - Community insights: Third-party developers can build dashboards
 - No secrets: All data is aggregate (no privacy risk)
 
Session Tracking (Client-Side Only)
Challenge: We track "first-in-session" events (e.g., cinematic_first), but we said no session IDs.
Solution: Client-side session flags (never sent to server).
Code (src/utils/usage.ts):
const sessionFlags = {
  cinematicUsed: false,
  routingUsed: false,
  searchUsed: false,
  // ... more flags
};
export function trackCinematicEnter() {
  track('cinematic_enter'); // Always increment
  
  if (!sessionFlags.cinematicUsed) {
    track('cinematic_first'); // Only first time
    track('cinematic_sessions'); // Increment session counter
    sessionFlags.cinematicUsed = true;
  }
}
Session duration (for bucket assignment):
const sessionStart = Date.now();
window.addEventListener('beforeunload', () => {
  const sessionDuration = Date.now() - sessionStart;
  const bucketId = getBucketId(sessionDuration);
  
  track(`session_bucket_${bucketId}`);
});
function getBucketId(ms: number): number {
  if (ms < 60_000) return 0; // <1 minute
  if (ms < 300_000) return 1; // 1-5 minutes
  if (ms < 900_000) return 2; // 5-15 minutes
  if (ms < 1_800_000) return 3; // 15-30 minutes
  return 4; // 30+ minutes
}
Result: Server sees only bucket counts (no individual session durations):
{
  "session_bucket_0": 142, // 142 sessions <1 min (bounces)
  "session_bucket_1": 387, // 387 sessions 1-5 min
  "session_bucket_2": 203, // 203 sessions 5-15 min
  "session_bucket_3": 89,  // 89 sessions 15-30 min
  "session_bucket_4": 54   // 54 sessions 30+ min
}
Bounce rate calculation:
Bounce rate = session_bucket_0 / (sum of all buckets)
            = 142 / (142+387+203+89+54)
            = 142 / 875
            = 16.2%
Stats Dashboard (Public Transparency)
Location: /stats page on EF-Map
Data source: Fetches /api/stats (public API)
Displays:
- Feature usage: Chart of top 10 most-used features
 - Session engagement: Bar chart of session buckets
 - Average metrics: Cinematic time, route length, search queries
 - Growth trends: Rolling 7-day counters (manual refresh for now)
 
Code (Stats page component):
function StatsPage() {
  const [stats, setStats] = useState<UsageSnapshot | null>(null);
  
  useEffect(() => {
    fetch('/api/stats')
      .then(r => r.json())
      .then(setStats);
  }, []);
  
  if (!stats) return <Loading />;
  
  const avgCinematicTime = stats.cinematic_time.sum / stats.cinematic_time.count;
  const bounceRate = stats.session_bucket_0 / getTotalSessions(stats);
  
  return (
    <div>
      <h1>EF-Map Usage Stats</h1>
      <p>Total cinematic mode activations: {stats.cinematic_enter}</p>
      <p>Average cinematic session: {(avgCinematicTime / 60).toFixed(1)} minutes</p>
      <p>Bounce rate: {(bounceRate * 100).toFixed(1)}%</p>
      {/* ... more charts */}
    </div>
  );
}
Why expose stats publicly?
- Trust: Users can verify we're not collecting PII
 - Accountability: If we add invasive tracking, it's visible
 - Learning: Community members can analyze trends
 
Privacy Policy Compliance
GDPR (EU)
Question: Do we need user consent?
Answer: No, because:
- We don't process personal data (GDPR Art. 4(1))
 - Aggregate counters are anonymous by design (no re-identification possible)
 - No cookies or tracking technologies (no Art. 7 consent required)
 
GDPR recital 26: "Statistical purposes" with proper anonymization is not processing of personal data.
CCPA (California)
Question: Do we need opt-out mechanisms?
Answer: No, because:
- We don't collect personal information (CCPA §1798.140(o))
 - No "sale" of data (we don't even collect data to sell)
 - No disclosure to third parties
 
Cookie Banners
Question: Do we need a cookie banner?
Answer: No, because:
- We don't use cookies (not even 
localStoragefor tracking) - Client-side session flags are ephemeral (cleared on page refresh)
 - No third-party trackers (no Google Analytics, no Facebook Pixel)
 
Result: Zero compliance overhead—no banners, no opt-outs, no data deletion requests.
Comparison with Traditional Analytics
| Feature | Traditional Analytics | EF-Map Privacy-First | 
|---|---|---|
| User IDs | ✅ Persistent tracking | ❌ No tracking | 
| Session IDs | ✅ Cross-request tracking | ❌ Client-side flags only | 
| IP addresses | ✅ Logged for geolocation | ❌ Never logged | 
| Timestamps | ✅ Per-event timestamps | ❌ Aggregate counts only | 
| Browsing paths | ✅ Page-by-page history | ❌ No path tracking | 
| Re-identification | ⚠️ Possible via correlation | ✅ Impossible (no PII) | 
| GDPR consent | ⚠️ Required (cookies) | ✅ Not required (anonymous) | 
| Data breaches | ⚠️ High risk (PII database) | ✅ Low risk (aggregate only) | 
| Transparency | ❌ Opaque (internal dashboards) | ✅ Public API | 
What We Learn (Examples)
Question 1: Is Cinematic Mode Worth Investing In?
Data:
{
  "cinematic_enter": 1247,
  "cinematic_sessions": 312,
  "cinematic_time": { "sum": 482100, "count": 312 }
}
Analysis:
- Adoption: 312 sessions used it (vs ~1,000 total sessions = 31% adoption)
 - Engagement: Average 26 minutes per session (high)
 - Re-use: 1247 / 312 = 4 activations per session (users toggle it frequently)
 
Conclusion: Yes—high adoption + high engagement + frequent re-use → invest in visual features.
Question 2: Do Users Prefer Fuel or Jumps Routing?
Data:
{
  "route_fuel_optimize": 2145,
  "route_jumps_optimize": 876
}
Analysis:
- Fuel mode: 2,145 routes (71%)
 - Jumps mode: 876 routes (29%)
 
Conclusion: Fuel mode is more popular—prioritize fuel optimizations (e.g., better stargate cost modeling).
Question 3: What's Our Bounce Rate?
Data:
{
  "session_bucket_0": 142, // <1 min
  "session_bucket_1": 387, // 1-5 min
  "session_bucket_2": 203, // 5-15 min
  "session_bucket_3": 89,  // 15-30 min
  "session_bucket_4": 54   // 30+ min
}
Analysis:
- Bounce rate: 142 / 875 = 16.2% (low, good)
 - Power users: 54 sessions >30 min (6% of sessions, very engaged)
 
Conclusion: Low bounce rate suggests good UX; invest in features for power users (e.g., multi-destination routing).
Limitations of Aggregate-Only Analytics
What We CAN'T Answer
Traditional analytics question: "What % of users who search also calculate routes?"
Our data:
{
  "search_execute": 8192,
  "routing_calculate": 3421
}
Problem: We can't correlate events—we don't know if the same users did both actions.
Workaround: Use session flags to approximate:
track('search_then_route_sessions'); // Only if both happened in same session
New data:
{
  "search_execute": 8192,
  "routing_calculate": 3421,
  "search_then_route_sessions": 1204
}
Approximation: ~35% of routing sessions also searched (1204 / 3421).
What We CAN'T Build
Impossible features:
- User segmentation: Can't identify "power users" vs "casual users"
 - Funnel analysis: Can't track multi-step conversion flows
 - Cohort retention: Can't track "users who joined in Week X"
 - A/B testing: Can't split users into control/test groups
 
Trade-off: We accept these limitations to preserve privacy.
Future Enhancements
Planned Features
- Rolling 7-day stats: Weekly trends (vs all-time totals)
 - Feature flags + analytics: Track adoption of experimental features
 - Performance metrics: Average route calculation time, render FPS
 
Not Planned (Privacy Violations)
- ❌ User IDs
 - ❌ Session IDs
 - ❌ Geolocation
 - ❌ Third-party trackers
 
If we ever change this policy, we'll announce it publicly and give users an opt-out before implementing.
Related Posts
- Cloudflare KV Optimization: Reducing Costs by 93% - How we store aggregate stats efficiently
 - Tribe Marks: Collaborative Tactical Notes Without the Overhead - Similar privacy-first design for shared annotations
 - Route Sharing: Building a URL Shortener for Spatial Navigation - Serverless architecture for ephemeral data
 
Privacy-first analytics proves you don't need to track users to understand how they use your product—aggregate metrics answer 90% of questions while respecting 100% of user privacy!