J'Blog
← 返回文章列表

日志打点规范

日志打点规范

背景

日志上报可以记录用户行为,方便运营查看后期转化;除了日常开发中涉及到比较多的业务事件上报以外,异常上报、过程日志上报等信息能让开发更好的排查问题

通用打点

一般包含为用户行为上报(pv、uv、用户点击按钮)、异常上报(js异常、source异常、promise异常)等,异常处理涉及到source-map的使用,写一个通用的sdk,这里结合腾讯云cls的sdk实现

import {
  Log,
  LogGroup,
  AsyncClient,
  PutLogsRequest,
} from "tencentcloud-cls-sdk-js-web";

const baseConfig = {
  autoTrack: true,
  env: "development",
  endpoint: "",
  retry_times: 10,
  address: "127.0.0.1",
  topic_id: "",
};

/**
 * @param {Object} config 日志追踪配置
 * @param {Boolean} config.autoTrack 是否自动追踪
 * @param {String} config.topic_id 日志主题ID
 * @param {String} config.env 环境 release | development
 */
export default class LogTracker {
  config: any;
  logger: any;
  pageStartTime: any;
  currentPath: any;
  constructor(config = {}) {
    this.config = {
      ...baseConfig,
      ...config,
    };

    this.logger = new AsyncClient({
      endpoint: this.config.endpoint,
      retry_times: this.config.retry_times,
    });

    // 点击事件上报
    this.initAutoTrack();
    // 访问页面、离开页面上报
    this.routerListen();
    // 异常上报
    this.initErrorHandler();
    // 性能指标监控
    this.initPerformanceTracking();
  }

  initPerformanceTracking() {
    // 核心性能指标自动采集
    this.trackCoreWebVitals();
    this.trackResourceTiming();
  }

  // 核心Web指标监控
  trackCoreWebVitals() {
    const metrics = {};
    
    const logMetric = (name: string, value: any) => {
      this.trackPerformance(name, { value });
    };

    // FP监控
    new PerformanceObserver(entryList => {
      for (const entry of entryList.getEntriesByName('first-paint')) {
        console.log('FP:', entry.startTime);
      }
    }).observe({ entryTypes: ['paint'] })

    // FCP监控
    // new PerformanceObserver(entryList => {
    //   for (const entry of entryList.getEntriesByName('first-contentful-paint')) {
    //     console.log('FCP:', entry.startTime);
    //   }
    // }).observe({ entryTypes: ['paint'] })

    // LCP监控
    new PerformanceObserver(entryList => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1];
      logMetric('LCP', lastEntry.startTime);
    }).observe({ type: 'largest-contentful-paint', buffered: true });

    // FID监控
    new PerformanceObserver(entryList => {
      const entries = entryList.getEntries();
      entries.forEach((entry: any) => {
        logMetric('FID', entry.processingStart - entry.startTime);
      });
    }).observe({ type: 'first-input', buffered: true });

    // CLS监控
    let clsValue = 0;
    new PerformanceObserver(entryList => {
      for (const entry of entryList.getEntries() as any) {
        if (!entry.hadRecentInput) {
          clsValue += entry.value;
        }
      }
      logMetric('CLS', clsValue);
    }).observe({ type: 'layout-shift', buffered: true });
  }

  // 资源性能监控
  trackResourceTiming() {
    const resources = performance.getEntriesByType('resource');
    resources.forEach((resource: any) => {
      this.trackPerformance('resource', {
        name: resource.name,
        type: resource.initiatorType,
        duration: resource.duration,
        dns: resource.domainLookupEnd - resource.domainLookupStart,
        tcp: resource.connectEnd - resource.connectStart,
        ttfb: resource.responseStart - resource.requestStart
      });
    });
  }

  // 统一性能上报方法
  trackPerformance(metricType: string, data: any) {
    this.sendLog('performance', {
      metricType,
      ...data
    });
  }

  routerListen() {
    // 监听浏览器前进/后退(原生事件)
    window.addEventListener("popstate", this.handleRouteChange);
    window.addEventListener("hashchange", this.handleRouteChange);

    // 劫持 History API 方法
    this.overrideHistoryMethods();

    // 初始触发一次路由检测
    this.handleRouteChange();
  }

  // 重写 pushState/replaceState
  overrideHistoryMethods() {
    const originalPushState = history.pushState;
    const originalReplaceState = history.replaceState;

    history.pushState = (state, title, url) => {
      originalPushState.call(history, state, title, url);
      this.handleRouteChange();
    };

    history.replaceState = (state, title, url) => {
      originalReplaceState.call(history, state, title, url);
      this.handleRouteChange();
    };
  }

  // 统一处理路由变化
  handleRouteChange = () => {
    const newPath = this.getCurrentPath();
    if (newPath === this.currentPath) return;

    // 触发离开事件
    if (this.currentPath) {
      this.trackPageLeave({
        from: this.currentPath,
        to: newPath,
      });
    }

    // 触发进入事件
    this.trackPageView({
      path: newPath,
      referrer: this.currentPath,
    });

    this.currentPath = newPath;
  };

  // 获取当前路径(兼容 Hash 和 History 模式)
  getCurrentPath() {
    return window.location.hash.slice(1) || window.location.pathname;
  }

  // 销毁监听
  destroy() {
    window.removeEventListener("popstate", this.handleRouteChange);
    window.removeEventListener("hashchange", this.handleRouteChange);
  }

  // 初始化自动追踪
  initAutoTrack() {
    if (!this.config.autoTrack) return;

    // 点击事件上报
    document.addEventListener(
      "click",
      (e) => {
        const target: any = e.target;
        this.trackEvent("click", {
          tag: target.tagName,
          id: target.id,
          class: target.className,
          text: target.innerText.slice(0, 50),
        });
      },
      true
    );
  }

  // 初始化错误监听
  initErrorHandler() {
    // 监听常规JS错误
    window.addEventListener(
      "error",
      (event: any) => {
        const target = event.target

        if (
          target &&
          (target instanceof HTMLImageElement ||
            target instanceof HTMLScriptElement ||
            target instanceof HTMLLinkElement ||
            target instanceof HTMLVideoElement)
        ) {
          const url = event.target?.src || event.target?.href
          const data: Record<string, any> = {
            stack: "",
            assetUrl: url,
            type: "SOURCE_ERROR",
          }

          this.trackError(data);
          return
        }

        const error = event.error
        const data: Record<string, any> = {
          type: "JS_ERROR",
          stack: error.stack,
        }
        this.trackError(data);
      },
      true,
    )

    // Promise错误
    window.addEventListener("unhandledrejection", (e) => {
      this.trackError({
        type: "promise_error",
        msg: e.reason?.message || e.reason,
        stack: e.reason?.stack,
      });
    });
  }

  // 通用上报方法
  async sendLog(logType: string, data: any) {
    const logData = {
      eventKey: logType,
      eventData: {
        timestamp: Date.now().toString(),
        env: this.config.env,
        page: window.location.href,
        userAgent: navigator.userAgent,
        ...data,
      },
    };

    try {
      // 创建 LogGroup 实例
      const logGroup = new LogGroup(this.config.address);
      logGroup.setSource(this.config.address);
      // 创建 Log 实例
      const log = this.createLog(logData);
      // 将 Log 添加到 LogGroup
      logGroup.addLog(log);
      // 创建 PutLogsRequest 实例
      const request = new PutLogsRequest(this.config.topic_id, logGroup);
      // 发送 PutLogsRequest
      const data = await this.logger.PutLogs(request);
      console.log("Logs uploaded successfully:", data);
    } catch (err) {
      console.error("日志上报失败:", err);
      // 失败处理逻辑(可选存储到localStorage后续重发)
    }
  }

  createLog(logData: any) {
    // 创建一个 log
    const log = new Log(Date.now());

    Object.keys(logData).forEach((key) => {
      const value =
        typeof logData[key] !== "object"
          ? logData[key]
          : logData[key] !== null
          ? JSON.stringify(logData[key])
          : logData[key];
      log.addContent(key, value);
    });

    return log;
  }

  // 页面访问追踪
  trackPageView(routeInfo: any = {}) {
    const now = Date.now();

    // 更新当前路径
    this.currentPath = routeInfo.to || window.location.pathname;

    // 上报新页面访问
    this.pageStartTime = now;
    this.trackEvent("page_view", {
      ...routeInfo,
      referrer: document.referrer,
      title: document.title,
    });
  }

  // 页面离开追踪
  trackPageLeave(routeInfo: any = {}) {
    const now = Date.now();

    // 计算页面停留时间
    const stayTime = now - this.pageStartTime;
    this.trackEvent("page_leave", {
      ...routeInfo,
      referrer: document.referrer,
      title: document.title,
      stayTime
    });
  }

  // 行为事件上报
  trackEvent(eventType: string, params = {}) {
    this.sendLog("behavior", {
      eventType,
      ...params,
    });
  }

  // 错误上报
  trackError(errorInfo: any) {
    this.sendLog("error", errorInfo);
  }

  // 过程日志上报
  log(level = "info", message: any, context = {}) {
    this.sendLog("process", {
      logLevel: level,
      message,
      ...context,
    });
  }

  // 业务事件上报
  reportBusinessEvent(eventName: string, customData = {}) {
    this.sendLog("business", {
      eventName,
      ...customData,
    });
  }
}