话说最近微信小程序的We分析不是开始收费了吗,免费版本一天日志额度只有5000条,对于一个高峰期间日活2万用户的小程序来说这点日志额度肯定是不够的,如果你的日志量很少完全可以用We分析的不需要自己折腾。
We分析日志怎么用
直接看小程序的 开发文档 ,对着操作步骤来很简单。想要查看的话去 We分析 网站下的实时日志菜单查看。
看上面截图很明显我的小程序日志已经丢失了,每天都超额上报几万条,这还不是高峰期呢。
想着怎么方便怎么来,大不了加钱买就是的了,不看不知道一看吓一跳,想要满足日常使用需求的话,一年要4288,这不是纯纯的大冤种吗?"打着给公司省钱的旗号"自己搞一套日志系统岂不美滋滋。
说干就干
由于之前搭建过内网的Web日志系统也写过文章,没看过的同学可以移步这里 从零开始搭建一个前端日志框架 ,那么小程序的日志系统还不是手到擒来嘛,日志采集模块直接copy不就好了,等等好像哪里不对,小程序没有 indexDB
没办法持久化存储日志,那用 localStorage
可以吗,可以是可以但是不建议,因为 localStorage
存储容量太小了,存储上限是10MB很容易爆仓。
那我们换个思路,日志不存储了上传失败了大不了不上传总行了吧,毕竟日志只是个辅助工具,不能因为日志搞的太复杂把小程序给搞崩溃了。我的思路就是日志先放在内存中,比如定义一个 logQueue
队列来存储日志,每生成一条日志就push到队列中,然后每隔几秒钟把日志上传到服务器完事,至于日志丢失,丢了就丢了嘛,有舍才有得嘛。
直接上小程序代码,有不懂的可以看注释或者移步上一篇文章 从零开始搭建一个前端日志框架。
ts
import dayjs from "dayjs"
export default class Logger {
/** 日志队列 */
logQueue!: string[]
/** 单条日志最大长度,超出截取 */
logMaxLength!: number
constructor() {
this.logQueue = []
this.logMaxLength = 5000
this.interceptConsole()
this.loopUpload()
}
/**
* 重写 console 函数
*/
interceptConsole() {
const consoleLog = console.log
console.log = (...args: any[]) => {
const now = dayjs().format("YYYY-MM-DD HH:mm:ss:SSS")
consoleLog.apply(console, [`[${now}] [info]`, ...args])
this.writeMessage(`[${now}] [info]`, ...args)
}
const consoleWarn = console.warn
console.warn = (...args: any[]) => {
const now = dayjs().format("YYYY-MM-DD HH:mm:ss:SSS")
consoleWarn.apply(console, [`[${now}] [warn]`, ...args])
this.writeMessage(`[${now}] [warn]`, ...args)
}
const consoleError = console.error
console.error = (...args: any[]) => {
const now = dayjs().format("YYYY-MM-DD HH:mm:ss:SSS")
consoleError.apply(console, [`[${now}] [error]`, ...args])
this.writeMessage(`[${now}] [error]`, ...args)
}
}
/**
* 日志格式化并添加到日志队列中
* @param prefix 日志前缀,时间戳和日志等级
* @param args 日志内容,可选参数
*/
writeMessage(prefix: string, ...args: any[]) {
let message = prefix
// 遍历参数,如果是字符串直接相加,如果是其他类型则要JSON转义后相加
args.forEach((arg) => {
if (typeof arg === "string") {
message += " " + arg
} else {
try {
message += " " + JSON.stringify(arg)
} catch {}
}
})
// 日志内容为空则不上传
if (message.trim() === prefix) {
return
}
// 日志内容超过最大长度截取,小于0不截断
if (this.logMaxLength > 0 && message.trim().length > this.logMaxLength) {
message = message.slice(0, this.logMaxLength)
}
this.logQueue.push(message)
}
/**
* 上传日志
*/
uploadLog() {
// 日志拷贝一份然后清空
const logQueue = Object.assign([], this.logQueue)
this.logQueue = []
wx.request({
// 这里地址填写你们实际部署的后端服务接口
url: "http://127.0.0.1:8080/logger",
method: "POST",
data: {
// 用户id,为了方便在日志系统中找到这个用户,作为搜索条件
userid: wx.getStorageSync("userid"),
// 未登录的时候可能没有用户id,但一定有openId
openid: wx.getStorageSync("openid"),
logs: logQueue
},
})
}
/**
* 循环上传日志
*/
loopUpload() {
setInterval(() => {
this.uploadLog()
}, 5 * 1000)
}
}
怎么使用呢?在小程序 app.js 中引入并实例化。后续你在小程序中任何地方调用 console.log
命令都会自动采集日志并上传了,当然了在本地控制台下也会打印出来。
js
import Logger from "./logger"
new Logger()
后端服务
在上一篇文章 从零开始搭建一个前端日志框架 里后端服务是用 node.js
写的,考虑到现网一万以上的并发请求,不知道 node.js
能不能扛得住。所以这次打算用 golang
写后端服务,用 lumberjack
对日志进行滚动切片,每个日志最大设置为100MB。然后用 Filebeat
采集日志最终在 Kibana
上查看。
其实我之前也没接触过 golang
,不过没关系有 ChatGPT,把我们的需求告诉 ChatGPT,他会帮我们写代码,考虑到很多前端同学都没接触过 golang
,这里我把步骤写的尽量详细点,把踩过的坑都列下。
第一步:下载安装 Go,地址 golang.google.cn/dl/
第二步:新建一个 logger 文件夹,新建一个 main.go
文件,把下面代码复制过去。
go
package main
import (
"log"
"net/http"
"os"
"github.com/gin-gonic/gin"
lumberjack "github.com/natefinch/lumberjack"
)
// LogRequest 表示请求体的结构体
type LogRequest struct {
OpenId string `json:"openid"`
UserID string `json:"userid"`
Logs []string `json:"logs"`
}
// 全局的日志写入器
var logWriter *os.File
func main() {
// 初始化日志文件的滚动配置
logPath := "./logs/web.log"
err := os.MkdirAll("./logs", 0755) // 确保logs目录存在
if err != nil {
log.Fatalf("Failed to create logs directory: %v", err)
}
// 使用lumberjack封装我们的logWriter
wrappedWriter := &lumberjack.Logger{
Filename: logPath,
MaxSize: 100, // 每个日志文件最大大小,单位MB
MaxAge: 28, // 存储的最大天数
Compress: false, // 过期的日志文件是否压缩
}
wrappedWriter.Rotate() // 立即执行一次滚动,确保从新的文件开始写入
// 将wrappedWriter的Write方法包装为gin.HandlerFunc以便在路由中使用
logMiddleware := func(c *gin.Context) {
// 实际的中间件逻辑可以在这里执行,例如记录请求信息等
c.Next() // 继续处理请求
}
// 创建Gin路由器实例
r := gin.Default()
// 使用自定义的中间件,可以在这里添加更多中间件逻辑
r.Use(logMiddleware)
// 提供POST请求的/logger接口
r.POST("/logger", func(c *gin.Context) {
var logReq LogRequest
if err := c.ShouldBindJSON(&logReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 将logs数组中的每个日志条目写入文件
for _, message := range logReq.Logs {
if _, err := wrappedWriter.Write([]byte("[" + logReq.OpenId + "] " + "[" + logReq.UserID + "] " + message + "\n")); err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to log file"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Logs written successfully"})
})
// 启动服务,监听在8080端口
if err := r.Run(":8080"); err != nil {
log.Fatal("Failed to start server:", err)
}
}
第三步: 在 logger 目录下执行命令 go mod init logger
初始化。
第四步:设置代理地址,go env -w GOPROXY=https://goproxy.cn
第五步:下载依赖,执行命令 go mod tidy
第六步:启动服务,执行命令 go run .\main.go
如果顺利的话且你的小程序也开着的,那么此时在服务器上已经能看到小程序上传的日志了,不顺利的话就告辞了🤪。
ELK
日志目前还是存储在文件中,日志一旦多了就不好查找了,我建议配合ELK去采集日志并查看,这个不会的可以找运维帮忙,最终效果大家看下
如果公司没有运维或者搞不定ELK的话,只想在服务器上看日志怎么办呢?我的建议是按日期每天新建一个文件夹,然后每个 openid 一个文件,这样查找起来也很方便,只需要把 main.go
稍微改造下就可以了。
go
package main
import (
"log"
"net/http"
"os"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
lumberjack "github.com/natefinch/lumberjack"
)
// LogRequest 表示请求体的结构体
type LogRequest struct {
OpenID string `json:"openid"`
UserID string `json:"userid"`
Logs []string `json:"logs"`
}
func main() {
// 创建Gin路由器实例
r := gin.Default()
// 定义路由处理函数
r.POST("/logger", func(c *gin.Context) {
var logReq LogRequest
if err := c.ShouldBindJSON(&logReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 创建或获取openid对应的文件夹
logDir := filepath.Join("./logs", logReq.OpenID)
err := os.MkdirAll(logDir, 0755)
if err != nil {
log.Fatalf("Failed to create logs directory for openid %s: %v", logReq.OpenID, err)
}
// 获取当前日期,并格式化用于文件名
today := time.Now().Format("2006-01-02")
logFile := filepath.Join(logDir, today+".log")
// 创建或获取lumberjack Logger实例
wrappedWriter := &lumberjack.Logger{
Filename: logFile,
MaxSize: 10, // 每个日志文件最大大小,单位MB
MaxAge: 28, // 最大保存时间,单位天
Compress: false, // 是否压缩/归档旧文件
}
// 将logs数组中的每个日志条目写入文件
for _, message := range logReq.Logs {
logEntry := "[" + logReq.OpenID + "] " + "[" + logReq.UserID + "] " + message + "\n"
if _, err := wrappedWriter.Write([]byte(logEntry)); err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to log file"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Logs written successfully"})
})
// 启动服务,监听在8080端口
if err := r.Run(":8080"); err != nil {
log.Fatal("Failed to start server:", err)
}
}
看看最终效果,可以看到日志是以日期维度存储的。
请求鉴权
可以看到目前接口是不需要登录验证的,所有人都可以访问,虽然这个接口是辅助接口不是业务接口,不涉及到业务逻辑,但是如果被人利用一直调用也会把我们服务器写爆的,且会有很多无用的日志内容。但是这个接口加登录token又太麻烦了,我的想法是对Http Referer做检验,小程序接口都会带上 Referer,格式为 servicewechat.com/{appid}/{ve... ,那么对 Referer 中的 appId 做校验就可以满足目前的需求了,当然了如果别人知道了你的小程序 appId,且知道了你是这么校验的,那还是可以伪造的。老规则还是贴完整代码。
go
package main
import (
"log"
"net/http"
"os"
"regexp"
"github.com/gin-gonic/gin"
lumberjack "github.com/natefinch/lumberjack"
)
// LogRequest 表示请求体的结构体
type LogRequest struct {
OpenId string `json:"openid"`
UserID string `json:"userid"`
Logs []string `json:"logs"`
}
// 假设我们有一个配置文件或环境变量来定义小程序ID
var appID = "wxf195a50feba1dfc4" // 这里替换为你的小程序ID
// 全局的日志写入器
var logWriter *os.File
func main() {
// 初始化日志文件的滚动配置
logPath := "./logs/web.log"
err := os.MkdirAll("./logs", 0755) // 确保logs目录存在
if err != nil {
log.Fatalf("Failed to create logs directory: %v", err)
}
// 使用lumberjack封装我们的logWriter
wrappedWriter := &lumberjack.Logger{
Filename: logPath,
MaxSize: 100, // 每个日志文件最大大小,单位MB
MaxAge: 28, // 存储的最大天数
Compress: false, // 过期的日志文件是否压缩
}
wrappedWriter.Rotate() // 立即执行一次滚动,确保从新的文件开始写入
// 将wrappedWriter的Write方法包装为gin.HandlerFunc以便在路由中使用
logMiddleware := func(c *gin.Context) {
// 实际的中间件逻辑可以在这里执行,例如记录请求信息等
c.Next() // 继续处理请求
}
// 创建Gin路由器实例
r := gin.Default()
// 使用自定义的中间件,可以在这里添加更多中间件逻辑
r.Use(logMiddleware)
// 提供POST请求的/logger接口
r.POST("/logger", func(c *gin.Context) {
referer := c.Request.Header.Get("Referer")
if referer == "" {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Referer header is missing"})
return
}
// 正则表达式匹配规则
pattern := `^https://servicewechat\.com/` + regexp.QuoteMeta(appID) + `/.*/page-frame\.html$`
matched, err := regexp.MatchString(pattern, referer)
if err != nil {
log.Printf("Error matching referer: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
if !matched {
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid referer"})
return
}
var logReq LogRequest
if err := c.ShouldBindJSON(&logReq); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 将logs数组中的每个日志条目写入文件
for _, message := range logReq.Logs {
if _, err := wrappedWriter.Write([]byte("[" + logReq.OpenId + "] " + "[" + logReq.UserID + "] " + message + "\n")); err != nil {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to write to log file"})
return
}
}
c.JSON(http.StatusOK, gin.H{"message": "Logs written successfully"})
})
// 启动服务,监听在8080端口
if err := r.Run(":8080"); err != nil {
log.Fatal("Failed to start server:", err)
}
}
当在外部调用接口时就会报错。