OpenTelemetry 提供了一系列的自动插桩库,它们通常是通过 Hooks 或者 Monkey-Patching 的机制来实现的。
对于用户而言,原生库默认支持 OpenTelemetry 明显会有更好的体验,无需 Lib 库单独暴露 Hooks 机制:
- 自定义日志 hooks 其实可以被 OpenTelemetry API 来替换,用户可以只与 OpenTelemetry 进行交互;
- 应用代码和依赖库中得到的 trace, logs 和 metrics 是相互关联且连贯的;
- 通过一些统一的约定,可以使得用户在不同语言和框架中得到的遥测数据的格式是相似的;
- 遥测信号可以使用各种 OpenTelemetry 扩展点针对各种消费场景进行微调(过滤、处理、聚合)。
语义约定
https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/
一文中涵盖了 Web 框架、RPC 客户端、数据库、消息传递客户端、基础设施等通用的语义约定!
如果你想要开发的 Lib 库属于上述这些场景,那么你应该遵循上述约定在跨度中增加相关信息。通过上述语义约定,可以在不同的插桩库中,整个对用户的感受保持一致。对于用户而言,他们可以不再学习相关库的实现细节;对于后端服务供应商而言,可以为各种场景构建格式的可视化报表模式等。只要开发的 Lib 库符合相关的语义约定,那么很多场景就可以做到开箱即用,用户无需任何的配置。
什么场景无需插桩
一些 Lib 库是封装的网络调用的客户端,OpenTelemetry 可能会已经对底层的 RPC 调用库进行了自动插桩。此时,就不需要额外在外层的 Lib 库再次插桩了。
如果是下述场景,也没有必要进行插桩:
- 你的库本身就是在某个 APIs 上的代理;
- 底层封装的 RPC 调用请求已经被 OpenTelemetry 插桩;
- 你的库本身没有匹配的语义约定规则来构建遥测数据。
如果你不确定是否需要进行插桩,那就先别着急,等到确定需要的时候再进行插桩就行。
即使你在库开发的过程中默认没有进行插桩,但是你也应该在你的 RPC Client 实例中提供一个方式传入 OpenTelemetry Handler。这对于不支持自动插桩的场景而言仍然是必不可少的能力。
如果你觉得要自动插桩,那么下文的内容将会指导你在哪儿插桩以及如何进行插桩。
OpenTelemetry API
第一步就是依赖 OpenTelemetry API 包。
OpenTelemetry 主要包含两个模块:API 和 SDK。其中,API 是一组非操作类的功能实现;如果你的应用程序没有导入 SDK,那么插桩库不会执行任何实质性操作,也就不会影响程序的性能。
对于通用 Lib 库而言,它们应该仅仅引用 OpenTelemetry 的 API。
听到需要增加新的依赖你肯定会感觉到非常的头大,遵循以下一些思路可以帮助你最小化成本的进行依赖管理:
- OpenTelemetry Trace API 在 2021 年初已经达到稳定,它遵循 Semantic Versioning 2.0 标准,我们承诺 API 的稳定性。
- 如果你不想用新的功能,那么在获取依赖时,请使用稳定版本 OpenTelemetry API (1.0.*) 并且别更新它。
- 当你的插桩库稳定可用后,你可以考虑将其抽取出来作为单独的包,这样一来,就可以完全对不使用该能力的用户带来影响。此外,你还可以将你的库添加到 OpenTelemetry 库仓库中,和其他的插桩库组合使用。
- 目前语义约定还不太稳定,这基本不会带来什么功能问题,但是你最好定时更新一下你的插桩库。将它放到 OpenTelemetry contrib repo 可以保证语义约定能够始终保持最新。
获取 tracer
所有配置相关的读取已经全部封装到了 Tracer API 中,我们可以直接通过全局的 TracerProvider 来获取一个 tracer 实例,示例代码如下:
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("demo-db-client", "0.1.0-beta1");
我们在编写 Lib 功能库时,提供一个支持程序显示传入 TracerProvider 实例的 API 会很有用。在获取一个 tracer 实例时,传入的 tracer 名称和版本信息可以增加到遥测数据中。用户可以根据它们来进行处理和过滤遥测数据或者进行调试和定位。
在哪儿插桩?
公共 API
公共 API 是非常适合 Tracing 插桩的,在用户代码中用户可以在 API 调用的过程中创建 Spans 并记录对应调用库的持续时间和结果。需要 Trace 的调用场景包括:
- 那些耗时较长并且可能会失败的网络调用或者本地IO操作;
- 处理请求或者消息的 handler。
插桩示例代码如下:
private static final Tracer tracer = GlobalOpenTelemetry.getTracer("demo-db-client", "0.1.0-beta1");
private Response selectWithTracing(Query query) {
// check out conventions for guidance on span names and attributes
Span span = tracer.spanBuilder(String.format("SELECT %s.%s", dbName, collectionName))
.setSpanKind(SpanKind.CLIENT)
.setAttribute("db.name", dbName)
...
.startSpan();
// makes span active and allows correlating logs and nest spans
try (Scope unused = span.makeCurrent()) {
Response response = query.runWithRetries();
if (response.isSuccessful()) {
span.setStatus(StatusCode.OK);
}
if (span.isRecording()) {
// populate response attributes for response codes and other information
}
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
throw e;
} finally {
span.end();
}
}
其中,应该按照语义约定来进行对应的属性填充。
逐层网络调用
网络调用过程中,通常会在客户端通过自动插桩的机制实现 Trace 跟踪。
如果目前你使用的客户端目前还没有支持 OpenTelemetry 自动插桩,那么了解以下信息可能会对你有一些帮助:
- 对网络调用增加 tracing 是否能为你们的项目增强可观测性?
- 你的库是不是仅仅是对一些 RPC API 的简单封装?用户是否需要了解底层相关细节?
- 原则是需要在库中插桩从而保证每次网络调用都是被 trace 的。
- 使用 span 追踪这些信息是否会让你的调用变的非常的冗长?是否会显著影响到服务的性能?
- 如果是的话,子 span 可以转成使用包含 span 事件的日志,日志可以与父 API 调用相关联;span 事件仅在公共 API span 中进行设置;
- 如果需要进行追踪上下文传播等场景必须使用 span 时,那么可以把他们放到配置选项中,并在默认情况下需要禁用。
如果你使用的客户端已经支持了 OpenTelemetry 自动插桩的功能,但是你可能不想要使用它,而需要去复制一份进行修改,例如如下这些场景:
- 支持用户禁用自动插桩机制;
- 启用与底层服务的自定义关联和上下文传播机制;
- 丰富那些默认的自动插桩库中 span 没有包含的服务信息。
事件
Trace 是一种典型的遥测数据,而事件可以作为 Trace 的有效补充。当您有一些需要详细说明的内容时,日志(Events)相比 Trace 而言更加适用。
您的 Lib 库可能已经使用日志或一些类似的机制。 查看 OpenTelemetry 注册表以查看 OpenTelemetry 是否与其集成。 集成后,通常在所有日志上标记活动跟踪上下文,因此用户可以将它们关联起来。
如果您使用的语言和生态系统没有通用的日志库,那么可以使用 Span Events 来记录更多其他详细信息。 很多时候,添加事件会比添加属性要更加的方便。
通常而言,添加事件和Logs时,针对的往往不是 span。因此,如果可以的话,尽量直接针对某个span的实例来添加Events,而不是每次获取当前所处的 Span。因为,当前所处的 Span 可能会是频繁变化的,从而导致无法控制。
上下文传递
提取上下文
如果是在接收上游服务的调用,例如 Web 框架或 MQ Consumer,那么应该从传入的请求/消息中提取上下文。 OpenTelemetry 提供了 Propagator API,它隐藏了特定的 propagation 规范并读取 Trace 的上下文。 在解析到对应的上下文后,会基于之前的上下文创建一个新的 span。
示例代码如下:
// extract the context
Context extractedContext = propagator.extract(Context.current(), httpExchange, getter);
Span span = tracer.spanBuilder("receive")
.setSpanKind(SpanKind.SERVER)
.setParent(extractedContext)
.startSpan();
// make span active so any nested telemetry is correlated
try (Scope unused = span.makeCurrent()) {
userCode();
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
注入上下文
当你准备调用其他服务时,您通常希望将上下文内容到下游服务。在这种情况下,您应该创建一个新的 span 来跟踪外部调用并使用 Propagator API 将上下文注入到请求信息中。此外,例如在异步处理消息等场景,你可能也希望将上下文转为具体的数据信息。
我们来看一个示例:
Span span = tracer.spanBuilder("send")
.setSpanKind(SpanKind.CLIENT)
.startSpan();
// make span active so any nested telemetry is correlated
// even network calls might have nested layers of spans, logs or events
try (Scope unused = span.makeCurrent()) {
// inject the context
propagator.inject(Context.current(), transportLayer, setter);
send();
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end();
}
此处可能有一些例外:
- 下游服务可能不支持接收一些未知字段;
- 下游服务目前可能没有进行 OpenTelemetry 注入,导致无法解析接收的上下文数据;
- 下游服务支持一些定制化的上下文协议协议:
- 通过自定义 propagator 来尽可能的使用 OpenTelemetry Context。
- 在跨度上生成并标记满足定制化协议的相关 ID。
进程内传播
- 保证始终处于某个 active span 的状态下。这样可以保证创建的 span,添加的 log 等都可以自动当前的 span 自动关联。
- 如果一个 Lib 库有上下文的概念,那么应该支持传入一个可选的 Trace 上下文来进行显示传播。
- Active Span 在回调场景中有可能会丢失,因此需要尽可能显示传播。
- 如果您显式 Fork 了线程用于执行其他任务,那么上下文一定会丢失,务必需要进行显式传播来实现 span 传递。
Metrics 指标
Metrics API 指标目前尚未稳定,暂不编写相关文档。
其他
插桩库仓库
建议将您编写的插桩库添加到 OpenTelemetry registry 中,从而可以给所有用户共享。
性能
当应用程序中没有绑定 SDK 时,OpenTelemetry API 是无实际操作并且几乎没有性能损耗的。当配置 OpenTelemetry SDK 后,它就会产生一定程度的性能损耗。
生产环境中的应用程序经常会配置基于头部的采样。采样的成本很低,通常,可以判断当前的 Span 是否已经被采样从而避免之前采样的数据丢失。同时,通过采样的机制可以有效减少性能损耗。
// some attributes are important for sampling, they should be provided at creation time
Span span = tracer.spanBuilder(String.format("SELECT %s.%s", dbName, collectionName))
.setSpanKind(SpanKind.CLIENT)
.setAttribute("db.name", dbName)
...
.startSpan();
// other attributes, especially those that are expensive to calculate
// should be added if span is recording
if (span.isRecording()) {
span.setAttribute("db.statement", sanitize(query.statement()))
}
错误处理
OpenTelemetry API 在运行时是尽可能鲁棒的,不会由于无效的参数等导致服务异常,而会尽可能捕获各种异常,从而保证不会影响应用程序本身的逻辑。
测试
由于 OpenTelemetry 具有很多自动插桩库,互相之间的组合等是否能够正常工作是需要进行完善测试的。
对于单元测试来讲,我们通常会 Mock 一些 SpanProcessor 和 SpanExporter:
@Test
public void checkInstrumentation() {
SpanExporter exporter = new TestExporter();
Tracer tracer = OpenTelemetrySdk.builder()
.setTracerProvider(SdkTracerProvider.builder()
.addSpanProcessor(SimpleSpanProcessor.create(exporter)).build()).build()
.getTracer("test");
// run test ...
validateSpans(exporter.exportedSpans);
}
class TestExporter implements SpanExporter {
public final List<SpanData> exportedSpans = Collections.synchronizedList(new ArrayList<>());
@Override
public CompletableResultCode export(Collection<SpanData> spans) {
exportedSpans.addAll(spans);
return CompletableResultCode.ofSuccess();
}
...
}