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 实例,示例代码如下:

  1. private static final Tracer tracer = GlobalOpenTelemetry.getTracer("demo-db-client", "0.1.0-beta1");

我们在编写 Lib 功能库时,提供一个支持程序显示传入 TracerProvider 实例的 API 会很有用。在获取一个 tracer 实例时,传入的 tracer 名称和版本信息可以增加到遥测数据中。用户可以根据它们来进行处理和过滤遥测数据或者进行调试和定位。

在哪儿插桩?

image.png

公共 API

公共 API 是非常适合 Tracing 插桩的,在用户代码中用户可以在 API 调用的过程中创建 Spans 并记录对应调用库的持续时间和结果。需要 Trace 的调用场景包括:

  • 那些耗时较长并且可能会失败的网络调用或者本地IO操作;
  • 处理请求或者消息的 handler。

插桩示例代码如下:

  1. private static final Tracer tracer = GlobalOpenTelemetry.getTracer("demo-db-client", "0.1.0-beta1");
  2. private Response selectWithTracing(Query query) {
  3. // check out conventions for guidance on span names and attributes
  4. Span span = tracer.spanBuilder(String.format("SELECT %s.%s", dbName, collectionName))
  5. .setSpanKind(SpanKind.CLIENT)
  6. .setAttribute("db.name", dbName)
  7. ...
  8. .startSpan();
  9. // makes span active and allows correlating logs and nest spans
  10. try (Scope unused = span.makeCurrent()) {
  11. Response response = query.runWithRetries();
  12. if (response.isSuccessful()) {
  13. span.setStatus(StatusCode.OK);
  14. }
  15. if (span.isRecording()) {
  16. // populate response attributes for response codes and other information
  17. }
  18. } catch (Exception e) {
  19. span.recordException(e);
  20. span.setStatus(StatusCode.ERROR, e.getClass().getSimpleName());
  21. throw e;
  22. } finally {
  23. span.end();
  24. }
  25. }

其中,应该按照语义约定来进行对应的属性填充。

逐层网络调用

网络调用过程中,通常会在客户端通过自动插桩的机制实现 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。
示例代码如下:

  1. // extract the context
  2. Context extractedContext = propagator.extract(Context.current(), httpExchange, getter);
  3. Span span = tracer.spanBuilder("receive")
  4. .setSpanKind(SpanKind.SERVER)
  5. .setParent(extractedContext)
  6. .startSpan();
  7. // make span active so any nested telemetry is correlated
  8. try (Scope unused = span.makeCurrent()) {
  9. userCode();
  10. } catch (Exception e) {
  11. span.recordException(e);
  12. span.setStatus(StatusCode.ERROR);
  13. throw e;
  14. } finally {
  15. span.end();
  16. }

注入上下文

当你准备调用其他服务时,您通常希望将上下文内容到下游服务。在这种情况下,您应该创建一个新的 span 来跟踪外部调用并使用 Propagator API 将上下文注入到请求信息中。此外,例如在异步处理消息等场景,你可能也希望将上下文转为具体的数据信息。
我们来看一个示例:

  1. Span span = tracer.spanBuilder("send")
  2. .setSpanKind(SpanKind.CLIENT)
  3. .startSpan();
  4. // make span active so any nested telemetry is correlated
  5. // even network calls might have nested layers of spans, logs or events
  6. try (Scope unused = span.makeCurrent()) {
  7. // inject the context
  8. propagator.inject(Context.current(), transportLayer, setter);
  9. send();
  10. } catch (Exception e) {
  11. span.recordException(e);
  12. span.setStatus(StatusCode.ERROR);
  13. throw e;
  14. } finally {
  15. span.end();
  16. }

此处可能有一些例外:

  • 下游服务可能不支持接收一些未知字段;
  • 下游服务目前可能没有进行 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 是否已经被采样从而避免之前采样的数据丢失。同时,通过采样的机制可以有效减少性能损耗。

  1. // some attributes are important for sampling, they should be provided at creation time
  2. Span span = tracer.spanBuilder(String.format("SELECT %s.%s", dbName, collectionName))
  3. .setSpanKind(SpanKind.CLIENT)
  4. .setAttribute("db.name", dbName)
  5. ...
  6. .startSpan();
  7. // other attributes, especially those that are expensive to calculate
  8. // should be added if span is recording
  9. if (span.isRecording()) {
  10. span.setAttribute("db.statement", sanitize(query.statement()))
  11. }

错误处理

OpenTelemetry API 在运行时是尽可能鲁棒的,不会由于无效的参数等导致服务异常,而会尽可能捕获各种异常,从而保证不会影响应用程序本身的逻辑。

测试

由于 OpenTelemetry 具有很多自动插桩库,互相之间的组合等是否能够正常工作是需要进行完善测试的。
对于单元测试来讲,我们通常会 Mock 一些 SpanProcessor 和 SpanExporter:

  1. @Test
  2. public void checkInstrumentation() {
  3. SpanExporter exporter = new TestExporter();
  4. Tracer tracer = OpenTelemetrySdk.builder()
  5. .setTracerProvider(SdkTracerProvider.builder()
  6. .addSpanProcessor(SimpleSpanProcessor.create(exporter)).build()).build()
  7. .getTracer("test");
  8. // run test ...
  9. validateSpans(exporter.exportedSpans);
  10. }
  11. class TestExporter implements SpanExporter {
  12. public final List<SpanData> exportedSpans = Collections.synchronizedList(new ArrayList<>());
  13. @Override
  14. public CompletableResultCode export(Collection<SpanData> spans) {
  15. exportedSpans.addAll(spans);
  16. return CompletableResultCode.ofSuccess();
  17. }
  18. ...
  19. }