【可观测链路】多语言 Opentelemetry SDK 接入实践

2023-10-04 10:15:13 浏览数 (2)

一、背景

随着时间推移,笔者参与的平台建设已进入稳定的开发迭代版本,目前平台的各模块需要一个通用的、可扩展的问题定位、日志上报、跟踪调用链路的模块

为了契合可观测性标准,梳理内部调用链路,增强线上故障定位能力,选择接入 Opentelemetry SDK 实现链路跟踪上报。进一步监测内部调用及上下游调用时的时延、链路关系、报错信息,便于定位线上问题

二、Opentelemetry 简介

程序本身会具备一定的行为,在开发环境中如果想监测这些行为一般会通过下断点、打印日志来进行 Debug。而一旦程序被部署到线上环境之后,不能再借助 Debugger,这时就需要某种机制来观察在各时间节点上程序的行为。这种需求在分布式环境及云原生场景下尤为突出,因此 Opentelemetry 应运而生

OpenTelemetry 是 CNCF 的一个可观测性项目,旨在提供可观测性领域的标准化方案、管理观测类数据,如 trace、metrics、logs 等。Opentelemetry 制定了遥测数据搜集和传输到后台程序的标准格式,为可观测性提供了一套通用的标准。

Opentelemetry 提供三种主要的数据类——Traces、Logs、Metrics,这三者称为 "可观测性的三大支柱":

  • Logs : 指在特定时间发生的行为的文本记录,一般分为纯文本、结构化、非结构化三类
  • Metrics : 指在一定时间内监控并且量化的数据,与日志不同,它往往是结构化的
  • Traces : 一条链路代表着一个请求在分布式系统中的执行过程,在过程中会有很多程序行为,每个行为被定义成一个独立的 Span,整条链路就被抽象为一棵由 Span 组成的树

本文的主要关注点为 Traces 部分,更详细的介绍对概念的介绍可参考 官方文档。

三、Opentelemetry 接入实践

下面对平台中涉及到的部分语言及框架,以及如何应用 Opentelemetry SDK 进行链路跟踪上报进行梳理,如果有不对的地方欢迎在评论区中指出。

1. Golang 项目

平台 Golang 项目基于 gin 框架提供 HTTP 服务,下述流程均针对 gin 框架的链路跟踪接入。

go-gin 项目接入链路跟踪主要关注三个部分,一是从请求中提取 trace 信息(traceid等)并将该次请求的处理过程串联起来,形成一条完整链路;二是在向其他服务发出请求时,注入本链路的 trace 信息,三是内部埋点上报请求处理过程中的关键信息。

针对问题一,Opentelemetry 官方社区提供的 otelgin 插件可以以 gin 中间件的方式自动解析 gin.context 中包含的 trace 信息,生成 SpanContext 并注入到 context 中。代码如下:

代码语言:txt复制
import (
    "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
router := gin.Default()
router.Use(otelgin.Middleware("GoGinMiddleware"))

针对问题二,向下游服务请求时注入 trace 信息可以通过 Opentelemetry 社区提供的 instrumentation 包,关键代码如下所示,通过封装 net/http 库的 client.Do 方法,向 http.Request 中注入 trace 信息。

这部分要注意的是,在注入时需要提供包含 trace 的信息的 context,如果项目本身没有统一的请求接口的话,这里的改造工作量较大。

代码语言:txt复制
// 从 context 中提取 trace 信息并向 HTTP 请求头中注入
func InjectTrace(req *http.Request, c context.Context) {
	ctx, req := otelhttptrace.W3C(c, req)
	otelhttptrace.Inject(ctx, req)
}
// 对 net/http 库提供的 client.Do 方法进行一层封装
func Do(ctx *gin.Context, c *http.Client, req *http.Request) (*http.Response, error) {
    InjectTrace(req, ctx.Request.Context())
    resp, err := c.Do(req)
    return resp, err
}

问题三,内部埋点上报指的是在内部代码中增加关键信息的上报

内部埋点上报难点在于需要在函数调用过程中维护一个 context 来存储 SpanContext 的信息,但对存量代码的侵入较大,因此要么在项目构建之初就进行考量,否则需要进行大量改造。

部分关键代码如下,首先配置上报地址、租户名等信息(这里使用的非官方sdk,但接口基本一致)

代码语言:txt复制
// 初始化opentracing
func OpentracingInit(addr, tenantId, serviceName string, ratio float64, modName string) {
    if err := tpstelemetry.Setup(addr, // 设置上报地址
        tpstelemetry.WithTenantID(tenantId),         
	tpstelemetry.WithServiceName(serviceName),         // 设置服务名,选填
	tpstelemetry.WithSampler(sdktrace.AlwaysSample()), // 设置采样策略,此为全量采样
    }
}

 gin.Context 可以作为上下文的载体:

代码语言:txt复制
// 开启本次请求的链路跟踪
func InitTracing(ctx *gin.Context, name string) *OpentracingObjectSpan {
	// 从gin.Context中提取trace信息生成SpanContext, 开启 parent span
	c, parentSpan := tpstelemetry.GlobalTracer().Start(ctx.Request.Context(), moduleName name)
	ctx.Request = ctx.Request.WithContext(c)
	parentSpan.SetAttributes(attribute.Key("span_kind").String(name))
	opentracingObjectSpan := OpentracingObjectSpan{name: name, sp: parentSpan}
	return &opentracingObjectSpan
}

// 结束本次请求的链路跟踪
func EndTracing(ctx *gin.Context, os *OpentracingObjectSpan, tags, logs map[string]interface{}) {
	os.sp.SetAttributes(api.TpsDyeingKey.String("OpenTestParent"))
	os.EndSpan(ctx, tags, logs)  // 结束 parent span
	if spanJSON, err := os.sp.SpanContext().MarshalJSON(); err == nil {
		log.Debug("Tracing Span:%s", string(spanJSON))
	}
}

 在接入的过程中发现,开启链路跟踪不可避免的会对内存占用、延迟等造成影响,采样率也会较大的影响性能,需要进行全面的测试来最终确定方案。

2. 前端 Web 项目

前端的 Web 项目使用 Opentelemetry JS SDK 进行链路跟踪,为了对业务代码侵入最小,选择了 instrumentation 插桩的方式进行上报。

跟踪 XMLHttpRequest 并自动进行注入的关键代码如下:

代码语言:txt复制
// 设置远程上报地址(这里注意是HTTP地址)
const exporter = new OTLPTraceExporter({
      url: 'http://test.telemetry.woa.com:55681/v1/traces',
})
// 设置租户, 业务名等信息
const provider = new WebTracerProvider();
provider.addSpanProcessor(new BatchSpanProcessor(exporter));
provider.resource.attributes['tps.tenant.id'] = 'test';
provider.resource.attributes['service.name'] = "test-service";

provider.register({
    contextManager: new ZoneContextManager(),
    propagator: new W3CTraceContextPropagator(), // 注册W3C格式的传播器, 自动在HTTP Header中添加traceparent字段
});
  
// 注册XMLHttpRequest插件, 将自动跟踪xhr请求
registerInstrumentations({
instrumentations: [
    new XMLHttpRequestInstrumentation({
    ignoreUrls: [/localhost:8090/sockjs-node/],
    propagateTraceHeaderCorsUrls: [
        `/http://localhost:7777.*/`
    ],
    }),
],
});

如在业务中需要埋点上报,可使用如下方式:

代码语言:txt复制
const startTrace = (url, fn)=>{
  // 创建 trace 
  const webTracerWithZone = provider.getTracer('tracer-web');
  // 创建 Span
  const singleSpan = webTracerWithZone.startSpan(url);
  // 以 singleSpan 作为父 Span, 将其与 xhr 请求时自动生成的 Span 串联起来
  context.with(trace.setSpan(context.active(), singleSpan), () => {
      const promise = typeof fn ==='function' ? fn() : fn;
      promise?.then((_data) => {
          trace.getSpan(context.active()).addEvent('log'); // 增加事件
          singleSpan.end();
      }).catch(err=>{
          trace.getSpan(context.active()).addEvent('log-err');
          singleSpan.end();
      });
  });
}
export default startTrace;

在 Web 端上报的过程中碰到了几个问题,这里记录一下排查流程:

1)上报到远程服务失败

前端执行上报流程时报网络错误:net::ERR_CONNECTION_RESET

排查后发现因前端 JS 代码在本地执行,和远端上报地址不通。

该问题可通过搭建一个 proxy 转发前端的上报请求来解决,复制包头和包体信息转发到原上报地址的 proxy。该 proxy 可以实现前端的上报,但因为转发过程存在时延,会导致最终上报的链路时间轴不准确(该问题需进一步阅读源码确认上报时是通过相对时间还是绝对时间来确定时间轴的)。

PS : 此问题在 Web 端应该比较常见(当上报服务器在浏览器端访问不到时),但在介绍 Web-JS 上报的文档里没有看到相应的解决办法,有大佬了解的话可交流一下

2)没有成功注入 trace 信息

使用 W3CTraceContextPropagator 向后端传播 trace 信息时,发现后端没有读取到前端传过来的 trace 信息,重新生成 traceid 导致断链。排查后发现是前端没有将 trace 信息注入到 HTTP Header 中,Header 中没有相关字段

该问题可能是两种原因导致的,这篇里说的比较详细,简而言之就是 1. 因为前后端的 CORS 策略导致没有注入成功、2. 前端的白名单设置有误,导致请求时没有注入 trace 信息。其中 1 可以根据前面的文章一条条修改 CORS 策略来测试,这里主要分析 2 的原因。

关键代码中的如下片段,定义了在进行 xhr 请求时,需要被忽略的 URL (ignoreUrls) 以及需要注入 trace 信息的 URL (propagateTraceHeaderCorsUrls)。两者都接受纯字符串或者正则表达式作为参数,当传入的是字符串时,只做简单的相等判断,当传入正则时则做正则匹配,向命中的 URL 发起 xhr 请求时才会自动注入。

代码语言:txt复制
instrumentations: [
    new XMLHttpRequestInstrumentation({
    ignoreUrls: [/localhost:8090/sockjs-node/],
    propagateTraceHeaderCorsUrls: [
        `/http://localhost:7777.*/`
    ],
    })
]

 排查时发现传入是字符串而非正则导致没有命中,修改后 HTTP Header 中就出现了 traceparent 字段,后端也能正常解析了。

3)后端解析不成功

后端采用 opentelemetry 社区提供的插件对 HTTP Header 中的 trace 信息进行解析,在 opentelemetry-js 社区提供的示例中使用的是如下的 B3Propagator 传播器。

代码语言:txt复制
providerWithZone.register({
  contextManager: new ZoneContextManager(),
  propagator: new B3Propagator(),
});

 B3Propagator 会向 HTTP Header 增加 b3 字段(如下所示),但后端 gin 项目使用的插件 otelgin 不能解析 b3 头,使用W3CTraceContextPropagator ,增加 traceparent 字段才可正常解析。

PS: 文档没有采用propagator 的自动注入方式,采取的是手动 inject 的方式 )

NodeJS 项目

Node 与 Golang 项目一样,需要考虑从请求中解析 trace 信息,以及向请求中注入 trace 信息两个部分。 但 Node 和 Web-JS 的适配更好,接入流程也非常相似。

首先需要对 Node SDK 进行初始化,同样使用 instrumentation 包进行自动插桩:

代码语言:txt复制
// 设置上报地址
const exporter = new OTLPTraceExporter({
    url: 'http://xxxx.xxx.com',
});

module.exports = (serviceName) => {
  // 初始化业务名等
  const provider = new NodeTracerProvider({
    resource: new Resource({
      [SemanticResourceAttributes.SERVICE_NAME]: serviceName,
    }),
  });
  provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
  provider.register();

  // 在HTTP请求时自动注入trace信息
  registerInstrumentations({
    instrumentations: [
      new HttpInstrumentation(),
    ],
  });

  return opentelemetry.trace.getTracer('http-example');
};

 插桩后会自动跟踪 HTTP 请求,如果需要内部埋点自定义上报,可使用如下方法:

代码语言:txt复制
// 测试发送http请求
function makeRequest() {
  const span = tracer.startSpan('makeRequest');
  api.context.with(api.trace.setSpan(api.context.active(), span), () => {
    http.get({
      host: 'localhost',
      port: 8080,
      path: '/helloworld',
    }, (response) => {
        // ...
      });
    });
  });
}

在接入链路跟踪之后,项目整体的故障定位速度有了较大的提升,但链路跟踪带来的性能损耗和存储成本的增加也是不可忽视的问题,需要根据具体应用场景来决定


原创不易,转载请注明出处

我正在参与2023腾讯技术创作特训营第二期有奖征文,瓜分万元奖池和键盘手表

0 人点赞