增强您的可观察性:将 Logrus 与 Grafana Loki 集成

本文介绍如何通过 Logrus Hook 将 Go 应用日志直接发送到 Grafana Loki,并保留请求路径、方法、请求体、请求 ID 和耗时等上下文,方便在 Grafana 中使用 LogQL 查询和排障。

作者 译文

核心要点

  • 将 Logrus 与 Loki 集成,常见方式有 Promtail 采集容器日志和 Logrus Hook 直接推送两类。
  • Hook 方式适合在应用内控制日志字段、标签和上下文,但也要注意网络失败、敏感字段、请求体大小和高基数标签。
  • Loki 的查询效率依赖标签设计。job、服务名、环境、日志级别等适合做标签,完整请求体、用户 ID、request ID 更适合做日志字段。
  • 本文示例重点演示 Gin 中间件、Logger 接口、Loki Hook 初始化、上下文字段提取和 Grafana LogQL 查询路径。

在微服务和分布式系统的世界里,有效的日志记录不仅是一种便利,也是一种必要。今天,我将引导您将 Grafana Loki 与流行的 Go 日志库 logrus 集成,以创建一个强大的、可搜索的日志记录系统,从而显着提高您的可观察性能力。

Loki 集成的两种方式

有两种办法可以把日志发给 Loki:

  • 使用 Promtail:此代理收集容器日志并将它们转发给 Loki
  • 使用 logrus 中的 hook:这会捕获日志消息并通过 HTTP 将它们直接发送到 Loki

在本指南中,我们将重点介绍第二种方法,因为它简单且可以直接控制记录的内容。

方式 适合场景 注意事项
Promtail 采集 容器标准输出、文件日志、统一采集链路 应用侧改动少,但字段控制主要依赖日志格式和采集解析
Logrus Hook 直发 Go 应用需要主动控制字段、标签和发送逻辑 应用需要承担 Loki 写入失败、重试和字段治理的设计

理解 Logrus Hooks

Logrus 中的钩子是什么?

Logrus 中的钩子本质上是一个扩展点,允许您在创建日志条目时执行其他操作。钩子实现了一个简单的接口,其中包括 Fire()(在创建日志条目时调用)和 Levels()(定义应该为哪些日志级别触发此钩子)等方法。

type Hook interface {
    Levels() []Level
    Fire(*Entry) error
}

这个简单但功能强大的界面使 Logrus 能够专注于核心日志记录功能,同时实现几乎无限的可扩展性。

在我们使用 Loki 的特定案例中,Hook 允许我们在创建日志条目时拦截它们并立即通过其 HTTP API 将它们发送到 Loki,而无需更改我们在应用程序代码中与 logger 交互的方式。

找到正确的钩子实现

经过广泛的研究(和几杯咖啡),我发现虽然没有专门用于 Loki-logrus 集成的官方 Golang 包,但有几个社区选项。最强大和最值得信赖的似乎是 YuKitsune/lokirus,它提供了一个可靠的钩子实现,广泛用于生产环境。

了解 Loki 的基于标签的日志组织

在深入研究代码之前,重要的是要了解 Loki 使用标签组织日志,类似于 Prometheus 处理指标的方式。这种方法使日志高度可搜索和过滤,允许您快速深入到重要的事情。

对于我们的实现,我们将在日志中包含这些关键标签:

  • 日志级别:info、warning、error、critical
  • Request Path 请求路径
  • HTTP method
  • Message content 日志正文
  • Request body 请求正文
  • Request Duration 请求时长
  • Request ID 链路追踪中的请求 ID

这里要区分 Loki 标签和日志字段。标签适合放低基数、经常用于过滤的字段,例如服务名、环境、日志级别;请求体、request ID、用户 ID 这类高基数或大文本内容,更适合放在结构化日志字段里,用 LogQL 管道解析和过滤。

实施:完整的解决方案

让我们逐步构建一个健壮的日志系统。

第1步:创建请求跟踪中间件

首先,我们将为基于 Gin 的 API 创建一个中间件,该中间件捕获重要的请求详细信息并将它们添加到上下文中:

func RequestTracerMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        // Capture request start time for duration calculation
        startTime := time.Now()

        // Extract basic request information
        method := c.Request.Method
        path := c.Request.URL.Path

        // Capture request body while preserving it for further use
        bodyBytes, _ := io.ReadAll(c.Request.Body)
        c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))

        // Store all captured information in the context
        c.Set("requestBody", string(bodyBytes))
        c.Set("path", path)
        c.Set("method", method)
        c.Set("start_time", startTime.UnixMilli())

        // Continue request processing
        c.Next()
    }
}

该中间件捕获 HTTP 方法、路径、请求正文和请求的开始时间。通过将这些存储在 Gin 上下文中,我们可以稍后在日志代码中访问它们。

第2步:创建记录器接口

接下来,我们将定义一个干净的日志记录接口,抽象出实现细节:

package logging

import (
    "context"
    "github.com/sirupsen/logrus"
    "github.com/yukitsune/lokirus"
)

// Logger defines the standard logging interface.
type Logger interface {
    Debug(ctx context.Context, format string, args ...interface{})
    Info(ctx context.Context, format string, args ...interface{})
    Warn(ctx context.Context, format string, args ...interface{})
    Error(ctx context.Context, format string, args ...interface{})
}

此接口提供了一个干净的抽象,易于在整个应用程序中使用。

第3步:使用 Loki Hook 初始化 Logger

现在,让我们创建一个函数来使用 Loki hook 初始化我们的 Logger:

func New(logtag string) Logger {
    // Configure the Loki hook with appropriate options
    opts := lokirus.NewLokiHookOptions().
        // Map logrus panic level to Grafana's critical level
        // See: https://grafana.com/docs/grafana/latest/explore/logs-integration/
        WithLevelMap(lokirus.LevelMap{logrus.PanicLevel: "critical"}).
        WithFormatter(&logrus.JSONFormatter{}).
        WithStaticLabels(lokirus.Labels{
            "job": "BE_SERVER1", // Service identifier
            "tag": logtag,       // Custom tag for grouping related logs
        })

    // Create the Loki hook with specified log levels
    hook := lokirus.NewLokiHookWithOpts(
        "http://127.0.0.1:3100", // Loki endpoint
        opts,
        logrus.InfoLevel,
        logrus.WarnLevel,
        logrus.ErrorLevel,
        logrus.FatalLevel)

    // Create and configure the logrus logger
    l := logrus.New()
    l.AddHook(hook)
    l.SetFormatter(&logrus.JSONFormatter{})

    // Return our implementation
    return &LogrusLogger{
        logger: l,
    }
}

在此初始化中:

  1. 我们配置一个 Loki hook 来拦截日志条目
  2. 我们指定哪些日志级别应该触发 hook(info、warn、error、fatal)
  3. 我们设置了静态标签,将附加到每个日志条目
  4. 我们将 logrus 级别映射到 Loki 级别
  5. 我们将 hook 附加到 Logger 实例中

这种方法的美妙之处在于我们的记录器将继续正常运行,将日志写入其默认输出(通常是标准输出),并且它还会将这些日志发送到 Loki。如果 Loki 暂时不可用,我们的应用程序将继续在本地进行日志记录,没有问题。

第4步:实现 Logger 方法

最后,让我们实现实际的日志记录方法,这些方法将提取上下文信息并格式化我们的日志:

package logging

import (
    "context"
    "fmt"
    "time"
    "github.com/sirupsen/logrus"
)

// LogrusLogger implements the Logger interface using Logrus.
type LogrusLogger struct {
    logger *logrus.Logger
    fields logrus.Fields
}

// Debug logs at debug level.
func (l *LogrusLogger) Debug(ctx context.Context, format string, args ...interface{}) {
    entry := l.createEntry(ctx)
    entry.Debugf(format, args...)
}

// Info logs at info level.
func (l *LogrusLogger) Info(ctx context.Context, format string, args ...interface{}) {
    entry := l.createEntry(ctx)
    entry.Infof(format, args...)
}

// Warn logs at warn level.
func (l *LogrusLogger) Warn(ctx context.Context, format string, args ...interface{}) {
    entry := l.createEntry(ctx)
    entry.Warnf(format, args...)
}

// Error logs at error level.
func (l *LogrusLogger) Error(ctx context.Context, format string, args ...interface{}) {
    entry := l.createEntry(ctx)
    entry.Errorf(format, args...)
}

// createEntry creates a logrus entry with fields from context.
func (l *LogrusLogger) createEntry(ctx context.Context) *logrus.Entry {
    fields := copyFields(l.fields)

    // Add request ID for tracing
    if requestID, ok := ctx.Value("requestID").(string); ok {
        fields["request_id"] = requestID
    }

    // Add HTTP request body for detailed debugging
    if requestBody, ok := ctx.Value("requestBody").(string); ok {
        fields["request_body"] = requestBody
    }

    // Add path for request identification
    if path, ok := ctx.Value("path").(string); ok {
        fields["path"] = path
    }

    // Add HTTP method for request type
    if method, ok := ctx.Value("method").(string); ok {
        fields["method"] = method
    }

    // Calculate request duration for performance monitoring
    if startTime, ok := ctx.Value("start_time").(int64); ok {
        duration := time.Now().UnixMilli() - startTime
        fields["duration"] = fmt.Sprintf("%dms", duration)
    }

    return l.logger.WithFields(fields)
}

// copyFields creates a copy of the fields map to avoid modifying the original.
func copyFields(fields logrus.Fields) logrus.Fields {
    newFields := logrus.Fields{}
    for k, v := range fields {
        newFields[k] = v
    }
    return newFields
}

稍作解释:

  1. 为不同的日志级别提供方法
  2. 从上下文中提取有用的信息
  3. 用适当的标签格式化它
  4. 计算性能监控的请求持续时间

当调用这些日志记录方法中的任何一个时,我们的 Loki hook 将拦截日志条目并将其发送给 Grafana Loki,其中包含我们添加的所有字段和上下文。

在应用程序中使用记录器

随着我们的实施完成,在您的应用程序中使用它很简单:

// Initialize at application startup
logger := logging.New("api-service")

// Use in your request handlers
func HandleRequest(c *gin.Context) {
    // Create a context from the Gin context
    ctx := c.Request.Context()

    // Log request information
    logger.Info(ctx, "Processing request")

    // Your business logic here

    // Log completion
    logger.Info(ctx, "Request completed successfully")
}

配置 Grafana 以显示日志

日志数据进入 Loki 之后,就可以使用 Grafana 查看了,使用 logql:

{job="BE_SERVER1"} | json

这将从您的服务中提取所有日志并将它们解析为 JSON。然后,您可以添加过滤器:

{job="BE_SERVER1"} | json | path=~"/api/v1/.*" | duration > "100ms"

此查询查找处理时间超过100毫秒的对 API v1 端点的所有请求-非常适合识别慢速端点!

生产使用前要检查什么

  • Loki 地址、租户和认证方式是否已经按环境隔离。
  • Hook 写入失败时,应用是否能继续本地日志记录,不影响主流程。
  • 请求体、Token、Cookie、密码、个人信息等敏感内容是否已经过滤或脱敏。
  • 标签是否控制在低基数字段范围内,避免把 request ID、用户 ID 等放进标签。
  • 日志量较大的接口是否需要采样、限速或按级别过滤。

高级提示和技巧

1. 特殊情况下的自定义 Hook

当您需要特殊处理时,您可以创建自己的自定义 hook:

type CustomHook struct {
    // Configuration fields here
}

func (h *CustomHook) Fire(entry *logrus.Entry) error {
    // Custom processing logic
    return nil
}

func (h *CustomHook) Levels() []logrus.Level {
    return []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel}
}

这种模式对于与内部系统集成或对某些类型的日志实施专门处理特别有用。

2.使用逻辑运算符进行日志聚合

Loki 支持复杂查询的逻辑运算符:

{job="BE_SERVER1"} | json | level="error" or level="warn"

3.大容量服务的智能采样

对于高流量服务,请考虑对您的日志进行采样:

if rand.Float64() < 0.1 { // Log 10% of requests
    logger.Info(ctx, "Sampled request details", "full_details", true)
}

4.用于增强可搜索性的结构化日志记录

始终更喜欢结构化日志记录而不是字符串连接:

// Good
logger.Info(ctx, "User authentication", "user_id", userID, "result", "success")

// Avoid
logger.Info(ctx, fmt.Sprintf("User %s authenticated successfully", userID))

结构化方法使以后过滤和搜索日志变得更加容易。

总结

通过这个实现,您现在拥有了一个强大的、可搜索的日志系统,该系统与 Grafana 和 Loki 无缝集成。通过利用 Logrus hook,我们在应用程序逻辑和日志基础架构之间建立了清晰的分离,使我们的系统更易于维护和灵活。

基于 hook 的方法提供:

  • 集中式日志存储和可视化
  • 强大的查询功能
  • 每个日志条目的丰富上下文
  • 嵌入在日志中的性能指标
  • 对应用程序性能的影响最小

FAQ

Q1:什么时候应该用 Hook 直发 Loki,而不是 Promtail?

当你需要在应用内精确控制结构化字段、标签、请求上下文和发送逻辑时,Hook 更直接。如果只是采集容器标准输出或日志文件,Promtail 这类采集器通常更简单。

Q2:request ID 适合做 Loki 标签吗?

通常不适合。request ID 基数很高,更适合作为结构化日志字段,通过 LogQL 解析后过滤。标签应优先选择服务名、环境、日志级别等稳定字段。

Q3:能不能把请求体完整写入日志?

可以在特定排障场景使用,但生产环境应默认谨慎。请求体可能包含敏感信息,也可能放大日志体积,应先做脱敏、截断或采样。

原文:https://dev.to/srrathi/supercharging-your-observability-integrating-logrus-with-grafana-loki-3f8a
enjoy :-)
延伸路径

继续看解决方案和产品对比

如果你正在做监控、可观测性或故障定位相关选型,建议从解决方案和产品对比继续往下看。

标签 Loki Logging
快猫星云 联系方式 快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云 联系方式
快猫星云