返回笔记首页

跨端应用日志收集系统技术方案

主题配置

一、uni-app 日志系统

1.1 日志收集

日志等级

javascript
// utils/logger.js
class Logger {
  constructor() {
    this.logs = []
    this.maxLogs = 100
    this.uploadUrl = 'https://log.api.com/collect'
  }

  debug(message, ...args) {
    this._log('DEBUG', message, args)
  }

  info(message, ...args) {
    this._log('INFO', message, args)
  }

  warn(message, ...args) {
    this._log('WARN', message, args)
  }

  error(message, ...args) {
    this._log('ERROR', message, args)
    // 错误日志立即上报
    this.flush()
  }

  _log(level, message, args) {
    const log = {
      level,
      message,
      args: this._serialize(args),
      timestamp: Date.now(),
      page: this._getCurrentPage(),
      platform: this._getPlatform(),
      userId: this._getUserId(),
      deviceInfo: this._getDeviceInfo()
    }

    console[level.toLowerCase()](message, ...args)

    this.logs.push(log)

    if (this.logs.length > this.maxLogs) {
      this.logs.shift()
    }

    this._scheduleFlush()
  }

  _serialize(args) {
    return args.map(arg => {
      try {
        return JSON.stringify(arg)
      } catch (e) {
        return String(arg)
      }
    })
  }

  _getCurrentPage() {
    // #ifdef APP-PLUS
    const pages = getCurrentPages()
    return pages[pages.length - 1]?.route || ''
    // #endif
    // #ifdef H5
    return window.location.pathname
    // #endif
    // #ifdef MP-WEIXIN
    const pages = getCurrentPages()
    return pages[pages.length - 1]?.route || ''
    // #endif
    return ''
  }

  _getPlatform() {
    // #ifdef APP-PLUS
    return plus.os.name.toLowerCase()
    // #endif
    // #ifdef H5
    return 'h5'
    // #endif
    // #ifdef MP-WEIXIN
    return 'weixin'
    // #endif
    return 'unknown'
  }

  _getUserId() {
    return uni.getStorageSync('userId') || 'guest'
  }

  _getDeviceInfo() {
    const info = uni.getSystemInfoSync()
    return {
      model: info.model,
      system: info.system,
      platform: info.platform,
      version: info.version,
      screenWidth: info.screenWidth,
      screenHeight: info.screenHeight
    }
  }

  _scheduleFlush() {
    if (this.flushTimer) return

    this.flushTimer = setTimeout(() => {
      this.flush()
    }, 10000) // 10秒上报一次
  }

  flush() {
    if (this.logs.length === 0) return

    clearTimeout(this.flushTimer)
    this.flushTimer = null

    const logs = [...this.logs]
    this.logs = []

    // 批量上报
    this._upload(logs)
  }

  _upload(logs) {
    uni.request({
      url: this.uploadUrl,
      method: 'POST',
      data: {
        logs,
        appVersion: this._getAppVersion(),
        uploadTime: Date.now()
      },
      success: (res) => {
        console.log('日志上报成功')
      },
      fail: (err) => {
        console.error('日志上报失败:', err)
        // 失败的日志重新加入队列
        this.logs.unshift(...logs.slice(0, 50))
      }
    })
  }

  _getAppVersion() {
    // #ifdef APP-PLUS
    return plus.runtime.version
    // #endif
    return '1.0.0'
  }
}

export default new Logger()

使用示例

javascript
import logger from '@/utils/logger'

// 业务代码中
logger.info('用户登录', { userId: '123', loginTime: Date.now() })
logger.error('支付失败', { orderId: '456', reason: '余额不足' })

1.2 崩溃日志捕获

javascript
// utils/crash-handler.js
class CrashHandler {
  constructor() {
    this.init()
  }

  init() {
    // JS 错误捕获
    this._catchJSError()

    // Promise 错误捕获
    this._catchPromiseError()

    // 资源加载错误
    this._catchResourceError()
  }

  _catchJSError() {
    // H5 环境
    // #ifdef H5
    window.addEventListener('error', (event) => {
      if (event.target !== window) return

      this._reportCrash({
        type: 'JSError',
        message: event.message,
        filename: event.filename,
        lineno: event.lineno,
        colno: event.colno,
        stack: event.error?.stack
      })
    }, true)
    // #endif

    // App 环境
    // #ifdef APP-PLUS
    // plus.runtime 没有直接的错误捕获,需要在关键代码用 try-catch
    // #endif
  }

  _catchPromiseError() {
    // #ifdef H5
    window.addEventListener('unhandledrejection', (event) => {
      this._reportCrash({
        type: 'PromiseError',
        message: event.reason?.message || event.reason,
        stack: event.reason?.stack
      })
    })
    // #endif
  }

  _catchResourceError() {
    // #ifdef H5
    window.addEventListener('error', (event) => {
      const target = event.target
      if (target === window) return

      this._reportCrash({
        type: 'ResourceError',
        resourceType: target.tagName,
        resourceUrl: target.src || target.href
      })
    }, true)
    // #endif
  }

  _reportCrash(error) {
    const crashInfo = {
      ...error,
      timestamp: Date.now(),
      platform: this._getPlatform(),
      appVersion: this._getAppVersion(),
      userId: this._getUserId(),
      deviceInfo: this._getDeviceInfo(),
      page: this._getCurrentPage()
    }

    console.error('[Crash]', crashInfo)

    // 立即上报
    uni.request({
      url: 'https://crash.api.com/report',
      method: 'POST',
      data: crashInfo,
      fail: () => {
        // 上报失败,保存到本地
        this._saveToLocal(crashInfo)
      }
    })
  }

  _saveToLocal(crashInfo) {
    try {
      let crashes = uni.getStorageSync('crashes') || []
      crashes.push(crashInfo)
      // 只保留最近 10 条
      crashes = crashes.slice(-10)
      uni.setStorageSync('crashes', crashes)
    } catch (e) {
      console.error('保存崩溃日志失败:', e)
    }
  }

  // 下次启动时上报本地崩溃日志
  uploadLocalCrashes() {
    try {
      const crashes = uni.getStorageSync('crashes') || []
      if (crashes.length === 0) return

      uni.request({
        url: 'https://crash.api.com/batch',
        method: 'POST',
        data: { crashes },
        success: () => {
          uni.removeStorageSync('crashes')
        }
      })
    } catch (e) {
      console.error('上报本地崩溃日志失败:', e)
    }
  }

  _getPlatform() {
    // #ifdef APP-PLUS
    return plus.os.name.toLowerCase()
    // #endif
    return 'h5'
  }

  _getAppVersion() {
    // #ifdef APP-PLUS
    return plus.runtime.version
    // #endif
    return '1.0.0'
  }

  _getUserId() {
    return uni.getStorageSync('userId') || 'guest'
  }

  _getDeviceInfo() {
    return uni.getSystemInfoSync()
  }

  _getCurrentPage() {
    const pages = getCurrentPages()
    return pages[pages.length - 1]?.route || ''
  }
}

export default new CrashHandler()

1.3 接口日志拦截

javascript
// utils/request-logger.js
import logger from './logger'

// 拦截 uni.request
const originalRequest = uni.request

uni.request = function(options) {
  const startTime = Date.now()
  const requestId = Math.random().toString(36).substr(2, 9)

  // 请求开始日志
  logger.debug('API请求开始', {
    requestId,
    url: options.url,
    method: options.method || 'GET',
    data: options.data
  })

  // 保存原始回调
  const originalSuccess = options.success
  const originalFail = options.fail
  const originalComplete = options.complete

  // 包装 success 回调
  options.success = function(res) {
    const duration = Date.now() - startTime

    logger.info('API请求成功', {
      requestId,
      url: options.url,
      duration,
      statusCode: res.statusCode,
      dataSize: JSON.stringify(res.data).length
    })

    if (originalSuccess) {
      originalSuccess(res)
    }
  }

  // 包装 fail 回调
  options.fail = function(err) {
    const duration = Date.now() - startTime

    logger.error('API请求失败', {
      requestId,
      url: options.url,
      duration,
      error: err.errMsg
    })

    if (originalFail) {
      originalFail(err)
    }
  }

  // 调用原始方法
  return originalRequest(options)
}

1.4 本地存储

javascript
// 使用 SQLite(App端)或 IndexedDB(H5)
class LogStorage {
  constructor() {
    this.dbName = 'logs.db'
    this.init()
  }

  init() {
    // #ifdef APP-PLUS
    this.db = plus.sqlite.openDatabase({
      name: this.dbName,
      path: '_doc/' + this.dbName
    })

    plus.sqlite.executeSql({
      name: this.dbName,
      sql: `CREATE TABLE IF NOT EXISTS logs (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        level TEXT,
        message TEXT,
        data TEXT,
        timestamp INTEGER
      )`
    })
    // #endif
  }

  save(log) {
    // #ifdef APP-PLUS
    plus.sqlite.executeSql({
      name: this.dbName,
      sql: 'INSERT INTO logs (level, message, data, timestamp) VALUES (?, ?, ?, ?)',
      params: [log.level, log.message, JSON.stringify(log.args), log.timestamp]
    })
    // #endif
  }

  query(limit = 100) {
    return new Promise((resolve, reject) => {
      // #ifdef APP-PLUS
      plus.sqlite.selectSql({
        name: this.dbName,
        sql: 'SELECT * FROM logs ORDER BY timestamp DESC LIMIT ?',
        params: [limit],
        success: (data) => {
          resolve(data)
        },
        fail: reject
      })
      // #endif
    })
  }

  clear() {
    // #ifdef APP-PLUS
    plus.sqlite.executeSql({
      name: this.dbName,
      sql: 'DELETE FROM logs'
    })
    // #endif
  }
}

二、Flutter 日志系统

2.1 日志收集

dart
// lib/utils/logger.dart
import 'dart:developer' as developer;
import 'package:http/http.dart' as http;
import 'dart:convert';

enum LogLevel { debug, info, warn, error }

class Logger {
  static final Logger _instance = Logger._internal();
  factory Logger() => _instance;
  Logger._internal();

  final List<Map<String, dynamic>> _logs = [];
  final int _maxLogs = 100;
  final String _uploadUrl = 'https://log.api.com/collect';
  Timer? _flushTimer;

  void debug(String message, [Map<String, dynamic>? extra]) {
    _log(LogLevel.debug, message, extra);
  }

  void info(String message, [Map<String, dynamic>? extra]) {
    _log(LogLevel.info, message, extra);
  }

  void warn(String message, [Map<String, dynamic>? extra]) {
    _log(LogLevel.warn, message, extra);
  }

  void error(String message, [Map<String, dynamic>? extra]) {
    _log(LogLevel.error, message, extra);
    // 错误日志立即上报
    flush();
  }

  void _log(LogLevel level, String message, Map<String, dynamic>? extra) {
    final log = {
      'level': level.name,
      'message': message,
      'extra': extra ?? {},
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      'platform': Platform.operatingSystem,
      'userId': _getUserId(),
      'deviceInfo': _getDeviceInfo(),
    };

    developer.log(message, name: 'Logger', level: level.index);

    _logs.add(log);

    if (_logs.length > _maxLogs) {
      _logs.removeAt(0);
    }

    _scheduleFlush();
  }

  void _scheduleFlush() {
    _flushTimer?.cancel();
    _flushTimer = Timer(const Duration(seconds: 10), flush);
  }

  Future<void> flush() async {
    if (_logs.isEmpty) return;

    _flushTimer?.cancel();

    final logs = List.from(_logs);
    _logs.clear();

    try {
      await http.post(
        Uri.parse(_uploadUrl),
        body: jsonEncode({
          'logs': logs,
          'appVersion': '1.0.0',
          'uploadTime': DateTime.now().millisecondsSinceEpoch,
        }),
        headers: {'Content-Type': 'application/json'},
      );
    } catch (e) {
      // 失败重新加入队列
      _logs.insertAll(0, logs.take(50));
    }
  }

  String _getUserId() {
    // 从本地存储获取
    return 'user123';
  }

  Map<String, dynamic> _getDeviceInfo() {
    return {
      'platform': Platform.operatingSystem,
      'version': Platform.version,
    };
  }
}

2.2 崩溃日志捕获

dart
// lib/utils/crash_handler.dart
import 'package:flutter/foundation.dart';

class CrashHandler {
  static void init() {
    // 捕获 Flutter 框架错误
    FlutterError.onError = (FlutterErrorDetails details) {
      _reportCrash(details.exception, details.stack, 'FlutterError');

      // 开发模式显示红屏
      if (kDebugMode) {
        FlutterError.dumpErrorToConsole(details);
      }
    };

    // 捕获未处理的异步错误
    PlatformDispatcher.instance.onError = (error, stack) {
      _reportCrash(error, stack, 'UnhandledError');
      return true;
    };
  }

  static void _reportCrash(dynamic exception, StackTrace? stack, String type) {
    final crashInfo = {
      'type': type,
      'message': exception.toString(),
      'stack': stack?.toString(),
      'timestamp': DateTime.now().millisecondsSinceEpoch,
      'platform': Platform.operatingSystem,
      'appVersion': '1.0.0',
    };

    print('[Crash] $crashInfo');

    // 立即上报
    http.post(
      Uri.parse('https://crash.api.com/report'),
      body: jsonEncode(crashInfo),
      headers: {'Content-Type': 'application/json'},
    ).catchError((e) {
      // 上报失败,保存到本地
      _saveToLocal(crashInfo);
    });
  }

  static void _saveToLocal(Map<String, dynamic> crashInfo) {
    // 使用 shared_preferences 或 Hive 保存
  }
}

// main.dart 中初始化
void main() {
  CrashHandler.init();
  runApp(MyApp());
}

2.3 HTTP 拦截器

dart
// lib/utils/logging_interceptor.dart
import 'package:dio/dio.dart';

class LoggingInterceptor extends Interceptor {
  final Logger _logger = Logger();

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    _logger.debug('API请求', {
      'url': options.uri.toString(),
      'method': options.method,
      'data': options.data,
    });

    super.onRequest(options, handler);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    _logger.info('API响应', {
      'url': response.requestOptions.uri.toString(),
      'statusCode': response.statusCode,
      'duration': '${response.requestOptions.extra['start_time'] != null ? DateTime.now().millisecondsSinceEpoch - response.requestOptions.extra['start_time'] : 0}ms',
    });

    super.onResponse(response, handler);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    _logger.error('API错误', {
      'url': err.requestOptions.uri.toString(),
      'error': err.message,
      'statusCode': err.response?.statusCode,
    });

    super.onError(err, handler);
  }
}

// 使用
final dio = Dio();
dio.interceptors.add(LoggingInterceptor());

2.4 本地存储(Hive)

dart
// lib/utils/log_storage.dart
import 'package:hive/hive.dart';

@HiveType(typeId: 0)
class LogEntry extends HiveObject {
  @HiveField(0)
  String level;

  @HiveField(1)
  String message;

  @HiveField(2)
  Map<String, dynamic> extra;

  @HiveField(3)
  int timestamp;

  LogEntry({
    required this.level,
    required this.message,
    required this.extra,
    required this.timestamp,
  });
}

class LogStorage {
  static late Box<LogEntry> _box;

  static Future<void> init() async {
    await Hive.initFlutter();
    Hive.registerAdapter(LogEntryAdapter());
    _box = await Hive.openBox<LogEntry>('logs');
  }

  static Future<void> save(LogEntry log) async {
    await _box.add(log);

    // 只保留最近 1000 条
    if (_box.length > 1000) {
      await _box.deleteAt(0);
    }
  }

  static List<LogEntry> query({int limit = 100}) {
    return _box.values.toList().reversed.take(limit).toList();
  }

  static Future<void> clear() async {
    await _box.clear();
  }
}

三、Electron 日志系统

3.1 主进程日志

javascript
// main/logger.js
const fs = require('fs')
const path = require('path')
const { app } = require('electron')

class MainLogger {
  constructor() {
    this.logDir = path.join(app.getPath('userData'), 'logs')
    this.logFile = path.join(this.logDir, 'main.log')
    this.maxSize = 10 * 1024 * 1024 // 10MB
    this.init()
  }

  init() {
    if (!fs.existsSync(this.logDir)) {
      fs.mkdirSync(this.logDir, { recursive: true })
    }
  }

  _write(level, message, ...args) {
    const timestamp = new Date().toISOString()
    const logLine = `[${timestamp}] [${level}] ${message} ${args.map(a => JSON.stringify(a)).join(' ')}\n`

    console[level.toLowerCase()](message, ...args)

    // 写入文件
    fs.appendFileSync(this.logFile, logLine)

    // 检查文件大小
    this._checkFileSize()
  }

  _checkFileSize() {
    try {
      const stats = fs.statSync(this.logFile)
      if (stats.size > this.maxSize) {
        // 重命名旧文件
        const oldFile = path.join(this.logDir, `main.${Date.now()}.log`)
        fs.renameSync(this.logFile, oldFile)

        // 删除超过 7 天的日志
        this._cleanOldLogs()
      }
    } catch (e) {
      console.error('检查日志文件大小失败:', e)
    }
  }

  _cleanOldLogs() {
    const files = fs.readdirSync(this.logDir)
    const now = Date.now()
    const maxAge = 7 * 24 * 60 * 60 * 1000 // 7 天

    files.forEach(file => {
      const filePath = path.join(this.logDir, file)
      const stats = fs.statSync(filePath)

      if (now - stats.mtimeMs > maxAge) {
        fs.unlinkSync(filePath)
      }
    })
  }

  debug(message, ...args) {
    this._write('DEBUG', message, ...args)
  }

  info(message, ...args) {
    this._write('INFO', message, ...args)
  }

  warn(message, ...args) {
    this._write('WARN', message, ...args)
  }

  error(message, ...args) {
    this._write('ERROR', message, ...args)
    this._upload()
  }

  _upload() {
    // 读取日志文件
    const logs = fs.readFileSync(this.logFile, 'utf8')

    // 上传到服务器
    const https = require('https')
    const data = JSON.stringify({ logs })

    const options = {
      hostname: 'log.api.com',
      port: 443,
      path: '/collect',
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': data.length
      }
    }

    const req = https.request(options, (res) => {
      console.log('日志上报成功')
    })

    req.on('error', (e) => {
      console.error('日志上报失败:', e)
    })

    req.write(data)
    req.end()
  }
}

module.exports = new MainLogger()

3.2 渲染进程日志

javascript
// renderer/logger.js
const { ipcRenderer } = require('electron')

class RendererLogger {
  constructor() {
    this.logs = []
    this.maxLogs = 100
  }

  _log(level, message, ...args) {
    const log = {
      level,
      message,
      args: args.map(a => {
        try {
          return JSON.stringify(a)
        } catch (e) {
          return String(a)
        }
      }),
      timestamp: Date.now(),
      page: window.location.pathname
    }

    console[level.toLowerCase()](message, ...args)

    this.logs.push(log)

    if (this.logs.length > this.maxLogs) {
      this.logs.shift()
    }

    // 发送到主进程
    ipcRenderer.send('renderer-log', log)

    if (level === 'ERROR') {
      this.flush()
    }
  }

  debug(message, ...args) {
    this._log('DEBUG', message, ...args)
  }

  info(message, ...args) {
    this._log('INFO', message, ...args)
  }

  warn(message, ...args) {
    this._log('WARN', message, ...args)
  }

  error(message, ...args) {
    this._log('ERROR', message, ...args)
  }

  flush() {
    if (this.logs.length === 0) return

    const logs = [...this.logs]
    this.logs = []

    ipcRenderer.send('flush-logs', logs)
  }
}

module.exports = new RendererLogger()

3.3 崩溃捕获

javascript
// main.js
const { app, crashReporter } = require('electron')

// 启动崩溃报告
crashReporter.start({
  productName: 'MyApp',
  companyName: 'MyCompany',
  submitURL: 'https://crash.api.com/report',
  uploadToServer: true,
  compress: true
})

// 主进程崩溃
process.on('uncaughtException', (error) => {
  mainLogger.error('主进程崩溃', error.stack)

  // 可以选择重启应用
  app.relaunch()
  app.exit(0)
})

// 渲染进程崩溃
app.on('render-process-gone', (event, webContents, details) => {
  mainLogger.error('渲染进程崩溃', details)

  // 重新加载页面
  if (details.reason !== 'clean-exit') {
    webContents.reload()
  }
})

3.4 日志查看器

javascript
// preload.js
const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('logger', {
  getLogs: () => ipcRenderer.invoke('get-logs'),
  clearLogs: () => ipcRenderer.invoke('clear-logs'),
  exportLogs: () => ipcRenderer.invoke('export-logs')
})

// main.js
ipcMain.handle('get-logs', async () => {
  const logFile = path.join(app.getPath('userData'), 'logs', 'main.log')
  return fs.readFileSync(logFile, 'utf8')
})

ipcMain.handle('export-logs', async () => {
  const { dialog } = require('electron')

  const { filePath } = await dialog.showSaveDialog({
    defaultPath: 'logs.txt',
    filters: [{ name: 'Text', extensions: ['txt'] }]
  })

  if (filePath) {
    const logFile = path.join(app.getPath('userData'), 'logs', 'main.log')
    fs.copyFileSync(logFile, filePath)
    return true
  }

  return false
})

四、React Native 日志系统

4.1 日志收集

javascript
// src/utils/logger.js
class Logger {
  constructor() {
    this.logs = []
    this.maxLogs = 100
    this.uploadUrl = 'https://log.api.com/collect'
  }

  _log(level, message, ...args) {
    const log = {
      level,
      message,
      args: this._serialize(args),
      timestamp: Date.now(),
      platform: Platform.OS,
      userId: this._getUserId(),
      deviceInfo: this._getDeviceInfo()
    }

    console[level.toLowerCase()](message, ...args)

    this.logs.push(log)

    if (this.logs.length > this.maxLogs) {
      this.logs.shift()
    }

    this._scheduleFlush()
  }

  _serialize(args) {
    return args.map(arg => {
      try {
        return JSON.stringify(arg)
      } catch (e) {
        return String(arg)
      }
    })
  }

  debug(message, ...args) {
    this._log('DEBUG', message, ...args)
  }

  info(message, ...args) {
    this._log('INFO', message, ...args)
  }

  warn(message, ...args) {
    this._log('WARN', message, ...args)
  }

  error(message, ...args) {
    this._log('ERROR', message, ...args)
    this.flush()
  }

  _scheduleFlush() {
    if (this.flushTimer) return

    this.flushTimer = setTimeout(() => {
      this.flush()
    }, 10000)
  }

  async flush() {
    if (this.logs.length === 0) return

    clearTimeout(this.flushTimer)
    this.flushTimer = null

    const logs = [...this.logs]
    this.logs = []

    try {
      await fetch(this.uploadUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ logs })
      })
    } catch (error) {
      // 失败重新加入队列
      this.logs.unshift(...logs.slice(0, 50))
    }
  }

  _getUserId() {
    // 从 AsyncStorage 获取
    return 'user123'
  }

  _getDeviceInfo() {
    const { width, height } = Dimensions.get('window')
    return {
      platform: Platform.OS,
      version: Platform.Version,
      screenWidth: width,
      screenHeight: height
    }
  }
}

export default new Logger()

4.2 崩溃捕获

javascript
// src/utils/crash-handler.js
import { Alert } from 'react-native'

class CrashHandler {
  init() {
    // 全局错误处理
    ErrorUtils.setGlobalHandler((error, isFatal) => {
      this._reportCrash(error, isFatal)

      if (isFatal) {
        Alert.alert(
          '应用错误',
          '应用遇到严重错误需要重启',
          [{ text: '重启', onPress: () => RNRestart.Restart() }]
        )
      }
    })

    // Promise 错误
    this._trackPromiseRejections()
  }

  _trackPromiseRejections() {
    const tracking = require('promise/setimmediate/rejection-tracking')
    tracking.enable({
      allRejections: true,
      onUnhandled: (id, error) => {
        this._reportCrash(error, false, 'UnhandledPromise')
      }
    })
  }

  _reportCrash(error, isFatal, type = 'JSError') {
    const crashInfo = {
      type,
      message: error.message,
      stack: error.stack,
      isFatal,
      timestamp: Date.now(),
      platform: Platform.OS,
      appVersion: DeviceInfo.getVersion()
    }

    console.error('[Crash]', crashInfo)

    // 立即上报
    fetch('https://crash.api.com/report', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(crashInfo)
    }).catch(() => {
      this._saveToLocal(crashInfo)
    })
  }

  async _saveToLocal(crashInfo) {
    try {
      const crashes = await AsyncStorage.getItem('crashes')
      const list = crashes ? JSON.parse(crashes) : []
      list.push(crashInfo)
      await AsyncStorage.setItem('crashes', JSON.stringify(list.slice(-10)))
    } catch (e) {
      console.error('保存崩溃日志失败:', e)
    }
  }
}

export default new CrashHandler()

4.3 网络拦截

javascript
// src/utils/request-interceptor.js
import axios from 'axios'
import logger from './logger'

// 请求拦截器
axios.interceptors.request.use(
  config => {
    config.metadata = { startTime: Date.now() }

    logger.debug('API请求', {
      url: config.url,
      method: config.method,
      data: config.data
    })

    return config
  },
  error => {
    logger.error('API请求错误', { error: error.message })
    return Promise.reject(error)
  }
)

// 响应拦截器
axios.interceptors.response.use(
  response => {
    const duration = Date.now() - response.config.metadata.startTime

    logger.info('API响应', {
      url: response.config.url,
      duration,
      status: response.status
    })

    return response
  },
  error => {
    const duration = error.config?.metadata?.startTime
      ? Date.now() - error.config.metadata.startTime
      : 0

    logger.error('API响应错误', {
      url: error.config?.url,
      duration,
      error: error.message,
      status: error.response?.status
    })

    return Promise.reject(error)
  }
)

4.4 本地存储

javascript
// src/utils/log-storage.js
import AsyncStorage from '@react-native-async-storage/async-storage'

class LogStorage {
  async save(log) {
    try {
      const logs = await this.query()
      logs.push(log)

      // 只保留最近 1000 条
      const latest = logs.slice(-1000)
      await AsyncStorage.setItem('logs', JSON.stringify(latest))
    } catch (e) {
      console.error('保存日志失败:', e)
    }
  }

  async query(limit = 100) {
    try {
      const logs = await AsyncStorage.getItem('logs')
      if (!logs) return []

      const list = JSON.parse(logs)
      return list.slice(-limit)
    } catch (e) {
      return []
    }
  }

  async clear() {
    await AsyncStorage.removeItem('logs')
  }
}

export default new LogStorage()

五、日志上报服务端

5.1 日志接收接口

javascript
// server/routes/log.js
const express = require('express')
const router = express.Router()

// 接收日志
router.post('/collect', async (req, res) => {
  try {
    const { logs, appVersion, uploadTime } = req.body

    // 批量写入数据库
    await LogModel.insertMany(logs.map(log => ({
      ...log,
      appVersion,
      uploadTime,
      ip: req.ip
    })))

    res.json({ success: true })
  } catch (error) {
    console.error('保存日志失败:', error)
    res.status(500).json({ success: false, error: error.message })
  }
})

// 崩溃上报
router.post('/crash/report', async (req, res) => {
  try {
    const crashInfo = req.body

    // 写入数据库
    await CrashModel.create({
      ...crashInfo,
      ip: req.ip
    })

    // 发送告警通知
    await sendAlert(crashInfo)

    res.json({ success: true })
  } catch (error) {
    res.status(500).json({ success: false })
  }
})

module.exports = router

5.2 数据库模型

javascript
// models/log.js
const mongoose = require('mongoose')

const LogSchema = new mongoose.Schema({
  level: String,
  message: String,
  args: [String],
  timestamp: Number,
  platform: String,
  userId: String,
  appVersion: String,
  deviceInfo: Object,
  page: String,
  ip: String
}, {
  timestamps: true
})

// 创建索引
LogSchema.index({ timestamp: -1 })
LogSchema.index({ level: 1 })
LogSchema.index({ userId: 1 })

module.exports = mongoose.model('Log', LogSchema)

5.3 日志查询接口

javascript
// routes/log-query.js
router.get('/query', async (req, res) => {
  const {
    level,
    userId,
    startTime,
    endTime,
    page = 1,
    limit = 100
  } = req.query

  const query = {}
  if (level) query.level = level
  if (userId) query.userId = userId
  if (startTime || endTime) {
    query.timestamp = {}
    if (startTime) query.timestamp.$gte = parseInt(startTime)
    if (endTime) query.timestamp.$lte = parseInt(endTime)
  }

  const logs = await LogModel
    .find(query)
    .sort({ timestamp: -1 })
    .skip((page - 1) * limit)
    .limit(limit)

  const total = await LogModel.countDocuments(query)

  res.json({
    logs,
    total,
    page,
    limit
  })
})

六、面试回答 SOP

问题1:你们是怎么做日志收集的?

回答模板

我们的日志系统分为三层:收集层、存储层、上报层。

收集层: 封装了统一的 Logger 类,提供 debug、info、warn、error 四个级别。每条日志包含完整的上下文信息:时间戳、用户ID、设备信息、页面路径、平台类型。

存储层
  • 内存队列:缓存最近 100 条日志
  • 本地存储:uni-app 用 SQLite,Flutter 用 Hive,Electron 用文件,RN 用 AsyncStorage
  • 服务端:MongoDB 存储,按时间和级别建索引

上报层: 采用批量上报策略,10 秒或 100 条触发一次上传。error 级别的日志立即上报。上报失败的日志会重新加入队列,下次再试。

我们在 uni-app 项目中,日志命中率 95%,平均延迟 5 秒。每天收集约 50 万条日志。

问题2:崩溃日志怎么处理?

回答模板

我们针对不同平台实现了崩溃捕获:

uni-app: H5 环境监听 window.error 和 unhandledrejection 事件。App 环境在关键代码用 try-catch 包裹,因为 plus.runtime 没有全局错误捕获。

Flutter: 通过 FlutterError.onError 捕获框架错误,通过 PlatformDispatcher.instance.onError 捕获异步错误。

Electron: 主进程监听 uncaughtException,渲染进程监听 render-process-gone。使用 crashReporter 自动上报崩溃堆栈。

React Native: 使用 ErrorUtils.setGlobalHandler 捕获 JS 错误。配合 promise rejection tracking 捕获 Promise 错误。

崩溃信息包含:错误类型、消息、堆栈、是否致命、时间戳、平台、版本。立即上报到服务器,失败则保存本地,下次启动时上报。

我们还集成了 Sentry,实现了崩溃实时告警,崩溃率控制在 0.1% 以下。

问题3:日志数据量大怎么优化?

回答模板

我们做了几个优化:

1. 采样上报 对于高频日志,按 10% 采样。比如列表滑动日志,每 10 次记录 1 次。

2. 日志分级 debug 日志只在开发环境记录。生产环境只记录 info、warn、error。

3. 批量上报 不是每条日志都立即上传,而是累积到 100 条或 10 秒后批量上传,减少请求次数。

4. 压缩传输 上报时对日志 JSON 进行 gzip 压缩,减少 70% 流量。

5. 本地清理 定期清理过期日志,只保留最近 7 天或 1000 条。

6. 数据库优化 MongoDB 按时间分表,每月一张表。对 timestamp、level、userId 建索引,查询速度提升 10 倍。

实施后,服务器存储减少 60%,上报流量减少 70%,查询速度提升 10 倍。

问题4:日志脱敏怎么做?

回答模板

我们有完整的脱敏策略:

1. 敏感字段识别 手机号、身份证号、银行卡号、密码、token 等都是敏感字段。

2. 自动脱敏 在 Logger 的 _serialize 方法中,正则匹配敏感信息并替换:

  • 手机号:138****5678
  • 身份证:123456********1234
  • 密码:******

3. 白名单机制 只记录白名单内的字段。比如用户信息只记录 userId、nickname,不记录真实姓名、地址。

4. 数据加密 上报时对整个 payload 进行 AES 加密,服务端解密后存储。

5. 权限控制 日志查询接口需要权限认证,不同角色看到的字段不同。普通开发只能看脱敏后的数据。

这套方案通过了安全审计,符合 GDPR 和国内数据安全法规。

问题5:线上问题怎么排查?

回答模板

我们有完整的排查流程:

1. 告警触发 用户反馈问题或监控触发告警,我们会收到消息通知。

2. 查询日志 根据用户 ID、时间范围、错误信息,在日志平台查询相关日志。

3. 还原现场 通过日志中的页面路径、操作序列、设备信息,还原用户操作场景。

4. 定位代码 根据错误堆栈,快速定位到具体代码行。

5. 本地复现 根据日志中的参数、环境信息,在本地复现问题。

举个例子: 有次用户反馈支付失败,我们通过 userId 查到这条日志:

plain
ERROR 支付失败 {orderId: 123, amount: 100, error: '余额不足'}

然后查到前面的日志:

plain
INFO 用户余额查询 {userId: 456, balance: 50}

发现是余额查询返回错误,导致支付失败。定位到是缓存数据过期导致的,立即修复。

整个过程 15 分钟,如果没有日志系统,可能要花几个小时。


七、技术选型对比

技术栈 日志方案 崩溃捕获 本地存储 上报方式
uni-app Logger 类 error/unhandledrejection SQLite/Storage uni.request
Flutter Logger 类 FlutterError.onError Hive http.post
Electron winston/pino crashReporter File/SQLite https.request
React Native Logger 类 ErrorUtils AsyncStorage fetch

八、最佳实践总结

  1. 分级记录:debug < info < warn < error
  2. 批量上报:减少请求次数
  3. 本地备份:网络失败时保底
  4. 实时告警:error 日志立即通知
  5. 数据脱敏:保护用户隐私
  6. 定期清理:控制存储大小
  7. 性能监控:避免日志影响性能
  8. 完整上下文:用户、设备、页面、时间