🕵️ 用户行为埋点:数据侦探的秘密武器

9 阅读8分钟

知识点编号: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图表
  - 报表导出

关键要点 🎯

  1. 前端SDK - 自动采集 + 手动埋点
  2. 批量上报 - 减少请求次数
  3. 异步处理 - 不影响主业务
  4. 数据清洗 - 保证数据质量
  5. 实时+离线 - Redis + ClickHouse

数据驱动,科学决策! 📊📊📊