← Back to Blog

Privacy-First Analytics: Learning Without Tracking

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:

Use case: "Show me all actions by user X" or "What path did user Y take before converting?"

Privacy concerns:

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:

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:

What it DOESN'T tell us:

Use case: "Is cinematic mode a quick glance feature or a long-form exploration tool?"

Full Event Catalog

Feature usage events:

Discovery events:

Session buckets (engagement depth):

Total event types: ~45 (see Worker code for full list)

What We DON'T Track

Explicitly Prohibited

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:

  1. Cluster events by time proximity → identify individual sessions
  2. Correlate actions → build user profiles
  3. 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:

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:

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?

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:

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?

Privacy Policy Compliance

GDPR (EU)

Question: Do we need user consent?

Answer: No, because:

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:

Cookie Banners

Question: Do we need a cookie banner?

Answer: No, because:

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:

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:

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:

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:

Trade-off: We accept these limitations to preserve privacy.

Future Enhancements

Planned Features

Not Planned (Privacy Violations)

If we ever change this policy, we'll announce it publicly and give users an opt-out before implementing.

Related Posts

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!

privacyanalyticsgdprserverlessaggregate datatransparency