# loghook **Repository Path**: vipkwds/loghook ## Basic Information - **Project Name**: loghook - **Description**: 日志聚合与实时推送服务。接收外部日志,通过 WebSocket 实时推送到 Web UI - **Primary Language**: Go - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-06-10 - **Last Updated**: 2026-08-02 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # log-webhook 日志聚合与实时推送服务 日志聚合与实时推送服务。接收外部日志,通过 WebSocket 实时推送到 Web UI。 ## 核心问题 分离开发模式下,前端调用接口会产生大量日志,顶掉开发者的 debug 上下文。 **解决方案**:前端强大的搜索过滤能力,让开发者自由过滤不想看到的日志。 ## 安装服务端 ```bash go install gitee.com/vipkwds/loghook/cmd/loghook@latest ``` ## 运行 ```bash loghook # 默认端口 8080 loghook -p 9090 # 指定端口 loghook --port 9090 ``` ### 命令行选项 #### 所有 flag 一览 | 标志 | 含义 | |------|------| | `-p` / `--port` | 端口号(child option,含义见下表) | | `-h` / `--help` | 打印帮助信息并退出 | | `-v` / `--version` | 打印版本号并退出 | | `-s` / `--status` | 查看运行状态 | | `-y` / `--yes` | `--prune-stale` 跳过交互确认 | | `--pid` | 进程 PID(`--stop` 时使用,与 `-p` 二选一) | | `--token` | 启用 `/webhook` token 鉴权(服务端启动时) | | `--stop` | 停止运行中的实例(危险操作,仅长形式) | | `--ignore` | `--stop` 的 fallback 修饰符 | | `--prune-stale` | 批量清理 cli.log 中 stale 记录 | | `--gui` | 启动 GUI 模式(系统托盘 + 内嵌 webview) | #### Child Option 关系 | 父 flag | Child Option | 说明 | |---------|-------------|------| | *(无 flag)* | `-p`(默认 8080) | 服务端监听端口 | | *(无 flag)* | `--token`(可选) | 启用 `/webhook` 鉴权,留空则不鉴权 | | `--status` | `-p`(可选) | 不加 `-p` 列全部 RUNNING;加 `-p` 看指定端口详情 | | `--stop` | `-p` / `--pid` 必须二选一 | 二选一确定停止目标;`--pid` 仅处理 `lh -s` 列表里的 PID | | `--stop` | `--ignore`(可选) | fallback:PID 已死失败时写 ignore 事件,下次 `-s` 不再显示 | | `--prune-stale` | `-y` / `--yes`(可选) | 跳过交互确认(脚本 / CI 用) | | `--gui` | `-p`(可选) | attach 到指定端口的 daemon;端口空闲则 fork 子进程 | > 历史只追加,不膨胀——所有 start / stop / crash 都记入同一个 `cli.log`,启动时会自动裁剪到最近 100 条。 ### Token 鉴权(--token) `--token=` 启动后,**两件事同时启用**: 1. `/webhook` 必须通过鉴权才能接收日志(密码直接投递) 2. `/` 和 `/ws` 强制 UI 登录(弹口令框 → 签发独立 session ticket;详见 [UI 鉴权](#ui-鉴权--session-ticket)) 留空(默认)= 不鉴权,UI/WS/webhook 全公开,仅供本地调试。 #### /webhook 鉴权投递顺序 1. **Header `Authorization: Bearer `** 或裸 `` 2. **Header `X-Hook-Token: `** 3. **Body JSON**:在数组元素顶层或对象顶层查找 `hookToken` 字段(注意:handler 严格要求 JSON 数组形态,所以"对象信封 `{logs:[...]}`"不被支持——若需要,请改用 header/query) 4. **Query `?hookToken=`** ```bash loghook --token=mySecret # 启动并启用鉴权 # 投递示例 curl -X POST http://host:8080/webhook \ -H "Authorization: Bearer mySecret" \ -H "Content-Type: application/json" \ -d '[{"time":"...","level":"info","message":"hi"}]' curl -X POST http://host:8080/webhook \ -H "X-Hook-Token: mySecret" \ -H "Content-Type: application/json" \ -d '[...]' curl -X POST 'http://host:8080/webhook?hookToken=mySecret' \ -H "Content-Type: application/json" \ -d '[{"time":"...","message":"body auth","hookToken":"mySecret"}]' ``` 未匹配任意位置 → `401 unauthorized: missing or invalid token`。鉴权失败会在服务端日志看到 `[loghook] /webhook auth reject from (token_provided=true|false)`。 **留空(默认)行为**:`--token` 不传或为空字符串 = `/webhook` 不鉴权,向后兼容现有集成。 ### UI 鉴权 / Session Ticket `--token` 启用后,浏览器打开 `http://host:port` 会**先弹口令框**,用户输入密码(即 `--token` 配置的值)→ 服务端校验通过后签发独立 **session ticket**(24h 有效,存内存)→ UI 拿 ticket 走 `?token=` 连 WebSocket。 **为什么密码和 ticket 必须分开**: | 项 | 用途 | 泄露后果 | |---|---|---| | `--token`(密码) | `/webhook` POST + `/api/login` 校验 | webhook 也跟着炸 | | Session ticket | **仅**给 `/ws` 用 | 只能看日志,不能打 webhook | 严格分离:session ticket **不能**反向打 `/webhook`(已通过 `TestWebhookHandler_RejectsSessionTicket` 测试锁定);密码**也不能**直接当 ticket 用(`TestSessionToken_NotPassword` 锁定)。即使 ticket 在浏览器 localStorage 泄露,最坏后果是多几个人看 UI,webhook 推送路径不被破。 **端点列表**(仅 `--token` 启用时有意义): | 端点 | 方法 | 用途 | |---|---|---| | `/api/auth-status` | GET | 返回 `{auth_enabled: bool}`,客户端先调这个决定要不要弹 modal | | `/api/auth-check?ticket=xxx` | GET | 200 = ticket 有效 / 401 = 无效;连 WS 前预校验 | | `/api/login` | POST | body `{password}` → `{token, expires_in}`;密码错返回 401 | | `/ws` | WS upgrade | `?token=` 鉴权;握手失败浏览器 onclose code=1006 | **前端状态栏**会显式标注当前模式,避免误以为无鉴权: ``` [🔒 鉴权] /webhook 需口令 — 已连接 推送:http://host:8080/webhook [🔓 无鉴权] /webhook 公开 — 已连接 推送:http://host:8080/webhook ``` **典型流程**: ```bash # 1. 启服务(公网部署) loghook --token=$(openssl rand -hex 32) -p 8080 # 2. 浏览器开 http://host:8080 → 自动弹 modal「🔒 需要鉴权」 # 3. 输入口令(openssl rand 那串)→ 服务端校验 → 签发 ticket 存 localStorage # 4. WS 连上,状态栏显示「🔒 鉴权 /webhook 需口令 — 已连接 ...」 # 5. 关浏览器再开 → ticket 还在 localStorage → 不弹 modal 直连 # 6. 关浏览器 + 清缓存 → 重弹 modal # 7. 服务端重启 → 内存 session 清空 → ticket 全失效 → 重弹 modal 让用户重新登录 ``` **SDK 配套 API**(自托管场景): ```go loghook.SetAuthToken("mySecret") // 注入密码(与 CLI --token 等价) // 程序化签发 ticket(绕过 HTTP): // loghook.MintSessionToken() string // 测试用 // loghook.IsAuthEnabled() bool // 当前是否启用 // loghook.AuthStatusHandler / AuthCheckHandler / LoginHandler / WsHandler // 注册到 mux ``` **安全注意**: - Session ticket 存内存(map),**不持久化**。服务重启 = 全部失效,符合"最小暴露面"原则 - Session TTL 24h,goroutine 异步清理过期条目(懒清理兜底) - ticket 用 `crypto/rand` 生成 32 字节熵(64 hex 字符),无碰撞可枚举风险 - HTTPS 终结**强烈建议在 LB / nginx 完成**——ticket 通过 query 传输,明文 HTTP 下等于裸奔 ### GUI 模式(--gui) ```bash loghook --gui # 默认端口 8080 loghook --gui -p 9090 # 指定端口 ``` GUI 模式打开**系统托盘图标 + 内嵌 webview 窗口**(原生窗口,不是浏览器),直接渲染 `http://localhost:PORT`。 **两种触发关系**: - 端口空闲 → 自动 fork 一个子进程 `lh -p PORT`(皮模式:GUI 退出 = 子进程被杀) - 端口已被其他 lh 实例占用 → attach 到该 daemon,**不会被本次 GUI 退出影响** **托盘菜单**: - `打开 Web UI` —— 在系统浏览器中打开(webview 窗口被关掉后还能从这里复活) - `复制访问地址` —— 复制 `http://localhost:PORT` 到剪贴板 - `端口: XXXX` —— 只读标签,显示当前端口 - `显示运行信息` —— 弹窗显示端口 / PID / 运行时长(fork 模式显示子进程 PID;attach 模式显示 "—") - `刷新 UI` —— `location.reload()`,保留 webview 实例(同 Chromium 进程);治"内容卡住不动" - `重启 WebView` —— `Terminate + Destroy + New()`,重建 Chromium 实例;治"白屏 / 卡死 / 渲染异常" - `[✓] 开机自启` —— checkbox;勾选后注册到 HKCU\...\Run(Windows)/ LaunchAgent(macOS)/ .desktop autostart(Linux);命令携带当前 `-p `,重启后端口保持 - `退出 loghook` —— 关闭 GUI,并 kill 自己 fork 的子进程(如果有) **窗口交互**: - 点 `X` —— 窗口隐藏到托盘,daemon 继续在后台(皮模式:保留进程,方便下次直接拉起来) - 点 `_` —— 窗口最小化到任务栏(daemon 继续在后台) - 托盘左键单击 —— 切换窗口:隐藏→显示 / 显示→隐藏 - webview 内部右键 —— 已屏蔽(HTML 右键不再弹浏览器菜单) **"不使用 --gui" = 后台模式**: - `lh`(无 flag)= 长期 daemon,关闭终端也跑 - `lh --gui` = 临时前台,用完即走,附带桌面 UI **强杀也安全(Windows Job Object)**: 进程被 `taskkill /F` 强杀时,Windows JobObject 的 `KILL_ON_JOB_CLOSE` 机制保证 fork 出来的子进程跟着死,不会留下孤儿 daemon 占用端口。 **构建依赖**: - **Windows**:CGo + MSVC/MinGW;运行时需要 **WebView2 Runtime**(Win11 自带,Win10 1809+ 自动更新,老系统装 [Edge WebView2 Runtime Evergreen](https://developer.microsoft.com/microsoft-edge/webview2/)) - **macOS / Linux**:CGo + WebKitGTK(Linux 需 `libwebkit2gtk-4.0-dev`);理论上可工作,主测 Windows - 二进制大小约 +10MB(webview + systray + 嵌入图标) **首次启动白屏**:WebView2 首次冷启动要初始化用户数据目录 + Chromium 启动(5–10s)。窗口创建期间先展示一段 `loghook 启动中…` splash 页,随后 `Navigate` 覆盖。第二次起是缓存路径,几乎瞬开。如果一直白屏不消失,检查 WebView2 Runtime 是否安装正常。 ### 查看运行状态(-s / --status) 历史日志在 `os.UserConfigDir()/loghook/cli.log`(Windows: `%APPDATA%\loghook\cli.log`,Linux: `~/.config/loghook/cli.log`)。 ```bash loghook -s # 列出所有 RUNNING 实例 loghook -s -p 8080 # 查 8080 端口详情 loghook --status --port 9090 # 长形式 + 指定端口 loghook --stop -p 8080 # 停止 8080 实例;先探活再 kill,写 stop 事件 ``` **`lh -s` 输出**:只列**探活成功**的实例,并附 `launcher` 列标明启动者(父进程名)。常见值: - `cmd.exe` / `powershell.exe` / `WindowsTerminal.exe` —— 终端直接拉起 - `loghook.exe` —— 被 GUI 模式 fork 启动 - `explorer.exe` —— 双击可执行文件 / 快捷方式 - `autostart` —— svchost 启发式识别(父进程是 svchost.exe 且当前 exe 已注册 HKCU Run),即开机自启触发 - `wsl.exe` —— WSL 里跑 端口不通就不显示,活动进程就是 RUNNING,不存在 STALE 这种僵尸条目。 **`lh -s -p ` 三种返回**(均 exit 非零): - **RUNNING**(端口在听)→ 显示详情(PID、启动者、路径、启动时长、命令、版本) - **被外部 kill / 端口已死**(cli.log 有 start 但无对应 stop)→ 提示并列出最近一次 start 信息 - **从未启动**(cli.log 没有该端口任何记录)→ 提示 + 引导用 `lh -s` 看全部 ### 停止(--stop) `--stop` 是危险操作,**只接受长形式**且**必须显式指定目标**(`-p/--port` 或 `--pid`,**二选一**): ```bash loghook --stop # ✗ 拒绝执行,提示必须指定端口或 PID loghook --stop -p 8080 # ✓ 按端口停止;先探活再 kill,最多等 2s 端口释放 loghook --stop --port 9090 # ✓ 长形式 loghook --stop --pid 12345 # ✓ 按 PID 停止(仅处理 `lh -s` 列表中的 PID,详见下方安全规则) loghook --stop -p 8080 --pid 12345 # ✗ 拒绝执行,二选一 ``` **`--stop --pid` 安全规则**(防误杀): 1. **PID 必须出现在 `lh -s` 列表**(即 cli.log 里有未配对的 start 事件)。cli.log 从未记录过的 PID → 拒绝("不是已知的 loghook 实例") 2. **PID 在 `lh -s` 但端口已不通** → 拒绝("实例可能已崩溃或被外部 kill,但历史还没配对 stop 事件",避免 kill 错对象) 3. **PID 在 cli.log 但已被 stop 事件配对** → 拒绝("实例已正常停止,无需重复操作") 理由:OS 会复用 PID,原 daemon 死后同一 PID 可能分给完全无关的进程(比如你的 IDE、浏览器)。盲目 kill 会误杀。`--pid` 只信任自己 cli.log 标记为 RUNNING 且端口确实还活着的实例。 收到 `SIGINT/SIGTERM` 主动退出会**自动**写 stop 事件;被 `--stop` 强杀的进程来不及自记,由执行 stop 的 CLI 补登——cli.log 的 start/stop 始终配对,不留 STALE。 **`--stop ... --ignore`(fallback)**: `--ignore` 不是"无条件写 ignore 事件"的策略,而是**仅在 `--stop` 因 PID 已死失败时**激活的 fallback。设计动机:被外部 kill 的 daemon 在 cli.log 残留 RUNNING 记录,导致下次 `--stop` 拒绝(防误杀)、`-s` 也显示僵尸。 ```bash # 第一步:试 stop,失败看到完整诊断(实际端口被谁占用) $ lh --stop --pid 33584 ✗ 无法获取 PID 33584 的进程句柄(PID 已死) → cli.log 里 PID=33584 已死,但端口 9090 仍被监听 → 当前端口 9090 的实际占用方:PID 46328 (svchost.exe) → 如果确认 cli.log 这条记录已无意义,请重试并加 --ignore 标记忽略 # 第二步:确认 cli.log 这条已无意义,加 --ignore 重试 $ lh --stop --pid 33584 --ignore 📝 --ignore 已激活:已写入 ignore 事件(PID 已死(被外部 kill 或崩溃)) 下次 `lh -s` 不再显示该实例 ⚠️ 端口 9090 仍被 PID 46328 (svchost.exe) 占用,未释放 exit 0 ``` **注意**:`--ignore` **不释放端口**——端口可能仍被别的进程占用(svchost / IDE / 浏览器等)。它只是 cli.log 的自净机制。 **`--prune-stale [-y]`(批量清理)**: 断电 / 系统重启 / IDE 崩溃后,多个 daemon 一起死,cli.log 一堆残留。逐个 `--stop --ignore` 太啰嗦,这个命令一次性扫: ```bash $ lh --prune-stale 发现 3 条 stale 记录(PID 已死但 cli.log 未配对 stop/ignore): port=8080 pid=12345 started=2026-07-30T10:00:00+08:00 launcher=cmd.exe port=8081 pid=12346 started=2026-07-30T10:01:00+08:00 launcher=cmd.exe port=9090 pid=33584 started=2026-07-30T15:00:00+08:00 launcher=cmd.exe 是否写入 ignore 事件清理?[y/N]: y ✓ 已忽略 port=8080 pid=12345 ✓ 已忽略 port=8081 pid=12346 ✓ 已忽略 port=9090 pid=33584 (端口仍被 PID 46328 (svchost.exe) 占用) 📝 完成:3 条忽略事件已写入 cli.log # 脚本里用 -y 跳过交互 $ lh --prune-stale -y ``` **底层机制**:`--ignore` / `--prune-stale` 都往 cli.log 追加 `{"event":"ignore",...}` 事件(保留审计,带 ts),`liveStarts()` 把它当 `stop/crash` 一样配对消除。所以下次 `-s` 不再显示。 ### 为什么不用 PID 文件 旧版用过 `state-{port}.json` 类 PID 文件,发现三个问题: 1. 进程异常死后文件残留 → -s 列表里堆 STALE 僵尸 2. 多端口并存会污染目录 3. STALE 需要用户手动清理,违背"日志服务不该添堵" 新版改为 append-only 历史 + TCP 探活:状态从外部探测(端口)获取,不依赖进程自维护的文件。退出码语义不再需要"STALE"分支。 ## GO SDK 接入 ### 安装 ```bash go get gitee.com/vipkwds/loghook ``` ### 快速使用 ```go import "gitee.com/vipkwds/loghook" // 使用默认单例(http://localhost:8080/webhook) loghook.Send("info", "用户登录成功", reqID) // 或者创建自定义实例 logger := loghook.NewLogger("http://localhost:8080/webhook") logger.Send("debug", "进入 UserService.Login()", reqID) ``` ### 完整示例 ```go package main import ( "errors" "time" "gitee.com/vipkwds/loghook" ) func main() { // 方式1: 使用默认单例(自动连接 localhost:8080) loghook.Send("info", "服务启动", "") loghook.Send("error", "连接失败: timeout", "req-001") // 方式2: 创建自定义实例,支持异步批量发送 logger := loghook.NewLogger( "http://localhost:8080/webhook", loghook.WithAsync(100, time.Second), // 缓冲区100条或1秒刷新 ) defer logger.Close() // 发送单条(message 为 any,string/map/struct 都可以) logger.Send("debug", "查询用户信息", "req-002") logger.Send("info", map[string]any{"user_id": 42, "action": "login"}, "req-002") // level 快捷函数:reqID 可省略 logger.Debug("进入 UserService.Login()") // 无 reqID logger.Info("用户登录成功", "req-login-001") // 带 reqID logger.Warn("retry 1/3", "req-retry-001") logger.Error(map[string]any{"step": 3, "err": "E_PAYMENT"}, "req-002") // message 也可是对象 // 默认单例快捷函数(已 SetDefault 后) loghook.Info("服务启动") loghook.Error("致命错误") // 发送任意数据(自动 JSON 序列化到 message) user := map[string]any{"id": 123, "name": "张三"} logger.SendData("info", "查询结果", user, "req-003") // 上报 error:自动提取 type / details,level 自动设为 error if err := doSomething(); err != nil { logger.SendError("支付失败", err, "req-005") } // 完整字段:code + stack + previous(异常链) entries := []loghook.LogEntry{ {Level: "info", Message: "步骤1完成", RequestID: "req-004"}, {Level: "info", Message: "步骤2完成", RequestID: "req-004"}, {Level: "error", Message: "步骤3失败", RequestID: "req-004", Code: "E_PAYMENT_FAILED", Stack: []string{ "at checkout (router.js:42:11)", "at processOrder (service.js:128:5)", }, Previous: []any{ map[string]any{ "message": "upstream service unreachable", "code": "E_CONN_REFUSED", }, }, }, } logger.SendBatch(entries) } func doSomething() error { return errors.New("simulated failure") } ``` ### API 说明 | 方法 | 说明 | |------|------| | `NewLogger(endpoint)` | 创建客户端实例 | | `NewLogger(endpoint, WithAsync(...))` | 创建异步客户端 | | `.Send(level, message, reqID)` | 通用入口:发送单条日志(`message` 为 `any`,string/struct/map 都直接发) | | `.Debug(msg, reqID...)` / `.Info(msg, reqID...)` / `.Warn(msg, reqID...)` / `.Error(msg, reqID...)` | level 快捷函数(`reqID` 可选,省略传 `""`) | | `.SendEntry(entry)` | 发送日志条目(最灵活,可填 `Code`/`Stack`/`Previous` 等所有字段) | | `.SendData(level, message, data, reqID)` | 发送数据(自动序列化到 message) | | `.SendError(message, err, reqID)` | 发送 error(自动提取 `Code`/`Type`/`Stack`,level 自动 `error`) | | `.SendBatch(entries)` | 批量发送 | | `.Close()` | 关闭(刷新缓冲区) | | `logwebhook.Default()` | 获取默认单例 | | `logwebhook.SetDefault(endpoint)` | 设置默认单例 | | `logwebhook.Send(level, message, reqID)` | 默认单例通用入口 | | `logwebhook.Debug/Info/Warn/Error(msg, reqID...)` | 默认单例 level 快捷函数 | | `logwebhook.SendEntry(entry)` | 默认单例发送日志条目 | | `logwebhook.SendError(err, reqID)` | 默认单例发送 error | ### 配置选项 ```go // 异步模式:缓冲区100条,或每1秒刷新一次 logger := loghook.NewLogger( "http://localhost:8080/webhook", loghook.WithAsync(100, time.Second), ) ``` ## 直接 POST(不用 SDK) 跳过 GO SDK,直接通过 HTTP POST 发送日志。适用场景: - 调试 / 验证 loghook 服务是否正常 - 非 Go 应用接入(Python / Node / Rust / Shell 脚本等) - CI/CD 流水线日志收集 ### 消息格式 Webhook 接收 JSON 数组(**不接受**对象信封 `{logs:[...]}`): ```json [ { "time": "2026-06-08T10:00:00Z", "level": "info", "message": "用户登录成功", "request_id": "req-abc123", "file": "user.go", "line": 42, "tags": {"env":"prod","version":"1.2.3"} } ] ``` ### 字段说明 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `time` | string (RFC3339) | 否 | 时间戳;缺省用服务端接收时间 | | `level` | string | **是** | `debug` / `info` / `warn` / `error`,决定 UI 颜色与过滤 | | `message` | **any** | **是** | 日志正文。`string` 最常见,但 JSON 对象 / 数组 / 数字 / 布尔 / `null` 都直接发(前端按类型渲染:object → JSON 树,array → 列表,纯文本 → 多行文本)。支持 `\n` 多行 | | `code` | any (`int` \| `string`) | 否 | 错误码。映射 PHP `Throwable::getCode()` / Node `Error.code` / Go `errors.Is` 判定 | | `stack` | **any** | 否 | 异常堆栈。`string`(原始多行文本)/ `[]string`(每帧一行)/ JSON 对象(`file`/`line`/`func` 字段)都支持 | | `previous` | `[]any` | 否 | 异常链(PHP `Throwable::$previous` / Go `errors.Unwrap` / Node `error.cause`)。每个元素是任意类型,通常与外层同 schema(可递归嵌套) | | `request_id` | string | 否 | 请求 ID,UI 搜索 `request_id:` 用 | | `file` | string | 否 | 来源文件,UI 显示用 | | `line` | int | 否 | 文件行号,配合 `file` 用 | | `tags` | object | 否 | 自定义键值对,方便按维度过滤 | ### curl 调试 **最简版本**(仅必填字段): ```bash curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ -d '[{"level":"info","message":"hello loghook"}]' ``` **带时间戳 + 请求 ID**(Windows PowerShell): ```powershell curl -X POST http://localhost:8080/webhook ` -H "Content-Type: application/json" ` -d '[{"time":"'+(Get-Date -Format o)+'","level":"info","message":"build #42 succeeded","request_id":"req-abc123"}]' ``` **批量(一次 N 条,原子提交)**: ```bash curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ -d '[ {"level":"info","message":"step 1 done","request_id":"req-1"}, {"level":"info","message":"step 2 done","request_id":"req-1"}, {"level":"warn","message":"retry 1/3","request_id":"req-1"}, {"level":"error","message":"final failure","request_id":"req-1"} ]' ``` **带 token 鉴权**(任选一种投递方式,详见 [Token 鉴权](#token-鉴权--token)): ```bash # Header Authorization: Bearer curl -X POST http://localhost:8080/webhook \ -H "Authorization: Bearer mySecret" \ -H "Content-Type: application/json" \ -d '[{"level":"info","message":"auth via header"}]' # 或 X-Hook-Token curl -X POST http://localhost:8080/webhook \ -H "X-Hook-Token: mySecret" \ -H "Content-Type: application/json" \ -d '[{"level":"info","message":"auth via x-hook-token"}]' # 或 Query ?hookToken= curl -X POST 'http://localhost:8080/webhook?hookToken=mySecret' \ -H "Content-Type: application/json" \ -d '[{"level":"info","message":"auth via query"}]' ``` **结构化 message + code + stack(异常上报)**: ```bash curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ -d '[{ "level": "error", "message": {"action":"checkout","user_id":42,"order_id":"o-2026-001"}, "code": "E_PAYMENT_FAILED", "stack": [ "at checkout (router.js:42:11)", "at processOrder (service.js:128:5)", "at Layer.handle [as handle_request] (express/lib/router/layer.js:95:5)" ] }]' ``` **异常链(previous 嵌套,对标 PHP Throwable / Go errors.Unwrap)**: ```bash curl -X POST http://localhost:8080/webhook \ -H "Content-Type: application/json" \ -d '[{ "level": "error", "message": "API gateway timeout", "code": 504, "previous": [ {"message":"upstream service unreachable","code":"E_CONN_REFUSED"}, {"message":"DNS resolution failed","code":-2, "previous":[{"message":"no such host","code":"ENOTFOUND"}] } ] }]' ``` ### 常见问题 | 现象 | 原因 / 排查 | |------|------------| | 返回 200 但前端看不到新日志 | JSON 不是数组形态(必须是 `[...]`,不是 `{logs:[...]}` 对象信封) | | 返回 `401 unauthorized` | 启用了 `--token` 但没传;按上面"带 token 鉴权"补 header / query | | 返回 `400 invalid JSON` 但语法看着对 | 中文标点 / 全角空格 / 多余逗号;用 `python -m json.tool` 验一下 | | 前端按 `level` 过滤不生效 | 字段值必须严格匹配 `debug`/`info`/`warn`/`error`(区分大小写) | ### 类型容忍规则(dev tool 包容性设计) loghook 是 dev 工具不是金融系统,常见字段类型不一致会自动 coerce,不粗暴 reject: | 字段 | 接受 | 拒绝(仍会 400) | |------|------|------------------| | `line` | `number` / 数字字符串 `"42"`(自动 trim)/ `null` / 缺失 | 非整数数字 `"42.5"`、乱字符串 `"abc"`、bool、array、object | | `time` | RFC3339 string / Unix 时间戳 number(启发式:`< 1e11` = 秒,`>= 1e11` = 毫秒,自动转 RFC3339)/ `null` / 缺失 | array、object | | `request_id` / `file` | string / number / bool(`fmt.Sprint` coerce)/ `null` / 缺失 | array、object | | `tags` value | string / number / bool / null(全部 coerce 到 string) | array、object(tags 设计为标量维度) | > **典型场景**:前端 `fetch` 把 `line` 当 number 传、PHP `curl` 把 `line` 当 string `"42"` 传、Python 把 `time` 当 Unix 时间戳数字传 —— 都能自动落库,不需要先 `String(line)` 转换。 ## 架构 ``` ┌─────────────────────────────────────────┐ │ GO SDK (客户端) │ │ NewLogger().Send() / SendBatch() │ └─────────────────┬───────────────────────┘ │ HTTP POST ▼ ┌─────────────────┴───────────────────────┐ │ loghook 服务端 │ │ ┌─────────────────────────┐ │ │ │ WebhookHandler │ ◄────── 接收日志 │ └───────────┬─────────────┘ │ │ │ │ │ ┌───────────┴─────────────┐ │ │ │ LogManager │ ◄────── 内存存储(最多10000条) │ └───────────┬─────────────┘ │ │ │ broadcast() │ │ ┌───────────┴─────────────┐ │ │ │ WebSocket │ ───────► Web UI │ └─────────────────────────┘ │ └─────────────────────────────────────────┘ ``` ## API ### Webhook 接收日志 ```bash POST /webhook Content-Type: application/json [ { "time": "2026-06-08T10:00:00Z", "level": "info", "message": "用户登录成功", "request_id": "req-abc123", "file": "user.go", "line": 42 } ] ``` ### WebSocket 实时接收 ```bash ws://localhost:8080/ws ``` 连接后服务器先推送最近 100 条历史日志。 **启用 `--token` 后**:必须带 `?token=`,否则握手返回 401(浏览器表现为 onclose code=1006,UI 重弹登录 modal)。Ticket 通过 `POST /api/login` 用密码换取,TTL 24h。 ### 鉴权 / Session 管理(自托管 SDK 集成) ```go import "gitee.com/vipkwds/loghook" loghook.SetAuthToken("mySecret") // 注入密码 // 注册端点(顺序无所谓) mux := http.NewServeMux() mux.HandleFunc("/webhook", loghook.WebhookHandler) mux.HandleFunc("/ws", loghook.WsHandler) mux.HandleFunc("/api/auth-status", loghook.AuthStatusHandler) // UI 启动时探活 mux.HandleFunc("/api/auth-check", loghook.AuthCheckHandler) // 校验 ticket mux.HandleFunc("/api/login", loghook.LoginHandler) // 密码 → ticket http.ListenAndServe(":8080", mux) ``` | 函数 | 作用 | |---|---| | `SetAuthToken(s)` | 注入密码(空 = 关闭鉴权) | | `IsAuthEnabled() bool` | 当前是否启用 | | `WebhookHandler` | POST `/webhook`,仅认密码(header/body/query) | | `WsHandler` | WS `/ws`,仅认 session ticket(Authorization Bearer / `?token=`) | | `LoginHandler` | POST `/api/login`,密码 → ticket | | `AuthStatusHandler` | GET `/api/auth-status`,返回 `{auth_enabled}` | | `AuthCheckHandler` | GET `/api/auth-check?ticket=xxx`,200/401 | ## Web UI 搜索过滤 浏览器打开 `http://localhost:8080` ### 1. 搜索框(支持多种查询方式) ``` # 多关键字搜索(逗号分隔) error, user/login # 正则表达式(前后加 /) /^ERROR.*timeout/ # 指定字段搜索 request_id:req-abc message:sql file:main.go ``` ### 2. 级别过滤 下拉选择 `Debug / Info / Warn / Error` 过滤特定级别。 ### 3. 快捷标签 点击 `http / sql / error / panic` 快速过滤常见日志类型。 ### 4. 规则过滤(⚙️ 规则面板) ``` [显示包含] message: user/login # 只显示包含 user/login 的日志 [隐藏包含] request_id: req-xxx # 隐藏 request_id 为 req-xxx 的日志 ``` - 支持正则表达式(前后加 `/`) - 支持字段:`message`, `request_id`, `file`, `level` - 规则保存到 localStorage,刷新后保留 ### 5. 智能抑制(🔬 智能抑制高频日志) 勾选后自动识别**高频日志源**(5秒内超过50次),并自动隐藏。 可手动"放行"被识别的日志源。 ### 6. 暂停/继续 点击暂停按钮,累积期间的日志可选择回放。 ## 前端请求自动日志 ```javascript // 拦截 fetch,自动记录前端请求 const origFetch = window.fetch; window.fetch = async function(url, options = {}) { const reqID = crypto.randomUUID(); const start = Date.now(); try { const res = await origFetch(url, options); const ms = Date.now() - start; sendLog('info', `${options.method || 'GET'} ${url} ${res.status} ${ms}ms`, reqID); return res; } catch (err) { sendLog('error', `${options.method || 'GET'} ${url} ${err.message}`, reqID); throw err; } }; function sendLog(level, message, requestID) { fetch('http://localhost:8080/webhook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([{ time: new Date().toISOString(), level, message, request_id: requestID }]) }); } ``` ## 项目结构 ``` log-webhook/ ├── loghook.go # 服务端核心逻辑 ├── logger.go # GO SDK 客户端 ├── index.html # Web UI ├── go.mod └── cmd/loghook/ ├── main.go # 程序入口(CLI 解析 + daemon 启动) ├── state.go # --status / --stop / 历史日志 (cli.log) ├── gui.go # --gui 模式:托盘 + webview ├── gui_job_windows.go # Windows Job Object(强杀兜底) ├── gui_job_other.go # 非 Windows stub ├── assets/ │ ├── icon.ico # 托盘图标(Windows) │ └── icon.png # 托盘图标(macOS / Linux) └── index.html # 嵌入到二进制的 Web UI └── tools/ └── genicon/main.go # `go run ./tools/genicon` 重新生成图标 ``` ## License MIT