[博客翻译]如何仅使用Nginx和纯bash跟踪网站分析


原文地址:https://sanixdk.xyz/blogs/how-to-add-website-analytics-using-only-nginx


如何仅使用NGINX和纯BASH追踪网站分析数据

今天,我要和大家分享一个小技巧,仅使用bashnginx来实现类似Google Analytics的网站追踪服务。
你可能会问...“为什么要这么做?”;你看,很久以前,我决定只用C语言来制作我的个人网站。
没错,就是你现在正在浏览和阅读的这个网站;我使用了很多现有的“markdown -> html”C代码,并加入了自己的调味料(虽然有点乱,但我很喜欢)。
图片
查看仓库
这真的是一个非常非常非常糟糕的主意....我说“非常”够了吗?
为什么?嗯...基本上,我的大部分博客都是markdown格式的,为了符合“现代浏览互联网的方式”,我编写了一个lib.c来控制网站的构建(这篇文章不会深入讨论这个)。
但是,嘿,犯错让你成长...对吧?....对吧?
无论如何,构建一个只有html/markdown的东西的问题是,你没有涉及到JS...说实话,这是第一个原因(不是因为我讨厌现代JS 向我的JS朋友们眨眼);
因此,对我来说,Google Analytics是“不可能的”...
值得吗?绝对不值得,我还会再这么做吗?哦,当然会!
无论如何...让我们开始吧,好吗?

正式介绍

由于我只是在网站/博客构建后返回基本的html,没有“严肃的JS(除了博客底部的GitHub评论)”,我无法追踪谁、有多少人访问了这个页面或那个页面。
但是,我(仍然)使用nginx来处理请求部分,我问自己,如果这个服务基本上记录了访问日志,为什么不直接使用它来收集指标呢?(很合理,对吧?)。
因此,基于Nginx添加一个“分析系统”通常涉及配置Nginx以基本上只记录请求(带有足够的详细信息),处理这些日志以计算浏览量、IP、时间,并可能可视化数据。

如何“实际”做到这一点

在NGINX中启用日志记录

你知道,nginx默认记录请求,但你可能需要自定义日志格式以跟踪相关细节,例如URL或IP地址。
编辑你的Nginx配置文件,通常位于/etc/nginx/nginx.conf/etc/nginx/sites-available/<your-site>

$ vim /etc/nginx/nginx.conf
# 或者使用'nano'作为编辑器,如果你像我一样是个新手。

然后在http部分,

# 简单的浏览量示例
http {
  log_format views '$remote_addr - $remote_user [$time_local] "$request" '
           '$status $body_bytes_sent "$http_referer" '
           '"$http_user_agent" "$host" "$request_uri"';
  access_log /var/log/nginx/access.log views;
}
  • $remote_addr: 追踪客户端IP。
  • $host$request_uri: 捕获域名和URL。

重新加载Nginx以应用更改:

$ sudo systemctl reload nginx

可选地,你还可以检查你的nginx配置是否仍然正常:

$ nginx -t

下面是一个捕获更多跟踪信息的示例:

# 最详细的示例,比以前捕获更多信息
http {
  log_format analytics '$remote_addr - $remote_user [$time_local] '
            '"$request" $status $body_bytes_sent '
            '"$http_referer" "$http_user_agent" '
            '$request_time $upstream_response_time '
            '$scheme $server_name $request_method '
            '$ssl_protocol $ssl_cipher';
  access_log /var/log/nginx/access.log analytics;
}
  1. IP地址 : $remote_addr
  2. 用户名(如果使用基本认证): $remote_user
  3. 时间戳 : $time_local
  4. 原始请求 : $request(包括URL、查询、HTTP版本)
  5. HTTP状态 : $status
  6. 响应大小 : $body_bytes_sent
  7. 引用来源 : $http_referer
  8. 用户代理 : $http_user_agent
  9. 请求时间 : $request_time
  10. 上游时间 : $upstream_response_time
  11. 方案 : $scheme (http/https)
  12. 服务器名称 : $server_name
  13. 请求方法 : $request_method (GET/POST, 等)
  14. SSL协议 : $ssl_protocol (例如,TLSv1.3)
  15. SSL加密 : $ssl_cipher (例如,AES128-GCM-SHA256)

“瞧”,最重要的部分完成了,你现在所有的请求日志都会记录到配置中指定的路径,在我们的例子中是_/var/log/nginx/access.log_,它看起来像这样:

$ cat /var/log/nginx/access.log
18.220.122.122 - - [12/Jan/2025:17:15:19 +0000] "GET / HTTP/1.1" 400 666 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/126.0.0.0 Safari/537.36" "147.182.205.116" "/"$
54.36.148.9 - - [12/Jan/2025:17:16:15 +0000] "GET /robots.txt HTTP/1.1" 200 719 "-" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)" "sanixdk.xyz" "/robots.txt"$
51.222.253.18 - - [12/Jan/2025:17:16:16 +0000] "GET /blogs/how-to-make-a-password-generator-using-brainfuck-part-1-3 HTTP/1.1" 200 7569 "-" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)" "sanixdk.xyz" "/blogs/how-to-make-a-password-generator-using-brainfuck-part-1-3"$
124.44.90.81 - - [12/Jan/2025:17:22:41 +0000] "GET / HTTP/1.0" 404 162 "-" "curl/7.88.1" "147.182.205.116" "/"$
95.214.55.226 - - [12/Jan/2025:17:26:19 +0000] "GET / HTTP/1.1" 404 162 "-" "-" "147.182.205.116" "/"$

现在,我们有了这些日志,让我们来读取、解析、理解并利用它们。

现在,让我们做一些编程

你知道我喜欢bash...我是说,我热爱bash,为什么?
嗯,现在应该很明显了,但无论你使用什么Linux发行版...或者你刚刚安装的,它已经在那里了,不需要安装gcc,或python或任何东西...
因为它是shell,所以我们将使用它。
让我们分解每个步骤,从源代码到收集我们所需所有指标的服务。

源代码:

每条指令都尽可能详细地记录,如有问题请在评论区提问。

#!/usr/bin/env bash
#
# nginx-analytics.sh - 收集Nginx日志指标并输出JSON分析数据。
#
# 作者:dk (https://github.com/sanix-darker)
LOG_FILE="/var/log/nginx/access.log"
OUTPUT_FILE="/var/www/html/nginx-analytics.json"
# 安全检查:确保LOG_FILE存在
if [[ ! -f "$LOG_FILE" ]]; then
 echo "错误:在$LOG_FILE找不到Nginx日志文件"
 exit 1
fi
# 1) 按URL的访问量(前5)
#  - 使用'awk'从"$request"字段(假设它在$5)中提取URL。
#  - 然后将请求字符串“METHOD /some/url HTTP/1.x”拆分为令牌,
#   并保留第二个令牌(实际路径/some/url)。
visits_by_url=$(
 awk '{
  # $5可能是这样的:"GET /index.html HTTP/1.1"
  # 所以我们按空格拆分。
  split($5, req_parts, " ")
  url=req_parts[2]
  if (url != "") {
   urls[url]++
  }
 } END {
  # 按频率排序并选择前5
  for (u in urls) {
   print urls[u] " " u
  }
 }' "$LOG_FILE" \
 | sort -nr \
 | head -n 5
)
# 2) 前5个IP地址
top_ips=$(
 awk '{
  ips[$1]++
 } END {
  for (ip in ips) {
   print ips[ip] " " ip
  }
 }' "$LOG_FILE" \
 | sort -nr \
 | head -n 5
)
# 3) 平均请求时间($request_time),假设它是日志格式中的第10个字段
avg_request_time=$(
 awk '{
  sum+=$10; count++
 } END {
  if (count > 0) {
   printf("%.5f", sum/count)
  } else {
   print "0"
  }
 }' "$LOG_FILE"
)
# 4) 平均上游响应时间($upstream_response_time),假设它是日志格式中的第11个字段
avg_upstream_time=$(
 awk '{
  sum+=$11; count++
 } END {
  if (count > 0) {
   printf("%.5f", sum/count)
  } else {
   print "0"
  }
 }' "$LOG_FILE"
)
# 5) 前5个HTTP状态码,假设它是日志格式中的第6个字段
top_status_codes=$(
 awk '{
  status[$6]++
 } END {
  for (s in status) {
   print status[s] " " s
  }
 }' "$LOG_FILE" \
 | sort -nr \
 | head -n 5
)
# ------------------------------------------------------------
# 现在,不那么痛苦的部分,
# 将上述变量转换为可理解的JSON。
# 我们将进行最少的解析以生成有效的JSON数组/对象。
# ------------------------------------------------------------
# 'lines_to_json_array'将“计数 值”行转换为JSON数组条目
# 例如,“123 /home” -> { "value": "/home", "count": 123 }
function lines_to_json_array() {
 local input="$1"
 local result="["
 local first=1
 while IFS= read -r line; do
  # 这里我们只是将行拆分为“计数”和“值”
  count=$(echo "$line" | awk '{print $1}')
  value=$(echo "$line" | awk '{print $2}')
  # 重要提示:
  # 如果“值”有空格,请小心处理。(在简单情况下,不会有。)
  # 如果你有更复杂的解析,你会进行更健壮的拆分。
  if [ "$first" -eq 1 ]; then
   first=0
  else
   result="$result,"
  fi
  result="$result {\"value\":\"$value\",\"count\":$count}"
 done <<< "$input"
 result="$result]"
 echo "$result"
}
# visits_by_url -> JSON-array
visits_by_url_json=$(lines_to_json_array "$visits_by_url")
# top_ips -> JSON-array
top_ips_json=$(lines_to_json_array "$top_ips")
# top_status_codes -> JSON-array
top_status_codes_json=$(lines_to_json_array "$top_status_codes")
# 瞧,我们的json准备好了
json_output=$(
 cat <<EOF
{
 "visits_by_url": $visits_by_url_json,
 "top_ips": $top_ips_json,
 "avg_request_time": "$avg_request_time",
 "avg_upstream_time": "$avg_upstream_time",
 "top_status_codes": $top_status_codes_json
}
EOF
)
# 将JSON写入OUTPUT_FILE(例如,用于Web仪表板或进一步处理)
echo "$json_output" > "$OUTPUT_FILE"
# 或者简单地输出到stdout,如果你喜欢:
# echo "$json_output"
exit 0 # 你可以选择退出其他内容,你的代码,你选择 :wink:

将以下内容保存到/usr/local/bin/nginx-analytics.sh(根据需要调整路径)
**注意:**别忘了让它可执行

$ sudo chmod +x /usr/local/bin/nginx-analytics.sh

创建一个SYSTEMD单元

现在我们有了脚本,我们需要让它作为一个“服务”运行,这样如果服务器启动,我们仍然可以在后台启动它,所以下面是一个示例systemd服务文件,用于将此脚本作为一次性服务运行。创建一个文件/etc/systemd/system/nginx-analytics.service

[Unit]
Description=收集Nginx日志指标并输出JSON
After=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/nginx-analytics.sh
[Install]
WantedBy=multi-user.target

现在,_重新加载systemd_以注册新服务:

sudo systemctl daemon-reload

然后,手动运行服务(按需):

sudo systemctl start nginx-analytics.service

最后_启用启动时运行_(如果需要):

sudo systemctl enable nginx-analytics.service

(可选...但我强烈推荐)设置一个SYSTEMD计时器

如果你希望此脚本定期运行(例如,每5分钟或每小时,或者每秒钟为那些疯狂的读者),创建一个匹配的.timer单元。例如,/etc/systemd/system/nginx-analytics.timer

[Unit]
Description=定期运行nginx-analytics服务
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
[Install]
WantedBy=timers.target

然后:

sudo systemctl daemon-reload
sudo systemctl enable --now nginx-analytics.timer

这将每小时运行一次nginx-analytics.service(从而运行nginx-analytics.sh),并在启动后5分钟运行。

想看结果吗???

每次运行后,你都会有一个**nginx-analytics.json**文件(例如,在/var/www/html/nginx-analytics.json),包含一个JSON对象,如下所示:

{
 "visits_by_url": [
  {"value":"/index.html","count":125},
  {"value":"/contact","count":55},
  ...
 ],
 "top_ips": [
  {"value":"192.168.1.100","count":76},
  ...
 ],
 "avg_request_time": "0.03005",
 "avg_upstream_time": "0.01002",
 "top_status_codes": [
  {"value":"200","count":2000},
  {"value":"404","count":42},
  ...
 ]
}

“瞧”!
你现在可以在Web界面中使用或显示此JSON,将其推送到另一个系统,或者简单地使用其他工具进一步分析。
对于我的用例,我制作了一个小的html索引视图来使用它,看起来像这样:

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8" />
 <title>NGINX-ANALYTICS</title>
 <style>
  body { font-family: Arial, sans-serif; margin: 20px; background: #f7f7f7; color: #333; } h1, h2, h3 { margin-top: 1em; margin-bottom: 0.5em; } .stats-container { display: flex; flex-wrap: wrap; gap: 2rem; } .stats-block { background: #fff; padding: 1rem; border-radius: 6px; box-shadow: 0 2px 5px rgba(0,0,0,0.15); flex: 1 1 350px; max-width: 500px; } table { width: 100%; border-collapse: collapse; margin-bottom: 1em; } th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; } th { background-color: #fafafa; } .bar-chart { position: relative; height: 20px; background: #eee; border-radius: 4px; overflow: hidden; } .bar { position: absolute; left: 0; top: 0; bottom: 0; background: #007acc; text-align: right; color: #fff; padding-right: 4px; font-size: 13px; line-height: 20px; } .numeric { font-weight: bold; color: #007acc; display: inline-block; margin-left: 0.3em; } /* 响应式 */ @media (max-width: 600px) { .stats-container { flex-direction: column; } }
 </style>
</head>
<body>
 <h1>NGINX-ANALYTICS日志可视化</h1>
 <p>此页面获取<code>logs.json</code>并显示以下指标。</p>
 <small>作者:Sanix-Darker</small>
 <div class="stats-container">
  <!-- 前URL块 -->
  <div class="stats-block">
   <h2>前URL</h2>
   <table id="table-urls">
    <thead>
     <tr>
      <th>URL</th>
      <th>计数</th>
     </tr>
    </thead>
    <tbody></tbody>
   </table>
  </div>
  <!-- 前IP地址块 -->
  <div class="stats-block">
   <h2>前IP</h2>
   <table id="table-ips">
    <thead>
     <tr>
      <th>IP地址</th>
      <th>计数</th>
     </tr>
    </thead>
    <tbody></tbody>
   </table>
  </div>
  <!-- 前HTTP状态码块 -->
  <div class="stats-block">
   <h2>前状态码</h2>
   <table id="table-status-codes">
    <thead>
     <tr>
      <th>状态码</th>
      <th>计数</th>
     </tr>
    </thead>
    <tbody></tbody>
   </table>
  </div>
  <!-- 平均时间块 -->
  <div class="stats-block">
   <h2>时间</h2>
   <p>平均请求时间:<span id="avg-request-time" class="numeric">--</span></p>
   <p>平均上游时间:<span id="avg-upstream-time" class="numeric">--</span></p>
  </div>
 </div>
 <script>
  fetch('/logs.json')
   .then(response => {
    if (!response.ok) {
     throw new Error('无法获取logstats.json');
    }
    return response.json();
   })
   .then(data => {
    // 预期的数据结构:
    // {
    //  "visits_by_url": [{ "value":"/index.html","count":125 }, ... ],
    //  "top_ips": [{ "value":"192.168.1.1","count":76 }, ... ],
    //  "avg_request_time": "0.03005",
    //  "avg_upstream_time": "0.01002",
    //  "top_status_codes": [{ "value":"200","count":2000 }, ... ]
    // }
    // 1) 填充前URL
    const urlTableBody = document.querySelector('#table-urls tbody');
    populateTable(urlTableBody, data.visits_by_url);
    // 2) 填充前IP
    const ipTableBody = document.querySelector('#table-ips tbody');
    populateTable(ipTableBody, data.top_ips);
    // 3) 填充前状态码
    const statusTableBody = document.querySelector('#table-status-codes tbody');
    populateTable(statusTableBody, data.top_status_codes);
    // 4) 填充平均时间
    document.getElementById('avg-request-time').textContent = data.avg_request_time;
    document.getElementById('avg-upstream-time').textContent = data.avg_upstream_time;
   })
   .catch(err => {
    console.error(err);
    document.body.innerHTML += '<p style="color:red;">获取logstats.json时出错</p>';
   });
  /**
   * populateTable: 实用函数,用于填充表格体,给定一个
   * { value, count }对象数组,加上一个快速的“条形图”方法。
   */
  function populateTable(tbody, items) {
   // 找到最大计数以缩放条形宽度
   let maxCount = 0;
   if (Array.isArray(items)) {
    items.forEach(item => {
     if (item.count > maxCount) {
      maxCount = item.count;
     }
    });
   }
   // 清除任何现有行
   tbody.innerHTML = '';
   if (!Array.isArray(items) || items.length === 0) {
    tbody.innerHTML = '<tr><td colspan="2">无数据</td></tr>';
    return;
   }
   // 创建行
   items.forEach(item => {
    const row = document.createElement('tr');
    // 第一列(值)
    const tdValue = document.createElement('td');
    tdValue.textContent = item.value;
    row.appendChild(tdValue);
    // 第二列(计数 + 迷你条形图)
    const tdCount = document.createElement('td');
    // 条形图的容器
    const barWrapper = document.createElement('div');
    barWrapper.className = 'bar-chart';
    // 实际条形
    const bar = document.createElement('div');
    bar.className = 'bar';
    const percentage = (item.count / maxCount) * 100;
    bar.style.width = percentage.toFixed(2) + '%';
    bar.textContent = item.count; // 或者你可以保持空白或在右侧放置item.count
    barWrapper.appendChild(bar);
    tdCount.appendChild(barWrapper);
    row.appendChild(tdCount);
    tbody.appendChild(row);
   });
  }
 </script>
</body>
</html>
阅读全文(20积分)