package collect import ( "bytes" "context" "io" "os" "strings" "time" "git.opencomputing.cn/yumoqing/host-metrics-collector/internal/checkpoint" "git.opencomputing.cn/yumoqing/host-metrics-collector/internal/model" ) // LogTail 持续读取单个日志文件的新增内容,并通过 checkpoint.Store 记录 // 已消费 offset,实现进程重启后的断点续传。采用轮询式 tail,避免对 // inotify 的额外依赖,跨平台且足够轻量。 type LogTail struct { path string store checkpoint.Store poll time.Duration onEntry func(model.LogRecord) } // NewLogTail 创建日志 tail 采集器;onEntry 每读到一行完整日志时回调。 func NewLogTail(path string, store checkpoint.Store, poll time.Duration, onEntry func(model.LogRecord)) *LogTail { if poll <= 0 { poll = time.Second } return &LogTail{path: path, store: store, poll: poll, onEntry: onEntry} } // Run 阻塞执行采集,直到 ctx 被取消。 func (t *LogTail) Run(ctx context.Context) { offset, err := t.store.GetLogOffset(t.path) if err != nil { offset = 0 } var partial []byte for { select { case <-ctx.Done(): return default: } offset, partial = t.readOnce(offset, partial) select { case <-ctx.Done(): return case <-time.After(t.poll): } } } // readOnce 从 offset 处读取文件新增内容,返回新的 offset 与未成行的残留字节。 func (t *LogTail) readOnce(offset int64, partial []byte) (int64, []byte) { f, err := os.Open(t.path) if err != nil { return offset, partial } defer f.Close() info, err := f.Stat() if err != nil { return offset, partial } // 文件被截断或轮转:从头重新消费。 if info.Size() < offset { offset = 0 partial = partial[:0] } if _, err := f.Seek(offset, io.SeekStart); err != nil { return offset, partial } data, err := io.ReadAll(f) if err != nil { return offset, partial } if len(data) == 0 { return offset, partial } partial = append(partial, data...) for { idx := bytes.IndexByte(partial, '\n') if idx < 0 { break } line := partial[:idx] partial = partial[idx+1:] offset += int64(idx) + 1 t.emit(line, offset) } return offset, partial } func (t *LogTail) emit(line []byte, offset int64) { msg := strings.TrimRight(string(line), "\r") rec := model.LogRecord{ Timestamp: time.Now().Unix(), Level: detectLevel(msg), Source: t.path, Message: msg, File: t.path, Offset: offset, } // 先投递给上层(进入内存缓冲),再推进 offset;若进程在两步之间退出, // 重启后会从旧 offset 重读,得到「至少一次」语义,避免丢日志。 if t.onEntry != nil { t.onEntry(rec) } // 检查点写入失败不阻断采集;下轮会按旧 offset 重读(至少一次语义)。 if err := t.store.SetLogOffset(t.path, offset); err != nil { // 可选:由上层记录日志,这里保持轻量。 } } // detectLevel 从日志内容中识别常见级别,未命中默认 INFO。 func detectLevel(msg string) string { upper := strings.ToUpper(msg) for _, lvl := range []string{"FATAL", "ERROR", "WARN", "INFO", "DEBUG"} { if strings.Contains(upper, lvl) { return lvl } } return "INFO" }