前言

前段时间我们从 SkyWalking 切换到了 OpenTelemetry ,与此同时之前使用 SkyWalking 编写的插件也得转移到 OpenTelemetry 体系下。

我也写了相关介绍文章:

实战:如何优雅的从 SkyWalking 切换到 OpenTelemetry

好在 OpenTelemetry 社区也提供了 Extensions 的扩展开发,我们可以不用去修改社区发行版:opentelemetry-javaagent.jar 的源码也可以扩展其中的能力。

比如可以:

  • 修改一些 trace,某些 span 不想记录等。
  • 新增 metrics

这次我准备编写的插件也是和 metrics 有关的,因为 pulsar 的 Java sdk 中并没有暴露客户端的一些监控指标,所以我需要在插件中拦截到一些关键函数,然后执行暴露出指标。

截止到本文编写的时候, Pulsar 社区也已经将 Java-client 集成了 OpenTelemetry,后续正式发版后我这个插件也可以光荣退休了。


由于 OpenTelemetry 社区还处于高速发展阶段,我在中文社区没有找到类似的参考文章(甚至英文社区也没有,只有一些 example 代码,或者是只有去社区成熟插件里去参考代码)

其中也踩了不少坑,所以觉得非常有必要分享出来帮助大家减少遇到同类问题的机会。

开发流程

OpenTelemetry extension 的写法其实和 skywalking 相似,都是用的 bytebuddy这个字节码增强库,只是在一些 API 上有一些区别。

创建项目

首先需要创建一个 Java 项目,这里我直接参考了官方的示例,使用了 gradle 进行管理(理论上 maven 也是可以的,只是要找到在 gradle 使用的 maven 插件)。

这里贴一下简化版的 build.gradle 文件:

plugins {
id 'java'
id "com.github.johnrengelman.shadow" version "8.1.1"
id "com.diffplug.spotless" version "6.24.0"
} group = 'com.xx.otel.extensions'
version = '1.0.0' ext {
versions = [
// this line is managed by .github/scripts/update-sdk-version.sh
opentelemetrySdk : "1.34.1", // these lines are managed by .github/scripts/update-version.sh
opentelemetryJavaagent : "2.1.0-SNAPSHOT",
opentelemetryJavaagentAlpha: "2.1.0-alpha-SNAPSHOT", junit : "5.10.1"
] deps = [
// 自动生成服务发现 service 文件
autoservice: dependencies.create(group: 'com.google.auto.service', name: 'auto-service', version: '1.1.1')
]
} repositories {
mavenLocal()
maven { url "https://maven.aliyun.com/repository/public" }
mavenCentral()
} configurations {
otel
} dependencies { implementation(platform("io.opentelemetry:opentelemetry-bom:${versions.opentelemetrySdk}")) /*
Interfaces and SPIs that we implement. We use `compileOnly` dependency because during
runtime all necessary classes are provided by javaagent itself.
*/
compileOnly 'io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.34.1'
compileOnly 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:1.32.0'
compileOnly 'io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api:1.32.0-alpha' //Provides @AutoService annotation that makes registration of our SPI implementations much easier
compileOnly deps.autoservice
annotationProcessor deps.autoservice // https://mvnrepository.com/artifact/org.apache.pulsar/pulsar-client
compileOnly 'org.apache.pulsar:pulsar-client:2.8.0' } test {
useJUnitPlatform()
}

然后便是要创建 javaagent 的一个核心类:

@AutoService(InstrumentationModule.class)
public class PulsarInstrumentationModule extends InstrumentationModule {
public PulsarInstrumentationModule() {
super("pulsar-client-metrics", "pulsar-client-metrics-2.8.0");
}
}

在这个类中定义我们插件的名称,同时使用 @AutoService 注解可以在打包的时候帮我们在 META-INF/services/目录下生成 SPI 服务发现的文件:

这是一个 Google 的插件,本质是插件是使用 SPI 的方式进行开发的。

关于 SPI 以前也写过一篇文章,不熟的朋友可以用作参考:

创建 Instrumentation

之后就需要创建自己的 Instrumentation,这里可以把它理解为自己的拦截器,需要配置对哪个类的哪个函数进行拦截:

public class ProducerCreateImplInstrumentation implements TypeInstrumentation {

    @Override
public ElementMatcher<TypeDescription> typeMatcher() {
return named("org.apache.pulsar.client.impl.ProducerBuilderImpl");
}
@Override
public void transform(TypeTransformer transformer) {
transformer.applyAdviceToMethod(
isMethod()
.and(named("createAsync")),
ProducerCreateImplInstrumentation.class.getName() + "$ProducerCreateImplConstructorAdvice");
}

比如这就是对 ProducerBuilderImpl 类的 createAsync 创建函数进行拦截,拦截之后的逻辑写在了 ProducerCreateImplConstructorAdvice 类中。

值得注意的是对一些继承和实现类的拦截方式是不相同的:

@Override
public ElementMatcher<TypeDescription> typeMatcher() {
return extendsClass(named(ENHANCE_CLASS));
// return implementsInterface(named(ENHANCE_CLASS));
}

从这两个函数名称就能看出,分别是针对继承和实现类进行拦截的。

这里的 API 比 SkyWalking 的更易读一些。

之后需要把我们自定义的 Instrumentation 注册到刚才的 PulsarInstrumentationModule 类中:

    @Override
public List<TypeInstrumentation> typeInstrumentations() {
return Arrays.asList(
new ProducerCreateImplInstrumentation(),
new ProducerCloseImplInstrumentation(),
);
}

有多个的话也都得进行注册。

编写切面代码

之后便是编写我们自定义的切面逻辑了,也就是刚才自定义的 ProducerCreateImplConstructorAdvice 类:

    public static class ProducerCreateImplConstructorAdvice {

        @Advice.OnMethodEnter(suppress = Throwable.class)
public static void onEnter() {
// inert your code
MetricsRegistration.registerProducer();
} @Advice.OnMethodExit(suppress = Throwable.class)
public static void after(
@Advice.Return CompletableFuture<Producer> completableFuture) {
try {
Producer producer = completableFuture.get();
CollectionHelper.PRODUCER_COLLECTION.addObject(producer);
} catch (Throwable e) {
System.err.println(e.getMessage());
}
}
}

可以看得出来其实就是两个核心的注解:

  • @Advice.OnMethodEnter 切面函数调用之前
  • @Advice.OnMethodExit 切面函数调用之后

还可以在 @Advice.OnMethodExit的函数中使用 @Advice.Return获得函数调用的返回值。

当然也可以使用 @Advice.This 来获取切面的调用对象。

编写自定义 metrics

因为我这个插件的主要目的是暴露一些自定义的 metrics,所以需要使用到 io.opentelemetry.api.metrics 这个包:

这里以 Producer 生产者为例,整体流程如下:

  • 创建生产者的时候将生产者对象存储起来
  • OpenTelemetry 框架会每隔一段时间回调一个自定义的函数
  • 在这个函数中遍历所有的 producer 获取它的监控指标,然后暴露出去。

注册函数:

public static void registerObservers() {
Meter meter = MetricsRegistration.getMeter(); meter.gaugeBuilder("pulsar_producer_num_msg_send")
.setDescription("The number of messages published in the last interval")
.ofLongs()
.buildWithCallback(
r -> recordProducerMetrics(r, ProducerStats::getNumMsgsSent));

private static void recordProducerMetrics(ObservableLongMeasurement observableLongMeasurement, Function<ProducerStats, Long> getter) {
for (Producer producer : CollectionHelper.PRODUCER_COLLECTION.list()) {
ProducerStats stats = producer.getStats();
String topic = producer.getTopic();
if (topic.endsWith(RetryMessageUtil.RETRY_GROUP_TOPIC_SUFFIX)) {
continue;
} observableLongMeasurement.record(getter.apply(stats),
Attributes.of(PRODUCER_NAME, producer.getProducerName(), TOPIC, topic));
}}

回调函数,在这个函数中遍历所有的生产者,然后读取它的监控指标。

这样就完成了一个自定义指标的暴露,使用的时候只需要加载这个插件即可:

java -javaagent:opentelemetry-javaagent.jar \
-Dotel.javaagent.extensions=ext.jar
-jar myapp.jar

-Dotel.javaagent.extensions=/extensions

当然也可以指定一个目录,该目录下所有的 jar 都会被作为 extensions 被加入进来。

打包

使用 ./gradlew build 打包,之后可以在build/libs/目录下找到生成物。

当然也可以将 extension 直接打包到 opentelemetry-javaagent.jar中,这样就可以不用指定 -Dotel.javaagent.extensions参数了。

具体可以在 gradle 中加入以下 task:

task extendedAgent(type: Jar) {
dependsOn(configurations.otel)
archiveFileName = "opentelemetry-javaagent.jar"
from zipTree(configurations.otel.singleFile)
from(tasks.shadowJar.archiveFile) {
into "extensions"
}
//Preserve MANIFEST.MF file from the upstream javaagent
doFirst {
manifest.from(
zipTree(configurations.otel.singleFile).matching {
include 'META-INF/MANIFEST.MF'
}.singleFile
)
}
}

具体可以参考这里的配置:

https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/examples/extension/build.gradle#L125

踩坑

看起来这个开发过程挺简单的,但其中的坑还是不少。

NoClassDefFoundError

首先第一个就是我在调试过程中出现 NoClassDefFoundError 的异常。

但我把打包好的 extension 解压后明明是可以看到这个类的。

排查一段时间后没啥头绪,我就从头仔细阅读了开发文档:

发现我们需要重写 getAdditionalHelperClassNames函数,用于将我们外部的一些工具类加入到应用的 class loader 中,不然在应用在运行的时候就会报 NoClassDefFoundError 的错误。

因为是字节码增强的关系,所以很多日常开发觉得很常见的地方都不行了,比如:

  • 如果切面类是一个内部类的时候,必须使用静态函数
  • 只能包含静态函数
  • 不能包含任何字段,常量。
  • 不能使用任何外部类,如果要使用就得使用 getAdditionalHelperClassNames 额外加入到 class loader 中(这一条就是我遇到的问题)
  • 所有的函数必须使用 @Advice 注解

以上的内容其实在文档中都有写:

所以还是得仔细阅读文档。

缺少异常日志

其实上述的异常刚开始都没有打印出来,只有一个现象就是程序没有正常运行。

因为没有日志也不知道如何排查,也怀疑是不是运行过程中报错了,所以就尝试把@Advice 注解的函数全部 try catch ,果然打印了上述的异常日志。

之后我注意到了注解的这个参数,原来在默认情况下是不会打印任何日志的,需要手动打开。

比如这样:@Advice.OnMethodExit(suppress = Throwable.class)

调试日志

最后就是调试功能了,因为我这个插件的是把指标发送到 OpenTelemetry-collector ,再由它发往 VictoriaMetrics/Prometheus;由于整个链路比较长,我想看到最终生成的指标是否正常的干扰条件太多了。

好在 OpenTelemetry 提供了多种 metrics.exporter 的输出方式:

  • -Dotel.metrics.exporter=otlp (default),默认通过 otlp 协议输出到 collector 中。
  • -Dotel.metrics.exporter=logging,以 stdout 的方式输出到控制台,主要用于调试
  • -Dotel.metrics.exporter=logging-otlp
  • -Dotel.metrics.exporter=prometheus,以 Prometheus 的方式输出,还可以配置端口,这样也可以让 Prometheus 进行远程采集,同样的也可以在本地调试。

采用哪种方式可以根据环境情况自行选择。

Opentelemetry-operator 配置 extension

最近在使用 opentelemetry-operator注入 agent 的时候发现 operator 目前并不支持配置 extension,所以在社区也提交了一个草案,下周会尝试提交一个 PR 来新增这个特性。

这个需求我在 issue 列表中找到了好几个,时间也挺久远了,不太确定为什么社区还为实现。

目前 operator 只支持在自定义镜像中配置 javaagent.jar,无法配置 extension:

这个原理在之前的文章中有提到。

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: my-instrumentation
spec:
java:
image: your-customized-auto-instrumentation-image:java

我的目的是可以在自定义镜像中把 extension 也复制进去,类似于这样:

FROM busybox

ADD open-telemetry/opentelemetry-javaagent.jar /javaagent.jar

# Copy extensions to specify a path.
ADD open-telemetry/ext-1.0.0.jar /ext-1.0.0.jar RUN chmod -R go+r /javaagent.jar
RUN chmod -R go+r /ext-1.0.0.jar

然后在 CRD 中配置这个 extension 的路径:

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
name: my-instrumentation
spec:
java:
image: custom-image:1.0.0
extensions: /ext-1.0.0.jar
env:
# If extension.jar already exists in the container, you can only specify a specific path with this environment variable.
- name: OTEL_EXTENSIONS_DIR
value: /custom-dir

这样 operator 在拿到 extension 的路径时,就可以在环境变量中加入 -Dotel.javaagent.extensions=${java.extensions} 参数,从而实现自定义 extension 的目的。

总结

整个过程其实并不复杂,只是由于目前用的人还不算多,所以也很少有人写教程或者文章,相信用不了多久就会慢慢普及。

这里有一些官方的 example可以参考。

参考链接:

实战:如何编写一个 OpenTelemetry Extensions的更多相关文章

  1. C# 基础知识系列- 17 实战篇 编写一个小工具(1)

    0. 前言 这是对C# 基础系列的一个总结,现在我们利用之前学到的知识做一个小小的工具来给我们使用. 如果有看过IO篇的小伙伴,应该有印象.当时我提过一个场景描述,我们在平时使用系统的时候,经常会为了 ...

  2. 编译原理实战——使用Lex/Flex进行编写一个有一定词汇量的词法分析器

    编译原理实战--使用Lex/Flex进行编写一个有一定词汇量的词法分析器 by steve yu 2019.9.30 参考文档:1.https://blog.csdn.net/mist14/artic ...

  3. .NET Core RC2发布在即,我们试着用记事本编写一个ASP.NET Core RC2 MVC程序

    在.NET Core 1.0.0 RC2即将正式发布之际,我也应应景,针对RC2 Preview版本编写一个史上最简单的MVC应用.由于VS 2015目前尚不支持,VS Code的智能感知尚欠火候,所 ...

  4. 如何编写一个带命令行参数的Python文件

    看到别人执行一个带命令行参数的python文件,瞬间觉得高大上起来.牛逼起来,那么如何编写一个带命令行参数的python脚本呢?不用紧张,下面将简单易懂地让你学会如何让自己的python脚本,支持带命 ...

  5. Java入门篇(一)——如何编写一个简单的Java程序

    最近准备花费很长一段时间写一些关于Java的从入门到进阶再到项目开发的教程,希望对初学Java的朋友们有所帮助,更快的融入Java的学习之中. 主要内容包括JavaSE.JavaEE的基础知识以及如何 ...

  6. .NET 编写一个可以异步等待循环中任何一个部分的 Awaiter

    林德熙 小伙伴希望保存一个文件,并且希望如果出错了也要不断地重试.然而我认为如果一直错误则应该对外抛出异常让调用者知道为什么会一直错误. 这似乎是一个矛盾的要求.然而最终我想到了一个办法:让重试一直进 ...

  7. 尝鲜.net core2.1 ——编写一个global tool

    本文内容参考微软工程师Nate McMaster的博文.NET Core 2.1 Global Tools 用过npm开发都知道,npm包都可以以全局的方式安装,例如安装一个http-server服务 ...

  8. 如何编写一个WebPack的插件原理及实践

    _ 阅读目录 一:webpack插件的基本原理 二:理解 Compiler对象 和 Compilation 对象 三:插件中常用的API 四:编写插件实战 回到顶部 一:webpack插件的基本原理 ...

  9. android#编写一个聊天界面

    摘自<第一行代码>——郭霖 既然是要编写一个聊天界面,那就肯定要有收到的消息和发出的消息.上一节中我们制作的message_left.9.png可以作为收到消息的背景图,那么毫无疑问你还需 ...

  10. 编写一个通用的Makefile文件

    1.1在这之前,我们需要了解程序的编译过程 a.预处理:检查语法错误,展开宏,包含头文件等 b.编译:*.c-->*.S c.汇编:*.S-->*.o d.链接:.o +库文件=*.exe ...

随机推荐

  1. mongo Payload document size is larger than maximum of 16777216. 如何处理

    MongoDB中的文档大小限制为16MB(即16777216字节).如果你遇到Payload document size is larger than maximum of 16777216的错误,意 ...

  2. vite + vue3 打包后 本地直接运行 type="module" crossorigin 替换为defer - 多个vue文件就不好使了

    vite + vue3 打包后 本地直接运行 type="module" crossorigin 替换为defer 需求: 想打包后,双击运行,不启动服务 修改 vite.conf ...

  3. vscode 对js文件不格式化的修正方案 settings.json

    修正1 "javascript.format.enable": true, // 这里false 改true 修正2 注释掉这个地方 // "[javascript]&q ...

  4. vscode sftp 代码同步到服务器

    然后执行 ctrl+shift+p ,搜索 SFTP:Config 回车后,会生成一个".vscode/sftp.json",这个就是配置文件 参考:VsCode SFTP插件详细 ...

  5. 软件推荐: Sourcetree git软件

    注意事项: 破解的时候,json文件目录不是软件exe所在目录,是数据目录 %LocalAppData%\Atlassian\SourceTree 还有就是第一次运行弹出个 有个插件要不要用,选第3个 ...

  6. Jetpack架构组件学习(0)——总结篇

    原文地址:Jetpack架构组件学习(0)--总结篇 | Stars-One的杂货小窝 对之后学习的Jetpack架构开发优点进行简单总结,及对应的文章分类链接 LifeCycle 主要解决将一些初始 ...

  7. 使用maven和gradle命令打jar包

    参考:https://blog.csdn.net/qq_27525611/article/details/123487255 https://blog.csdn.net/qq_35860138/art ...

  8. 关于初始化page入参的设计思路

    最近在重构老的代码,在写的过程中发现之前的逻辑如果遇到没有入参pageNo会Npe,于是乎我想找找公司项目有啥方式处理page入参的有两种如下 使用三元表达式直接判断是否null,然后赋值 使用map ...

  9. 3DCAT 首届行业生态交流会 | 瑞云科技技术总监赵志杰:实时渲染助力元宇宙应用触手可及

    2021 年 12 月 17 日下午,由深圳市瑞云科技有限公司主办,深圳市虚拟现实产业联合会协办的云 XR 如何赋能元宇宙--3DCAT实时云渲染首届行业生态合作交流会圆满落幕.此次活动围绕 &quo ...

  10. 三维模型3DTile格式轻量化在网络传输中的重要性分析

    三维模型3DTile格式轻量化在网络传输中的重要性分析 三维模型3DTile格式轻量化在网络传输中扮演了至关重要的角色.随着数字化和虚拟化技术的发展,越来越多的应用需要通过网络来获取和分享大规模三维地 ...