前言

前段时间我们从 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. be动词 系动词 连缀动词 Linking Verb

    be动词 系动词 连缀动词 Linking Verb be 原型 am 第一人称单数形式 is 第三人称单数形式 are 第二人称单数和复数形式 been 过去分词 being 现在分词 was 第一 ...

  2. Linux安装Nginx详细教程

    一.下载Nginx安装包 Nginx官网下载地址 根据需求选择自己需要的版本下载后上传至服务器(路径自行决定). 如果服务器有外网,可以直接在服务器上下载. wget -c https://nginx ...

  3. 灰度发布、蓝绿部署、金丝雀发布和AB测试及在k8s中的实现

    灰度发布.蓝绿部署.金丝雀发布和AB测试都是软件开发和部署中常用的策略,每种策略都有其特定的用途和优势.下面是对这些策略的简要解释: 灰度发布(Grayscale Release): 灰度发布是一种逐 ...

  4. 五大基础dp

    动规条件 • 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构, 即满足最优化原理. • 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响.也就是说,某状 ...

  5. c语言中静态链接库的创建和使用

    静态链接库的创建 静态链接库其实就相当于压缩包,其内部可以包含多个源文件.但需要注意的是,并非任何一个源文件都可以被加工成静态链接库,其至少需要满足以下 2 个条件: 源文件中只提供可以重复使用的代码 ...

  6. C#开发计算器类库

    C#开发计算器类库:开发中所涉及到有虚方法,继承,简单工厂等基础知识(编程借鉴'小菜变成成长记'https://www.jb51.net/article/2851.htm) 1.创建父类:计算(Ope ...

  7. KingbaseES V8R6集群运维案例之---sys_rewind应用分析

    ​ 案例说明: sys_rewind是用于在数据库cluster的时间线分叉以后,同步一个 KingbaseES 数据库cluster 和同一数据库cluster另一份拷贝的工具.一种典型的场景是在失 ...

  8. stm32F103 移植Free RTOS

    stm32F103 移植Free RTOS 1. 下载FreeRTOS 源码 [官网下载] (http://www.freertos.org) [代码托管网站下载] (https://sourcefo ...

  9. WPF如何封装一个可扩展的Window

    前言 WPF中Window相信大家都很熟悉,有时我们有一些自定义需求默认Window是无法满足的,比如在标题栏上放一些自己东西,这个时候我们就需要写一个自己的Window,实现起来也很简单,只要给Wi ...

  10. List和ObservableCollection的转换

    1.我们后台查询全部List数据的时候,前台需要ObservableCollection展示 这个时候List需要转换成ObservableCollection public static Obser ...