不得不承认,微软的Teams聊天软件借(chao)鉴(xi)了很多Slack的理念,凭借其庞大的用户群体以及产品生态优势,使Teams在短时间内得以快速发展和推广。与其他消息工具类似,Teams也提供了webhook,使用它可以很容易地拓展Teams的功能,使其不仅限于一个聊天软件,更可以使其变成一个消息门户。今天我们就实现一个logback appender,使Teams可以显示应用程序的报警通知。

Webhook

在Teams中Connectors是用来与外部应用程序沟通的重要媒介,而Incoming webhooks就是一种特殊的Connector, 它本质上就是一个唯一的URL,可以通过向这个URL来post一组JSON信息,来达到与Teams沟通协作的目的。关于Teams中Connector与webhook的更多介绍,可以参考这里

在开始向Teams发送日志之前,我们应该准备好webhook,创建webhook很简单,大概就是下面的几个步骤,

  1. 首先在希望展示log信息的聊天群组中新建一个channel;
  2. 配置这个channel的Connectors,添加一个Incoming Webhook;
  3. 可以给这个webhook配置一个独特的头像,也可以使用默认;
  4. 拷贝生成的webhook URL备用。

关于创建webhook的详细步骤,可以参考这里

Message Card

发送至Teams的JSON结构要遵守office-365-connector-card的约束,这里我们只采用Cards中相对简单的Message Card来作为消息的载体,其JSON结构类似于,

1
2
3
4
5
6
7
{
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
"themeColor": "0076D7",
"title": "Larry Bryant created a new task",
"text": "Let's do it"
}

借助于Lombok与Jackson,MessageCard 的实现非常简单,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Data
@Builder
public class MessageCard {

@JsonProperty("@type")
private final String type = "MessageCard";

@JsonProperty("@context")
private final String context = "http://schema.org/extensions";

private String title;

private String text;

private String themeColor;

}

Http客户端

前面已经讲到,Teams中的消息是来自于POST到webhook的MessageCard的JSON,所以挑选一个合适的http客户端就非常的重要,我们希望这个http客户端能够具备以下特性:

  1. 高性能(不解释);
  2. 少依赖(我们自然不希望一个logback appender还有一大堆的依赖);
  3. 全特性(至少能够支持配置代理等特性,毕竟发送日志的应用不一定能够直接请求到webhook);

按照以上的要求,这里我们选择OkHttp,以上3点都可以满足。下面的代码段展示如何根据配置参数构建一个http client,支持以下属性:

  • 连接超时;
  • 读写超时;
  • 代理和代理凭据;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
private final OkHttpClient okHttpClient;

public MsTeamsAppender() {

final OkHttpClient.Builder builder = new OkHttpClient.Builder();

// Timeout Setting
builder.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS);

// Proxy Setting
if (Objects.nonNull(proxyHost) && Objects.nonNull(proxyPassword)) {
builder.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort)));
}

// Proxy Authentication Setting
if (Objects.nonNull(proxyUsername) && Objects.nonNull(proxyPassword)) {
final Authenticator proxyAuthenticator = (route, response) -> {
String credential = Credentials.basic(proxyUsername, proxyPassword);
return response.request().newBuilder()
.header("Proxy-Authorization", credential)
.build();
};

builder.proxyAuthenticator(proxyAuthenticator);
}

this.okHttpClient = builder.build();
}

以上代码中的 connectTimeoutwriteTimeoutreadTimeoutproxyHostproxyPortproxyUsernameproxyPassword 均来自于logback的配置文件。

消息内容的结构与样式

在Teams中显示系统异常信息,其主要目的还是为了提醒或者是报警,而不是用Teams来替代系统日志。当出现异常时,我们可以通过Teams的信息快速了解到报错的摘要,进而判断紧急程度,如果需要采取进一步的分析,仍然需要通过其他技术手段来查看详细日志,例如日志文件或者ELK。无论从业务需求或是性能需求看,发送到Teams的消息应该尽可能的简单高效,一目了然。
基于以上目的,我们用下面的结构与样式来表现一个消息的内容,

  • title

    • prefix(可选), 标题前缀会自动被[]包围,用来展示Server或者Appliation的名称,当然也可以直接指定环境profiel,例如staging、prod等;
    • title body, 出错日志的message部分,能够非常直观的了解到报错的消息内容,例如log.error("error occurred when processing request", e), 那么title body就是 error occurred when processing request ;
  • body

    • logger name, 输出这个日志的logger,一般是一个class的全名;
    • formatted message, 相当于Exception的名称和Error Message,假设抛出异常的代码是new IllegalStateException("OPS! You are not so good..."), 那么输出的消息就是 java.lang.IllegalStateException: OPS! You are not so good…;
    • exceptoin stack trace, 由于异常的stack trace往往都很长,这里我们会加以限制,最多打印N行,默认N=5,超出部分用 来代替;

    基于以上定义,如果抛出一个new IllegalStateException("OPS! You are not so good...")异常且被log.error("error occurred when processing request", e)捕获,在Teams中显示的消息格式大概就是酱紫,

logback中,消息来源于 ILoggingEvent ,通过这个对象,我们可以获取到上面title与body所需的所有信息,由于 ILoggingEvent 是一个interface,我们需要把它cast成具体的实现类从而获得更多的属性,详细代码如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
private MessageCard buildMessage(ILoggingEvent event) {

StringBuilder titleBuilder = new StringBuilder();
if (Objects.nonNull(this.titlePrefix) && !"".equals(this.titlePrefix)) {
titleBuilder.append("[").append(titlePrefix).append("]");
}

titleBuilder.append(event.getFormattedMessage());


StringBuilder bodyBuilder = new StringBuilder(event.getLoggerName());

Optional.ofNullable(event.getThrowableProxy())
.map(ThrowableProxy.class::cast)
.flatMap(throwableProxy -> Optional.ofNullable(throwableProxy.getThrowable()))
.ifPresent(throwable -> {

bodyBuilder.append(" - ").append(throwable.toString());

StackTraceElement[] elements = throwable.getStackTrace();

Function<StackTraceElement, String> mapper = traceElement -> "\tat " + traceElement;

final Stream<String> traces = elements.length >= this.stackTraceLines ?
Stream.concat(Stream.of(elements)
.limit(this.stackTraceLines)
.map(mapper),
Stream.of("\tat ...")) : Stream.of(elements).map(mapper);

final String stackTrace = traces.collect(Collectors.joining("\n"));

bodyBuilder.append("<br><pre>").append(stackTrace).append("</pre>");

});

return MessageCard.builder()
.title(titleBuilder.toString())
.text(bodyBuilder.toString())
.themeColor(getThemeColorByLevel(event.getLevel()))
.build();

}

限制与缺点

虽说Teams为我们提供了webhook的方式,但这种其终究还是一个企业级聊天平台,而非一个集中日志系统,而Connctors本身,也有一定的QPS限制,并不能及时处理海量的日志或报警,试想一下我们的系统采用大规模的集群部署或者容器化部署,在短时间内可能会产生大量的错误报警,这个时候对于Teams Connector的冲击还是非常大的。
Connector的QPS限制可以参考这里

所以在某些场景下,直接使用logback appender向Teams发送消息并不是一个非常好的选择,可以适当考虑采用ELK等集中式日志管理平台来收集日志,在出发某些特定条件,再由日志平台向Teams转发消息。

源代码

请参考
logback-msteams-appender-plus