知识点编号:275
难度系数:⭐⭐⭐⭐⭐
实用指数:💯💯💯💯💯
📖 开篇:一次产品改版的争论
产品经理A:
"我觉得这个按钮应该放在右上角,更符合用户习惯!"
产品经理B:
"不对!应该放在底部中间,用户操作更方便!"
开发小王:
"要不...你们猜拳决定?🙄"
这时,数据分析师拿出了一份报告:
用户行为分析报告:
右上角按钮点击率:15%
底部中间按钮点击率:45%
用户平均停留时长:从2分钟提升到5分钟
转化率:提升30%
结论:底部中间位置更优 ✅
产品经理A:
"数据说话,我服了!📊"
这就是用户行为埋点的力量! 🎯
今天,我们就来学习如何实现完整的用户行为埋点系统!
🎯 为什么需要埋点?
1. 了解用户行为 👤
- 用户从哪来?(渠道分析)
- 用户看什么?(页面浏览)
- 用户点什么?(点击热力图)
- 用户买什么?(转化漏斗)
- 用户在哪流失?(流失分析)
2. 优化产品体验 🎨
- A/B测试(哪个版本更好?)
- 功能使用率(哪些功能受欢迎?)
- 性能监控(页面加载慢吗?)
- 异常捕获(有bug吗?)
3. 数据驱动决策 📊
- 产品迭代方向
- 运营活动效果
- 广告投放ROI
- 用户画像分析
🎨 埋点类型
┌──────────────────────────────────────────────────────────┐
│ 埋点分类 │
└──────────────────────────────────────────────────────────┘
类型1: 页面浏览埋点(PV/UV)
- 页面访问次数
- 独立访客数
- 停留时长
类型2: 事件埋点(Event)
- 按钮点击
- 表单提交
- 商品加购
- 视频播放
类型3: 曝光埋点(Exposure)
- 广告曝光
- 商品曝光
- 推荐位曝光
类型4: 性能埋点(Performance)
- 页面加载时间
- 接口响应时间
- 资源加载时间
类型5: 异常埋点(Error)
- JS错误
- 接口错误
- 资源加载失败
💻 前端埋点实现
1. 基础埋点SDK
// tracker.js
class Tracker {
constructor(options = {}) {
this.appId = options.appId
this.userId = options.userId || this.getUserId()
this.sessionId = this.getSessionId()
this.apiUrl = options.apiUrl || '/api/track'
// 批量上报配置
this.queue = []
this.batchSize = options.batchSize || 10
this.flushInterval = options.flushInterval || 5000
// 初始化
this.init()
}
init() {
// 自动采集页面浏览
this.trackPageView()
// 监听页面卸载(上报剩余数据)
window.addEventListener('beforeunload', () => {
this.flush(true)
})
// 定时批量上报
setInterval(() => {
this.flush()
}, this.flushInterval)
// 监听错误
this.trackError()
}
/**
* 页面浏览埋点
*/
trackPageView() {
const startTime = Date.now()
// 页面进入
this.track('page_view', {
page_url: location.href,
page_title: document.title,
referrer: document.referrer
})
// 页面离开(计算停留时长)
window.addEventListener('beforeunload', () => {
const duration = Date.now() - startTime
this.track('page_leave', {
page_url: location.href,
duration: duration
})
})
}
/**
* 事件埋点
*/
track(eventName, properties = {}) {
const event = {
event_name: eventName,
app_id: this.appId,
user_id: this.userId,
session_id: this.sessionId,
timestamp: Date.now(),
// 设备信息
device: {
user_agent: navigator.userAgent,
screen_width: screen.width,
screen_height: screen.height,
platform: navigator.platform,
language: navigator.language
},
// 页面信息
page: {
url: location.href,
title: document.title,
referrer: document.referrer
},
// 自定义属性
properties: properties
}
// 添加到队列
this.queue.push(event)
// 达到批量大小,立即上报
if (this.queue.length >= this.batchSize) {
this.flush()
}
}
/**
* 批量上报
*/
flush(sync = false) {
if (this.queue.length === 0) return
const data = [...this.queue]
this.queue = []
if (sync) {
// 同步上报(页面卸载时)
navigator.sendBeacon(this.apiUrl, JSON.stringify(data))
} else {
// 异步上报
fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
}).catch(err => {
console.error('埋点上报失败', err)
// 失败重新入队
this.queue.unshift(...data)
})
}
}
/**
* 异常埋点
*/
trackError() {
// JS错误
window.addEventListener('error', (event) => {
this.track('js_error', {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error?.stack
})
})
// Promise未捕获错误
window.addEventListener('unhandledrejection', (event) => {
this.track('promise_error', {
reason: event.reason,
promise: event.promise
})
})
// 资源加载错误
window.addEventListener('error', (event) => {
if (event.target !== window) {
this.track('resource_error', {
tag: event.target.tagName,
src: event.target.src || event.target.href
})
}
}, true)
}
/**
* 性能埋点
*/
trackPerformance() {
window.addEventListener('load', () => {
setTimeout(() => {
const perfData = performance.timing
this.track('page_performance', {
// DNS查询时间
dns: perfData.domainLookupEnd - perfData.domainLookupStart,
// TCP连接时间
tcp: perfData.connectEnd - perfData.connectStart,
// 请求时间
request: perfData.responseStart - perfData.requestStart,
// 响应时间
response: perfData.responseEnd - perfData.responseStart,
// DOM解析时间
dom: perfData.domContentLoadedEventEnd - perfData.domLoading,
// 页面加载完成时间
load: perfData.loadEventEnd - perfData.navigationStart,
// 白屏时间
white_screen: perfData.responseStart - perfData.navigationStart,
// 首屏时间
first_screen: perfData.domContentLoadedEventEnd - perfData.navigationStart
})
}, 0)
})
}
/**
* 获取/生成用户ID
*/
getUserId() {
let userId = localStorage.getItem('__tracker_user_id')
if (!userId) {
userId = 'user_' + Date.now() + '_' + Math.random().toString(36).substr(2)
localStorage.setItem('__tracker_user_id', userId)
}
return userId
}
/**
* 获取/生成会话ID
*/
getSessionId() {
let sessionId = sessionStorage.getItem('__tracker_session_id')
if (!sessionId) {
sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2)
sessionStorage.setItem('__tracker_session_id', sessionId)
}
return sessionId
}
}
// 导出
window.Tracker = Tracker
2. 使用示例
<!DOCTYPE html>
<html>
<head>
<title>电商首页</title>
<script src="tracker.js"></script>
</head>
<body>
<h1>欢迎来到电商平台</h1>
<button id="addCart">加入购物车</button>
<button id="buy">立即购买</button>
<script>
// 初始化埋点SDK
const tracker = new Tracker({
appId: 'my-app',
apiUrl: 'https://api.example.com/track'
})
// 性能埋点
tracker.trackPerformance()
// 按钮点击埋点
document.getElementById('addCart').addEventListener('click', () => {
tracker.track('add_cart', {
product_id: 12345,
product_name: 'iPhone 15',
price: 5999,
quantity: 1
})
})
document.getElementById('buy').addEventListener('click', () => {
tracker.track('click_buy', {
product_id: 12345,
product_name: 'iPhone 15',
price: 5999
})
})
</script>
</body>
</html>
3. Vue指令封装
// track-directive.js
export default {
mounted(el, binding) {
const { event, data } = binding.value
el.addEventListener('click', () => {
window.tracker.track(event, data)
})
}
}
// main.js
import trackDirective from './directives/track-directive'
app.directive('track', trackDirective)
<!-- 使用 -->
<template>
<button v-track="{ event: 'click_buy', data: { product_id: 123 }}">
购买
</button>
<div v-track="{ event: 'view_product', data: product }">
商品详情
</div>
</template>
🔧 后端接收与存储
1. 接收接口
@RestController
@RequestMapping("/api/track")
@Slf4j
public class TrackController {
@Autowired
private TrackService trackService;
/**
* 批量接收埋点数据
*/
@PostMapping
public Result<Void> track(@RequestBody List<TrackEvent> events) {
try {
// 异步处理
trackService.handleEvents(events);
return Result.success();
} catch (Exception e) {
log.error("埋点数据处理失败", e);
return Result.fail("处理失败");
}
}
}
@Data
public class TrackEvent {
private String eventName; // 事件名称
private String appId; // 应用ID
private String userId; // 用户ID
private String sessionId; // 会话ID
private Long timestamp; // 时间戳
private DeviceInfo device; // 设备信息
private PageInfo page; // 页面信息
private Map<String, Object> properties; // 自定义属性
}
2. 异步处理
@Service
public class TrackService {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 处理埋点事件
*/
@Async("trackExecutor")
public void handleEvents(List<TrackEvent> events) {
for (TrackEvent event : events) {
// 1. 数据清洗
cleanEvent(event);
// 2. 数据验证
if (!validateEvent(event)) {
log.warn("无效的埋点数据:{}", event);
continue;
}
// 3. 数据enrichment(补充信息)
enrichEvent(event);
// 4. 发送到Kafka
sendToKafka(event);
// 5. 实时统计(Redis)
updateRealTimeStats(event);
}
}
/**
* 数据清洗
*/
private void cleanEvent(TrackEvent event) {
// 去除空字段
if (event.getProperties() != null) {
event.getProperties().entrySet().removeIf(e -> e.getValue() == null);
}
// URL参数清洗
if (event.getPage() != null && event.getPage().getUrl() != null) {
String url = event.getPage().getUrl();
// 移除敏感参数
url = url.replaceAll("(token|password)=[^&]*", "$1=***");
event.getPage().setUrl(url);
}
}
/**
* 数据验证
*/
private boolean validateEvent(TrackEvent event) {
if (StringUtils.isBlank(event.getEventName())) {
return false;
}
if (StringUtils.isBlank(event.getUserId())) {
return false;
}
if (event.getTimestamp() == null) {
return false;
}
return true;
}
/**
* 数据enrichment
*/
private void enrichEvent(TrackEvent event) {
// 补充IP地址
event.setIp(getClientIp());
// 补充地理位置
GeoInfo geo = geoService.getGeoInfo(event.getIp());
event.setGeo(geo);
// 补充用户信息
UserInfo userInfo = userService.getUserInfo(event.getUserId());
event.setUserInfo(userInfo);
}
/**
* 发送到Kafka
*/
private void sendToKafka(TrackEvent event) {
String topic = "track-events-" + event.getEventName();
kafkaTemplate.send(topic, JSON.toJSONString(event));
}
/**
* 实时统计
*/
private void updateRealTimeStats(TrackEvent event) {
String date = LocalDate.now().toString();
// PV统计
String pvKey = "stats:pv:" + date;
redisTemplate.opsForValue().increment(pvKey);
// UV统计(HyperLogLog)
String uvKey = "stats:uv:" + date;
redisTemplate.opsForHyperLogLog().add(uvKey, event.getUserId());
// 事件统计
String eventKey = "stats:event:" + event.getEventName() + ":" + date;
redisTemplate.opsForValue().increment(eventKey);
}
}
3. Kafka消费
@Component
@Slf4j
public class TrackEventConsumer {
@Autowired
private ClickHouseClient clickHouseClient;
@KafkaListener(topics = "track-events-*", groupId = "track-consumer")
public void consume(String message) {
try {
TrackEvent event = JSON.parseObject(message, TrackEvent.class);
// 写入ClickHouse
saveToClickHouse(event);
} catch (Exception e) {
log.error("消费埋点数据失败", e);
}
}
/**
* 批量写入ClickHouse
*/
private void saveToClickHouse(TrackEvent event) {
String sql = "INSERT INTO track_events " +
"(event_name, app_id, user_id, session_id, timestamp, " +
" device_info, page_info, properties) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
clickHouseClient.execute(sql,
event.getEventName(),
event.getAppId(),
event.getUserId(),
event.getSessionId(),
event.getTimestamp(),
JSON.toJSONString(event.getDevice()),
JSON.toJSONString(event.getPage()),
JSON.toJSONString(event.getProperties())
);
}
}
4. ClickHouse表设计
-- 埋点事件表
CREATE TABLE track_events (
event_name String, -- 事件名称
app_id String, -- 应用ID
user_id String, -- 用户ID
session_id String, -- 会话ID
timestamp DateTime, -- 时间戳
-- 设备信息
device_info String, -- JSON格式
-- 页面信息
page_info String, -- JSON格式
-- 自定义属性
properties String, -- JSON格式
-- 补充信息
ip String, -- IP地址
geo_country String, -- 国家
geo_province String, -- 省份
geo_city String, -- 城市
create_date Date DEFAULT today()
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(timestamp)
ORDER BY (app_id, event_name, timestamp)
SETTINGS index_granularity = 8192;
-- 页面浏览表(按天分区)
CREATE TABLE page_views (
user_id String,
session_id String,
page_url String,
page_title String,
referrer String,
duration UInt32, -- 停留时长(秒)
timestamp DateTime,
create_date Date DEFAULT today()
) ENGINE = MergeTree()
PARTITION BY create_date
ORDER BY (user_id, timestamp)
SETTINGS index_granularity = 8192;
📊 数据分析
1. 实时大屏
@RestController
@RequestMapping("/api/stats")
public class StatsController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 实时统计
*/
@GetMapping("/realtime")
public Result<RealtimeStats> getRealtime() {
String date = LocalDate.now().toString();
// PV
String pvKey = "stats:pv:" + date;
Long pv = (Long) redisTemplate.opsForValue().get(pvKey);
// UV(HyperLogLog)
String uvKey = "stats:uv:" + date;
Long uv = redisTemplate.opsForHyperLogLog().size(uvKey);
// 热门事件TOP10
String eventKey = "stats:event:*:" + date;
Set<String> eventKeys = redisTemplate.keys(eventKey);
Map<String, Long> topEvents = new HashMap<>();
if (eventKeys != null) {
for (String key : eventKeys) {
String eventName = key.split(":")[2];
Long count = (Long) redisTemplate.opsForValue().get(key);
topEvents.put(eventName, count);
}
}
RealtimeStats stats = new RealtimeStats();
stats.setPv(pv);
stats.setUv(uv);
stats.setTopEvents(topEvents);
return Result.success(stats);
}
}
2. 漏斗分析
/**
* 转化漏斗分析
*/
public FunnelResult analyzeFunnel(FunnelRequest request) {
String sql =
"SELECT " +
" countIf(event_name = 'page_view') AS step1, " +
" countIf(event_name = 'add_cart') AS step2, " +
" countIf(event_name = 'create_order') AS step3, " +
" countIf(event_name = 'pay_success') AS step4 " +
"FROM track_events " +
"WHERE create_date >= ? AND create_date <= ? " +
"AND app_id = ?";
// 执行查询
Map<String, Long> result = clickHouseClient.queryForMap(sql,
request.getStartDate(),
request.getEndDate(),
request.getAppId()
);
// 计算转化率
long step1 = result.get("step1");
long step2 = result.get("step2");
long step3 = result.get("step3");
long step4 = result.get("step4");
FunnelResult funnelResult = new FunnelResult();
funnelResult.addStep("浏览商品", step1, 100.0);
funnelResult.addStep("加入购物车", step2, step2 * 100.0 / step1);
funnelResult.addStep("创建订单", step3, step3 * 100.0 / step2);
funnelResult.addStep("支付成功", step4, step4 * 100.0 / step3);
return funnelResult;
}
3. 用户留存
/**
* 留存分析
*/
public RetentionResult analyzeRetention(RetentionRequest request) {
String sql =
"SELECT " +
" toDate(timestamp) AS date, " +
" uniqExact(user_id) AS new_users, " +
" uniqExactIf(user_id, " +
" timestamp >= date AND timestamp < date + INTERVAL 1 DAY" +
" ) AS day1_retention, " +
" uniqExactIf(user_id, " +
" timestamp >= date AND timestamp < date + INTERVAL 7 DAY" +
" ) AS day7_retention " +
"FROM track_events " +
"WHERE event_name = 'page_view' " +
"AND create_date >= ? " +
"GROUP BY date " +
"ORDER BY date";
List<Map<String, Object>> rows = clickHouseClient.queryForList(sql,
request.getStartDate()
);
// 构建结果
RetentionResult result = new RetentionResult();
for (Map<String, Object> row : rows) {
String date = (String) row.get("date");
long newUsers = (Long) row.get("new_users");
long day1 = (Long) row.get("day1_retention");
long day7 = (Long) row.get("day7_retention");
result.addData(date, newUsers,
day1 * 100.0 / newUsers,
day7 * 100.0 / newUsers
);
}
return result;
}
🎨 可视化展示
1. ECharts图表
<template>
<div class="dashboard">
<el-row :gutter="20">
<!-- 实时数据 -->
<el-col :span="6">
<el-card>
<div class="stat-item">
<div class="label">今日PV</div>
<div class="value">{{ stats.pv }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-item">
<div class="label">今日UV</div>
<div class="value">{{ stats.uv }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 漏斗图 -->
<el-card title="转化漏斗">
<div ref="funnelChart" style="width: 100%; height: 400px;"></div>
</el-card>
<!-- 留存曲线 -->
<el-card title="用户留存">
<div ref="retentionChart" style="width: 100%; height: 400px;"></div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as echarts from 'echarts'
import { getRealtimeStats, getFunnel, getRetention } from '@/api/stats'
const stats = ref({ pv: 0, uv: 0 })
const funnelChart = ref()
const retentionChart = ref()
onMounted(async () => {
// 获取实时数据
const statsRes = await getRealtimeStats()
stats.value = statsRes.data
// 绘制漏斗图
drawFunnel()
// 绘制留存曲线
drawRetention()
})
const drawFunnel = async () => {
const res = await getFunnel()
const data = res.data
const chart = echarts.init(funnelChart.value)
chart.setOption({
title: {
text: '转化漏斗'
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} ({d}%)'
},
series: [{
type: 'funnel',
data: data.steps.map(step => ({
name: step.name,
value: step.count
}))
}]
})
}
const drawRetention = async () => {
const res = await getRetention()
const data = res.data
const chart = echarts.init(retentionChart.value)
chart.setOption({
title: {
text: '用户留存率'
},
xAxis: {
type: 'category',
data: data.dates
},
yAxis: {
type: 'value',
axisLabel: {
formatter: '{value}%'
}
},
series: [
{
name: '次日留存',
type: 'line',
data: data.day1Retention
},
{
name: '7日留存',
type: 'line',
data: data.day7Retention
}
]
})
}
</script>
📝 总结
完整架构
┌──────────────────────────────────────────────────────────┐
│ 埋点系统架构 │
└──────────────────────────────────────────────────────────┘
前端(数据采集):
- Tracker SDK
- 批量上报
- 异常捕获
后端(数据接收):
- Spring Boot
- 数据清洗
- 数据验证
消息队列:
- Kafka
- 削峰填谷
- 解耦
存储:
- ClickHouse(离线分析)
- Redis(实时统计)
分析:
- 漏斗分析
- 留存分析
- 用户画像
可视化:
- 实时大屏
- ECharts图表
- 报表导出
关键要点 🎯
- 前端SDK - 自动采集 + 手动埋点
- 批量上报 - 减少请求次数
- 异步处理 - 不影响主业务
- 数据清洗 - 保证数据质量
- 实时+离线 - Redis + ClickHouse
数据驱动,科学决策! 📊📊📊