Log 工具很多,但是不解决我的痛点

最近看到知乎上的一篇关于工业无人机的贴。里面有一句话触动了我:所有的无人机都在和我们说续航说载重,但问题是我们根本不关注这些,我们最关心的是能不能帮我们解决问题。这句话完全可以成为一种模式,所有的 XXX 都在说 XXX,但是它们不解决我的痛点。今天我们就说一下 Log。

Log 工具很多很多,大的例如:Google Could Platform、Splunk、Elmah.io……,这些解决的是 Log Storage,Log Searching & Analyzing 以及 Dashboard。小的,例如,log4X,NLog……,解决的是如何将日志写入介质的问题。但是这些工具没有办法告诉你,你应该在哪儿记录 Log,记录什么信息,最后一公里连不起来。我们讨论的焦点就在这里。

记 Log 似乎很简单,工具的接口也很统一。于是,我们看到了好多好多这样的代码:

logger.Error("Entity id is invalid.");

这种日志相关的代码在功能 sign off 的时候或用户 acceptance test 的时候往往被忽略。但是好产品都是运维出来的。你有没有想过这样的代码会让 Ops 疯掉呢?

好的,现在产品环境出现了一个问题,Ops 接到了反馈后在日志中查到了 N 条这样的日志

2016/02/02 22:22:22 Entity id is invalid.

你能想到他们的表情用一句话表示就是:大哥你逗我呢。表情结束后,他们需要解决几个问题:

  • 我怎么知道客户遇到的问题就是这种日志所描述问题?
  • 这种日志同一时间有好多我怎么知道哪一条是真正造成问题的?
  • 出问题的 Entity Id 长什么样?
  • 我怎么知道这个问题出现的时候客户在干什么?
  • 这个问题的代码在什么位置?

OK,你看到痛点了么?日志系统根本不是用日志工具记点东西就搞定了的,记录只是最后一步。整个日志系统是需要根据运维的需求进行设计的。虽然运维的需求有很多,例如,我想报告系统的问题,我想分析用户的行为……但是总结起来就是记叙文的套路:谁,什么时候,在什么地方,干了什么,造成了什么样的后果。而翻译成技术语言就是:

  • 谁:谁调用了这个 API;
  • 什么时候:事情发生的统一时间;
  • 在什么地方:指代调用入口,往往调用入口代表了相应的功能;
  • 干了什么:以什么样的参数执行了什么过程;
  • 造成了什么样的后果:应当如何评价这个日志?一般有 Information、Warning、Error 以及 Fatal。

接下来我们就从这些方面考虑日志到底怎么记。我们的目标是:如果我是运维,面对这样一条日志,我能够知道这个日志从何而来,我接下来应该做什么?当然从步骤上就是:

  • 尽可能的收集信息;
  • 把这些信息记下来。

简单吧。

开始收集信息吧

我们刚才提到了日志包含信息。但是这些信息从何而来呢?

  • 谁:这个概念一般是系统的 Authentication 部分回答的。至于 authorization 部分该不该包含,包含多少,则需要根据业务规则进行分析了,但是我更倾向于 authorization 是一个业务相关的东西,应该放在 干了什么 里面。
  • 什么时候:这个信息应该是最容易获得的了。如果考虑 global 问题则应当使用统一的标准,例如,全部使用 UTC 日期时间。
  • 在什么地方:调用入口应该是和业务无关的。例如 request URI。
  • 干了什么:这个应当是完全业务相关的部分。只有在业务上下文下这些数据才有意义。
  • 造成了什么样的后果:这种后果实际上可以通过以下问题来区分:
    • Information:这不是一个错误吧?我只是希望在“烦人”模式下获得更多的调试信息。
    • Warning:这个问题是由用户的请求的数据造成的么?或者这个问题是我们事前就可以预料到的么?
    • Error:哎呀,竟然发生了一个错误?@_@
    • Fatal:我知道这个错误,但是一旦它发生了,我们就要完蛋了。

在开始收集信息之前,我们应该搞清楚我们的重点是和业务无关的部分。因为它是典型的花 20% 的精力解决 80% 事情的部分,即使是不同的系统也有非常多的共性。这些部分我们可以从功能上将其内聚起来,称之为 Logging Context

你猜 这个部分应当记些什么呢?姓名?年龄?出生年月?地址?约不约?别忘了,这个东西是干什么的——

查他/她!
--《黎明之前》

姓名年龄什么的都没有用,因为可以重名可以同年同月同日生。身份证?先别说你搞得到搞不到国外的同学怎么办?万一是一个虚拟角色(例如,新浪调了我的服务)怎么办?因此你记录的应该是查到使用者的唯一标识。那么这个标识是什么呢?具体系统具体分析,但是你在这儿放一个字符串就应该已经足够了。

那么我什么时候取到这个信息呢?在 Authentication 过后就可以拿到。在很多 Web Service Framework 中都具有 pipeline 的概念,而 Authentication 往往位于这条 pipeline 的最前端,这样只要我们将记录 的操作放在其后就可以了。

范例

以 C# 为例。代码很简单,就是单纯的 getter 和 setter。例如:

public interface ILoggingContext {
    string Subject { get; set; }
    // ...
}

如果你使用的是 ASP.NET WebAPI,那么就可以将赋值操作放在 Message Handler 里面,例如:

public class LoggingContextHandler : DelegatingHandler
{
    protected override Task SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        var context = (ILoggingContext)request
           .GetDependencyScope()
           .GetService(typeof(ILoggingContext));
        
        // ...
        context.Subject = GetIdentityFromRequest(request);
        // ...
        return base.SendAsync(request, cancellationToken);
    }
}

上述代码中我们假定了一个 ILoggingContext 实现的实例是 RequestScope 的。在大多数情况下这非常合适。

什么时候

几乎各个系统都有相应的代表时刻的接口。因此这里只是提醒一下尽量的使用 UTC 时间以便可以在分布式系统中比较一致的进行时间的标记。这并不需要什么特别的接口。

在什么地方

对于这个信息,我们可以统一在入口处处理。例如,对一个 Web 应用来说,这个信息就是 Request 的 Uri。而我们可以在 web service pipeline 的任意位置得到这个信息。

范例

我们不妨将这个信息称为为 EntryPoint。也是一个非常简单的属性:

public interface ILoggingContext {
    string EntryPoint { get; set; }
    // ...
}

仍然以 ASP.NET WebAPI 应用为例,我们可以很容易在 MessageHandler 里面记录这个信息。 例如:

public class LoggingContextHandler : DelegatingHandler
{
    protected override Task SendAsync(
        HttpRequestMessage request, 
        CancellationToken cancellationToken)
    {
        var context = (ILoggingContext)request
           .GetDependencyScope()
           .GetService(typeof(ILoggingContext));
        
        // ...
        context.EntryPoint = request.RequestUri.AbsoluteUri;
        // ...
        return base.SendAsync(request, cancellationToken);
    }
}

干了什么 + 造成了什么后果

这两个部分的信息和业务是最相关的,我们将其称之为 LoggingScenario。由于一个请求可能会执行多个业务流程,因此一个请求可以包含多个 LoggingScenario。由于和业务相关,我们无法推测出它的具体内容。但是我们却可以确定它的形式。

对于 干了什么,包含两个部分的信息。首先,我执行了什么过程或者我将要执行什么过程,称为 description;第二,我执行这个过程中使用了什么参数或配置,称为 parameters。而 造成的后果 就是一个简单的枚举(level)。

范例

Logging Scenario 包含三部分信息。

[Serializable]
public class LoggingScenario
{
    public string Description { get; set; }
    public LoggingLevel Level { get; set; }
    public object Parameters { get; set; }
}

我们在 ILoggingContext 中添加接口将 LoggingScenario 写入到日志文件中,这样每一次的写入都包含了所有 Ops 友好的信息。

public interface ILoggingContext
{
    // ...
    void WriteScenario(LoggingScenario scenario);
}

其使用方法如下:

var scenario = new LoggingScenairo(
    "Update Permission",
    new {
        TargetUserId = targetUser.Id
    },
    LoggingLevel.Info);
    
loggingContext.WriteScenario(scenario);

为异常创建并还原 LoggingScenario

异常是日志的一大信息来源。一般的,我们需要在未处理异常出现的时候记录日志。下面我们专门来讨论一下如何从一个异常自动创建 LoggingScenario

未处理异常一般表示了一个我们事先没有预判到的状态,这个异常可能是我们主动抛出的,也可能是由其他的代码产生的。因此必须顾及这两种情况。对于前者,可以在异常上追加一些信息描述业务场景,这样我们可以使用简短明确的日志准确描述问题;而对于后者,我们将尽可能多的记录异常的信息,通过 EntryPoint 结合详细的异常调用栈定位问题发生的场景。

在大部分的技术栈中,异常可以携带额外的信息。对于 .NET 而言,可以将这些信息存储在 Exception.Data 中。我们可以创建一个扩展方法将 scenario 相关的信息追加到我们主动抛出的异常中。

public static T MarkScenario(
    this T error,
    string scenarioName,
    object parameters,
    LoggingLevel level)
    where T : Exception
{
    IDictionary errorData = error.Data;
    if (errorData.IsReadOnly) { return error; }
    errorData[ExceptionScenarioKey] = 
        new LoggingScenario(scenarioName, parameters, level);
    return error;
}

为了使用的方便我们还可以创建诸如以下形式的重载:

public static T MarkAsInfo(
    this T error,
    string scenarioName,
    object parameters = null) where T : Exception
{
    return MarkScenario(
        error, scenarioName, parameters, LoggingLevel.Info);
}

在异常向上传播的过程中,可能会被其他的异常包裹。因此我们只需要顺着 InnerException 的链条递归上去就可以确定这个异常链是否属于一个已知的 logging scenario。如果一个异步过程产生了一个 AggregateException 则我们需要处理多个 InnerExceptions 链条,并分别标记为不同的 Logging Scenario。如果是已知的,则我们将会采用这些已知的信息进行日志记录(而非记录详细的异常堆栈),不但准确,而且节省日志存储空间。而如果是未知的,则我们会记录所有的调用堆栈,以便最大可能的记录有用的信息。

对于 Web 应用程序,我们还可以根据异常的类型确定 Logging Level,即对于 HttpExceptionHttpResponseException 类型的异常,如果其 Status Code 不代表一个错误,则是 Information,如果 Status Code 是 4XX 客户端错误,则记录为 Warning,否则记录为 Error

在大部分的技术栈下,总有一个点可以集中处理未处理的异常。我们就可以在这一点从异常中创建 LoggingScenario。并追加到 ILoggingContext 实例中。

把收集到的信息记录下来吧

好了现在我们有了全知全能的 Logging Context。接下来的事情是都变得非常简单了呢。我们只要把所有信息拿出来一股脑的交给 Log4X 库就好了。很遗憾,并非如此。请不要忘记以 Ops 的视角观察问题。

Ops 面对的是成山的 Log,因此,我们应当尽量令其易解析,可查询(这里也要考虑 Log 分析工具的支持,例如 Splunk)。因此推荐使用 XML 或者 JSON 的方式进行日志的存储。例如,我们可以创建一个 ILogFormatter 用于格式化日志信息,再创建一个 ILogWriter 接口用于和不同的日志 library 进行集成。

例如:


public interface ILogFormatter
{
    string Format(
        DateTime time,
        string subject,
        string entryPoint,
        string description,
        object parameters);
}

public interface ILogWriter
{
    void Write(string message, LoggingLevel level);
}

public void WriteScenario(LoggingScenario scenario)
{
    string message;

    try
    {
        message = m_formatter.Format(
            scenario.Time,
            Subject,
            EntryPoint,
            scenario.Description,
            scenario.Parameters);
    }
    catch (Exception error)
    {
        m_writer.Write($"An error occured while generate log message: {error}", LoggingLevel.Error);
        return;
    }

    m_writer.Write(message, scenario.Level);
}

详细的代码请参见 我的 Github Repository。

总结

总之,我们要做的工作就是连接日志工具和应用程序的最后一公里距离。将最有用的信息:谁,什么时候,在什么地方,干了什么,造成了什么后果记录下来。这样 Ops 看到将是一篇篇格式明确,内容充实,业务相关的记叙文而不只是冗长的堆栈信息了。

你可能感兴趣的:(Log 工具很多,但是不解决我的痛点)