Approach (brief): compute each user’s first signup and first purchase timestamps (first purchase that occurred at/after signup), exclude known bots, assign users to acquisition cohorts (signup_date), compute per-user time-to-first-purchase in seconds/days, then aggregate cohort-level distributions using APPROX_QUANTILES (or exact percentiles if your warehouse supports PERCENTILE_CONT).SQL (BigQuery-style pseudocode):sql
WITH events_clean AS (
SELECT
user_id,
event_name,
TIMESTAMP(event_timestamp) AS ts,
user_agent,
ip,
-- simple bot filters: known bot user_agents, internal ip ranges, test accounts
CASE
WHEN REGEXP_CONTAINS(LOWER(user_agent), r'(bot|spider|crawler)') THEN TRUE
WHEN ip LIKE '10.%' OR ip LIKE '192.168.%' THEN TRUE
WHEN user_id IN ('test_user_1','automation_service') THEN TRUE
ELSE FALSE
END AS is_bot
FROM raw.events
WHERE event_timestamp IS NOT NULL
),
users_events AS (
SELECT * EXCEPT(is_bot)
FROM events_clean
WHERE NOT is_bot
),
first_events AS (
-- per-user first signup (global min)
SELECT
user_id,
MIN(IF(event_name = 'signup', ts, NULL)) AS first_signup_ts,
MIN(IF(event_name = 'purchase', ts, NULL)) AS first_purchase_ts_any
FROM users_events
GROUP BY user_id
),
first_purchase_after_signup AS (
-- for each user find earliest purchase >= signup (handles late arrivals)
SELECT
e.user_id,
MIN(e.ts) AS first_purchase_after_signup_ts
FROM users_events e
JOIN first_events f ON e.user_id = f.user_id
WHERE e.event_name = 'purchase'
AND f.first_signup_ts IS NOT NULL
AND e.ts >= f.first_signup_ts -- ignore purchases before signup
AND e.ts <= TIMESTAMP_ADD(f.first_signup_ts, INTERVAL 365 DAY) -- optional censoring window
GROUP BY e.user_id
),
per_user AS (
SELECT
f.user_id,
f.first_signup_ts,
p.first_purchase_after_signup_ts,
DATE(f.first_signup_ts, 'UTC') AS acquisition_cohort_date,
TIMESTAMP_DIFF(p.first_purchase_after_signup_ts, f.first_signup_ts, SECOND) AS time_to_purchase_seconds
FROM first_events f
LEFT JOIN first_purchase_after_signup p USING(user_id)
WHERE f.first_signup_ts IS NOT NULL
)
-- cohort aggregation with robust distributions
SELECT
acquisition_cohort_date,
COUNTIF(time_to_purchase_seconds IS NOT NULL) AS converting_users,
COUNT(*) AS total_signups,
SAFE_DIVIDE(COUNTIF(time_to_purchase_seconds IS NOT NULL), COUNT(*)) AS conversion_rate,
-- median and percentiles (use APPROX_QUANTILES for performance; 0..100 yields deciles)
APPROX_QUANTILES(time_to_purchase_seconds, 100)[OFFSET(50)] AS median_seconds,
APPROX_QUANTILES(time_to_purchase_seconds, 100)[OFFSET(25)] AS p25_seconds,
APPROX_QUANTILES(time_to_purchase_seconds, 100)[OFFSET(75)] AS p75_seconds,
-- optional: histogram buckets
(SELECT AS STRUCT
COUNTIF(time_to_purchase_seconds <= 86400) AS within_1d,
COUNTIF(time_to_purchase_seconds <= 604800) AS within_7d,
COUNTIF(time_to_purchase_seconds <= 2592000) AS within_30d
) AS bucket_counts
FROM per_user
GROUP BY acquisition_cohort_date
ORDER BY acquisition_cohort_date;
Key points / reasoning:- Compute per-user first event timestamps using MIN to avoid multiple-event noise.- Ensure purchase is after signup to measure true funnel progress; use a censoring window (e.g., 365 days) to avoid spurious long tails.- Exclude bots early (user_agent/ip/test accounts) to avoid skew; consider expanding bot detection with high-frequency events or ML labeling.- APPROX_QUANTILES is fast and stable for large cohorts; for small cohorts use exact PERCENTILE_CONT if available.- Use SAFE_DIVIDE and NULL-aware functions to avoid runtime errors.Edge cases:- Users with signup but no purchase => time_to_purchase NULL (counted in denominator).- Purchases before signup => ignored unless business logic says otherwise.- Timezones: normalize timestamps to UTC or to business timezone before DATE() cohorting.This yields robust cohort medians and full distribution buckets suitable for dashboards and alerts.