Add a structured logger and collector so runs can be traced across processes.
Sync and async sinks, redaction, and a queryable HTTP service keep concurrent logs tied to a trace id. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
vet-and-test:
|
||||
runs-on: nightly
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: go vet ./... && go test -count=1 ./...
|
||||
docker-checkout-hostgateway:
|
||||
runs-on: ubuntu-latest
|
||||
options: --add-host gitea:host-gateway
|
||||
env:
|
||||
GITHUB_SERVER_URL: http://gitea:3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: test -f go.mod && echo "docker-mode checkout via host-gateway OK"
|
||||
@@ -0,0 +1,2 @@
|
||||
*.test
|
||||
/bin/
|
||||
@@ -0,0 +1,83 @@
|
||||
# Logging-System
|
||||
|
||||
跨系统共用的结构化日志库,以及可独立部署的采集服务。目标是把一次请求的运行日志和错误码对齐到同一个 `trace_id` 上,方便事后追溯。
|
||||
|
||||
本仓库是该模块的源码位置。Go 模块路径为 `logging-system`。只依赖标准库,不依赖错误码模块:只要错误实现了 `ErrorCode()` 和 `ErrorFields()`,错误日志会自动带上码和字段。
|
||||
|
||||
## 分层
|
||||
|
||||
| 包 | 职责 | 依赖边界 |
|
||||
|---|---|---|
|
||||
| logsys | 级别、条目、Logger、上下文追踪、全局默认日志器 | 只依赖标准库 |
|
||||
| sink | console / 滚动文件 / 异步队列 / HTTP 批量 / 多路分发 | 只依赖 logsys |
|
||||
| redact | 写前脱敏(JWT、sk-key、Bearer、api_key、password,可追加正则) | 只依赖标准库 |
|
||||
| config | 默认值 ← JSON ← `LOGSYS_*` 环境变量 | 组装 logsys 与 sink |
|
||||
| slogadapter | 与 `log/slog` 双向转发 | 只依赖 logsys |
|
||||
| server | 采集服务,默认 `:8092` | 组合根之一 |
|
||||
| cli | `logctl` | 组合根之一 |
|
||||
| conformance | 并发不丢、异步排空、脱敏、多路一致 | 只用公开 API |
|
||||
|
||||
## 同步与异步
|
||||
|
||||
每个出口自己保证并发安全。
|
||||
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| 同步 | 调用方 goroutine 在互斥锁内写完(console、file,或 `async: false` 的 HTTP) |
|
||||
| 异步 | 有界队列加单写者。队列满时丢弃**最新**一条并计数,不阻塞业务 goroutine。`Sync` / `Close` 排空队列,关闭有超时 |
|
||||
|
||||
HTTP 出口默认异步攒批(条数或时间间隔)。文件可按大小滚动并保留 N 份历史。
|
||||
|
||||
模块级别可以比全局更细,例如全局 `info`、`engine` 为 `debug`、`server` 为 `warn`。出口还可以再设自己的最低级别。
|
||||
|
||||
## 配置
|
||||
|
||||
完整示例见 [docs/example.json](docs/example.json)。
|
||||
|
||||
环境变量覆盖文件:`LOGSYS_CONFIG`、`LOGSYS_LEVEL`、`LOGSYS_SERVICE`、`LOGSYS_REDACT`(on/off)。
|
||||
|
||||
```go
|
||||
cfg, err := config.Load("log.json")
|
||||
log, err := config.Build(cfg)
|
||||
defer log.Close()
|
||||
log.Module("engine").WithContext(ctx).Error("provider down", err, "step", id)
|
||||
```
|
||||
|
||||
`log.Error` 的 `err` 若带有错误码,条目里会出现 `code` 和该错误的字段。追踪标识用 `logsys.WithTrace` 放进 context,或由调用方写入 `X-Trace-Id`。
|
||||
|
||||
## 采集 API
|
||||
|
||||
| 方法/路径 | 说明 |
|
||||
|---|---|
|
||||
| `GET /v1/health` | 健康检查 |
|
||||
| `POST /v1/logs` | 正文 `{"entries":[...]}`。配置了 token 时要求 `Authorization: Bearer` 或 `X-Log-Token` |
|
||||
| `GET /v1/logs` | `trace_id`、`level`(精确)、`service`、`q`、`cursor`、`limit` |
|
||||
| `GET /v1/logs/tail` | SSE。`cursor` 之后先回放再实时推送;慢消费者丢帧 |
|
||||
|
||||
写入时再脱敏一次,避免客户端绕过进程内红线。
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
go build -o bin/logctl .
|
||||
./bin/logctl serve --addr :8092 --data logs/collector.jsonl --token dev
|
||||
./bin/logctl query --addr http://127.0.0.1:8092 --trace trace-abc-1 --token dev
|
||||
./bin/logctl tail --addr http://127.0.0.1:8092 --token dev
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
go test -count=1 ./...
|
||||
```
|
||||
|
||||
## 依赖本仓库
|
||||
|
||||
```
|
||||
require logging-system v0.0.0
|
||||
|
||||
replace logging-system => ../Logging-System
|
||||
```
|
||||
|
||||
远程:`ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Logging-System.git`
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
// Package cli 实现 logctl:serve / tail / query。
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"logging-system/server"
|
||||
)
|
||||
|
||||
// Main 执行命令行并返回退出码。
|
||||
func Main(args []string) int { return Run(args, os.Stdout, os.Stderr) }
|
||||
|
||||
// Run 执行命令。
|
||||
func Run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "serve":
|
||||
return cmdServe(args[1:], stderr)
|
||||
case "query":
|
||||
return cmdQuery(args[1:], stdout, stderr)
|
||||
case "tail":
|
||||
return cmdTail(args[1:], stdout, stderr)
|
||||
case "-h", "--help", "help":
|
||||
fmt.Fprintln(stdout, usage)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintf(stderr, "未知命令 %q\n%s\n", args[0], usage)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
const usage = `用法:
|
||||
logctl serve [--addr :8092] [--data 文件] [--token 密钥]
|
||||
logctl query --addr URL [--trace ID] [--level 级别] [--service 名] [--q 关键词] [--token 密钥]
|
||||
logctl tail --addr URL [--trace ID] [--token 密钥]`
|
||||
|
||||
func cmdServe(args []string, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
addr := fs.String("addr", server.DefaultAddr, "监听地址")
|
||||
data := fs.String("data", "", "JSONL 落盘路径,空则只放内存")
|
||||
token := fs.String("token", "", "可选写入令牌")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
srv, err := server.New(*data, *token, 0)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
defer srv.Close()
|
||||
fmt.Fprintf(stderr, "logctl serve %s\n", *addr)
|
||||
if err := srv.ListenAndServe(*addr); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdQuery(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("query", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
addr := fs.String("addr", "http://127.0.0.1:8092", "采集器地址")
|
||||
traceID := fs.String("trace", "", "追踪标识")
|
||||
level := fs.String("level", "", "级别(精确匹配)")
|
||||
service := fs.String("service", "", "服务名")
|
||||
q := fs.String("q", "", "关键词")
|
||||
token := fs.String("token", "", "令牌")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
u, err := url.Parse(strings.TrimRight(*addr, "/") + "/v1/logs")
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
params := u.Query()
|
||||
set := func(k, v string) {
|
||||
if v != "" {
|
||||
params.Set(k, v)
|
||||
}
|
||||
}
|
||||
set("trace_id", *traceID)
|
||||
set("level", *level)
|
||||
set("service", *service)
|
||||
set("q", *q)
|
||||
u.RawQuery = params.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
if *token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+*token)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(stderr, "query %s: %s\n", res.Status, body)
|
||||
return 1
|
||||
}
|
||||
var pretty bytes.Buffer
|
||||
if err := json.Indent(&pretty, body, "", " "); err != nil {
|
||||
stdout.Write(body)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintln(stdout, pretty.String())
|
||||
return 0
|
||||
}
|
||||
|
||||
func cmdTail(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("tail", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
addr := fs.String("addr", "http://127.0.0.1:8092", "采集器地址")
|
||||
traceID := fs.String("trace", "", "只打印该追踪标识")
|
||||
token := fs.String("token", "", "令牌")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
u := strings.TrimRight(*addr, "/") + "/v1/logs/tail"
|
||||
req, err := http.NewRequest(http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
if *token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+*token)
|
||||
}
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
fmt.Fprintf(stderr, "tail %s: %s\n", res.Status, body)
|
||||
return 1
|
||||
}
|
||||
sc := bufio.NewScanner(res.Body)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
payload := strings.TrimPrefix(line, "data: ")
|
||||
if *traceID != "" && !strings.Contains(payload, *traceID) {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(stdout, payload)
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logging-system/server"
|
||||
)
|
||||
|
||||
func TestQueryCommand(t *testing.T) {
|
||||
srv, err := server.New("", "", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
res, err := http.Post(ts.URL+"/v1/logs", "application/json", strings.NewReader(`{"entries":[{"level":"info","msg":"hello-cli","service":"demo"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
var out, errBuf bytes.Buffer
|
||||
code := Run([]string{"query", "--addr", ts.URL, "--service", "demo"}, &out, &errBuf)
|
||||
if code != 0 {
|
||||
t.Fatal(errBuf.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "hello-cli") {
|
||||
t.Fatalf("query output: %s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// Package config 加载日志配置:默认值,再被 JSON 文件覆盖,再被环境变量覆盖。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/redact"
|
||||
"logging-system/sink"
|
||||
)
|
||||
|
||||
// Config 是日志系统的全部可配置项。
|
||||
type Config struct {
|
||||
Service string `json:"service"`
|
||||
Level string `json:"level"`
|
||||
Modules map[string]string `json:"modules"`
|
||||
Sinks []SinkConfig `json:"sinks"`
|
||||
Redaction RedactionConfig `json:"redaction"`
|
||||
Trace TraceConfig `json:"trace"`
|
||||
}
|
||||
|
||||
// SinkConfig 描述一个出口。Async 为空时:http 默认异步,其余默认同步。
|
||||
type SinkConfig struct {
|
||||
Type string `json:"type"`
|
||||
Format string `json:"format"`
|
||||
Level string `json:"level"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
Async *bool `json:"async"`
|
||||
Queue int `json:"queue"`
|
||||
Rotation *RotationConfig `json:"rotation"`
|
||||
Batch *BatchConfig `json:"batch"`
|
||||
}
|
||||
|
||||
// RotationConfig 控制文件滚动。
|
||||
type RotationConfig struct {
|
||||
MaxBytes int64 `json:"max_bytes"`
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
|
||||
// BatchConfig 控制 HTTP 批量发送。
|
||||
type BatchConfig struct {
|
||||
MaxEntries int `json:"max_entries"`
|
||||
FlushIntervalMs int `json:"flush_interval_ms"`
|
||||
}
|
||||
|
||||
// RedactionConfig 控制写前脱敏。
|
||||
type RedactionConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ExtraPatterns []string `json:"extra_patterns"`
|
||||
}
|
||||
|
||||
// TraceConfig 控制追踪标识。生成动作由调用方的 HTTP 中间件执行。
|
||||
type TraceConfig struct {
|
||||
Generate bool `json:"generate"`
|
||||
Header string `json:"header"`
|
||||
}
|
||||
|
||||
// Defaults 返回可用的默认配置:info 级别、同步 stderr、开启脱敏。
|
||||
func Defaults() Config {
|
||||
return Config{
|
||||
Level: "info",
|
||||
Redaction: RedactionConfig{Enabled: true},
|
||||
Trace: TraceConfig{Generate: true, Header: "X-Trace-Id"},
|
||||
Sinks: []SinkConfig{{
|
||||
Type: "console",
|
||||
Format: "human",
|
||||
Level: "info",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 读取 JSON 文件并应用环境变量。path 为空时只使用默认值与环境变量。
|
||||
// 若 path 为空且设置了 LOGSYS_CONFIG,则读取该路径。
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Defaults()
|
||||
if path == "" {
|
||||
path = os.Getenv("LOGSYS_CONFIG")
|
||||
}
|
||||
if path != "" {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if cfg.Level == "" {
|
||||
cfg.Level = "info"
|
||||
}
|
||||
if cfg.Trace.Header == "" {
|
||||
cfg.Trace.Header = "X-Trace-Id"
|
||||
}
|
||||
}
|
||||
applyEnv(&cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyEnv(cfg *Config) {
|
||||
if v := strings.TrimSpace(os.Getenv("LOGSYS_LEVEL")); v != "" {
|
||||
cfg.Level = v
|
||||
}
|
||||
if v := strings.TrimSpace(os.Getenv("LOGSYS_SERVICE")); v != "" {
|
||||
cfg.Service = v
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("LOGSYS_REDACT"))) {
|
||||
case "1", "true", "yes", "on":
|
||||
cfg.Redaction.Enabled = true
|
||||
case "0", "false", "no", "off":
|
||||
cfg.Redaction.Enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
// Build 按配置组装日志器。未知出口类型或非法级别会失败。
|
||||
func Build(cfg Config) (*logsys.Logger, error) {
|
||||
level, err := logsys.ParseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modules := make(map[string]logsys.Level, len(cfg.Modules))
|
||||
for name, raw := range cfg.Modules {
|
||||
lv, err := logsys.ParseLevel(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: module %s: %w", name, err)
|
||||
}
|
||||
modules[name] = lv
|
||||
}
|
||||
var redactFn func(string) string
|
||||
if cfg.Redaction.Enabled {
|
||||
rd, err := redact.New(cfg.Redaction.ExtraPatterns)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
redactFn = rd.Apply
|
||||
}
|
||||
sinks := make([]logsys.Sink, 0, len(cfg.Sinks))
|
||||
for i, sc := range cfg.Sinks {
|
||||
built, err := buildSink(sc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: sinks[%d]: %w", i, err)
|
||||
}
|
||||
sinks = append(sinks, built)
|
||||
}
|
||||
return logsys.New(logsys.Options{
|
||||
Service: cfg.Service,
|
||||
Level: level,
|
||||
ModuleLevels: modules,
|
||||
Sinks: sinks,
|
||||
Redact: redactFn,
|
||||
}), nil
|
||||
}
|
||||
|
||||
func buildSink(sc SinkConfig) (logsys.Sink, error) {
|
||||
async := false
|
||||
if sc.Type == "http" && sc.Async == nil {
|
||||
async = true
|
||||
}
|
||||
if sc.Async != nil {
|
||||
async = *sc.Async
|
||||
}
|
||||
var inner logsys.Sink
|
||||
switch sc.Type {
|
||||
case "console", "":
|
||||
format := sc.Format
|
||||
if format == "" {
|
||||
format = "human"
|
||||
}
|
||||
inner = &sink.Console{Format: format}
|
||||
case "file":
|
||||
format := sc.Format
|
||||
if format == "" {
|
||||
format = "json"
|
||||
}
|
||||
f := &sink.File{Path: sc.Path, Format: format}
|
||||
if sc.Rotation != nil {
|
||||
f.MaxBytes = sc.Rotation.MaxBytes
|
||||
f.Keep = sc.Rotation.Keep
|
||||
}
|
||||
inner = f
|
||||
case "http":
|
||||
h := &sink.HTTPBatch{URL: sc.URL, Token: sc.Token, Async: async}
|
||||
if sc.Batch != nil {
|
||||
h.MaxEntries = sc.Batch.MaxEntries
|
||||
if sc.Batch.FlushIntervalMs > 0 {
|
||||
h.FlushInterval = ms(sc.Batch.FlushIntervalMs)
|
||||
}
|
||||
}
|
||||
if !async {
|
||||
h.Async = false
|
||||
}
|
||||
inner = h
|
||||
async = false // 批量器自己有后台循环,不再套一层队列
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type %q", sc.Type)
|
||||
}
|
||||
if sc.Level != "" {
|
||||
lv, err := logsys.ParseLevel(sc.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inner = sink.MinLevel{Min: lv, Next: inner}
|
||||
}
|
||||
if async {
|
||||
wrapped := &sink.Async{Inner: inner, Queue: sc.Queue}
|
||||
wrapped.Start()
|
||||
inner = wrapped
|
||||
}
|
||||
return inner, nil
|
||||
}
|
||||
|
||||
func ms(v int) time.Duration { return time.Duration(v) * time.Millisecond }
|
||||
@@ -0,0 +1,82 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/sink"
|
||||
)
|
||||
|
||||
func TestLoadEnvOverridesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "log.json")
|
||||
raw := []byte(`{
|
||||
"service": "from-file",
|
||||
"level": "debug",
|
||||
"modules": {"engine": "trace"},
|
||||
"redaction": {"enabled": true},
|
||||
"sinks": [{"type": "console", "format": "json", "level": "warn"}]
|
||||
}`)
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("LOGSYS_LEVEL", "error")
|
||||
t.Setenv("LOGSYS_SERVICE", "from-env")
|
||||
t.Setenv("LOGSYS_REDACT", "off")
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Level != "error" || cfg.Service != "from-env" || cfg.Redaction.Enabled {
|
||||
t.Fatalf("%+v", cfg)
|
||||
}
|
||||
if cfg.Modules["engine"] != "trace" || cfg.Trace.Header != "X-Trace-Id" {
|
||||
t.Fatalf("%+v", cfg)
|
||||
}
|
||||
log, err := Build(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mem := &sink.Memory{}
|
||||
log2 := logsys.New(logsys.Options{Service: cfg.Service, Level: logsys.LevelError, Sinks: []logsys.Sink{mem}})
|
||||
log2.Info("skip")
|
||||
log2.Error("keep", nil)
|
||||
if len(mem.Snapshot()) != 1 {
|
||||
t.Fatal(mem.Snapshot())
|
||||
}
|
||||
if err := log.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFileSink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := Defaults()
|
||||
cfg.Sinks = []SinkConfig{{
|
||||
Type: "file",
|
||||
Path: filepath.Join(dir, "app.jsonl"),
|
||||
Format: "json",
|
||||
Rotation: &RotationConfig{MaxBytes: 1024, Keep: 2},
|
||||
}}
|
||||
log, err := Build(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
log.Info("hello", "token", "sk-abcdefghijklmnopqrstuvwxyz")
|
||||
if err := log.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := log.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "app.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) == "" || strings.Contains(string(data), "sk-abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Fatalf("redaction failed: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package conformance
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/redact"
|
||||
"logging-system/sink"
|
||||
)
|
||||
|
||||
func TestSyncConcurrentNoLoss(t *testing.T) {
|
||||
mem := &sink.Memory{}
|
||||
log := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{mem}})
|
||||
const goroutines = 100
|
||||
const per = 1000
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(goroutines)
|
||||
for g := 0; g < goroutines; g++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < per; i++ {
|
||||
log.Info("sync")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if err := log.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(mem.Snapshot()); got != goroutines*per {
|
||||
t.Fatalf("got %d want %d", got, goroutines*per)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAsyncCloseDrains(t *testing.T) {
|
||||
mem := &sink.Memory{}
|
||||
async := &sink.Async{Inner: mem, Queue: 4096}
|
||||
log := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{async}})
|
||||
const n = 500
|
||||
for i := 0; i < n; i++ {
|
||||
log.Info("async")
|
||||
}
|
||||
if err := log.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(mem.Snapshot()); got != n {
|
||||
t.Fatalf("drained %d want %d dropped %d", got, n, async.Dropped())
|
||||
}
|
||||
if async.Dropped() != 0 {
|
||||
t.Fatalf("dropped %d", async.Dropped())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactDoesNotLeak(t *testing.T) {
|
||||
rd, err := redact.New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mem := &sink.Memory{}
|
||||
log := logsys.New(logsys.Options{
|
||||
Level: logsys.LevelInfo,
|
||||
Sinks: []logsys.Sink{mem},
|
||||
Redact: rd.Apply,
|
||||
})
|
||||
log.Error("call", nil, "header", "Bearer abcdefghijklmnop", "key", "sk-abcdefghijklmnopqrstuvwxyz")
|
||||
raw := mem.Snapshot()[0]
|
||||
blob := raw.Message + raw.Fields["header"].(string) + raw.Fields["key"].(string)
|
||||
for _, secret := range []string{"abcdefghijklmnop", "sk-abcdefghijklmnopqrstuvwxyz"} {
|
||||
if stringsContains(blob, secret) {
|
||||
t.Fatalf("leaked %s in %+v", secret, raw.Fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutConsistent(t *testing.T) {
|
||||
a, b := &sink.Memory{}, &sink.Memory{}
|
||||
log := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{sink.Fanout{Sinks: []logsys.Sink{a, b}}}})
|
||||
var seq atomic.Int64
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(20)
|
||||
for i := 0; i < 20; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 50; j++ {
|
||||
log.Info("fan", "n", seq.Add(1))
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if len(a.Snapshot()) != 1000 || len(b.Snapshot()) != 1000 {
|
||||
t.Fatalf("a=%d b=%d", len(a.Snapshot()), len(b.Snapshot()))
|
||||
}
|
||||
_ = time.Second
|
||||
}
|
||||
|
||||
func stringsContains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(sub) > 0 && index(s, sub))
|
||||
}
|
||||
|
||||
func index(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"service": "agent-squad",
|
||||
"level": "info",
|
||||
"modules": { "engine": "debug", "server": "warn" },
|
||||
"sinks": [
|
||||
{ "type": "console", "format": "human", "level": "info" },
|
||||
{
|
||||
"type": "file",
|
||||
"path": "logs/app.jsonl",
|
||||
"format": "json",
|
||||
"rotation": { "max_bytes": 104857600, "keep": 10 }
|
||||
},
|
||||
{
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:8092/v1/logs",
|
||||
"async": true,
|
||||
"batch": { "max_entries": 200, "flush_interval_ms": 2000 }
|
||||
}
|
||||
],
|
||||
"redaction": { "enabled": true, "extra_patterns": [] },
|
||||
"trace": { "generate": true, "header": "X-Trace-Id" }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package logsys
|
||||
|
||||
import "context"
|
||||
|
||||
type traceKey struct{}
|
||||
|
||||
// WithTrace 把追踪标识放进上下文。空标识不写入。
|
||||
func WithTrace(ctx context.Context, id string) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if id == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, traceKey{}, id)
|
||||
}
|
||||
|
||||
// TraceFrom 读取追踪标识。不存在时返回空字符串。
|
||||
func TraceFrom(ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return ""
|
||||
}
|
||||
id, _ := ctx.Value(traceKey{}).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// WithContext 返回带有上下文追踪标识的子 logger。上下文没有标识时原样返回副本。
|
||||
func (l *Logger) WithContext(ctx context.Context) *Logger {
|
||||
if l == nil {
|
||||
return Nop()
|
||||
}
|
||||
id := TraceFrom(ctx)
|
||||
if id == "" {
|
||||
return l.clone()
|
||||
}
|
||||
n := l.clone()
|
||||
n.traceID = id
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package logsys
|
||||
|
||||
import "time"
|
||||
|
||||
// Entry 是一条已结构化的日志。JSON 字段名跨进程稳定。
|
||||
type Entry struct {
|
||||
Time time.Time `json:"ts"`
|
||||
Level string `json:"level"`
|
||||
Service string `json:"service,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Message string `json:"msg"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// Sink 是日志出口。实现必须允许多个 goroutine 同时 Write。
|
||||
// Sync 把缓冲刷出;Close 之后不得再 Write。
|
||||
type Sink interface {
|
||||
Write(e Entry) error
|
||||
Sync() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// codedError 是错误码模块的鸭子接口。实现这两个方法即可被展开,无需互相引用。
|
||||
type codedError interface {
|
||||
error
|
||||
ErrorCode() string
|
||||
ErrorFields() map[string]any
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package logsys
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FormatJSON 把条目编码为一行 JSON(含换行)。
|
||||
func FormatJSON(e Entry) ([]byte, error) {
|
||||
buf := &bytes.Buffer{}
|
||||
enc := json.NewEncoder(buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
if err := enc.Encode(e); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// FormatHuman 把条目格式化成单行文本(含换行)。
|
||||
func FormatHuman(e Entry) string {
|
||||
ts := e.Time.UTC().Format(time.RFC3339Nano)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s %s", ts, strings.ToUpper(e.Level))
|
||||
if e.Service != "" || e.Module != "" {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(e.Service)
|
||||
if e.Module != "" {
|
||||
if e.Service != "" {
|
||||
b.WriteByte('/')
|
||||
}
|
||||
b.WriteString(e.Module)
|
||||
}
|
||||
}
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(e.Message)
|
||||
if e.Code != "" {
|
||||
fmt.Fprintf(&b, " code=%s", e.Code)
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
fmt.Fprintf(&b, " trace=%s", e.TraceID)
|
||||
}
|
||||
if e.Error != "" {
|
||||
fmt.Fprintf(&b, " error=%q", e.Error)
|
||||
}
|
||||
if len(e.Fields) > 0 {
|
||||
keys := make([]string, 0, len(e.Fields))
|
||||
for k := range e.Fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(&b, " %s=%v", k, e.Fields[k])
|
||||
}
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package logsys 是进程内结构化日志核心。
|
||||
//
|
||||
// 它不绑定具体出口:实现 Sink 即可接到控制台、文件或远端采集器。
|
||||
// 错误码通过鸭子接口识别(ErrorCode / ErrorFields),本包不导入错误码模块。
|
||||
package logsys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Level 是日志级别。数值越大越严重。
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelTrace Level = iota
|
||||
LevelDebug
|
||||
LevelInfo
|
||||
LevelWarn
|
||||
LevelError
|
||||
LevelFatal
|
||||
)
|
||||
|
||||
// ParseLevel 解析级别文本。
|
||||
func ParseLevel(s string) (Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "trace":
|
||||
return LevelTrace, nil
|
||||
case "debug":
|
||||
return LevelDebug, nil
|
||||
case "info", "":
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return 0, fmt.Errorf("logsys: empty level")
|
||||
}
|
||||
return LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return LevelWarn, nil
|
||||
case "error":
|
||||
return LevelError, nil
|
||||
case "fatal":
|
||||
return LevelFatal, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("logsys: unknown level %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// String 返回级别名。
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelTrace:
|
||||
return "trace"
|
||||
case LevelDebug:
|
||||
return "debug"
|
||||
case LevelInfo:
|
||||
return "info"
|
||||
case LevelWarn:
|
||||
return "warn"
|
||||
case LevelError:
|
||||
return "error"
|
||||
case LevelFatal:
|
||||
return "fatal"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package logsys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Options 构造一个 Logger。构造完成后不要修改切片和映射。
|
||||
type Options struct {
|
||||
Service string
|
||||
Module string
|
||||
Level Level
|
||||
ModuleLevels map[string]Level
|
||||
Sinks []Sink
|
||||
Redact func(string) string
|
||||
Fields map[string]any
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// Logger 是并发安全的日志器。With / Module 返回副本,不改变原对象。
|
||||
type Logger struct {
|
||||
service string
|
||||
module string
|
||||
min Level
|
||||
moduleLevels map[string]Level
|
||||
fields map[string]any
|
||||
traceID string
|
||||
sinks []Sink
|
||||
redact func(string) string
|
||||
now func() time.Time
|
||||
nop bool
|
||||
}
|
||||
|
||||
// New 创建日志器。没有 sink 时写入被丢弃(与 Nop 相同,但保留级别与字段)。
|
||||
func New(opt Options) *Logger {
|
||||
now := opt.Now
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Now().UTC() }
|
||||
}
|
||||
levels := make(map[string]Level, len(opt.ModuleLevels))
|
||||
for k, v := range opt.ModuleLevels {
|
||||
levels[k] = v
|
||||
}
|
||||
return &Logger{
|
||||
service: opt.Service,
|
||||
module: opt.Module,
|
||||
min: opt.Level,
|
||||
moduleLevels: levels,
|
||||
fields: cloneFields(opt.Fields),
|
||||
sinks: append([]Sink(nil), opt.Sinks...),
|
||||
redact: opt.Redact,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
// Nop 丢弃全部日志。
|
||||
func Nop() *Logger { return &Logger{nop: true, now: time.Now} }
|
||||
|
||||
// Module 返回绑定模块名的子 logger,用于匹配模块级别。
|
||||
func (l *Logger) Module(name string) *Logger {
|
||||
if l == nil {
|
||||
return Nop()
|
||||
}
|
||||
n := l.clone()
|
||||
n.module = name
|
||||
return n
|
||||
}
|
||||
|
||||
// With 追加字段。键必须是非空字符串,且与值成对;否则 panic。
|
||||
func (l *Logger) With(kv ...any) *Logger {
|
||||
if l == nil {
|
||||
return Nop()
|
||||
}
|
||||
n := l.clone()
|
||||
n.fields = mergeFields(n.fields, fieldsFrom(kv))
|
||||
return n
|
||||
}
|
||||
|
||||
// WithTraceID 固定本 logger 的追踪标识。
|
||||
func (l *Logger) WithTraceID(id string) *Logger {
|
||||
if l == nil {
|
||||
return Nop()
|
||||
}
|
||||
n := l.clone()
|
||||
n.traceID = id
|
||||
return n
|
||||
}
|
||||
|
||||
// Enabled 报告该级别是否会写出去。
|
||||
func (l *Logger) Enabled(level Level) bool {
|
||||
if l == nil || l.nop {
|
||||
return false
|
||||
}
|
||||
return l.enabled(level)
|
||||
}
|
||||
|
||||
// Trace 记录 trace 级别日志。
|
||||
func (l *Logger) Trace(msg string, kv ...any) { l.log(LevelTrace, msg, nil, kv...) }
|
||||
|
||||
// Debug 记录 debug 级别日志。
|
||||
func (l *Logger) Debug(msg string, kv ...any) { l.log(LevelDebug, msg, nil, kv...) }
|
||||
|
||||
// Info 记录 info 级别日志。
|
||||
func (l *Logger) Info(msg string, kv ...any) { l.log(LevelInfo, msg, nil, kv...) }
|
||||
|
||||
// Warn 记录 warn 级别日志。
|
||||
func (l *Logger) Warn(msg string, kv ...any) { l.log(LevelWarn, msg, nil, kv...) }
|
||||
|
||||
// Error 记录 error 级别日志。err 可为 nil;若实现 ErrorCode/ErrorFields,会展开进条目。
|
||||
func (l *Logger) Error(msg string, err error, kv ...any) { l.log(LevelError, msg, err, kv...) }
|
||||
|
||||
// Fatal 记录 fatal 级别日志,不退出进程。
|
||||
func (l *Logger) Fatal(msg string, err error, kv ...any) { l.log(LevelFatal, msg, err, kv...) }
|
||||
|
||||
// Sync 刷出全部 sink。
|
||||
func (l *Logger) Sync() error {
|
||||
if l == nil || l.nop {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for _, s := range l.sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Sync(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return joinErrs(errs)
|
||||
}
|
||||
|
||||
// Close 关闭全部 sink。
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil || l.nop {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
for _, s := range l.sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return joinErrs(errs)
|
||||
}
|
||||
|
||||
func (l *Logger) log(level Level, msg string, err error, kv ...any) {
|
||||
extra := fieldsFrom(kv)
|
||||
if l == nil || l.nop || !l.enabled(level) {
|
||||
return
|
||||
}
|
||||
fields := mergeFields(l.fields, extra)
|
||||
entry := Entry{
|
||||
Time: l.now(),
|
||||
Level: level.String(),
|
||||
Service: l.service,
|
||||
Module: l.module,
|
||||
Message: msg,
|
||||
TraceID: l.traceID,
|
||||
Fields: fields,
|
||||
}
|
||||
applyError(&entry, err)
|
||||
if l.redact != nil {
|
||||
entry = redactEntry(entry, l.redact)
|
||||
}
|
||||
for _, s := range l.sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
_ = s.Write(entry)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) enabled(level Level) bool {
|
||||
min := l.min
|
||||
if l.module != "" {
|
||||
if ml, ok := l.moduleLevels[l.module]; ok {
|
||||
min = ml
|
||||
}
|
||||
}
|
||||
return level >= min
|
||||
}
|
||||
|
||||
func (l *Logger) clone() *Logger {
|
||||
n := *l
|
||||
n.fields = cloneFields(l.fields)
|
||||
n.moduleLevels = l.moduleLevels
|
||||
n.sinks = l.sinks
|
||||
return &n
|
||||
}
|
||||
|
||||
func applyError(entry *Entry, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
entry.Error = err.Error()
|
||||
if coded, ok := err.(codedError); ok {
|
||||
entry.Code = coded.ErrorCode()
|
||||
for k, v := range coded.ErrorFields() {
|
||||
if entry.Fields == nil {
|
||||
entry.Fields = map[string]any{}
|
||||
}
|
||||
entry.Fields[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func redactEntry(entry Entry, redact func(string) string) Entry {
|
||||
entry.Message = redact(entry.Message)
|
||||
entry.Error = redact(entry.Error)
|
||||
entry.Fields = redactFields(entry.Fields, redact)
|
||||
return entry
|
||||
}
|
||||
|
||||
func redactFields(in map[string]any, redact func(string) string) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[redact(k)] = redactValue(v, redact)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func redactValue(v any, redact func(string) string) any {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return redact(t)
|
||||
case error:
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return redact(t.Error())
|
||||
case map[string]any:
|
||||
return redactFields(t, redact)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func fieldsFrom(kv []any) map[string]any {
|
||||
if len(kv) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(kv)%2 != 0 {
|
||||
panic("logsys: odd number of key-value arguments")
|
||||
}
|
||||
out := make(map[string]any, len(kv)/2)
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
key, ok := kv[i].(string)
|
||||
if !ok || key == "" {
|
||||
panic(fmt.Sprintf("logsys: field key must be a non-empty string, got %T", kv[i]))
|
||||
}
|
||||
out[key] = kv[i+1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneFields(in map[string]any) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeFields(base, extra map[string]any) map[string]any {
|
||||
if len(base) == 0 && len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := cloneFields(base)
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinErrs(errs []error) error {
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return errs[0]
|
||||
}
|
||||
return &multiErr{errs: errs}
|
||||
}
|
||||
|
||||
type multiErr struct{ errs []error }
|
||||
|
||||
func (m *multiErr) Error() string {
|
||||
var b bytesTrim
|
||||
for i, err := range m.errs {
|
||||
if i > 0 {
|
||||
b.WriteString("; ")
|
||||
}
|
||||
b.WriteString(err.Error())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *multiErr) Unwrap() []error { return m.errs }
|
||||
|
||||
// bytesTrim 避免为拼接错误再引入 bytes 与 strings 的混用。
|
||||
type bytesTrim struct{ b []byte }
|
||||
|
||||
func (b *bytesTrim) WriteString(s string) { b.b = append(b.b, s...) }
|
||||
func (b *bytesTrim) String() string { return string(b.b) }
|
||||
|
||||
var (
|
||||
defaultMu sync.RWMutex
|
||||
defaultL = Nop()
|
||||
)
|
||||
|
||||
// SetDefault 设置进程默认日志器。nil 等价于 Nop。
|
||||
func SetDefault(l *Logger) {
|
||||
if l == nil {
|
||||
l = Nop()
|
||||
}
|
||||
defaultMu.Lock()
|
||||
defaultL = l
|
||||
defaultMu.Unlock()
|
||||
}
|
||||
|
||||
// Default 返回进程默认日志器。未设置时为 Nop。
|
||||
func Default() *Logger {
|
||||
defaultMu.RLock()
|
||||
defer defaultMu.RUnlock()
|
||||
return defaultL
|
||||
}
|
||||
|
||||
// Sync 刷出默认日志器。
|
||||
func Sync() error { return Default().Sync() }
|
||||
@@ -0,0 +1,134 @@
|
||||
package logsys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type memSink struct {
|
||||
mu sync.Mutex
|
||||
entries []Entry
|
||||
}
|
||||
|
||||
func (m *memSink) Write(e Entry) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.entries = append(m.entries, e)
|
||||
return nil
|
||||
}
|
||||
func (m *memSink) Sync() error { return nil }
|
||||
func (m *memSink) Close() error { return nil }
|
||||
|
||||
func (m *memSink) list() []Entry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]Entry, len(m.entries))
|
||||
copy(out, m.entries)
|
||||
return out
|
||||
}
|
||||
|
||||
type stubErr struct{}
|
||||
|
||||
func (stubErr) Error() string { return "dial failed" }
|
||||
func (stubErr) ErrorCode() string { return "agent.provider_unavailable" }
|
||||
func (stubErr) ErrorFields() map[string]any {
|
||||
return map[string]any{"model": "glm-4", "reason": "模型不可达"}
|
||||
}
|
||||
|
||||
func TestLevelFilterModuleAndTrace(t *testing.T) {
|
||||
mem := &memSink{}
|
||||
fixed := time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC)
|
||||
log := New(Options{
|
||||
Service: "agent-squad",
|
||||
Level: LevelInfo,
|
||||
ModuleLevels: map[string]Level{
|
||||
"engine": LevelDebug,
|
||||
"server": LevelWarn,
|
||||
},
|
||||
Sinks: []Sink{mem},
|
||||
Now: func() time.Time { return fixed },
|
||||
})
|
||||
log.Debug("hidden")
|
||||
log.Info("visible")
|
||||
log.Module("engine").Debug("engine-debug")
|
||||
log.Module("server").Info("server-info")
|
||||
log.Module("server").Warn("server-warn")
|
||||
ctx := WithTrace(context.Background(), "trace-1")
|
||||
log.WithContext(ctx).With("step", "plan").Info("traced")
|
||||
|
||||
got := mem.list()
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len %d %+v", len(got), got)
|
||||
}
|
||||
if got[0].Message != "visible" || got[0].Time != fixed || got[0].Service != "agent-squad" {
|
||||
t.Fatalf("first %+v", got[0])
|
||||
}
|
||||
if got[1].Module != "engine" || got[1].Message != "engine-debug" {
|
||||
t.Fatalf("engine %+v", got[1])
|
||||
}
|
||||
if got[2].Message != "server-warn" {
|
||||
t.Fatalf("server %+v", got[2])
|
||||
}
|
||||
if got[3].TraceID != "trace-1" || got[3].Fields["step"] != "plan" {
|
||||
t.Fatalf("trace %+v", got[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorDuckTypingAndRedact(t *testing.T) {
|
||||
mem := &memSink{}
|
||||
log := New(Options{
|
||||
Level: LevelInfo,
|
||||
Sinks: []Sink{mem},
|
||||
Redact: func(s string) string { return strings.ReplaceAll(s, "glm-4", "***") },
|
||||
})
|
||||
log.Error("provider down", stubErr{}, "attempt", 2)
|
||||
got := mem.list()
|
||||
if len(got) != 1 {
|
||||
t.Fatal(len(got))
|
||||
}
|
||||
e := got[0]
|
||||
if e.Code != "agent.provider_unavailable" || e.Level != "error" {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
if e.Fields["model"] != "***" || e.Fields["reason"] != "模型不可达" || e.Fields["attempt"] != 2 {
|
||||
t.Fatalf("fields %+v", e.Fields)
|
||||
}
|
||||
if !strings.Contains(e.Error, "dial failed") {
|
||||
t.Fatal(e.Error)
|
||||
}
|
||||
plain := errors.New("boom")
|
||||
log.Error("plain", plain)
|
||||
if mem.list()[1].Code != "" || mem.list()[1].Error != "boom" {
|
||||
t.Fatalf("%+v", mem.list()[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultLogger(t *testing.T) {
|
||||
prev := Default()
|
||||
t.Cleanup(func() { SetDefault(prev) })
|
||||
mem := &memSink{}
|
||||
SetDefault(New(Options{Level: LevelInfo, Sinks: []Sink{mem}}))
|
||||
Default().Info("from-default")
|
||||
if err := Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mem.list()) != 1 {
|
||||
t.Fatal(mem.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanAndJSON(t *testing.T) {
|
||||
e := Entry{Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Level: "info", Service: "svc", Message: "hello", Fields: map[string]any{"b": 1, "a": "x"}}
|
||||
line := FormatHuman(e)
|
||||
if !strings.Contains(line, "INFO svc hello") || !strings.Contains(line, "a=x") {
|
||||
t.Fatal(line)
|
||||
}
|
||||
raw, err := FormatJSON(e)
|
||||
if err != nil || !strings.Contains(string(raw), `"msg":"hello"`) {
|
||||
t.Fatalf("%s %v", raw, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"logging-system/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(cli.Main(os.Args[1:]))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package redact 在日志落盘前去掉常见秘密形状。
|
||||
// 规则来自 agent-squad 的事件脱敏,并允许追加自定义正则。
|
||||
package redact
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Redactor 替换秘密子串。宁可多脱,不漏已识别的形状。
|
||||
type Redactor struct {
|
||||
extra []*regexp.Regexp
|
||||
}
|
||||
|
||||
var (
|
||||
reJWT = regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)
|
||||
reSKKey = regexp.MustCompile(`(?:sk|rk|pk)-[A-Za-z0-9_\-]{12,}`)
|
||||
reBearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._\-]{12,}`)
|
||||
reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key["':=\s]+)[A-Za-z0-9_\-\.]{12,}`)
|
||||
rePass = regexp.MustCompile(`(?i)(password["':=\s]+)[^\s"',}]+`)
|
||||
)
|
||||
|
||||
// New 编译额外正则。任一非法则失败,不返回半成品。
|
||||
func New(extra []string) (*Redactor, error) {
|
||||
compiled := make([]*regexp.Regexp, 0, len(extra))
|
||||
for _, pattern := range extra {
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("redact: pattern %q: %w", pattern, err)
|
||||
}
|
||||
compiled = append(compiled, re)
|
||||
}
|
||||
return &Redactor{extra: compiled}, nil
|
||||
}
|
||||
|
||||
// Apply 脱敏一段文本。
|
||||
func (r *Redactor) Apply(s string) string {
|
||||
if r == nil || s == "" {
|
||||
return s
|
||||
}
|
||||
s = reJWT.ReplaceAllString(s, "***")
|
||||
s = reSKKey.ReplaceAllString(s, "***")
|
||||
s = reBearer.ReplaceAllString(s, "Bearer ***")
|
||||
s = reAPIKey.ReplaceAllString(s, "${1}***")
|
||||
if strings.Contains(strings.ToLower(s), "password") {
|
||||
s = rePass.ReplaceAllString(s, "${1}***")
|
||||
}
|
||||
for _, re := range r.extra {
|
||||
s = re.ReplaceAllString(s, "***")
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package redact
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuiltinShapes(t *testing.T) {
|
||||
r, err := New(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := "token=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.signatureX sk-abcdefghijklmnopqrstuvwxyz Bearer abcdefghijklmnop api_key=abcdefghijklmnop password=hunter2"
|
||||
out := r.Apply(in)
|
||||
for _, secret := range []string{"eyJhbGciOiJIUzI1NiJ9", "sk-abcdefghijklmnopqrstuvwxyz", "abcdefghijklmnop", "hunter2"} {
|
||||
if strings.Contains(out, secret) {
|
||||
t.Fatalf("leaked %s in %s", secret, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "Bearer ***") || !strings.Contains(out, "password=***") {
|
||||
t.Fatalf("markers missing: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtraPattern(t *testing.T) {
|
||||
r, err := New([]string{`session-[0-9]+`})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.Apply("session-42"); got != "***" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if _, err := New([]string{"("}); err == nil {
|
||||
t.Fatal("expected bad pattern")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
// Package server 是日志采集服务:批量写入、条件查询、SSE 尾随。默认监听 :8092。
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/redact"
|
||||
)
|
||||
|
||||
// DefaultAddr 是 logcollector 的默认监听地址。
|
||||
const DefaultAddr = ":8092"
|
||||
|
||||
// TraceHeader 与错误码系统使用同一个追踪头。
|
||||
const TraceHeader = "X-Trace-Id"
|
||||
|
||||
// Server 提供日志采集 HTTP API。
|
||||
type Server struct {
|
||||
Store *Store
|
||||
Token string
|
||||
}
|
||||
|
||||
// New 创建服务。redact 为 nil 时仍做内置脱敏。
|
||||
func New(path, token string, limit int) (*Server, error) {
|
||||
rd, err := redact.New(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := OpenStore(path, limit, rd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Server{Store: st, Token: token}, nil
|
||||
}
|
||||
|
||||
// Handler 返回路由。
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||
mux.HandleFunc("POST /v1/logs", s.handleIngest)
|
||||
mux.HandleFunc("GET /v1/logs", s.handleQuery)
|
||||
mux.HandleFunc("GET /v1/logs/tail", s.handleTail)
|
||||
return mux
|
||||
}
|
||||
|
||||
// ListenAndServe 在 addr 上服务。
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
if strings.TrimSpace(addr) == "" {
|
||||
addr = DefaultAddr
|
||||
}
|
||||
return http.ListenAndServe(addr, s.Handler())
|
||||
}
|
||||
|
||||
// Close 关闭存储。
|
||||
func (s *Server) Close() error {
|
||||
if s.Store == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Store.Close()
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 8<<20))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "body too large or unreadable"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Entries []logsys.Entry `json:"entries"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
traceID := sanitizeTrace(r.Header.Get(TraceHeader))
|
||||
for i := range req.Entries {
|
||||
if req.Entries[i].TraceID == "" && traceID != "" {
|
||||
req.Entries[i].TraceID = traceID
|
||||
}
|
||||
}
|
||||
recs := s.Store.Append(req.Entries)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"accepted": len(recs)})
|
||||
}
|
||||
|
||||
func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
cursor, _ := strconv.ParseInt(q.Get("cursor"), 10, 64)
|
||||
limit, _ := strconv.Atoi(q.Get("limit"))
|
||||
recs, next := s.Store.Query(Query{
|
||||
TraceID: q.Get("trace_id"),
|
||||
Level: q.Get("level"),
|
||||
Service: q.Get("service"),
|
||||
Text: q.Get("q"),
|
||||
Cursor: cursor,
|
||||
Limit: limit,
|
||||
})
|
||||
if recs == nil {
|
||||
recs = []Record{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": recs, "next_cursor": next})
|
||||
}
|
||||
|
||||
func (s *Server) handleTail(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.authorized(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "streaming unsupported"})
|
||||
return
|
||||
}
|
||||
cursor, _ := strconv.ParseInt(r.URL.Query().Get("cursor"), 10, 64)
|
||||
ch, cancel := s.Store.Subscribe(cursor)
|
||||
defer cancel()
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case rec, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authorized(r *http.Request) bool {
|
||||
if s.Token == "" {
|
||||
return true
|
||||
}
|
||||
if r.Header.Get("X-Log-Token") == s.Token {
|
||||
return true
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
return auth == "Bearer "+s.Token
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
_ = enc.Encode(v)
|
||||
}
|
||||
|
||||
func sanitizeTrace(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 8 || len(s) > 128 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.', r == ':':
|
||||
continue
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIngestQueryTail(t *testing.T) {
|
||||
srv, err := New(t.TempDir()+"/logs.jsonl", "tok", 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = srv.Close() })
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
|
||||
body := []byte(`{"entries":[{"ts":"2026-09-29T00:00:00Z","level":"error","service":"agent-squad","msg":"provider down","trace_id":"trace-abc-1","code":"agent.provider_unavailable","fields":{"api_key":"sk-abcdefghijklmnopqrstuvwxyz"}}]}`)
|
||||
req, err := http.NewRequest(http.MethodPost, ts.URL+"/v1/logs", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer tok")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("ingest %d", res.StatusCode)
|
||||
}
|
||||
|
||||
unauth, err := http.Get(ts.URL + "/v1/logs")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
unauth.Body.Close()
|
||||
if unauth.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("auth %d", unauth.StatusCode)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/v1/logs?trace_id=trace-abc-1&level=error", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Log-Token", "tok")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
var payload struct {
|
||||
Entries []Record `json:"entries"`
|
||||
Next int64 `json:"next_cursor"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Entries) != 1 || payload.Entries[0].Code != "agent.provider_unavailable" {
|
||||
t.Fatalf("%+v", payload.Entries)
|
||||
}
|
||||
raw, _ := json.Marshal(payload.Entries[0])
|
||||
if strings.Contains(string(raw), "sk-abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Fatalf("secret leaked: %s", raw)
|
||||
}
|
||||
|
||||
req, err = http.NewRequest(http.MethodGet, ts.URL+"/v1/logs/tail?cursor=0", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-Log-Token", "tok")
|
||||
tailRes, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tailRes.Body.Close()
|
||||
reader := bufio.NewReader(tailRes.Body)
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") || !strings.Contains(line, "provider down") {
|
||||
t.Fatalf("sse %q", line)
|
||||
}
|
||||
|
||||
health, err := http.Get(ts.URL + "/v1/health")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
health.Body.Close()
|
||||
if health.StatusCode != http.StatusOK {
|
||||
t.Fatal(health.StatusCode)
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/redact"
|
||||
)
|
||||
|
||||
// Record 是带序号的日志,序号即查询游标。
|
||||
type Record struct {
|
||||
Seq int64 `json:"seq"`
|
||||
logsys.Entry
|
||||
}
|
||||
|
||||
// Store 是线程安全的内存日志表,可选追加到 JSONL 文件。
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
records []Record
|
||||
nextSeq int64
|
||||
limit int
|
||||
path string
|
||||
file *os.File
|
||||
redact *redact.Redactor
|
||||
subs map[int]chan Record
|
||||
nextSub int
|
||||
}
|
||||
|
||||
// OpenStore 打开存储。path 为空则只放内存。limit <= 0 时保留最近 100000 条。
|
||||
func OpenStore(path string, limit int, redactor *redact.Redactor) (*Store, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100000
|
||||
}
|
||||
s := &Store{
|
||||
limit: limit,
|
||||
path: path,
|
||||
redact: redactor,
|
||||
subs: map[int]chan Record{},
|
||||
}
|
||||
if path == "" {
|
||||
return s, nil
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.file = f
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Store) load() error {
|
||||
f, err := os.Open(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 2<<20)
|
||||
for sc.Scan() {
|
||||
var rec Record
|
||||
if err := json.Unmarshal(sc.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
s.records = append(s.records, rec)
|
||||
if rec.Seq > s.nextSeq {
|
||||
s.nextSeq = rec.Seq
|
||||
}
|
||||
}
|
||||
if len(s.records) > s.limit {
|
||||
s.records = s.records[len(s.records)-s.limit:]
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
|
||||
// Append 写入一批日志并通知订阅者。慢订阅者丢帧,不阻塞写入。
|
||||
func (s *Store) Append(entries []logsys.Entry) []Record {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Record, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if s.redact != nil {
|
||||
e.Message = s.redact.Apply(e.Message)
|
||||
e.Error = s.redact.Apply(e.Error)
|
||||
e.Fields = redactFields(e.Fields, s.redact)
|
||||
}
|
||||
s.nextSeq++
|
||||
rec := Record{Seq: s.nextSeq, Entry: e}
|
||||
s.records = append(s.records, rec)
|
||||
if s.file != nil {
|
||||
if data, err := json.Marshal(rec); err == nil {
|
||||
_, _ = s.file.Write(append(data, '\n'))
|
||||
}
|
||||
}
|
||||
out = append(out, rec)
|
||||
for _, ch := range s.subs {
|
||||
select {
|
||||
case ch <- rec:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(s.records) > s.limit {
|
||||
s.records = s.records[len(s.records)-s.limit:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Query 按条件过滤。cursor 为已见过的最大序号,只返回更大的序号。
|
||||
func (s *Store) Query(q Query) (records []Record, next int64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if q.Limit <= 0 {
|
||||
q.Limit = 100
|
||||
}
|
||||
if q.Limit > 1000 {
|
||||
q.Limit = 1000
|
||||
}
|
||||
for _, rec := range s.records {
|
||||
if rec.Seq <= q.Cursor {
|
||||
continue
|
||||
}
|
||||
if q.TraceID != "" && rec.TraceID != q.TraceID {
|
||||
continue
|
||||
}
|
||||
if q.Level != "" && rec.Level != q.Level {
|
||||
continue
|
||||
}
|
||||
if q.Service != "" && rec.Service != q.Service {
|
||||
continue
|
||||
}
|
||||
if q.Text != "" && !contains(rec, q.Text) {
|
||||
continue
|
||||
}
|
||||
records = append(records, rec)
|
||||
next = rec.Seq
|
||||
if len(records) >= q.Limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == 0 {
|
||||
next = q.Cursor
|
||||
}
|
||||
return records, next
|
||||
}
|
||||
|
||||
// Query 是检索条件。
|
||||
type Query struct {
|
||||
TraceID string
|
||||
Level string
|
||||
Service string
|
||||
Text string
|
||||
Cursor int64
|
||||
Limit int
|
||||
}
|
||||
|
||||
func contains(rec Record, text string) bool {
|
||||
if substr(rec.Message, text) || substr(rec.Error, text) || substr(rec.Code, text) || substr(rec.TraceID, text) {
|
||||
return true
|
||||
}
|
||||
for k, v := range rec.Fields {
|
||||
if substr(k, text) || substr(toString(v), text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func substr(s, sub string) bool {
|
||||
return len(sub) > 0 && len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Subscribe 从 after 序号之后回放,并继续接收新日志。
|
||||
func (s *Store) Subscribe(after int64) (<-chan Record, func()) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var replay []Record
|
||||
for _, rec := range s.records {
|
||||
if rec.Seq > after {
|
||||
replay = append(replay, rec)
|
||||
}
|
||||
}
|
||||
buf := len(replay) + 256
|
||||
ch := make(chan Record, buf)
|
||||
for _, rec := range replay {
|
||||
ch <- rec
|
||||
}
|
||||
id := s.nextSub
|
||||
s.nextSub++
|
||||
s.subs[id] = ch
|
||||
return ch, func() {
|
||||
s.mu.Lock()
|
||||
delete(s.subs, id)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close 关闭落盘文件。
|
||||
func (s *Store) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := s.file.Close()
|
||||
s.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func redactFields(in map[string]any, r *redact.Redactor) map[string]any {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = r.Apply(s)
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// ErrClosed 表示出口已经关闭。
|
||||
var ErrClosed = errors.New("sink: closed")
|
||||
|
||||
// Async 用有界队列把写日志从调用方 goroutine 挪走。
|
||||
// 队列满时丢弃最新一条并计数(不阻塞调用方)。Close 会排空队列并限时等待。
|
||||
type Async struct {
|
||||
Inner logsys.Sink
|
||||
Queue int
|
||||
FlushTimeout time.Duration
|
||||
|
||||
ch chan logsys.Entry
|
||||
syncCh chan chan struct{}
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
dropped atomic.Int64
|
||||
accepted atomic.Int64
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// Start 启动后台写者。重复调用无效果。
|
||||
func (a *Async) Start() {
|
||||
a.once.Do(func() {
|
||||
if a.Inner == nil {
|
||||
a.Inner = &Memory{}
|
||||
}
|
||||
if a.Queue < 1 {
|
||||
a.Queue = 1024
|
||||
}
|
||||
if a.FlushTimeout <= 0 {
|
||||
a.FlushTimeout = 5 * time.Second
|
||||
}
|
||||
a.ch = make(chan logsys.Entry, a.Queue)
|
||||
a.syncCh = make(chan chan struct{})
|
||||
a.stop = make(chan struct{})
|
||||
a.done = make(chan struct{})
|
||||
go a.loop()
|
||||
})
|
||||
}
|
||||
|
||||
// Write 非阻塞入队。队列满时丢弃本条。
|
||||
func (a *Async) Write(e logsys.Entry) error {
|
||||
a.Start()
|
||||
select {
|
||||
case <-a.stop:
|
||||
return ErrClosed
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case a.ch <- e:
|
||||
a.accepted.Add(1)
|
||||
return nil
|
||||
case <-a.stop:
|
||||
return ErrClosed
|
||||
default:
|
||||
a.dropped.Add(1)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Dropped 返回因队列满被丢弃的条数。
|
||||
func (a *Async) Dropped() int64 { return a.dropped.Load() }
|
||||
|
||||
// Accepted 返回成功入队的条数。
|
||||
func (a *Async) Accepted() int64 { return a.accepted.Load() }
|
||||
|
||||
// Sync 等待已入队的日志写完并刷出底层出口。
|
||||
func (a *Async) Sync() error {
|
||||
a.Start()
|
||||
req := make(chan struct{})
|
||||
select {
|
||||
case <-a.stop:
|
||||
return ErrClosed
|
||||
case a.syncCh <- req:
|
||||
case <-time.After(a.FlushTimeout):
|
||||
return errors.New("sink: async sync enqueue timeout")
|
||||
}
|
||||
select {
|
||||
case <-req:
|
||||
return nil
|
||||
case <-time.After(a.FlushTimeout):
|
||||
return errors.New("sink: async sync timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// Close 通知写者排空队列、刷盘并关闭底层出口。重复调用返回同一次结果。
|
||||
func (a *Async) Close() error {
|
||||
a.Start()
|
||||
a.closeOnce.Do(func() {
|
||||
close(a.stop)
|
||||
select {
|
||||
case <-a.done:
|
||||
a.closeErr = a.Inner.Close()
|
||||
case <-time.After(a.FlushTimeout):
|
||||
a.closeErr = errors.New("sink: async close timeout")
|
||||
}
|
||||
})
|
||||
return a.closeErr
|
||||
}
|
||||
|
||||
func (a *Async) loop() {
|
||||
defer close(a.done)
|
||||
for {
|
||||
select {
|
||||
case <-a.stop:
|
||||
a.drain()
|
||||
_ = a.Inner.Sync()
|
||||
return
|
||||
case e := <-a.ch:
|
||||
_ = a.Inner.Write(e)
|
||||
case req := <-a.syncCh:
|
||||
a.drain()
|
||||
_ = a.Inner.Sync()
|
||||
close(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Async) drain() {
|
||||
for {
|
||||
select {
|
||||
case e := <-a.ch:
|
||||
_ = a.Inner.Write(e)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Console 把日志写到 io.Writer。Format 为 human 或 json,空值按 human。
|
||||
type Console struct {
|
||||
mu sync.Mutex
|
||||
Out io.Writer
|
||||
Format string
|
||||
}
|
||||
|
||||
// Write 写入一行。
|
||||
func (c *Console) Write(e logsys.Entry) error {
|
||||
if c.Out == nil {
|
||||
c.Out = os.Stderr
|
||||
}
|
||||
line, err := formatLine(c.Format, e, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err = c.Out.Write(line)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync 无独立缓冲。
|
||||
func (c *Console) Sync() error { return nil }
|
||||
|
||||
// Close 不关闭底层 Writer(它通常是 stderr)。
|
||||
func (c *Console) Close() error { return nil }
|
||||
|
||||
func formatLine(format string, e logsys.Entry, humanDefault bool) ([]byte, error) {
|
||||
if format == "" && humanDefault {
|
||||
format = "human"
|
||||
}
|
||||
if format == "" || format == "json" {
|
||||
return logsys.FormatJSON(e)
|
||||
}
|
||||
if format == "human" {
|
||||
return []byte(logsys.FormatHuman(e)), nil
|
||||
}
|
||||
return nil, errFormat(format)
|
||||
}
|
||||
|
||||
type formatError string
|
||||
|
||||
func errFormat(f string) error { return formatError(f) }
|
||||
|
||||
func (f formatError) Error() string { return "sink: unknown format " + string(f) }
|
||||
@@ -0,0 +1,91 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Fanout 把同一条日志写到多个出口。单个出口失败不影响其他出口。
|
||||
type Fanout struct {
|
||||
Sinks []logsys.Sink
|
||||
}
|
||||
|
||||
// Write 逐个写入。
|
||||
func (f Fanout) Write(e logsys.Entry) error {
|
||||
var errs []error
|
||||
for _, s := range f.Sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Write(e); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Sync 刷出全部出口。
|
||||
func (f Fanout) Sync() error {
|
||||
var errs []error
|
||||
for _, s := range f.Sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Sync(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Close 关闭全部出口。
|
||||
func (f Fanout) Close() error {
|
||||
var errs []error
|
||||
for _, s := range f.Sinks {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if err := s.Close(); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// MinLevel 丢弃低于阈值的日志。
|
||||
type MinLevel struct {
|
||||
Min logsys.Level
|
||||
Next logsys.Sink
|
||||
}
|
||||
|
||||
// Write 在级别足够时转发。
|
||||
func (m MinLevel) Write(e logsys.Entry) error {
|
||||
lv, err := logsys.ParseLevel(e.Level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if lv < m.Min {
|
||||
return nil
|
||||
}
|
||||
if m.Next == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Next.Write(e)
|
||||
}
|
||||
|
||||
// Sync 转发。
|
||||
func (m MinLevel) Sync() error {
|
||||
if m.Next == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Next.Sync()
|
||||
}
|
||||
|
||||
// Close 转发。
|
||||
func (m MinLevel) Close() error {
|
||||
if m.Next == nil {
|
||||
return nil
|
||||
}
|
||||
return m.Next.Close()
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// File 以互斥锁保护的方式追加日志,可选按大小滚动。
|
||||
// MaxBytes 为 0 时不滚动。Keep 是保留的历史文件份数(不含当前文件)。
|
||||
type File struct {
|
||||
mu sync.Mutex
|
||||
Path string
|
||||
Format string
|
||||
MaxBytes int64
|
||||
Keep int
|
||||
|
||||
f *os.File
|
||||
size int64
|
||||
}
|
||||
|
||||
// Write 追加一条日志,必要时先滚动。
|
||||
func (f *File) Write(e logsys.Entry) error {
|
||||
line, err := formatLine(f.Format, e, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if err := f.open(); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.MaxBytes > 0 && f.size > 0 && f.size+int64(len(line)) > f.MaxBytes {
|
||||
if err := f.rotate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
n, err := f.f.Write(line)
|
||||
f.size += int64(n)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync 把文件缓冲刷到磁盘。
|
||||
func (f *File) Sync() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.f == nil {
|
||||
return nil
|
||||
}
|
||||
return f.f.Sync()
|
||||
}
|
||||
|
||||
// Close 关闭文件句柄。
|
||||
func (f *File) Close() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.f == nil {
|
||||
return nil
|
||||
}
|
||||
err := f.f.Close()
|
||||
f.f = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *File) open() error {
|
||||
if f.f != nil {
|
||||
return nil
|
||||
}
|
||||
if f.Path == "" {
|
||||
return fmt.Errorf("sink: file path is required")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(f.Path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
fp, err := os.OpenFile(f.Path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := fp.Stat()
|
||||
if err != nil {
|
||||
_ = fp.Close()
|
||||
return err
|
||||
}
|
||||
f.f = fp
|
||||
f.size = info.Size()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *File) rotate() error {
|
||||
if f.f != nil {
|
||||
if err := f.f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.f = nil
|
||||
}
|
||||
keep := f.Keep
|
||||
if keep < 1 {
|
||||
keep = 1
|
||||
}
|
||||
_ = os.Remove(fmt.Sprintf("%s.%d", f.Path, keep))
|
||||
for i := keep - 1; i >= 1; i-- {
|
||||
src := fmt.Sprintf("%s.%d", f.Path, i)
|
||||
dst := fmt.Sprintf("%s.%d", f.Path, i+1)
|
||||
if _, err := os.Stat(src); err == nil {
|
||||
_ = os.Rename(src, dst)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(f.Path, f.Path+".1"); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
f.size = 0
|
||||
return f.open()
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// HTTPBatch 把日志攒批 POST 到采集器。Async 为 false 时在 Write 里同步发送。
|
||||
type HTTPBatch struct {
|
||||
URL string
|
||||
Token string
|
||||
MaxEntries int
|
||||
FlushInterval time.Duration
|
||||
MaxRetries int
|
||||
Async bool
|
||||
Client *http.Client
|
||||
FlushTimeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
buf []logsys.Entry
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
started bool
|
||||
dropped atomic.Int64
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// Start 启动异步刷盘。同步模式不必调用。
|
||||
func (h *HTTPBatch) Start() {
|
||||
if !h.Async {
|
||||
h.defaults()
|
||||
return
|
||||
}
|
||||
h.once.Do(func() {
|
||||
h.defaults()
|
||||
h.stop = make(chan struct{})
|
||||
h.done = make(chan struct{})
|
||||
h.started = true
|
||||
go h.loop()
|
||||
})
|
||||
}
|
||||
|
||||
func (h *HTTPBatch) defaults() {
|
||||
if h.MaxEntries < 1 {
|
||||
h.MaxEntries = 200
|
||||
}
|
||||
if h.FlushInterval <= 0 {
|
||||
h.FlushInterval = 2 * time.Second
|
||||
}
|
||||
if h.MaxRetries < 1 {
|
||||
h.MaxRetries = 3
|
||||
}
|
||||
if h.Client == nil {
|
||||
h.Client = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
if h.FlushTimeout <= 0 {
|
||||
h.FlushTimeout = 5 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// Write 追加一条日志。同步模式立即发送;异步模式达到批量上限时触发发送。
|
||||
func (h *HTTPBatch) Write(e logsys.Entry) error {
|
||||
h.Start()
|
||||
if !h.Async {
|
||||
return h.post([]logsys.Entry{e})
|
||||
}
|
||||
h.mu.Lock()
|
||||
h.buf = append(h.buf, e)
|
||||
full := len(h.buf) >= h.MaxEntries
|
||||
var batch []logsys.Entry
|
||||
if full {
|
||||
batch = h.take()
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if full {
|
||||
if err := h.post(batch); err != nil {
|
||||
h.dropped.Add(int64(len(batch)))
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dropped 返回发送失败后丢弃的条数。
|
||||
func (h *HTTPBatch) Dropped() int64 { return h.dropped.Load() }
|
||||
|
||||
// Sync 立刻送出缓冲。
|
||||
func (h *HTTPBatch) Sync() error {
|
||||
h.Start()
|
||||
h.mu.Lock()
|
||||
batch := h.take()
|
||||
h.mu.Unlock()
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := h.post(batch); err != nil {
|
||||
h.dropped.Add(int64(len(batch)))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close 停止后台循环并送出剩余日志。
|
||||
func (h *HTTPBatch) Close() error {
|
||||
h.Start()
|
||||
if h.Async && h.started {
|
||||
select {
|
||||
case <-h.stop:
|
||||
default:
|
||||
close(h.stop)
|
||||
}
|
||||
select {
|
||||
case <-h.done:
|
||||
case <-time.After(h.FlushTimeout):
|
||||
return errors.New("sink: http batch close timeout")
|
||||
}
|
||||
}
|
||||
return h.Sync()
|
||||
}
|
||||
|
||||
func (h *HTTPBatch) loop() {
|
||||
defer close(h.done)
|
||||
ticker := time.NewTicker(h.FlushInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-h.stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = h.Sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HTTPBatch) take() []logsys.Entry {
|
||||
if len(h.buf) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := h.buf
|
||||
h.buf = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *HTTPBatch) post(batch []logsys.Entry) error {
|
||||
if len(batch) == 0 {
|
||||
return nil
|
||||
}
|
||||
if h.URL == "" {
|
||||
return errors.New("sink: http url is required")
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"entries": batch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var last error
|
||||
for attempt := 0; attempt < h.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequest(http.MethodPost, h.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if h.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+h.Token)
|
||||
}
|
||||
res, err := h.Client.Do(req)
|
||||
if err == nil {
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode >= 200 && res.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
err = errors.New(res.Status)
|
||||
}
|
||||
last = err
|
||||
if attempt+1 < h.MaxRetries {
|
||||
time.Sleep(time.Duration(attempt+1) * 10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Memory 把日志留在内存里,供测试和进程内订阅前的缓冲。
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
Entries []logsys.Entry
|
||||
}
|
||||
|
||||
// Write 追加一条日志。
|
||||
func (m *Memory) Write(e logsys.Entry) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.Entries = append(m.Entries, e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync 无缓冲可刷。
|
||||
func (m *Memory) Sync() error { return nil }
|
||||
|
||||
// Close 不释放已记录的日志。
|
||||
func (m *Memory) Close() error { return nil }
|
||||
|
||||
// Snapshot 返回当前副本。
|
||||
func (m *Memory) Snapshot() []logsys.Entry {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]logsys.Entry, len(m.Entries))
|
||||
copy(out, m.Entries)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package sink
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
func entry(i int) logsys.Entry {
|
||||
return logsys.Entry{Time: time.Unix(0, 0).UTC(), Level: "info", Message: "m", Fields: map[string]any{"i": i}}
|
||||
}
|
||||
|
||||
func TestFileRotateAndConsole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "app.jsonl")
|
||||
f := &File{Path: path, Format: "json", MaxBytes: 80, Keep: 2}
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := f.Write(entry(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(dir, "app.jsonl*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) < 2 {
|
||||
t.Fatalf("rotated files: %v", matches)
|
||||
}
|
||||
var lines int
|
||||
for _, name := range matches {
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range b {
|
||||
if c == '\n' {
|
||||
lines++
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines != 3 {
|
||||
t.Fatalf("kept lines %d, want current+2 backups, files %v", lines, matches)
|
||||
}
|
||||
|
||||
var buf safeBuf
|
||||
c := &Console{Out: &buf, Format: "human"}
|
||||
if err := c.Write(logsys.Entry{Time: time.Unix(0, 0).UTC(), Level: "info", Message: "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if buf.String() == "" {
|
||||
t.Fatal("empty console")
|
||||
}
|
||||
}
|
||||
|
||||
type safeBuf struct {
|
||||
mu sync.Mutex
|
||||
b []byte
|
||||
}
|
||||
|
||||
func (s *safeBuf) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.b = append(s.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
func (s *safeBuf) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return string(s.b)
|
||||
}
|
||||
|
||||
func TestAsyncDropAndDrain(t *testing.T) {
|
||||
hold := &lockSink{entered: make(chan struct{})}
|
||||
hold.mu.Lock()
|
||||
a := &Async{Inner: hold, Queue: 2, FlushTimeout: 2 * time.Second}
|
||||
go func() { _ = a.Write(entry(0)) }()
|
||||
select {
|
||||
case <-hold.entered:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("worker did not enter write")
|
||||
}
|
||||
for i := 0; i < a.Queue; i++ {
|
||||
if err := a.Write(entry(i + 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const extra = 15
|
||||
for i := 0; i < extra; i++ {
|
||||
if err := a.Write(entry(100 + i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if a.Dropped() != extra {
|
||||
t.Fatalf("dropped %d", a.Dropped())
|
||||
}
|
||||
hold.mu.Unlock()
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hold.written.Load() != int64(1+a.Queue) {
|
||||
t.Fatalf("written %d accepted %d", hold.written.Load(), a.Accepted())
|
||||
}
|
||||
}
|
||||
|
||||
type lockSink struct {
|
||||
mu sync.Mutex
|
||||
entered chan struct{}
|
||||
written atomic.Int64
|
||||
}
|
||||
|
||||
func (s *lockSink) Write(e logsys.Entry) error {
|
||||
select {
|
||||
case <-s.entered:
|
||||
default:
|
||||
close(s.entered)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.mu.Unlock()
|
||||
s.written.Add(1)
|
||||
return nil
|
||||
}
|
||||
func (s *lockSink) Sync() error { return nil }
|
||||
func (s *lockSink) Close() error { return nil }
|
||||
|
||||
func TestAsyncSyncDrains(t *testing.T) {
|
||||
mem := &Memory{}
|
||||
a := &Async{Inner: mem, Queue: 64}
|
||||
for i := 0; i < 30; i++ {
|
||||
if err := a.Write(entry(i)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mem.Snapshot()) != 30 {
|
||||
t.Fatalf("synced %d", len(mem.Snapshot()))
|
||||
}
|
||||
if err := a.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFanoutAndHTTPBatch(t *testing.T) {
|
||||
a, b := &Memory{}, &Memory{}
|
||||
f := Fanout{Sinks: []logsys.Sink{
|
||||
MinLevel{Min: logsys.LevelWarn, Next: a},
|
||||
b,
|
||||
}}
|
||||
_ = f.Write(logsys.Entry{Level: "info", Message: "low"})
|
||||
_ = f.Write(logsys.Entry{Level: "error", Message: "high"})
|
||||
if len(a.Snapshot()) != 1 || len(b.Snapshot()) != 2 {
|
||||
t.Fatalf("a=%d b=%d", len(a.Snapshot()), len(b.Snapshot()))
|
||||
}
|
||||
|
||||
var got atomic.Int32
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
calls.Add(1)
|
||||
if r.Header.Get("Authorization") != "Bearer secret" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if calls.Load() == 1 {
|
||||
http.Error(w, "busy", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Entries []logsys.Entry `json:"entries"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
got.Add(int32(len(body.Entries)))
|
||||
}))
|
||||
defer srv.Close()
|
||||
h := &HTTPBatch{
|
||||
URL: srv.URL, Token: "secret", Async: true,
|
||||
MaxEntries: 2, FlushInterval: time.Hour, MaxRetries: 3,
|
||||
}
|
||||
if err := h.Write(entry(1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Write(entry(2)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Write(entry(3)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Load() != 3 {
|
||||
t.Fatalf("posted %d dropped %d", got.Load(), h.Dropped())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package slogadapter 在 logsys 与标准库 log/slog 之间双向转发。
|
||||
package slogadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"logging-system/logsys"
|
||||
)
|
||||
|
||||
// Handler 把 slog 记录写入 logsys。
|
||||
type Handler struct {
|
||||
logger *logsys.Logger
|
||||
group string
|
||||
attrs []slog.Attr
|
||||
}
|
||||
|
||||
// NewHandler 包装一个 logsys 日志器。
|
||||
func NewHandler(l *logsys.Logger) *Handler {
|
||||
if l == nil {
|
||||
l = logsys.Nop()
|
||||
}
|
||||
return &Handler{logger: l}
|
||||
}
|
||||
|
||||
// Slog 返回写入 logsys 的 *slog.Logger。
|
||||
func Slog(l *logsys.Logger) *slog.Logger { return slog.New(NewHandler(l)) }
|
||||
|
||||
// Enabled 按 logsys 的级别过滤。
|
||||
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
|
||||
return h.logger.Enabled(fromSlogLevel(level))
|
||||
}
|
||||
|
||||
// Handle 转发一条记录。
|
||||
func (h *Handler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
kv := make([]any, 0, (len(h.attrs)+rec.NumAttrs())*2)
|
||||
for _, attr := range h.attrs {
|
||||
kv = append(kv, h.name(attr.Key), attr.Value.Any())
|
||||
}
|
||||
rec.Attrs(func(attr slog.Attr) bool {
|
||||
kv = append(kv, h.name(attr.Key), attr.Value.Any())
|
||||
return true
|
||||
})
|
||||
l := h.logger.WithContext(ctx)
|
||||
switch {
|
||||
case rec.Level >= slog.LevelError+4:
|
||||
l.Fatal(rec.Message, nil, kv...)
|
||||
case rec.Level >= slog.LevelError:
|
||||
l.Error(rec.Message, nil, kv...)
|
||||
case rec.Level >= slog.LevelWarn:
|
||||
l.Warn(rec.Message, kv...)
|
||||
case rec.Level >= slog.LevelInfo:
|
||||
l.Info(rec.Message, kv...)
|
||||
case rec.Level >= slog.LevelDebug:
|
||||
l.Debug(rec.Message, kv...)
|
||||
default:
|
||||
l.Trace(rec.Message, kv...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAttrs 追加属性。
|
||||
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
n := *h
|
||||
n.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
|
||||
return &n
|
||||
}
|
||||
|
||||
// WithGroup 追加分组前缀。
|
||||
func (h *Handler) WithGroup(name string) slog.Handler {
|
||||
n := *h
|
||||
if name == "" {
|
||||
return &n
|
||||
}
|
||||
if n.group != "" {
|
||||
n.group += "."
|
||||
}
|
||||
n.group += name
|
||||
return &n
|
||||
}
|
||||
|
||||
func (h *Handler) name(key string) string {
|
||||
if h.group == "" {
|
||||
return key
|
||||
}
|
||||
return h.group + "." + key
|
||||
}
|
||||
|
||||
// Sink 把 logsys 条目写进 slog.Handler。
|
||||
type Sink struct {
|
||||
Handler slog.Handler
|
||||
}
|
||||
|
||||
// Write 转发一条日志。
|
||||
func (s Sink) Write(e logsys.Entry) error {
|
||||
if s.Handler == nil {
|
||||
return nil
|
||||
}
|
||||
rec := slog.NewRecord(e.Time, toSlogLevel(e.Level), e.Message, 0)
|
||||
if e.Service != "" {
|
||||
rec.AddAttrs(slog.String("service", e.Service))
|
||||
}
|
||||
if e.Module != "" {
|
||||
rec.AddAttrs(slog.String("module", e.Module))
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
rec.AddAttrs(slog.String("trace_id", e.TraceID))
|
||||
}
|
||||
if e.Code != "" {
|
||||
rec.AddAttrs(slog.String("code", e.Code))
|
||||
}
|
||||
if e.Error != "" {
|
||||
rec.AddAttrs(slog.String("error", e.Error))
|
||||
}
|
||||
for k, v := range e.Fields {
|
||||
rec.AddAttrs(slog.Any(k, v))
|
||||
}
|
||||
return s.Handler.Handle(context.Background(), rec)
|
||||
}
|
||||
|
||||
// Sync 无独立缓冲。
|
||||
func (s Sink) Sync() error { return nil }
|
||||
|
||||
// Close 不关闭 slog.Handler。
|
||||
func (s Sink) Close() error { return nil }
|
||||
|
||||
func fromSlogLevel(level slog.Level) logsys.Level {
|
||||
switch {
|
||||
case level >= slog.LevelError+4:
|
||||
return logsys.LevelFatal
|
||||
case level >= slog.LevelError:
|
||||
return logsys.LevelError
|
||||
case level >= slog.LevelWarn:
|
||||
return logsys.LevelWarn
|
||||
case level >= slog.LevelInfo:
|
||||
return logsys.LevelInfo
|
||||
case level >= slog.LevelDebug:
|
||||
return logsys.LevelDebug
|
||||
default:
|
||||
return logsys.LevelTrace
|
||||
}
|
||||
}
|
||||
|
||||
func toSlogLevel(level string) slog.Level {
|
||||
switch level {
|
||||
case "trace":
|
||||
return slog.LevelDebug - 4
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
case "fatal":
|
||||
return slog.LevelError + 4
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package slogadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"logging-system/logsys"
|
||||
"logging-system/sink"
|
||||
)
|
||||
|
||||
func TestSlogToLogsys(t *testing.T) {
|
||||
mem := &sink.Memory{}
|
||||
base := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{mem}})
|
||||
logger := Slog(base).With("component", "api")
|
||||
logger.Info("hello", "n", 1)
|
||||
logger.Debug("hidden")
|
||||
got := mem.Snapshot()
|
||||
if len(got) != 1 || got[0].Message != "hello" || got[0].Fields["component"] != "api" || got[0].Fields["n"] != int64(1) && got[0].Fields["n"] != 1 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsysToSlog(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
log := logsys.New(logsys.Options{Level: logsys.LevelInfo, Sinks: []logsys.Sink{Sink{Handler: h}}})
|
||||
log.WithTraceID("trace-9").Error("boom", nil, "code_hint", "x")
|
||||
if !strings.Contains(buf.String(), "boom") || !strings.Contains(buf.String(), "trace-9") {
|
||||
t.Fatalf("slog output: %s", buf.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user