埋点 面试指南
面试者视角回答
埋点是一种数据采集技术,通过在应用中嵌入代码来收集用户行为和业务数据。埋点是实现数据驱动运营的基础,是产品和运营了解用户行为的核心手段。
核心概念
埋点类型
| 类型 | 说明 | 适用场景 |
|---|---|---|
| 代码埋点 | 手动在代码中嵌入埋点 | 关键业务节点 |
| 全埋点 | 自动采集所有交互 | 页面浏览、点击 |
| 可视化埋点 | 通过工具配置选择 | 快速验证 |
| 无埋点 | 采集所有数据后筛选 | 数据回溯 |
埋点数据模型
javascript
{
event: 'page_view', // 事件名称
event_id: 'uuid', // 事件唯一 ID
user_id: 'user_123', // 用户 ID
device_id: 'device_abc', // 设备 ID
timestamp: 1712345678901, // 时间戳
platform: 'web', // 平台
version: '1.0.0', // 版本
channel: 'appstore', // 渠道
page: '/home', // 页面
element: 'button_pay', // 元素
data: { // 自定义数据
product_id: 'P001',
price: 99.9
}
}代码埋点实现
基础埋点 SDK
javascript
class Tracker {
constructor(options = {}) {
this.appId = options.appId || "default";
this.serverUrl = options.serverUrl;
this.userId = options.userId;
this设备Id = this.getDeviceId();
this.isDebug = options.debug || false;
}
getDeviceId() {
let deviceId = localStorage.getItem("__device_id__");
if (!deviceId) {
deviceId = "d_" + Math.random().toString(36).substr(2, 9);
localStorage.setItem("__device_id__", deviceId);
}
return deviceId;
}
setUserId(userId) {
this.userId = userId;
}
track(eventName, data = {}) {
const eventData = {
event: eventName,
event_id: this.generateId(),
user_id: this.userId,
device_id: this.deviceId,
app_id: this.appId,
timestamp: Date.now(),
platform: "web",
version: window.__APP_VERSION__ || "1.0.0",
url: location.href,
user_agent: navigator.userAgent,
data: data,
};
if (this.isDebug) {
console.log("[Tracker]", eventName, eventData);
}
this.send(eventData);
}
generateId() {
return "e_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6);
}
send(data) {
if (navigator.sendBeacon) {
navigator.sendBeacon(this.serverUrl, JSON.stringify(data));
} else {
fetch(this.serverUrl, {
method: "POST",
body: JSON.stringify(data),
keepalive: true,
});
}
}
// 页面浏览
pageView(pageName, data = {}) {
this.track("page_view", {
page: pageName,
referrer: document.referrer,
...data,
});
}
// 点击事件
click(elementId, data = {}) {
this.track("click", {
element: elementId,
...data,
});
}
// 自定义事件
custom(eventName, data = {}) {
this.track(eventName, data);
}
}
// 使用
const tracker = new Tracker({
appId: "my_app",
serverUrl: "https://analytics.example.com/collect",
debug: true,
});
tracker.pageView("home");
// 绑定点击事件
document.querySelector("#btn").addEventListener("click", () => {
tracker.click("btn_pay", { product_id: "P001" });
});自动埋点实现
页面自动埋点
javascript
class AutoTracker {
constructor(tracker) {
this.tracker = tracker;
this.initPageTracker();
}
initPageTracker() {
// 页面加载时自动上报
window.addEventListener("load", () => {
this.tracker.pageView(location.pathname, {
title: document.title,
load_time: performance.now(),
});
});
// 页面离开时上报
window.addEventListener("beforeunload", () => {
this.tracker.track("page_leave", {
page: location.pathname,
stay_time: Date.now() - this.pageStartTime,
});
});
this.pageStartTime = Date.now();
}
}点击自动埋点
javascript
class ClickTracker {
constructor(tracker, options = {}) {
this.tracker = tracker;
this.options = {
selector: options.selector || "[data-track]",
excludes: options.excludes || [],
...options,
};
this.init();
}
init() {
document.addEventListener(
"click",
(e) => {
const target = e.target.closest(this.options.selector);
if (!target) return;
// 检查是否排除
if (this.isExcluded(target)) return;
const trackId = target.dataset.track;
const trackData = this.parseTrackData(target);
this.tracker.click(trackId, trackData);
},
true,
);
}
isExcluded(target) {
return this.options.excludes.some((selector) => {
return target.matches(selector);
});
}
parseTrackData(target) {
const data = {};
// 从 data-* 属性收集
const attrs = target.dataset;
for (const key in attrs) {
if (key.startsWith("track")) {
const dataKey = key.replace("track", "").toLowerCase();
data[dataKey] = attrs[key];
}
}
return data;
}
}
// HTML 使用
// <button data-track="btn_pay" data-track-product-id="P001" data-track-price="99">购买</button>全埋点方案
无痕埋点实现
javascript
class SilentTracker {
constructor(tracker) {
this.tracker = tracker;
this.setupInterceptors();
this.setupListeners();
}
setupInterceptors() {
// 拦截 XMLHttpRequest
const originalXHROpen = XMLHttpRequest.prototype.open;
const originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._method = method;
this._url = url;
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (data) {
this.addEventListener("load", () => {
if (this._url.includes("/api/")) {
this.tracker.track("api_request", {
method: this._method,
url: this._url,
status: this.status,
});
}
});
return originalXHRSend.apply(this, arguments);
};
// 拦截 fetch
const originalFetch = window.fetch;
window.fetch = async (...args) => {
const startTime = Date.now();
const response = await originalFetch.apply(this, args);
this.tracker.track("fetch", {
url: typeof args[0] === "string" ? args[0] : args[0].url,
method: args[1]?.method || "GET",
duration: Date.now() - startTime,
status: response.status,
});
return response;
};
}
setupListeners() {
// 监控所有点击
document.addEventListener(
"click",
(e) => {
const target = e.target;
this.tracker.track("element_click", {
tag: target.tagName,
id: target.id,
class: target.className,
text: target.innerText?.substring(0, 50),
xpath: this.getXPath(target),
});
},
true,
);
// 监控输入
document.querySelectorAll("input, textarea").forEach((el) => {
el.addEventListener("input", (e) => {
this.tracker.track("input_change", {
name: el.name || el.id,
type: el.type,
value_length: el.value.length,
});
});
});
// 监控路由变化(History API)
const originalPushState = history.pushState;
history.pushState = (...args) => {
originalPushState.apply(history, args);
this.tracker.pageView(location.pathname);
};
window.addEventListener("popstate", () => {
this.tracker.pageView(location.pathname);
});
}
getXPath(element) {
if (element.id) return `//*[@id="${element.id}"]`;
const parts = [];
while (element && element.nodeType === Node.ELEMENT_NODE) {
let index = 1;
let sibling = element.previousSibling;
while (sibling) {
if (sibling.nodeType === Node.ELEMENT_NODE && sibling.nodeName === element.nodeName) {
index++;
}
sibling = sibling.previousSibling;
}
parts.unshift(`${element.nodeName.toLowerCase()}[${index}]`);
element = element.parentNode;
}
return "/" + parts.join("/");
}
}常用埋点事件
页面事件
javascript
// 页面浏览
tracker.pageView("/home", {
page_type: "首页",
category: "landing",
});
// 页面停留时长(通过 beforeunload 或 visibilitychange)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
tracker.track("page_stay", {
page: location.pathname,
duration: Date.now() - pageStartTime,
});
}
});用户事件
javascript
// 用户登录
tracker.track("user_login", {
login_type: "phone",
success: true,
});
// 用户注册
tracker.track("user_register", {
register_type: "invite",
channel: "sms",
});
// 用户点击
tracker.track("user_click", {
element: "nav_search",
position: "header",
});业务事件
javascript
// 商品浏览
tracker.track("product_view", {
product_id: "P001",
product_name: "iPhone 15",
category: "手机",
price: 5999,
currency: "CNY",
});
// 加入购物车
tracker.track("add_cart", {
product_id: "P001",
quantity: 1,
total_price: 5999,
});
// 下单
tracker.track("create_order", {
order_id: "O123456",
products: [{ id: "P001", quantity: 1, price: 5999 }],
total_amount: 5999,
payment_method: "wechat",
});
// 支付
tracker.track("payment", {
order_id: "O123456",
amount: 5999,
status: "success",
});埋点管理系统
埋点配置中心
javascript
class TrackConfig {
constructor() {
this.config = {
events: {
page_view: { enable: true, sampling: 1 },
click: { enable: true, sampling: 0.1 },
custom_purchase: { enable: true, sampling: 1 },
},
filters: {
excludeUrls: ["/admin/**", "/test/**"],
excludeUsers: ["*@test.com"],
},
};
}
isEnabled(eventName) {
const eventConfig = this.config.events[eventName];
if (!eventConfig || !eventConfig.enable) return false;
// 采样判断
if (eventConfig.sampling < 1) {
return Math.random() < eventConfig.sampling;
}
return true;
}
}面试题精选
面试题 1:埋点的类型有哪些?各有什么优缺点?
参考答案:
| 类型 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 代码埋点 | 手动在关键位置插入代码 | 精准、可自定义数据 | 工作量大、易遗漏 |
| 全埋点 | 自动采集所有点击/浏览 | 全面、覆盖广 | 数据量大、噪声多 |
| 可视化埋点 | 通过 UI 工具配置 | 简单、无需编码 | 功能有限 |
| 无埋点 | 采集所有数据后端筛选 | 可回溯、数据完整 | 隐私问题、数据量大 |
实际应用建议:
javascript
// 核心业务采用代码埋点
tracker.track("purchase", { order_id: "xxx", amount: 100 });
// 通用交互采用全埋点
// 通过 [data-track] 属性自动采集面试题 2:如何实现一个高效的埋点 SDK?
参考答案:
核心设计原则:
- 低侵入性
javascript
// 不好:侵入业务代码
function handleClick() {
sendAnalytics(); // 埋点代码混入业务逻辑
doBusinessLogic();
}
// 好:非侵入式
class Tracker {
// 自动拦截业务调用
intercept(target, method, callback) {
const original = target[method];
target[method] = function (...args) {
callback(args);
return original.apply(this, args);
};
}
}- 批量上报
javascript
class BatchTracker extends Tracker {
constructor() {
super();
this.queue = [];
this.batchSize = 10;
this.flushInterval = 5000;
setInterval(() => this.flush(), this.flushInterval);
}
track(event, data) {
this.queue.push({ event, data, time: Date.now() });
if (this.queue.length >= this.batchSize) {
this.flush();
}
}
flush() {
if (this.queue.length === 0) return;
const batch = [...this.queue];
this.queue = [];
navigator.sendBeacon(this.url, JSON.stringify(batch));
}
}- 离线缓存
javascript
class OfflineTracker extends Tracker {
constructor() {
super();
this.storageKey = "__tracker_queue__";
this.restoreQueue();
}
track(event, data) {
const item = { event, data, time: Date.now() };
if (navigator.onLine) {
this.send(item);
} else {
this.saveToLocal(item);
}
}
saveToLocal(item) {
const queue = this.getQueue();
queue.push(item);
localStorage.setItem(this.storageKey, JSON.stringify(queue));
}
restoreQueue() {
window.addEventListener("online", () => {
const queue = this.getQueue();
queue.forEach((item) => this.send(item));
localStorage.removeItem(this.storageKey);
});
}
getQueue() {
try {
return JSON.parse(localStorage.getItem(this.storageKey)) || [];
} catch {
return [];
}
}
}面试题 3:如何保证埋点数据的准确性?
参考答案:
1. 用户识别
javascript
// 设备 ID 生成
function generateDeviceId() {
const key = "__device_id__";
let deviceId = localStorage.getItem(key);
if (!deviceId) {
deviceId = "d_" + Date.now().toString(36) + Math.random().toString(36).substr(2, 9);
localStorage.setItem(key, deviceId);
}
return deviceId;
}
// 登录时关联用户 ID
tracker.setUserId(userId);2. 时间同步
javascript
// 校准服务器时间
async function syncTime() {
const start = Date.now();
const res = await fetch("/api/time");
const end = Date.now();
const serverTime = res.timestamp;
const delay = (end - start) / 2;
return {
offset: serverTime - (start + delay),
delay: delay,
};
}3. 数据校验
javascript
// 上报前校验
function validateEvent(event) {
const required = ['event', 'timestamp', 'device_id'];
for (const field of required) {
if (!event[field]) {
console.error(`Missing required field: ${field}`);
return false;
}
}
return true;
}
track(eventName, data) {
const event = this.buildEvent(eventName, data);
if (this.validateEvent(event)) {
this.send(event);
}
}面试题 4:埋点数据如何存储和分析?
参考答案:
数据存储架构:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ 客户端 │───>│ 数据网关 │───>│ Kafka │───>│ Flink │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│
v
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Hive │<───│ ClickHouse│<───│实时数据 │ │ 离线数据 │
└─────────┘ └─────────┘ └─────────┘ └─────────┘实时分析(Flink):
java
public class EventProcess {
public static void main(String[] args) {
StreamExecutionEnvironment env =
StreamExecutionEnvironment.getExecutionEnvironment();
DataStream<String> stream = env
.addSource(new KafkaSource<>("events_topic"))
.map(json -> JSON.parseObject(json));
// 实时统计 UV
stream.keyBy(event -> event.getString("device_id"))
.window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
.apply((key, window, input, out) -> {
out.collect(new UVStat(window.getEnd(), input.size()));
});
env.execute();
}
}离线分析(Hive):
sql
-- 用户行为分析
SELECT
date,
page,
COUNT(DISTINCT device_id) as uv,
COUNT(*) as pv,
AVG(stay_duration) as avg_stay
FROM events
WHERE event = 'page_view'
GROUP BY date, page
ORDER BY uv DESC;
-- 转化漏斗分析
WITH funnels AS (
SELECT
user_id,
MAX(CASE WHEN event = 'page_product' THEN 1 ELSE 0 END) as view_product,
MAX(CASE WHEN event = 'add_cart' THEN 1 ELSE 0 END) as add_cart,
MAX(CASE WHEN event = 'create_order' THEN 1 ELSE 0 END) as create_order,
MAX(CASE WHEN event = 'payment' THEN 1 ELSE 0 END) as payment
FROM events
WHERE date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY user_id
)
SELECT
COUNT(*) as total_users,
SUM(view_product) as view_product,
SUM(add_cart) as add_cart,
SUM(create_order) as create_order,
SUM(payment) as payment,
ROUND(SUM(payment) * 100.0 / COUNT(*), 2) as convert_rate
FROM funnels;面试题 5:如何处理埋点的性能问题?
参考答案:
1. 异步上报
javascript
class AsyncTracker extends Tracker {
track(event, data) {
// 使用 requestIdleCallback 不阻塞主线程
if (window.requestIdleCallback) {
window.requestIdleCallback(() => {
this.send({ event, data });
});
} else {
setTimeout(() => this.send({ event, data }), 0);
}
}
}2. 数据压缩
javascript
// 简化字段名
const FIELD_MAP = {
event: "e",
user_id: "u",
device_id: "d",
timestamp: "t",
page_url: "p",
};
function compressEvent(event) {
const compressed = {};
for (const key in event) {
const shortKey = FIELD_MAP[key] || key;
compressed[shortKey] = event[key];
}
return compressed;
}3. 本地采样
javascript
class SampledTracker extends Tracker {
constructor(options) {
super(options);
this.sampleRate = options.sampleRate || 1;
}
track(event, data) {
// 采样过滤
if (Math.random() > this.sampleRate) {
return;
}
this.send({ event, data });
}
}面试题 6:埋点如何保证用户隐私?
参考答案:
1. 数据脱敏
javascript
function sanitizeData(data) {
const sensitiveFields = ["password", "id_card", "phone", "email"];
function sanitize(obj) {
if (typeof obj !== "object") return obj;
const result = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (sensitiveFields.some((f) => key.toLowerCase().includes(f))) {
result[key] = "***";
} else if (typeof obj[key] === "object") {
result[key] = sanitize(obj[key]);
} else {
result[key] = obj[key];
}
}
return result;
}
return sanitize(data);
}2. 用户授权
javascript
async function requestTrackingConsent() {
const consent = await new Promise((resolve) => {
// 展示隐私政策
showPrivacyModal({
onAccept: () => resolve("accepted"),
onReject: () => resolve("rejected"),
});
});
if (consent === "accepted") {
enableFullTracking();
} else if (consent === "rejected") {
disableTracking();
enableMinimalTracking(); // 仅收集必要数据
}
localStorage.setItem("tracking_consent", consent);
}3. 遵循法规
- GDPR(欧盟):用户知情权、删除权
- CCPA(加州):选择退出权
- 中国《个人信息保护法》:最小必要原则
面试题 7:前端埋点有哪些常见问题?
参考答案:
1. 重复上报
javascript
// 场景:快速点击导致多次触发
class DebouncedTracker {
track(event, data) {
if (this.pending) return;
this.pending = true;
setTimeout(() => {
this.send({ event, data });
this.pending = false;
}, 1000);
}
}2. 漏报问题
javascript
// 场景:页面跳转时数据未发送
window.addEventListener("beforeunload", () => {
// 同步发送未发送的数据
flushPendingEvents();
});
// 或使用 sendBeacon
navigator.sendBeacon(url, JSON.stringify(data));3. 跨页面数据传递
javascript
// 场景:页面 A 事件需要传递到页面 B
sessionStorage.setItem(
"pending_event",
JSON.stringify({
event: "form_submit",
data: { conversion: true },
}),
);
// 页面 B 加载时检查
const pending = sessionStorage.getItem("pending_event");
if (pending) {
const event = JSON.parse(pending);
tracker.track(event.event, event.data);
sessionStorage.removeItem("pending_event");
}面试题 8:如何设计一个可扩展的埋点系统?
参考答案:
系统架构:
┌─────────────────────────────────────────────────────────┐
│ 客户端 SDK │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 代码埋点 │ │ 全埋点 │ │ 可视化埋点│ │ 业务埋点 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ SDK Core │ │
│ │ 过滤/采样 │ │
│ │ 压缩/加密 │ │
│ └──────┬──────┘ │
└───────────────────────────┼───────────────────────────────┘
│
┌───────────────────────────▼───────────────────────────────┐
│ 数据采集层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ HTTP API │ │ WebSocket│ │ MQTT │ │ GRPC │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────┬───────────────────────────────┘
│
┌───────────────────────────▼───────────────────────────────┐
│ 数据处理层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 实时流 │ │ 离线批处理│ │ 数据清洗 │ │ 数据关联 │ │
│ │ (Kafka) │ │ (Spark) │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────┬───────────────────────────────┘
│
┌───────────────────────────▼───────────────────────────────┐
│ 数据存储层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ClickHouse│ │ HBase │ │ Hive │ │ Redis │ │
│ │ (OLAP) │ │ (NoSQL) │ │ (数据湖) │ │ (缓存) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────────────────────────────────┘SDK 插件化设计:
javascript
class Tracker {
constructor() {
this.plugins = [];
}
use(plugin) {
this.plugins.push(plugin);
return this;
}
track(event, data) {
const context = {
event,
data,
timestamp: Date.now(),
// 上下文信息
};
// 插件链处理
for (const plugin of this.plugins) {
if (plugin.process) {
const result = plugin.process(context);
if (result === false) return; // 中断
context = result;
}
}
this.send(context);
}
}
// 插件示例
const clickPlugin = {
name: "click",
process(ctx) {
if (ctx.event === "click") {
ctx.data.xpath = generateXPath(ctx.data.element);
}
return ctx;
},
};
tracker.use(clickPlugin);面试题 9:如何验证埋点数据的正确性?
参考答案:
1. 联调测试
javascript
class DebugTracker extends Tracker {
constructor() {
super();
this.enableDebug();
}
enableDebug() {
window.addEventListener("message", (e) => {
if (e.data.type === "check_tracker") {
this.showPendingEvents();
}
});
}
showPendingEvents() {
console.table(this.queue);
}
}2. 数据校验脚本
python
import json
def validate_event(event):
required_fields = ['event', 'timestamp', 'device_id', 'user_id']
# 检查必填字段
for field in required_fields:
if field not in event:
print(f"Missing field: {field}")
return False
# 检查数据类型
if not isinstance(event['timestamp'], (int, float)):
print(f"Invalid timestamp type: {type(event['timestamp'])}")
return False
# 检查时间范围
now = time.time() * 1000
if abs(event['timestamp'] - now) > 24 * 60 * 60 * 1000:
print(f"Suspicious timestamp: {event['timestamp']}")
return False
return True3. 自动化测试
javascript
// Jest 测试
describe("Tracker", () => {
it("should track page view", () => {
const tracker = new Tracker({ serverUrl: "http://test" });
tracker.pageView("/test");
expect(sendBeacon).toHaveBeenCalledWith("http://test", expect.stringContaining("page_view"));
});
});面试题 10:埋点与数据分析的关系是什么?
参考答案:
数据驱动决策流程:
埋点采集 ──> 数据清洗 ──> 数据存储 ──> 数据分析 ──> 业务优化
│ │
└──────────────── 反馈循环 ───────────────────┘常见分析场景:
1. 漏斗分析
sql
-- 电商转化漏斗
WITH events AS (
SELECT
user_id,
MAX(CASE WHEN event = 'page_home' THEN 1 ELSE 0 END) as home,
MAX(CASE WHEN event = 'page_product' THEN 1 ELSE 0 END) as product,
MAX(CASE WHEN event = 'add_cart' THEN 1 ELSE 0 END) as cart,
MAX(CASE WHEN event = 'create_order' THEN 1 ELSE 0 END) as order,
MAX(CASE WHEN event = 'payment' THEN 1 ELSE 0 END) as payment
FROM user_events
GROUP BY user_id
)
SELECT
COUNT(*) as users,
SUM(home) as home_users,
SUM(product) as product_users,
SUM(cart) as cart_users,
SUM(order) as order_users,
SUM(payment) as payment_users,
ROUND(SUM(cart) * 100.0 / SUM(product), 2) as product_to_cart_rate
FROM events;2. 用户留存分析
sql
-- 次日留存
SELECT
date,
COUNT(DISTINCT user_id) as new_users,
SUM(CASE WHEN retention_1 = 1 THEN 1 ELSE 0 END) as retained,
ROUND(SUM(CASE WHEN retention_1 = 1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 2) as retention_rate
FROM (
SELECT
first_login_date as date,
user_id,
MAX(CASE WHEN event = 'login' AND date >= first_login_date + INTERVAL 1 DAY
THEN 1 ELSE 0 END) as retention_1
FROM user_events
GROUP BY user_id, first_login_date
) t
GROUP BY date;3. 路径分析
sql
-- 用户路径
SELECT
CONCAT(step1, ' -> ', step2, ' -> ', step3) as path,
COUNT(*) as users
FROM (
SELECT
user_id,
MAX(CASE WHEN step = 1 THEN page ELSE NULL END) as step1,
MAX(CASE WHEN step = 2 THEN page ELSE NULL END) as step2,
MAX(CASE WHEN step = 3 THEN page ELSE NULL END) as step3
FROM (
SELECT
user_id,
page,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY timestamp) as step
FROM events
WHERE event = 'page_view'
) t
WHERE step <= 3
GROUP BY user_id
) t
GROUP BY step1, step2, step3
ORDER BY users DESC;