Logbook HTTP日志框架
- GitHub 文档及代码地址:https://github.com/zalando/logbook
- SpringBoot使用Logbook记录HTTP请求响应日志:https://mp.weixin.qq.com/s/9LITBfpGqTDTLbpfzp7tfA
Spring Boot的httptrace端口能够记录每次访问的请求和响应信息,但是不能记录body,这样在出问题时就不方便排查,而且httptrace不方便在原有的基础上进行扩展,所以只能寻求其他方式进行记录。
- 允许web应用记录程序接收或发送的所有HTTP通信
- 易于保留和进行分析
Logbook在大部分情况下是开箱即用的,即使对于一些不常用的技术或者应用,实现它们也非常简单。
特性简介
- 日志记录:HTTP请求和响应,包含body;未授权的请求会记录部分日志(不包含body)
- 自定义:能够自定义记录格式、记录方式以及请求记录的条件
- 支持框架:Servlet容器、Apache’s HTTP client、Square’s OkHttp等
- 混淆敏感数据
- Spring Boot自动配置
- 兼容 Scalyr
- 合理的默认值
快速开始
Logbook为SpringBoot用户提供了很方便的自动配置功能,即我们所熟悉的starter。它使用了合理的默认值自动配置了以下功能:
- Servlet filter
- 适用于未授权请求的Servlet filter(如果检测到项目中使用Spring Security)
- Header过滤器、Parameter过滤器、Body过滤器
- HTTP格式化器、JSON格式化器
- 日志写入方式
引入starter模块后SpringBooot会自动装配
<dependency>
<groupId>org.zalando</groupId>
<artifactId>logbook-spring-boot-starter</artifactId>
<version>2.14.0</version>
</dependency>
日志记录器必须配置为trace才能记录请求和响应。 SpringBoot可以通过将以下行添加到 application.properties 来实现
logging.level.org.zalando.logbook = trace
默认配置下,输出的日志为JSON格式:Request、Response
{
"origin":"remote",
"type":"request",
"correlation":"2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b",
"protocol":"HTTP/1.1",
"sender":"127.0.0.1",
"method":"GET",
"path":"http://example.org/test",
"headers":{
"Accept":[
"application/json"
],
"Content-Type":[
"text/plain"
]
},
"body":"Hello world!"
}
{
"origin":"local",
"type":"response",
"correlation":"2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b",
"duration":25,
"protocol":"HTTP/1.1",
"status":200,
"headers":{
"Content-Type":[
"text/plain"
]
},
"body":"Hello world!"
}
配置选项
下面的展示了SpringBoot中可配置的选项:
配置项 | 描述 | 默认值 |
---|
logbook.include | 仅包含某些URL(如果设置的话) | [] | logbook.exclude | 排除某些URL(会覆盖logbook.include)设置了exclude的url是不会触发logbook | [] | logbook.filter.enabled | 是否启用LogbookFilter | true | logbook.filter.form-request-mode | 如何处理表单请求 | body | logbook.secure-filter.enabled | 是否启用SecureLogbookFilter(同时项目中使用Spring Security才会生效) | true | logbook.format.style | 格式化样式(http,json,curl,splunk) | json | logbook.strategy | 策略(default,status-at-least, body-only-if-status-at-least,without-body) | default | logbook.minimum-status | 启用日志记录的最小HTTP响应状态值,当策略值为status-at-least或body-only-if-status-at-least时设置 | 400 | logbook.obfuscate.headers | 需要混淆的HTTP Header集合,默认脱敏符为X | [Authorization] | logbook.obfuscate.paths | 需要混淆的path集合,默认脱敏符为X | [] | logbook.obfuscate.parameters | 需要混淆的parameter集合,默认脱敏符为X | [access_token] | logbook.write.chunk-size | 日志拆分块的大小,默认不拆分 | 0 (禁用) | logbook.write.max-body-size | 截取Body的最大长度,后面使用... 拼接 | |
logging:
level:
org.zalando.logbook: trace
logbook:
include:
- /api/**
exclude:
- /actuator/**
filter:
enabled: true
secure-filter:
enabled: true
format:
style: json
minimum-status: 400
obfuscate:
headers:
- Authorization
- X-Secret
parameters:
- access_token
- password
paths:
- user_id
write:
chunk-size: 1000
max-body-size: 10000
详细用法
所有的功能集成都需要一个Logbook实例来完成,它保存了所有的配置并将所有需要的组件连接在一起。你可以使用所有的默认值创建一个实例:
Logbook logbook = Logbook.create();
或使用以下命令创建自定义版本:LogbookBuilder
Logbook logbook = Logbook.builder()
.condition(new CustomCondition())
.queryFilter(new CustomQueryFilter())
.pathFilter(new CustomPathFilter())
.headerFilter(new CustomHeaderFilter())
.bodyFilter(new CustomBodyFilter())
.requestFilter(new CustomRequestFilter())
.responseFilter(new CustomResponseFilter())
.sink(new DefaultSink(
new CustomHttpLogFormatter(),
new CustomHttpLogWriter()
))
.build();
策略(Strategy)
Logbook使用一个非常硬性的策略来执行请求/响应日志记录:
- 请求/响应分开记录
- 请求/响应尽快记录
- 请求/响应一起记录或不记录(即没有部分流量记录)
其中一些限制可以通过自定义HttpLogWriter实现来缓解,但都不是理想。从2.0版本开始,Logbook引入了一个新的策略模式为核心,它内置了部分策略:
- BodyOnlyIfStatusAtLeastStrategy
- StatusAtLeastStrategy
- WithoutBodyStrategy
阶段(Phases)
Logbook工作在几个不同的阶段:
- 条件(Conditional)
- 过滤(Filtering)
- 格式化(Formatting)
- 记录(Writing)
每个阶段都由一个或多个可以自定义的接口完成。每个阶段都有一个合理的默认值。
条件(Conditional)
记录HTTP消息并且包含其body代价是非常大的,所以禁用某些请求的日志记录非常有意义。常见情景就是忽略一些不必要的请求,比如SpringBoot的Actuator端点。定义一个条件非常简单,只需要编写一个Predicate来决定请求是否需要记录。当然,你也可以组合预定义的 Predicate:
注意关键类:org.zalando.logbook.Conditions、org.zalando.logbook.HttpRequest
import static org.zalando.logbook.Conditions.*;
@Bean
public Logbook logbook() {
return Logbook.builder()
.condition(exclude(
requestTo("/health"),
requestTo("/admin/**"),
contentType("application/octet-stream"),
header("X-Secret", Stream.of("1", "X-Secret").collect(Collectors.toSet())::contains)))
.build();
}
package com.xyz.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.zalando.logbook.HttpRequest;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.zalando.logbook.Conditions.*;
@Configuration
public class LogbookConfig {
@Bean
public Predicate<HttpRequest> requestCondition() {
return exclude(
requestTo("/health"),
requestTo("/admin/**"),
contentType("application/octet-stream"),
header("X-Secret", Stream.of("1", "X-Secret").collect(Collectors.toSet())::contains));
}
}
对于路径的包含和排除也可以通过设置 logbook.include和 logbook.exclude属性实现。并且能使用通配符:例如 /admin/** 松散地遵循 Ant 的路径模式风格,而不考虑 URL 的查询字符串。
过滤(Filtering)
过滤的目的是防止记录HTTP请求和响应的某些敏感数据。通常包括Authorization请求头,但也可以用于某些明文查询或表单参数,如access_token和password
Logbook支持不同类型的过滤器:
类型 | 作用于 | 适用于 | 默认值 |
---|
QueryFilter | 请求参数 | request | access_token | PathFilter | 路径 | request | | HeaderFilter | 请求头 | request/response | Authorization | BodyFilter | Content-Type and body | request/response | json格式:access_token和refresh_token,form表单:client_secret和password | RequestFilter | HttpRequest | request | 替换二进制、文件上传和流 | ResponseFilter | HttpResponse | response | 替换二进制、文件上传和流 |
QueryFilter, PathFilter, HeaderFilter 和 BodyFilter能够满足绝大多数情况下的需求,对于更复杂的需求,可以使用 RequestFilter 和 ResponseFilter(与ForwardingHttpRequest、ForwardingHttpResponse结合使用)
import static org.zalando.logbook.HeaderFilters.authorization;
import static org.zalando.logbook.HeaderFilters.eachHeader;
import static org.zalando.logbook.QueryFilters.accessToken;
import static org.zalando.logbook.QueryFilters.replaceQuery;
Logbook logbook = Logbook.builder()
.requestFilter(RequestFilters.replaceBody(message -> contentType("audio/*").test(message) ? "mmh mmh mmh" : null))
.responseFilter(ResponseFilters.replaceBody(message -> contentType("*/*-stream").test(message) ? "keeps going" : null))
.queryFilter(accessToken())
.queryFilter(replaceQuery("password", "<secret>"))
.headerFilter(authorization())
.headerFilter(eachHeader("X-Secret"::equalsIgnoreCase, "<secret>"))
.build();
package com.xyz.config;
import org.springframework.context.annotation.Bean;
import org.zalando.logbook.*;
import org.zalando.logbook.json.JacksonJsonFieldBodyFilter;
import org.zalando.logbook.json.JsonBodyFilters;
import java.util.Collections;
public class LogbookConfig {
@Bean
public QueryFilter queryFilter() {
return QueryFilters.replaceQuery("access_token", "***");
}
@Bean
public PathFilter pathFilter() {
return PathFilters.replace("userId", "***");
}
@Bean
public HeaderFilter headerFilter() {
HeaderFilters.replaceHeaders("Authorization", "***");
HeaderFilter.merge(HeaderFilters.defaultValue(),HeaderFilters.replaceHeaders("filed","***"));
HeaderFilter.merge(HeaderFilters.defaultValue(),HeaderFilters.replaceHeaders(Collections.EMPTY_SET,"***"));
return HeaderFilters.replaceHeaders(Collections.singleton("secret"), "***");
}
@Bean
public BodyFilter bodyFilter() {
BodyFilter.merge(BodyFilters.defaultValue(), new JacksonJsonFieldBodyFilter(Collections.EMPTY_SET, "***"));
return BodyFilter.merge(BodyFilters.defaultValue(),
JsonBodyFilters.replaceJsonStringProperty(Collections.singleton("secret"), "***"));
}
@Bean
public RequestFilter requestFilter () {
return RequestFilters.replaceBody(
BodyReplacers.replaceBody(Conditions.contentType("audio/*"), "<audio>"));
}
@Bean
public ResponseFilter responseFilter () {
return ResponseFilters.replaceBody(
BodyReplacers.replaceBody(Conditions.contentType("*/*-stream"), "<stream>"));
}
}
您可以根据需要配置任意数量的过滤器 - 它们将连续运行。
JsonPath body 过滤(实验),您可以将 JSONPath 过滤应用于 JSON 正文。 这里有些例子:
import static org.zalando.logbook.json.JsonPathBodyFilters.jsonPath;
import static java.util.regex.Pattern.compile;
Logbook logbook = Logbook.builder()
.bodyFilter(jsonPath("$.password").delete())
.bodyFilter(jsonPath("$.active").replace("unknown"))
.bodyFilter(jsonPath("$.address").replace("X"))
.bodyFilter(jsonPath("$.name").replace(compile("^(\\w).+"), "$1."))
.bodyFilter(jsonPath("$.friends.*.name").replace(compile("^(\\w).+"), "$1."))
.bodyFilter(jsonPath("$.grades.*").replace(1.0))
.build();
在应用过滤之前和之后:
{
"id": 1,
"name": "Alice",
"password": "s3cr3t",
"active": true,
"address": "Anhalter Stra?e 17 13, 67278 Bockenheim an der Weinstra?e",
"friends": [
{
"id": 2,
"name": "Bob"
},
{
"id": 3,
"name": "Charlie"
}
],
"grades": {
"Math": 1.0,
"English": 2.2,
"Science": 1.9,
"PE": 4.0
}
}
{
"id": 1,
"name": "Alice",
"active": "unknown",
"address": "XXX",
"friends": [
{
"id": 2,
"name": "B."
},
{
"id": 3,
"name": "C."
}
],
"grades": {
"Math": 1.0,
"English": 1.0,
"Science": 1.0,
"PE": 1.0
}
}
格式化(Formatting)
格式化是把请求和响应转换为字符串。格式化不会指定请求和响应的记录位置,这是由Writer完成的。Logbook有两种默认格式化:HTTP 和 JSON
HTTP
HTTP 是默认的格式化样式,由DefaultHttpLogFormatter提供。 它主要用于本地开发和调试,而不是用于生产用途。 这是因为它不像JSON那样易于读取SpringBoot只需要配置:logbook.format.style=http ,如下是 Request 和 Response
Incoming Request: 2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b
GET http://example.org/test HTTP/1.1
Accept: application/json
Host: localhost
Content-Type: text/plain
Hello world!
Outgoing Response: 2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b
Duration: 25 ms
HTTP/1.1 200
Content-Type: application/json
{"value":"Hello world!"}
JSON
JSON 是另一种格式样式,由 JsonHttpLogFormatter 提供。 与 HTTP 不同,它主要是为生产使用而设计的——解析器和日志消费者可以轻松地使用它。SpringBoot只需要配置:logbook.format.style=json (默认也是JSON),如下是 Request 和 Response
{
"origin":"remote",
"type":"request",
"correlation":"2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b",
"protocol":"HTTP/1.1",
"sender":"127.0.0.1",
"method":"GET",
"path":"http://example.org/test",
"headers":{
"Accept":[
"application/json"
],
"Content-Type":[
"text/plain"
]
},
"body":"Hello world!"
}
{
"origin":"local",
"type":"response",
"correlation":"2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b",
"duration":25,
"protocol":"HTTP/1.1",
"status":200,
"headers":{
"Content-Type":[
"text/plain"
]
},
"body":"Hello world!"
}
Common Log Format
通用日志格式 (CLF) 是 Web 服务器在生成服务器日志文件时使用的标准化文本文件格式。 通过 CommonsLogFormatSink 支持该格式:
185.85.220.253 - - [02/Aug/2019:08:16:41 0000] "GET /search?q=zalando HTTP/1.1" 200 -
cURL
cURL 是另一种格式样式,由 CurlHttpLogFormatter 提供,它将请求呈现为可执行的 cURL 命令。 与 JSON 不同,它主要是为人类设计的。SpringBoot只需要配置:logbook.format.style=curl ,如下是 Request 和 Response
curl -v -X GET 'http://localhost/test' -H 'Accept: application/json'
Outgoing Response: 2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b
Duration: 25 ms
HTTP/1.1 200
Content-Type: application/json
{"value":"Hello world!"}
Splunk
Splunk 是另一种格式样式,由 SplunkHttpLogFormatter 提供,它将请求和响应呈现为键值对。SpringBoot只需要配置:logbook.format.style=splunk ,如下是 Request 和 Response
origin=remote type=request correlation=2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b protocol=HTTP/1.1 sender=127.0.0.1 method=POST uri=http://example.org/test host=example.org scheme=http port=null path=/test headers={Accept=[application/json], Content-Type=[text/plain]} body=Hello world!
origin=local type=response correlation=2d66e4bc-9a0d-11e5-a84c-1f39510f0d6b duration=25 protocol=HTTP/1.1 status=200 headers={Content-Type=[text/plain]} body=Hello world!
记录(Writer)
Writer定义了格式化后的请求和响应写入的位置。Logbook内置了三种实现:Logger、Stream、Chunking
Logger
默认情况下,使用 org.zalando.logbook.Logbook 类别和日志级别 trace 的 slf4j 记录器记录请求和响应。 也可以自定义:
默认情况下,请求和响应使用了slf4j来进行日志记录,日志的级别为 trace。也可以自定义:
Logbook logbook = Logbook.builder()
.sink(new DefaultSink(
new DefaultHttpLogFormatter(),
new DefaultHttpLogWriter()
))
.build();
Stream
另一种实现是将请求和响应记录到 PrintStream,例如 System.out 或 System.err。 在生产环境中这是一个糟糕的选择,但有时对短期本地开发和/或调查很有用
Logbook logbook = Logbook.builder()
.sink(new DefaultSink(
new DefaultHttpLogFormatter(),
new StreamHttpLogWriter(System.err)
))
.build();
Chunking
ChunkingSink 会把长的消息分割成较小的块,并且会委托给另一个sink将它们写入,只需要设置 logbook.write.chunk-size 属性即可
Logbook logbook = Logbook.builder()
.sink(new ChunkingSink(sink, 1000))
.build();
关联ID(Correlation)
在SpringCloud应用中一般会集成Zipkin进行链路追踪,此时可以使用TraceId来关联请求和响应日志记录。
Logbook使用一个id来关联请求和响应,因为请求和响应通常位于日志文件中的不同位置(默认ID是16位随机字符组成)如默认不满足可以自定义实现:
@Bean
public org.zalando.logbook.CorrelationId correlationId () {
return request -> UUID.randomUUID().toString();
}
Logbook logbook = Logbook.builder().correlationId(new CustomCorrelationId()).build();
Sink
HttpLogFormatter 和 HttpLogWriter 的组合能够适用于大部分场合,但也有一些局限性。 实现 Sink 接口允许更复杂的用例,例如把请求和响应持久化到数据库。你可以使用 CompositeSink 将多个Sink合并为一个。
其他框架支持
Servlet
在Servlet环境中,Logbook是通过 LogbookFilter来实现的。默认情况下,对于application/x-www-form-urlencoded 请求会同等对待,即你会在日志中看到请求body。这种方法的缺点是下游代码将无法使用任何 HttpServletRequest.getParameter*(…)方法。
从Logbook 1.5.0开始,可使用 logbook.servlet.form-request系统属性(System Property)指定三种策略之一,这些策略定义Logbook如何处理这种情况(可以从源码中查看:(org.zalando.logbook.servlet.FormRequestMode)
属性值 | 优点 | 缺点 |
---|
body(默认) | body会被记录 | 下游代码不能使用getParameter() | parameter | body会被记录 | 下游代码不能使用getInputStream() | off | 下游代码可以使用getParameter()或getInputStream() | body不会被记录 |
Logbook默认还提供了对:Servlet、HTTP Client、JAX-RS、Netty、OkHttp v2.x、OkHttp v3.x的支持,具体使用方法可以参考官方文档
|