静态站点埋点日志本地聚合与自助分析脚本实践

举报
yd_282816122 发表于 2026/11/11 18:14:27 2026/11/11
【摘要】 背景痛点静态站点的埋点数据一般是离散 JSON 行日志,直接存储在对象存储或者简单 Serverless 日志库中,原生存在几个问题:原始日志混杂心跳、异常、页面访问、自定义事件,格式不统一,脏数据多(上报超时、浏览器非法参数、爬虫扫描请求);原始数据无法直接看大盘:UV、PV、LCP 分位、页面停留时长、错误分布;引入专业日志系统太重,对于校园导览这类小流量静态站点,维护成本高;日志没有...

背景痛点

静态站点的埋点数据一般是离散 JSON 行日志,直接存储在对象存储或者简单 Serverless 日志库中,原生存在几个问题:

  1. 原始日志混杂心跳、异常、页面访问、自定义事件,格式不统一,脏数据多(上报超时、浏览器非法参数、爬虫扫描请求);
  2. 原始数据无法直接看大盘:UV、PV、LCP 分位、页面停留时长、错误分布;
  3. 引入专业日志系统太重,对于校园导览这类小流量静态站点,维护成本高;
  4. 日志没有脱敏,UA、IP 原始信息直接存储,存在合规风险。

我们的目标:构建一个本地/CI 可定时执行的日志聚合脚本,输入原始埋点日志,输出结构化指标报表(JSON + Markdown 大盘)。

整体设计思路

整体流水线分为 4 个阶段:
日志拉取 -> 清洗过滤 & 脱敏 -> 指标聚合计算 -> 输出报表 + 持久化快照

  • 过滤层:过滤爬虫、无效心跳、非法格式、测试标记的埋点;
  • 脱敏层:IP 掩码、UA 只保留设备/浏览器大类,不存储完整识别信息;
  • 聚合层:按页面路径、日期、设备维度,统计 PV/UV、性能指标分位数、JS 错误 TOP;
  • 输出层:机器可读 JSON 指标,同时生成可读的 Markdown 报表,可直接放进仓库归档。

核心实现:Node 日志分析脚本

// log-analyzer.js
const fs = require('fs');
const path = require('path');

const RAW_LOG_PATH = './logs/raw.log';
const OUTPUT_JSON = './logs/report.json';
const OUTPUT_MD = './logs/report.md';

// 简单脱敏:IP掩码
function maskIp(ip) {
  if (!ip || ip === '-') return 'unknown';
  const parts = ip.split('.');
  if (parts.length === 4) return `${parts[0]}.${parts[1]}.*.*`;
  return ip;
}

// 简易UV标识(业务侧使用匿名sessionId,不依赖真实用户标识)
function isValidRecord(item) {
  if (!item || typeof item !== 'object') return false;
  // 过滤爬虫、心跳ping、非法上报
  const isPing = item.event_type === 'ping';
  const isBot = item.ua?.toLowerCase().includes('bot') || item.ua?.toLowerCase().includes('spider');
  const hasRequired = !!item.sessionId && !!item.page_path && !!item.timestamp;
  return !isPing && !isBot && hasRequired;
}

function calcQuantile(arr, q) {
  if (arr.length === 0) return null;
  const sorted = [...arr].sort((a, b) => a - b);
  const idx = Math.floor(sorted.length * q);
  return sorted[idx];
}

function main() {
  if (!fs.existsSync(RAW_LOG_PATH)) {
    console.log('原始日志文件不存在');
    return;
  }
  const rawText = fs.readFileSync(RAW_LOG_PATH, 'utf-8');
  const lines = rawText.split('\n').filter(Boolean);

  const records = [];
  const errorMap = new Map();
  const lcpList = [];
  const pageStat = new Map();
  const sessionSet = new Set();

  for (const line of lines) {
    try {
      const item = JSON.parse(line);
      if (!isValidRecord(item)) continue;
      item.ip = maskIp(item.ip);
      records.push(item);
      sessionSet.add(item.sessionId);

      // 页面维度统计
      if (!pageStat.has(item.page_path)) {
        pageStat.set(item.page_path, { pv: 0, uvSet: new Set() });
      }
      const p = pageStat.get(item.page_path);
      p.pv += 1;
      p.uvSet.add(item.sessionId);

      // LCP性能指标收集
      if (item.event_type === 'performance' && item.lcp) {
        lcpList.push(Number(item.lcp));
      }
      // JS错误统计
      if (item.event_type === 'error' && item.msg) {
        errorMap.set(item.msg, (errorMap.get(item.msg) || 0) + 1);
      }
    } catch (e) {
      // 忽略JSON解析失败脏日志
      continue;
    }
  }

  // 汇总指标
  const totalPV = records.length;
  const totalUV = sessionSet.size;
  const lcpP50 = calcQuantile(lcpList, 0.5);
  const lcpP90 = calcQuantile(lcpList, 0.9);
  const lcpP95 = calcQuantile(lcpList, 0.95);

  // 页面维度转为普通对象
  const pageResult = {};
  for (const [k, v] of pageStat.entries()) {
    pageResult[k] = { pv: v.pv, uv: v.uvSet.size };
  }

  // 错误TOP10
  const sortedError = Array.from(errorMap.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10);

  const report = {
    meta: { generateAt: new Date().toISOString() },
    summary: { totalPV, totalUV, lcpP50, lcpP90, lcpP95 },
    page: pageResult,
    topError: sortedError
  };

  // 输出JSON
  fs.writeFileSync(OUTPUT_JSON, JSON.stringify(report, null, 2), 'utf-8');

  // 输出Markdown报表
  let md = `# 站点埋点自助分析报表\n\n生成时间:${report.meta.generateAt}\n\n## 大盘总览\n\n| 指标 | 值 |\n| ---- | ---- |\n| PV | ${totalPV} |\n| UV | ${totalUV} |\n| LCP P50(ms) | ${lcpP50 ?? '-' } |\n| LCP P90(ms) | ${lcpP90 ?? '-' } |\n| LCP P95(ms) | ${lcpP95 ?? '-' } |\n\n## 页面访问分布\n\n| 页面路径 | PV | UV |\n| ---- | ---- | ---- |\n`;
  for (const [path, stat] of Object.entries(pageResult)) {
    md += `| ${path} | ${stat.pv} | ${stat.uv} |\n`;
  }
  md += `\n## TOP 前端错误\n\n| 错误信息 | 次数 |\n| ---- | ---- |\n`;
  for (const [msg, cnt] of sortedError) {
    md += `| ${msg} | ${cnt} |\n`;
  }
  fs.writeFileSync(OUTPUT_MD, md, 'utf-8');
  console.log('报表生成完成', OUTPUT_JSON, OUTPUT_MD);
}

main();

配套 CI 定时执行方案

在之前的 GitCode CI 流水线里新增定时任务,比如每 6 小时拉取一次远端日志、执行分析脚本,产出报表并提交回仓库:

  • 拉取日志:从对象存储 / Serverless 日志接口追加下载原始日志,避免重复计算;
  • 执行脚本:node log-analyzer.js;
  • 产物归档:把 report.md、report.json 提交到仓库 docs/report 目录,形成可追溯的历史大盘快照。

工程权衡与边界说明

  1. UV 精度取舍:这里依赖前端生成的匿名 sessionId,不是严格真实用户,适合小站点趋势观察,不适合高精度用户统计;
  2. 数据容量上限:纯本地脚本适合十万行以内日志,数据量暴涨之后需要换成流式处理;
  3. 合规边界:全程不收集手机号、实名信息,IP 做掩码,符合静态站点轻量埋点的隐私要求;
  4. 告警能力缺失:脚本只做统计,不自动告警;扩展方向:增加阈值判断(比如 LCP P95 > 3000ms、错误突增),对接 Webhook 推送通知。

后续演进方向

  • 增加按地域、设备类型的多维度下钻;
  • 增加环比计算,自动对比昨日同时段流量;
  • 接入简单图表渲染,直接输出带 mermaid 图表的 Markdown 报表。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。