使用 Nginx 構建前端日誌統計服務

前端森林發表於2021-12-27

背景

之前的幾篇文章都是關於之前提到的低程式碼平臺的。

這個大的專案以 low code 為核心,囊括了編輯器前端、編輯器後端、C 端 H5、元件庫、元件平臺、後臺管理系統前端、後臺管理系統後臺、統計服務、自研 CLI 九大系統。

今天就來說一下其中的統計服務:目的主要是為了實現 H5 頁面的分渠道統計(其實不僅僅是分渠道統計,核心是想做一個自定義事件統計服務,只是目前有分渠道統計的需求),檢視每個渠道具體的 PV 情況。(具體會在 url 上面體現,會帶上頁面名稱、id、渠道型別等)

先放一下整體流程圖吧:

日誌收集

常見的日誌收集方式有手動埋點和自動埋點,這裡我們不關注於如何收集日誌,而是如何將收集的日誌的傳送到伺服器。

在常見的埋點方案中,通過圖片來傳送埋點請求是一種經常被採納的,它有很多優勢:

  • 沒有跨域
  • 體積小
  • 能夠完成整個 HTTP 請求+響應(儘管不需要響應內容)
  • 執行過程無阻塞

這裡的方案就是在 nginx 上放一張 1px * 1px 的靜態圖片,然後通過訪問該圖片(http://xxxx.png?env=xx&event=xxx),並將埋點資料放在query引數上,以此將埋點資料落到nginx日誌中。

iOS 上會限制 get 請求的 url 長度,但我們這裡真實場景傳送的資料不會太多,所以目前暫時採用這種方案

這裡簡單闡述一下為什麼圖片地址的query key 要這麼設計,如果單純是為了統計渠道和作品,很有可能會把key設計為channelworkId這種,但上面也說到了,我們是想做一個自定義事件統計服務,那麼就要考慮欄位的可擴充套件性,欄位應更有通用語義。所以參考了很多統計服務的設計,這裡採用的欄位為:

  • env
  • event
  • key
  • value

之後每次訪問頁面,nginx就會自動記錄日誌到access_log中。

有了日誌,下面我們來看下如何來對其進行拆分。

日誌拆分

為何要拆分日誌

access.log日誌預設不會拆分,會越積累越多,系統磁碟的空間會被消耗得越來越多,將來可能面臨著日誌寫入失敗、服務異常的問題。

日誌檔案內容過多,對於後續的問題排查和分析也會變得很困難。

所以日誌的拆分是有必要也是必須的。

如何拆分日誌

我們這裡拆分日誌的核心思路是:將當前的access.log複製一份重新命名為新的日誌檔案,之後清空老的日誌檔案。

視流量情況(流量越大日誌檔案積累的越快),按天、小時、分鐘來拆分。可以把access.log按天拆分到某個資料夾中。

log_by_day/2021-12-19.log
log_by_day/2021-12-20.log
log_by_day/2021-12-21.log

但上面的複製 -> 清空操作肯定是要自動處理的,這裡就需要啟動定時任務,在每天固定的時間(我這裡是在每天凌晨 00:00)來處理。

定時任務

其實定時任務不僅在日誌拆分的時候會用到,在後面的日誌分析和日誌清除都會用到,這裡先簡單介紹一下,最終會整合拆分、分析和清除。

linux中內建的cron程式就是來處理定時任務的。在node中我們一般會用node-schedulecron來處理定時任務。

這裡使用的是cron

/**
    cron 定時規則 https://www.npmjs.com/package/cron
    *    *    *    *    *    *
    ┬    ┬    ┬    ┬    ┬    ┬
    │    │    │    │    │    │
    │    │    │    │    │    └ day of week (0 - 6) (Sun-Sat)
    │    │    │    │    └───── month (1 - 12)
    │    │    │    └────────── day of month (1 - 31)
    │    │    └─────────────── hour (0 - 23)
    │    └──────────────────── minute (0 - 59)
    └───────────────────────── second (0 - 59)
 */

具體使用方式就不展開說明了。

編碼

有了上面這些儲備,下面我就來寫一下這塊程式碼,首先梳理下邏輯:

1️⃣ 讀取原始檔 access.log

2️⃣ 建立拆分後的資料夾(不存在時需自動建立)

3️⃣ 建立日誌檔案(天維度,不存在時需自動建立)

4️⃣ 拷貝源日誌至新檔案

5️⃣ 清空 access.log

/**
 * 拆分日誌檔案
 *
 * @param {*} accessLogPath
 */
function splitLogFile(accessLogPath) {
  const accessLogFile = path.join(accessLogPath, "access.log");

  const distFolder = path.join(accessLogPath, DIST_FOLDER_NAME);
  fse.ensureDirSync(distFolder);

  const distFile = path.join(distFolder, genYesterdayLogFileName());
  fse.ensureFileSync(distFile);
  fse.outputFileSync(distFile, ""); // 防止重複,先清空

  fse.copySync(accessLogFile, distFile);

  fse.outputFileSync(accessLogFile, "");
}

日誌分析

日誌分析就是讀取上一步拆分好的檔案,然後按照一定規則去處理、落庫。這裡有一個很重要的點要提一下:node在處理大檔案或者未知記憶體檔案大小的時候千萬不要使用readFile,會突破 V8 記憶體限制。正是考慮到這種情況,所以這裡讀取日誌檔案的方式應該是:createReadStream建立一個可讀流交給 readline 逐行讀取處理

readline

readline 模組提供了用於從可讀流每次一行地讀取資料的介面。 可以使用以下方式訪問它:

const readline = require("readline");

readline 的使用也非常簡單:建立一個介面例項,傳入對應的引數:

const readStream = fs.createReadStream(logFile);
const rl = readline.createInterface({
  input: readStream,
});

然後監聽對應事件即可:

rl.on("line", (line) => {
  if (!line) return;

  // 獲取 url query
  const query = getQueryFromLogLine(line);
  if (_.isEmpty(query)) return;

  // 累加邏輯
  // ...
});
rl.on("close", () => {
  // 逐行讀取結束,存入資料庫
  const result = eventData.getResult();
  resolve(result);
});

這裡用到了lineclose事件:

  • line事件:每當 input 流接收到行尾輸入(\n、\r 或 \r\n)時,則會觸發 'line' 事件
  • close事件:一般在傳輸結束時會觸發該事件

逐行分析日誌結果

瞭解了readline 的使用,下面讓我們來逐行對日誌結果進行分析吧。

首先來看下access.log中日誌的格式:

我們取其中一行來分析:

127.0.0.1 - - [19/Feb/2021:15:22:06 +0800] "GET /event.png?env=h5&event=pv&key=24&value=2 HTTP/1.1" 200 5233 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_0_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.150 Safari/537.36" "-"

我們要拿到的就是urlquery部分,也就是我們在h5中自定義的資料。

通過正則匹配即可:

const reg = /GET\s\/event.png\?(.+?)\s/;
const matchResult = line.match(reg);
console.log("matchResult", matchResult);

const queryStr = matchResult[1];
console.log("queryStr", queryStr);

列印結果為:

queryStr可通過node中的querystring.parse()來處理:

const query = querystring.parse(queryStr);

console.log('query', query)
{
  env: 'h5',
  event: 'pv',
  key: '24',
  value: '2'
}

剩下的就是對資料做累加處理了。

但如何去做累加,我們要想一下,最開始也說了是要去做分渠道統計,那麼最終的結果應該可以清晰的看到兩個資料:

  • 所有渠道的資料
  • 每個渠道單獨的資料

只有這樣的資料對於運營才是有價值的,資料的好壞也直接決定了後面在每個渠道投放的力度。

這裡我參考了 Google Analytics中的多渠道漏斗的概念,由上到下分維度記錄每個維度的資料,這樣就可以清晰的知道每個渠道的情況了。

具體實現也不麻煩,我們先來看下剛剛從一條連結中得到的有用資料:

{
  env: 'h5',
  event: 'pv',
  key: '24',
  value: '2'
}

這裡的env代表環境,這裡統計的都是來源於h5頁面,所以envh5,但是為了擴充套件,所以設定了這個欄位。

event表示事件名稱,這裡主要是統計訪問量,所以為pv

key是作品 id。

value是渠道 code,目前主要有:1-微信、2-小紅書、3-抖音。

再來看下最終統計得到的結果吧:

{
  date: '2021-12-21',
  key: 'h5',
  value: { num: 1276}
}
{
  date: '2021-12-21',
  key: 'h5.pv',
  value: { num: 1000}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12',
  value: { num: 200}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.1',
  value: { num: 56}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.2',
  value: { num: 84}
}
{
  date: '2021-12-21',
  key: 'h5.pv.12.3',
  value: { num: 60}
}

這是擷取了2021-12-21當天的資料,我給大家分析一波:

1️⃣ h5:當天 h5 頁面的自定義事件上報總數為 1276

2️⃣ h5.pv:其中 所有 pv(也就是 h5.pv)為 1000

3️⃣ h5.pv.12:作品 id 為 12 的 pv 一共有 200

4️⃣ h5.pv.12.1:作品 id 為 12 的在微信渠道的 pv 為 56

5️⃣ h5.pv.12.2:作品 id 為 12 的在小紅書渠道的 pv 為 84

6️⃣ h5.pv.12.2:作品 id 為 12 的在抖音渠道的 pv 為 60

這樣就能清楚的得到某一天某個作品在某條渠道的訪問情況了,後續再以這些資料為支撐做成視覺化報表,效果就一目瞭然了。

統計結果入庫

目前這部分資料是放在了mongoDB中,關於node中使用mongoDB就不展開說了,不熟悉的可以參考我另外一篇文章Koa2+MongoDB+JWT 實戰--Restful API 最佳實踐

這裡貼下model吧:

/**
 * @description event 資料模型
 */
const mongoose = require("../db/mongoose");

const schema = mongoose.Schema(
  {
    date: Date,
    key: String,
    value: {
      num: Number,
    },
  },
  {
    timestamps: true,
  }
);

const EventModel = mongoose.model("event_analytics_data", schema);

module.exports = EventModel;

日誌刪除

隨著頁面的持續訪問,日誌檔案會快速增加,超過一定時間的日誌檔案存在的價值也不是很大,所以我們要定期清除日誌檔案。

這個其實比較簡單,遍歷檔案,因為檔名都是以日期命名的(格式:2021-12-14.log),所以只要判斷時間間隔大於 90 天就刪除日誌檔案。

貼一下核心實現:

// 讀取日誌檔案
const fileNames = fse.readdirSync(distFolder);
fileNames.forEach((fileName) => {
  try {
    // fileName 格式 '2021-09-14.log'
    const dateStr = fileName.split(".")[0];
    const d = new Date(dateStr);
    const t = Date.now() - d.getTime();
    if (t / 1000 / 60 / 60 / 24 > 90) {
      // 時間間隔,大於 90 天,則刪除日誌檔案
      const filePath = path.join(distFolder, fileName);
      fse.removeSync(filePath);
    }
  } catch (error) {
    console.error(`日誌檔案格式錯誤 ${fileName}`, error);
  }
});

定時任務整合

到這裡,日誌的拆分、分析和清除都說完了,現在要用cron來對他們做整合了。

首先來建立定時任務:

function schedule(cronTime, onTick) {
  if (!cronTime) return;
  if (typeof onTick !== "function") return;

  // 建立定時任務
  const c = new CronJob(
    cronTime,
    onTick,
    null, // onComplete 何時停止任務
    true, // 初始化之後立刻執行
    "Asia/Shanghai" // 時區
  );

  // 程式結束時,停止定時任務
  process.on("exit", () => c.stop());
}

然後每一階段都在不同的時間階段去處理(定時拆分 -> 定時分析 -> 定時刪除)

定時拆分

function splitLogFileTiming() {
  const cronTime = "0 0 0 * * *"; // 每天的 00:00:00
  schedule(cronTime, () => splitLogFile(accessLogPath));
  console.log("定時拆分日誌檔案", cronTime);
}

定時分析併入庫

function analysisLogsTiming() {
  const cronTime = "0 0 3 * * *"; // 每天的 3:00:00 ,此時凌晨,訪問量較少,伺服器資源處於閒置狀態
  schedule(cronTime, () => analysisLogsAndWriteDB(accessLogPath));
  console.log("定時分析日誌併入庫", cronTime);
}

定時刪除

function rmLogsTiming() {
  const cronTime = "0 0 4 * * *"; // 每天的 4:00:00 ,此時凌晨,訪問量較少,伺服器資源處於閒置狀態
  schedule(cronTime, () => rmLogs(accessLogPath));
  console.log("定時刪除過期日誌檔案", cronTime);
}

然後在應用入口按序呼叫即可:

// 定時拆分日誌檔案
splitLogFileTiming();
// 定時分析日誌併入庫
analysisLogsTiming();
// 定時刪除過期日誌檔案
rmLogsTiming();

總結

ok,到這裡,一個簡易的統計服務就完成了。

相關文章