Skip to content
Unverified — AI-generated content. Help verify this page

Analytics Query Engine

Query Architecture

Query Types

Analytics platforms primarily serve four query types:

Query TypeDescriptionExample
FunnelConversion through sequential stepsSign Up → Email Verify → First Purchase
RetentionUsers returning over time cohortsDay 1, Day 7, Day 30 retention
SegmentationUser counts by attributeActive users by country
Event StreamRaw event chronologyUser's session replay data

Funnel Analysis

The Problem

A funnel measures what fraction of users complete each step in a sequence. The critical constraint: steps must happen in order within a time window.

Funnel SQL

sql
-- Funnel query: 5-step checkout funnel, 7-day conversion window
-- Returns: per-step user counts
WITH
  funnel_steps AS (
    -- Get first occurrence of each step per user
    SELECT
      user_id,
      minIf(timestamp, event = 'page_viewed') AS t1,
      minIf(timestamp, event = 'product_viewed'
        AND timestamp > minIf(timestamp, event = 'page_viewed')) AS t2,
      minIf(timestamp, event = 'cart_item_added'
        AND timestamp > minIf(timestamp, event = 'product_viewed')) AS t3,
      minIf(timestamp, event = 'checkout_started'
        AND timestamp > minIf(timestamp, event = 'cart_item_added')) AS t4,
      minIf(timestamp, event = 'order_completed'
        AND timestamp > minIf(timestamp, event = 'checkout_started')) AS t5
    FROM tracking.events
    WHERE
      event IN ('page_viewed', 'product_viewed', 'cart_item_added',
                'checkout_started', 'order_completed')
      AND timestamp >= {startDate: DateTime}
      AND timestamp <= {endDate: DateTime}
      AND user_id != ''
    GROUP BY user_id
  )

SELECT
  countIf(t1 IS NOT NULL AND t1 != toDateTime(0)) AS step_1_users,
  countIf(t2 IS NOT NULL AND t2 != toDateTime(0)
    AND dateDiff('day', t1, t2) <= 7) AS step_2_users,
  countIf(t3 IS NOT NULL AND t3 != toDateTime(0)
    AND dateDiff('day', t1, t3) <= 7) AS step_3_users,
  countIf(t4 IS NOT NULL AND t4 != toDateTime(0)
    AND dateDiff('day', t1, t4) <= 7) AS step_4_users,
  countIf(t5 IS NOT NULL AND t5 != toDateTime(0)
    AND dateDiff('day', t1, t5) <= 7) AS step_5_users
FROM funnel_steps;

TypeScript Funnel Query Builder

typescript
// src/query/funnel.ts
import { createClient, ClickHouseClient } from '@clickhouse/client';

interface FunnelStep {
  event: string;
  name: string;
  filters?: Record<string, unknown>;  // Additional conditions on the step
}

interface FunnelQueryOptions {
  steps: FunnelStep[];
  conversionWindowDays: number;
  startDate: Date;
  endDate: Date;
  breakdown?: string;  // Attribute to break down by (e.g., "geo_country")
  filters?: Array<{ attribute: string; operator: string; value: unknown }>;
}

interface FunnelResult {
  steps: Array<{
    event: string;
    name: string;
    users: number;
    conversionFromPrevious: number;   // 0–1
    conversionFromFirst: number;      // 0–1
    avgTimeToNextStepMs?: number;
  }>;
  breakdown?: Record<string, FunnelResult['steps']>;
}

export class FunnelQueryEngine {
  constructor(private clickhouse: ClickHouseClient) {}

  async query(options: FunnelQueryOptions): Promise<FunnelResult> {
    const { steps, conversionWindowDays, startDate, endDate } = options;

    if (steps.length < 2) throw new Error('Funnel requires at least 2 steps');
    if (steps.length > 8) throw new Error('Funnel supports at most 8 steps');

    const query = this.buildFunnelSQL(steps, conversionWindowDays);

    const result = await this.clickhouse.query({
      query,
      query_params: {
        events: steps.map((s) => s.event),
        startDate: startDate.toISOString(),
        endDate: endDate.toISOString(),
        conversionWindowDays,
      },
      format: 'JSONEachRow',
    });

    const rows = await result.json<Record<string, number>>();
    const row = rows[0] ?? {};

    const stepResults: FunnelResult['steps'] = steps.map((step, i) => {
      const users = row[`step_${i + 1}_users`] ?? 0;
      const prevUsers = i === 0 ? users : row[`step_${i}_users`] ?? 0;
      const firstUsers = row['step_1_users'] ?? 1;

      return {
        event: step.event,
        name: step.name,
        users,
        conversionFromPrevious: prevUsers > 0 ? users / prevUsers : 0,
        conversionFromFirst: firstUsers > 0 ? users / firstUsers : 0,
      };
    });

    return { steps: stepResults };
  }

  private buildFunnelSQL(steps: FunnelStep[], windowDays: number): string {
    // Build step timestamp selections
    const timestampSelects = steps.map((step, i) => {
      const prevCondition = i === 0
        ? ''
        : `AND timestamp > step_t${i}`;
      return `minIf(timestamp, event = '${step.event}' ${prevCondition}) AS step_t${i + 1}`;
    }).join(',\n      ');

    // Build count conditions
    const countConditions = steps.map((step, i) => {
      if (i === 0) {
        return `countIf(step_t1 IS NOT NULL) AS step_1_users`;
      }
      return `countIf(
        step_t${i + 1} IS NOT NULL
        AND dateDiff('day', step_t1, step_t${i + 1}) <= ${windowDays}
      ) AS step_${i + 1}_users`;
    }).join(',\n  ');

    const eventList = steps.map((s) => `'${s.event}'`).join(', ');

    return `
      WITH funnel_steps AS (
        SELECT
          user_id,
          ${timestampSelects}
        FROM tracking.events
        WHERE
          event IN (${eventList})
          AND timestamp >= {startDate: DateTime}
          AND timestamp <= {endDate: DateTime}
          AND user_id != ''
        GROUP BY user_id
      )
      SELECT
        ${countConditions}
      FROM funnel_steps
    `;
  }
}

Retention Analysis

Retention measures how many users return to the product over time. Cohort retention tracks users who started in a specific period.

Retention Query

sql
-- N-day retention for February 2026 cohort
-- Returns: cohort_date, day_number, retained_users, cohort_size
WITH
  first_seen AS (
    SELECT
      user_id,
      toDate(min(timestamp)) AS cohort_date
    FROM tracking.events
    WHERE
      event = 'user_signed_up'
      AND timestamp >= '2026-02-01'
      AND timestamp < '2026-03-01'
      AND user_id != ''
    GROUP BY user_id
  ),
  active_days AS (
    SELECT DISTINCT
      e.user_id,
      fs.cohort_date,
      dateDiff('day', fs.cohort_date, toDate(e.timestamp)) AS day_number
    FROM tracking.events e
    JOIN first_seen fs ON e.user_id = fs.user_id
    WHERE
      e.timestamp >= '2026-02-01'
      AND e.timestamp < '2026-04-01'  -- 60 days of data
      AND day_number >= 0
      AND day_number <= 30
  )
SELECT
  cohort_date,
  day_number,
  count(DISTINCT user_id) AS retained_users,
  -- Cohort size (day 0 = everyone)
  countIf(day_number = 0) OVER (PARTITION BY cohort_date) AS cohort_size,
  retained_users / cohort_size AS retention_rate
FROM active_days
GROUP BY cohort_date, day_number
ORDER BY cohort_date, day_number;

Retention Result Format

typescript
// src/query/retention.ts

interface RetentionResult {
  cohorts: Array<{
    cohortDate: string;
    cohortSize: number;
    retention: Array<{
      dayNumber: number;
      users: number;
      rate: number;
    }>;
  }>;
  averageRetention: Array<{
    dayNumber: number;
    averageRate: number;
  }>;
}

export async function queryRetention(
  clickhouse: ClickHouseClient,
  params: {
    cohortStartDate: Date;
    cohortEndDate: Date;
    retentionDays: number;
    cohortEvent: string;
    returnEvent: string;
    cohortGranularity: 'day' | 'week';
  }
): Promise<RetentionResult> {
  const result = await clickhouse.query({
    query: RETENTION_QUERY,
    query_params: params,
    format: 'JSONEachRow',
  });

  const rows = await result.json<{
    cohort_date: string;
    day_number: number;
    retained_users: number;
    cohort_size: number;
    retention_rate: number;
  }>();

  // Group by cohort
  const cohortMap = new Map<string, RetentionResult['cohorts'][0]>();

  for (const row of rows) {
    if (!cohortMap.has(row.cohort_date)) {
      cohortMap.set(row.cohort_date, {
        cohortDate: row.cohort_date,
        cohortSize: row.cohort_size,
        retention: [],
      });
    }

    cohortMap.get(row.cohort_date)!.retention.push({
      dayNumber: row.day_number,
      users: row.retained_users,
      rate: row.retention_rate,
    });
  }

  const cohorts = Array.from(cohortMap.values());

  // Compute average retention across cohorts
  const dayGroups = new Map<number, number[]>();
  for (const cohort of cohorts) {
    for (const r of cohort.retention) {
      const arr = dayGroups.get(r.dayNumber) ?? [];
      arr.push(r.rate);
      dayGroups.set(r.dayNumber, arr);
    }
  }

  const averageRetention = Array.from(dayGroups.entries())
    .map(([dayNumber, rates]) => ({
      dayNumber,
      averageRate: rates.reduce((s, r) => s + r, 0) / rates.length,
    }))
    .sort((a, b) => a.dayNumber - b.dayNumber);

  return { cohorts, averageRetention };
}

const RETENTION_QUERY = `/* retention SQL from above */`;

Query Cache Layer

Analytics queries are expensive. Most dashboards show the same data to many users. Cache aggressively.

Redis-Based Query Cache

typescript
// src/query/cache.ts
import Redis from 'ioredis';
import { createHash } from 'crypto';

interface CacheConfig {
  defaultTtlSeconds: number;
  maxEntries: number;
}

export class QueryCache {
  constructor(
    private redis: Redis,
    private config: CacheConfig = {
      defaultTtlSeconds: 300,  // 5 minutes
      maxEntries: 10_000,
    }
  ) {}

  private getCacheKey(query: string, params: Record<string, unknown>): string {
    const payload = JSON.stringify({ query, params });
    const hash = createHash('sha256').update(payload).digest('hex').slice(0, 32);
    return `query:${hash}`;
  }

  async get<T>(
    query: string,
    params: Record<string, unknown>
  ): Promise<T | null> {
    const key = this.getCacheKey(query, params);
    const cached = await this.redis.get(key);
    if (!cached) return null;
    return JSON.parse(cached) as T;
  }

  async set<T>(
    query: string,
    params: Record<string, unknown>,
    result: T,
    ttlSeconds?: number
  ): Promise<void> {
    const key = this.getCacheKey(query, params);
    const ttl = ttlSeconds ?? this.config.defaultTtlSeconds;
    await this.redis.setex(key, ttl, JSON.stringify(result));
  }

  async invalidateForSource(sourceId: string): Promise<void> {
    // Invalidate all cached queries for a specific data source
    // (called when new data is ingested)
    const pattern = `query:*:${sourceId}:*`;
    const keys = await this.redis.keys(pattern);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
  }

  // Wrapper for cached query execution
  async cached<T>(
    query: string,
    params: Record<string, unknown>,
    executor: () => Promise<T>,
    ttlSeconds?: number
  ): Promise<{ result: T; fromCache: boolean }> {
    const cached = await this.get<T>(query, params);
    if (cached !== null) {
      return { result: cached, fromCache: true };
    }

    const result = await executor();
    await this.set(query, params, result, ttlSeconds);
    return { result, fromCache: false };
  }
}

Cache TTL Strategy

Query TypeTTLReason
Funnel (last 30 days)5 minutesSemi-real-time
Retention cohort1 hourHistorical, rarely changes
Daily aggregates15 minutesNear-real-time dashboards
User event stream30 secondsNear-real-time for user pages
Annual reports24 hoursStatic historical data

GraphQL API

typescript
// src/api/schema.graphql
type Query {
  funnel(input: FunnelInput!): FunnelResult!
  retention(input: RetentionInput!): RetentionResult!
  eventCounts(input: EventCountsInput!): [EventCountResult!]!
  userTimeline(userId: String!, limit: Int): [Event!]!
}

input FunnelInput {
  steps: [FunnelStepInput!]!
  conversionWindowDays: Int!
  startDate: String!
  endDate: String!
  breakdown: String
}

input FunnelStepInput {
  event: String!
  name: String!
}

type FunnelResult {
  steps: [FunnelStep!]!
  totalEnteredFunnel: Int!
  totalCompletedFunnel: Int!
  overallConversionRate: Float!
}

type FunnelStep {
  event: String!
  name: String!
  users: Int!
  conversionFromPrevious: Float!
  conversionFromFirst: Float!
}

input RetentionInput {
  cohortStartDate: String!
  cohortEndDate: String!
  retentionDays: Int!
  cohortEvent: String!
  returnEvent: String!
}

type RetentionResult {
  cohorts: [CohortRetention!]!
  averageRetention: [DayRetention!]!
}

type CohortRetention {
  cohortDate: String!
  cohortSize: Int!
  retention: [DayRetention!]!
}

type DayRetention {
  dayNumber: Int!
  users: Int
  rate: Float!
}
typescript
// src/api/resolvers.ts
import { FunnelQueryEngine } from '../query/funnel';
import { QueryCache } from '../query/cache';

export const resolvers = {
  Query: {
    funnel: async (
      _: unknown,
      args: { input: FunnelInput },
      ctx: { funnelEngine: FunnelQueryEngine; cache: QueryCache }
    ) => {
      const { result, fromCache } = await ctx.cache.cached(
        'funnel',
        args.input,
        () => ctx.funnelEngine.query({
          steps: args.input.steps,
          conversionWindowDays: args.input.conversionWindowDays,
          startDate: new Date(args.input.startDate),
          endDate: new Date(args.input.endDate),
        }),
        300  // 5 minute TTL
      );

      if (!fromCache) {
        // Track API usage
        ctx.analytics?.track('system', 'api_query_executed', {
          queryType: 'funnel',
          stepCount: args.input.steps.length,
        });
      }

      return result;
    },

    userTimeline: async (
      _: unknown,
      args: { userId: string; limit: number },
      ctx: { clickhouse: ClickHouseClient }
    ) => {
      const result = await ctx.clickhouse.query({
        query: `
          SELECT event, timestamp, properties
          FROM tracking.events
          WHERE user_id = {userId: String}
          ORDER BY timestamp DESC
          LIMIT {limit: UInt32}
        `,
        query_params: {
          userId: args.userId,
          limit: Math.min(args.limit ?? 100, 1000),
        },
        format: 'JSONEachRow',
      });
      return result.json();
    },
  },
};

Performance Benchmarks

Tested on: 2-node ClickHouse cluster (32 vCPU, 128 GB RAM each), 10 billion events, 2 years data.

Query TypeData ScannedP50 LatencyP99 Latency
Funnel (30 days, 5 steps)30 days80ms450ms
Funnel (30 days, from MV)Pre-aggregated8ms45ms
Retention (90-day cohort)90 days320ms1.8s
Daily event counts1 day from MV5ms25ms
User timeline (100 events)User index15ms120ms
Segment breakdown (30 days)30 days200ms900ms

Query Optimization Tips

sql
-- SLOW: Using JSONExtractString in WHERE clause
SELECT count()
FROM tracking.events
WHERE JSONExtractString(properties, 'country') = 'US';

-- FAST: Use stored column
SELECT count()
FROM tracking.events
WHERE geo_country = 'US';

-- SLOW: DISTINCT + large date range
SELECT count(DISTINCT user_id)
FROM tracking.events
WHERE timestamp > now() - INTERVAL 30 DAY;

-- FAST: uniqHLL12 approximate count (2% error, 1000x faster)
SELECT uniqHLL12(user_id)
FROM tracking.events
WHERE timestamp > now() - INTERVAL 30 DAY;

War Story

The Dashboard That Cost $2,000/Month

A team built beautiful dashboards that ran complex ClickHouse queries on every page load. Each query took 2–8 seconds. With 50 dashboard users refreshing every minute, they were running ~3,000 queries/hour against their ClickHouse cluster.

The ClickHouse cluster needed to be over-provisioned by 5x to handle this load. Estimated cost: $2,000/month in cloud infrastructure.

After adding a 5-minute Redis cache in front of all dashboard queries, the ClickHouse query rate dropped to ~30/hour (cache hits + 1 per 5 min per unique query). The cluster was scaled down 80%. Lesson: dashboards rarely need sub-second freshness, and most users see the same data anyway.

"What I cannot create, I do not understand." — Richard Feynman