Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- `-s` flag to run in the foreground (console), which is also the default when no flag is given.
- `-d` flag to install and run LogMonitor as a background system service (Windows Service / systemd) via `kardianos/service`.
- `-c` flag 指定配置文件路径(原为 `-config`)。
- 飞书告警消息改为 interactive 卡片:头部颜色与表情按日志级别区分(ERROR/FATAL 🚨 红、WARN ⚠️ 橙、其余 ℹ️ 蓝),并通过字段展示来源、级别、文件、时间与日志上下文。

### Fixed

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ sudo ./LogMonitor -d -c config.yaml # 后台(Linux 需 root 安装服务)

日志级别通过每个日志源的 `level_regex` 提取。正则必须包含一个捕获组,捕获组内容会与 `levels` 比较。对于包含方括号级别的日志,程序只匹配级别字段,不会因为日志正文中出现 `ERROR` 而误报。

告警以飞书 interactive 卡片消息发送:头部颜色与表情图标按日志级别区分(`ERROR`/`FATAL` 为 🚨 红色,`WARN` 为 ⚠️ 橙色,其余为 ℹ️ 蓝色),卡片内展示来源、级别、文件、时间与日志上下文代码块。

例如你的日志格式可以使用默认配置:

```yaml
Expand Down
66 changes: 64 additions & 2 deletions internal/feishu/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"time"

"LogMonitor/internal/config"
Expand All @@ -21,12 +22,73 @@ type FeishuClient struct {
httpClient *http.Client
}

// Message is the structured payload sent to Feishu.
type Message struct {
Title string
Level string
Source string
File string
Time string
Content string
}

func NewClient(config config.FeishuConfig, notification config.NotificationConfig) *FeishuClient {
return &FeishuClient{config: config, notification: notification, httpClient: &http.Client{Timeout: 10 * time.Second}}
}

func (c *FeishuClient) Send(title, content string) error {
payload := map[string]any{"msg_type": "text", "content": map[string]string{"text": title + "\n\n" + content}}
func levelColor(level string) string {
switch strings.ToUpper(level) {
case "FATAL", "ERROR", "CRITICAL":
return "red"
case "WARN", "WARNING":
return "orange"
default:
return "blue"
}
}

func levelEmoji(level string) string {
switch strings.ToUpper(level) {
case "FATAL", "ERROR", "CRITICAL":
return "🚨"
case "WARN", "WARNING":
return "⚠️"
default:
return "ℹ️"
}
}

func (c *FeishuClient) Send(m Message) error {
card := map[string]any{
"config": map[string]any{"wide_screen_mode": true},
"header": map[string]any{
"title": map[string]any{"tag": "plain_text", "content": levelEmoji(m.Level) + " " + m.Title},
"template": levelColor(m.Level),
},
"elements": []any{
map[string]any{
"tag": "div",
"fields": []any{
map[string]any{"is_short": true, "text": map[string]any{"tag": "lark_md", "content": "**📦 来源**\n" + m.Source}},
map[string]any{"is_short": true, "text": map[string]any{"tag": "lark_md", "content": "**🔖 级别**\n" + m.Level}},
},
},
map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**📁 文件**\n" + m.File},
},
map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "**⏰ 时间**\n" + m.Time},
},
map[string]any{"tag": "hr"},
map[string]any{
"tag": "div",
"text": map[string]any{"tag": "lark_md", "content": "📜 日志上下文\n```\n" + m.Content + "\n```"},
},
},
}
payload := map[string]any{"msg_type": "interactive", "card": card}
if c.config.SignatureEnabled() {
ts := strconv.FormatInt(time.Now().Unix(), 10)
payload["timestamp"], payload["sign"] = ts, signature(ts, c.config.Secret)
Expand Down
30 changes: 22 additions & 8 deletions internal/monitor/monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"LogMonitor/internal/config"
"LogMonitor/internal/feishu"
)

type Monitor struct {
Expand All @@ -25,7 +26,7 @@ type Monitor struct {
}

type Sender interface {
Send(title, content string) error
Send(msg feishu.Message) error
}

type tracker struct {
Expand All @@ -41,6 +42,7 @@ type tracker struct {

type pendingAlert struct {
trigger string
level string
lines []string
remain int
}
Expand Down Expand Up @@ -188,13 +190,13 @@ func (m *Monitor) processLine(path, line string, t *tracker) {
i++
}

if LevelMatches(line, t.source.Levels, t.source.LevelRegex) && !m.duplicate(path, line) {
if level, ok := MatchLevel(line, t.source.Levels, t.source.LevelRegex); ok && !m.duplicate(path, line) {
lines := append([]string(nil), t.previous...)
if len(lines) >= m.config.Notification.MaxContextLines {
lines = lines[len(lines)-m.config.Notification.MaxContextLines+1:]
}
lines = append(lines, line)
alert := &pendingAlert{trigger: line, lines: lines, remain: t.source.AfterLines}
alert := &pendingAlert{trigger: line, level: level, lines: lines, remain: t.source.AfterLines}
if alert.remain == 0 || len(alert.lines) >= m.config.Notification.MaxContextLines {
m.sendAlert(path, t.source, alert)
} else {
Expand All @@ -209,18 +211,23 @@ func (m *Monitor) processLine(path, line string, t *tracker) {
}

func LevelMatches(line string, levels []string, levelRegex string) bool {
_, ok := MatchLevel(line, levels, levelRegex)
return ok
}

// MatchLevel returns the configured level that matched the line, if any.
func MatchLevel(line string, levels []string, levelRegex string) (string, bool) {
if levelRegex == "" {
levelRegex = `\[\s*([A-Za-z][A-Za-z0-9_-]*)\s*\]`
}
if level, ok := LogLevel(line, levelRegex); ok {
for _, configured := range levels {
if strings.EqualFold(level, configured) {
return true
return configured, true
}
}
return false
}
return false
return "", false
}

// LogLevel extracts levels such as [INFO], [WARN] and [ERROR].
Expand Down Expand Up @@ -251,8 +258,15 @@ func (m *Monitor) sendAlert(path string, source config.LogSourceConfig, alert *p
if len(alert.lines) > m.config.Notification.MaxContextLines {
alert.lines = alert.lines[:m.config.Notification.MaxContextLines]
}
content := fmt.Sprintf("Source: %s\nFile: %s\nTime: %s\n\n%s", source.Name, path, time.Now().Format(time.RFC3339), strings.Join(alert.lines, "\n"))
if err := m.sender.Send("LogMonitor Alert", content); err != nil {
msg := feishu.Message{
Title: fmt.Sprintf("LogMonitor 告警 - %s", alert.level),
Level: alert.level,
Source: source.Name,
File: path,
Time: time.Now().Format(time.RFC3339),
Content: strings.Join(alert.lines, "\n"),
}
if err := m.sender.Send(msg); err != nil {
m.logger.Printf("send alert for %s: %v", path, err)
}
}
Expand Down
9 changes: 5 additions & 4 deletions internal/monitor/monitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ import (
"testing"

"LogMonitor/internal/config"
"LogMonitor/internal/feishu"
)

type recordingSender struct{ messages []string }
type recordingSender struct{ messages []feishu.Message }

func (s *recordingSender) Send(_, content string) error {
s.messages = append(s.messages, content)
func (s *recordingSender) Send(m feishu.Message) error {
s.messages = append(s.messages, m)
return nil
}

Expand All @@ -29,7 +30,7 @@ func TestMonitorCollectsBeforeAndAfterContext(t *testing.T) {
t.Fatalf("messages = %d", len(sender.messages))
}
for _, expected := range []string{"before 1", "before 2", "ERROR failed", "after 1", "after 2"} {
if !strings.Contains(sender.messages[0], expected) {
if !strings.Contains(sender.messages[0].Content, expected) {
t.Errorf("message does not contain %q", expected)
}
}
Expand Down
Loading