下面,我们将会介绍在 Go 语言中如何使用 OpenTelemetry 。在本文中,我们将会带你一步步经历依赖库的安装、代码插桩、配置、并导入 OpenTelemetry 遥测数据。在开始之前,你首先需要安装 1.16 版本或更新的 Go 语言版本。
在定位和解决问题时,了解系统的失败根因是至关重要的。分析根因的一种常用手段就是 trace 。在接下来的讲解中,我们将会讲解如何将 OpenTelemetry 应用于一个示例项目中用于 trace 收集。你将会从一个简单的 Fibonacci 计算的项目开始,并逐步进行 OpenTelemetry 插桩并得到相关的 trace 遥测数据。

项目准备

下面,在开始编写该应用之前,你首先需要创建一个 fib 目录用于存放我们的 Fibonacci 项目。
然后,你需要在该目录下在创建一个 fib.go 的文件,内容如下:

  1. package main
  2. // Fibonacci returns the n-th fibonacci number.
  3. func Fibonacci(n uint) (uint64, error) {
  4. if n <= 1 {
  5. return uint64(n), nil
  6. }
  7. var n2, n1 uint64 = 0, 1
  8. for i := uint(2); i < n; i++ {
  9. n2, n1 = n1, n1+n2
  10. }
  11. return n2 + n1, nil
  12. }

可以看到,我们在上述代码中已经实现了 Fibonacci 的核心计算逻辑。
下面,我们需要来添加一个 app.go 文件来构建一个应用逻辑:

  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. "io"
  6. "log"
  7. )
  8. // App is a Fibonacci computation application.
  9. type App struct {
  10. r io.Reader
  11. l *log.Logger
  12. }
  13. // NewApp returns a new App.
  14. func NewApp(r io.Reader, l *log.Logger) *App {
  15. return &App{r: r, l: l}
  16. }
  17. // Run starts polling users for Fibonacci number requests and writes results.
  18. func (a *App) Run(ctx context.Context) error {
  19. for {
  20. n, err := a.Poll(ctx)
  21. if err != nil {
  22. return err
  23. }
  24. a.Write(ctx, n)
  25. }
  26. }
  27. // Poll asks a user for input and returns the request.
  28. func (a *App) Poll(ctx context.Context) (uint, error) {
  29. a.l.Print("What Fibonacci number would you like to know: ")
  30. var n uint
  31. _, err := fmt.Fscanf(a.r, "%d\n", &n)
  32. return n, err
  33. }
  34. // Write writes the n-th Fibonacci number back to the user.
  35. func (a *App) Write(ctx context.Context, n uint) {
  36. f, err := Fibonacci(n)
  37. if err != nil {
  38. a.l.Printf("Fibonacci(%d): %v\n", n, err)
  39. } else {
  40. a.l.Printf("Fibonacci(%d) = %d\n", n, f)
  41. }
  42. }

可以看到,我们定义了 App 结构体用于表示对应 Fibonacci 计算的应用。
在 App 中,我们定义了一个 r 和一个 l 属性,分别用于读取传入的参数并输出计算结果。此外,我们还定义了 Run, Pool 和 Write 三个方法,分别用于启动应用、读取输入数据、计算并输出计算结果。最后,我们还提供了一个 NewApp 函数获取获取一个 App 实例。

随着 App 功能开发完成,我们还需要一个 main 函数来启动并运行该 App。下面,我们需要创建一个 main.go 的文件,文件内容如下:

  1. package main
  2. import (
  3. "context"
  4. "log"
  5. "os"
  6. "os/signal"
  7. )
  8. func main() {
  9. l := log.New(os.Stdout, "", 0)
  10. sigCh := make(chan os.Signal, 1)
  11. signal.Notify(sigCh, os.Interrupt)
  12. errCh := make(chan error)
  13. app := NewApp(os.Stdin, l)
  14. go func() {
  15. errCh <- app.Run(context.Background())
  16. }()
  17. select {
  18. case <-sigCh:
  19. l.Println("\ngoodbye")
  20. return
  21. case err := <-errCh:
  22. if err != nil {
  23. l.Fatal(err)
  24. }
  25. }
  26. }

可以看到,通过 main 函数,我们可以启动一个 App 服务,同时,等待接收退出信号后再退出服务。
到此为止,我们最基本的 Fibonacci 计算的项目代码就告一段落了。下面,我们还需要在 fib 目录下运行如下代码来初始化 Go Modules 文件:

  1. go mod init fib

执行完成后,你会看到在 fib 目录下会创建一个 go.mod 文件,该文件用于 Go Modules 项目的依赖管理。
下面,我们来执行如下代码运行看看吧:

  1. go run .
  2. # What Fibonacci number would you like to know:
  3. 42
  4. # Fibonacci(42) = 267914296
  5. # What Fibonacci number would you like to know:
  6. ^C
  7. # goodbye

可以看到,我们的应用程序已经能够按照我们的期望来正常执行了。输入 Ctrl + C 后,你可以退出该程序。

Trace 插桩

OpenTelemetry 项目可以包含两个部分:API 用于项目插桩、SDKs 用于 API 的具体实现。如果想要在项目中使用 OpenTelemetry ,那么 API 是必不可少的,需要通过它来进行插桩。OpenTelemetry Trace API 位于 go.opentelemetry.io/otel/trace 包中。
首先,你需要安装 OpenTelemetry API 依赖的包,安装代码如下:

  1. go get go.opentelemetry.io/otel
  2. go get go.opentelemetry.io/otel/trace

下面,你需要在 app.go 的代码中导入你安装的相关依赖了:

  1. import (
  2. "go.opentelemetry.io/otel"
  3. "go.opentelemetry.io/otel/attribute"
  4. "go.opentelemetry.io/otel/trace"
  5. )

随着上述依赖的导入,接下来我们就可以开始代码插桩了。
OpenTelemetry Tracing API 中提供了一个 Tracer 函数用于创建 Traces 。这些 Traces 从设计上来讲,是需要与一个插桩库进行绑定的。这样一来,就可以基于相关代码来得到的遥测数据。为了向 Tracer 表示你当前的 App ,可以在 app.go 文件中创建一个对应名称的常量作为应用名称,用于后续传递给 tracer:

  1. const name = "fib"

该名称取决与你自己的项目,可以根据不同的项目进行对应的调整。
现在,可以说是万事俱备了,我们可以开始 Trace 我们的程序了。但是在开始之前,我们还需要在了解一下什么是 Trace?我们应该如何在 App 中增加 Trace?
Trace 本身上是一种遥测数据,它可以用于记录一个服务处理过的任务。一个 Trace 可以对应与通过各种形式进行通信的客户端/服务端处理过程并记录互相之间的访问记录。
服务处理过程中的每个任务都可以对应于 Trace 中的一个 Span。这些 span 还不仅仅是简单的集合,它们之间就像程序调用堆栈一样,互相之间都可以存在依赖关系。root span 是一个 Trace 中唯一没有父 span 的 span,它表示完成的处理工作。Trace 中其他的 span 都是以 Trace 中其他的 span 作为父 span 构建得到的。
如果你对上面关于 span 的描述还没有那么了解,也不要担心。下面,我们将会通过代码来演示如何创建相关的 span。通过下述的插桩代码,相信你会对 span 的理解更近一步,下面我们就看开始吧。
首先从 Run 函数开始,我们需要修改 Run 函数如下:

  1. func (a *App) Run(ctx context.Context) error {
  2. for {
  3. var span trace.Span // 新增代码,定义一个 span
  4. // 新增代码,开启一个 Span,并命名为 Run
  5. ctx, span = otel.Tracer(name).Start(ctx, "Run")
  6. n, err := a.Poll(ctx)
  7. if err != nil {
  8. // 异常后,结束该 span
  9. span.End()
  10. return err
  11. }
  12. a.Write(ctx, n)
  13. // 正常处理完成后,结束该 span
  14. span.End()
  15. }
  16. }

在上述代码中,我们在每次循环的内部通过之前提到的 Tracer 函数创建了一个 span。本质上,Tracer 函数是由一个全局的 TracerProvider 提供的。在下文的讲解中,我们也会具体讲解如何通过 SDK 来设置对应的全局 TracerProvider。目前,作为插桩场景而言,你还不需要关心需要 TracerProvider 是什么。
下面,我们需要在 Poll 方法中进行插桩:

  1. // Poll asks a user for input and returns the request.
  2. func (a *App) Poll(ctx context.Context) (uint, error) {
  3. // 新增代码,开启一个 Span,并命名为 Poll
  4. _, span := otel.Tracer(name).Start(ctx, "Poll")
  5. // 新增代码,当函数退出时,结束该 span
  6. defer span.End()
  7. a.l.Print("What Fibonacci number would you like to know: ")
  8. var n uint
  9. _, err := fmt.Fscanf(a.r, "%d", &n)
  10. // Store n as a string to not overflow an int64.
  11. nStr := strconv.FormatUint(uint64(n), 10)
  12. // 新增代码,设置 span 的属性
  13. span.SetAttributes(attribute.String("request.n", nStr))
  14. return n, err
  15. }

与 Run 方法类似,上述代码在 Pool 的过程中创建了一个 span 来进行记录。此外,它还添加了一个属性来进一步描述 span 的细节。当你觉得通过增加一些描述信息可以帮助你在问题定位或其他场景是有帮助的话,那么你就可以通过 SetAttributes 的方式来增加相关属性信息。
最后,我们来看一下 Write 方法:

  1. // Write writes the n-th Fibonacci number back to the user.
  2. func (a *App) Write(ctx context.Context, n uint) {
  3. // 新增代码,定义一个 span
  4. var span trace.Span
  5. // 新增代码,开启一个 Span,并命名为 Write
  6. ctx, span = otel.Tracer(name).Start(ctx, "Write")
  7. // 新增代码,当函数退出时,结束该 span
  8. defer span.End()
  9. f, err := func(ctx context.Context) (uint64, error) {
  10. // 新增代码,开启一个 Span,并命名为 Fibonacci
  11. _, span := otel.Tracer(name).Start(ctx, "Fibonacci")
  12. // 新增代码,当函数退出时,结束该 span
  13. defer span.End()
  14. return Fibonacci(n)
  15. }(ctx)
  16. if err != nil {
  17. a.l.Printf("Fibonacci(%d): %v\n", n, err)
  18. } else {
  19. a.l.Printf("Fibonacci(%d) = %d\n", n, f)
  20. }
  21. }

在该函数中,我们创建了两个 span。一个是用于跟踪 Write 方法本身,另一个是跟踪我们核心的 Fibonacci 计算函数。现在,你知道上下文是如何通过 spans 来进行传递的了么?你了解上述4个span之间的关系不?
在 OpenTelemetry Go 项目中,Span 关系其实是直接在 context.Context 中保存的。当创建一个 span 时,需要传入一个 context 变量,同时也会在该 context 变量中增加该 span 相关的信息。当一个已经创建过 span 的 Context 对象再次创建一个新的 span 时,这两个 span 将会进行相互关联,之前的 span 将会成为新创建的 Span 的父 span。这样一来,Trace 之间就是有层级关系的了,可以更加有助于 trace 数据的理解和分析。基于上述的描述和我们插桩的代码,那么,我们得到的 trace 中 span 的层级关系预期应该如下:

  1. Run
  2. ├── Poll
  3. └── Write
  4. └── Fibonacci

Run span 是 Poll 和 Write span 的父 span。同时 Write span 还是 Fibonacci span 的父 span。
那么,我们具体怎么才能得到真实的 span 数据呢?下面,我们需要配置和安装 OpenTelemetry SDK 。

OpenTelemetry SDK 安装

OpenTelemetry 在 OpenTelemetry API 的实现中是按照模块化进行设计的。OpenTelemetry Go 项目还提供了一个 SDK 的包 go.opentelemetry.io/otel/sdk,它遵守 OpenTelemetry 规范并用于实现 API 具体的功能。
想要使用该 SDK,我们首先还需要创建一个 Exporter。
在开始之前,我们将来安装上述内容的一些依赖库吧,进入 fib 目录下运行如下命令:

  1. go get go.opentelemetry.io/otel/sdk
  2. go get go.opentelemetry.io/otel/exporters/stdout/stdouttrace

可以看到,我们安装了 sdk 包和 stdouttrace 的 export 包。
下面,我们需要在 main.go 文件中添加如下依赖:

  1. import (
  2. "go.opentelemetry.io/otel"
  3. "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
  4. "go.opentelemetry.io/otel/sdk/trace"
  5. "go.opentelemetry.io/otel/semconv/v1.7.0"
  6. )

创建 Console Exporter

SDK 用于将 OpenTelemetry API 和 exporters 进行了关联。其中,Exporters 是一种用于将插桩得到的遥测数据发送给指定地址的一组包,例如,通过 exporters,可以将遥测数据打印到终端、远程的存储系统或者是 collector 代理等。OpenTelemetry 有强大的生态从而支持了多种多样的 Exporters,例如 Jaeger、Zipkin和 Prometheus 等。
下面,我们以 Console Exporter 为例进行演示,我们需要在 main.go 文件中创建一个初始化 Exporter 的函数:

  1. // newExporter returns a console exporter.
  2. func newExporter(w io.Writer) (trace.SpanExporter, error) {
  3. return stdouttrace.New(
  4. stdouttrace.WithWriter(w),
  5. // Use human-readable output.
  6. stdouttrace.WithPrettyPrint(),
  7. // Do not print timestamps for the demo.
  8. stdouttrace.WithoutTimestamps(),
  9. )
  10. }

通过上述函数,我们可以通过一些基本的参数来创建一个 Console Exporter。你会在后续的工作中通过该函数创建 Exporter 并在 SDK 中使用用于发送遥测数据。

创建 Resource

遥测数据在定位服务问题时非常重要,因此,我们还需要给每个服务、甚至是每个实例进行标识,从而明确知道遥测数据的来源,从而能够进行有效的过滤。OpenTelemetry 通过 Resource 来标识遥测数据的生产源。我们需要在 main.go 的代码中增加一个如下函数用于生成应用对应的 Resource 标识:

  1. // newResource returns a resource describing this application.
  2. func newResource() *resource.Resource {
  3. r, _ := resource.Merge(
  4. resource.Default(),
  5. resource.NewWithAttributes(
  6. semconv.SchemaURL,
  7. semconv.ServiceNameKey.String("fib"),
  8. semconv.ServiceVersionKey.String("v0.1.0"),
  9. attribute.String("environment", "demo"),
  10. ),
  11. )
  12. return r
  13. }

你希望与遥测数据关联的相关信息都可以添加到 Resource 中,最终通过 TracerProvider ,Resource 的内容就会被提交到 tracer 中。

安装 Tracer Provider

现在,你已经在代码中进行了插桩,同时你还知道了如何创建 Exporter 并定义了 Resource 标识,那么应该如何将它们相互关联起来呢?这就是 TracerProvider 的作用了。TracerProvider 是整个过程的核心,插桩库中会从 TracerProvider 中创建 tracer 对象,同时 tracer 对象得到的 span 数据将进入 TracerProvider 中配置的 Pipeline 中。
这些 Pipeline 用于接收数据并最终将数据输出到期望的 Exporter 中,我们也将这些 Pipeline 称之为 SpanProcessors。一个 TracerProvider 可以配置多个 SpanProcessors,不过在下文的演示中,我们仅仅会配置一个 SpanProcessors。下面,你需要在你的 main 函数中增加如下内容:

  1. func main() {
  2. l := log.New(os.Stdout, "", 0)
  3. // 创建一个文件用于写入 trace 数据
  4. f, err := os.Create("traces.txt")
  5. if err != nil {
  6. l.Fatal(err)
  7. }
  8. defer f.Close()
  9. // 根据文件句柄创建一个 Exporter
  10. exp, err := newExporter(f)
  11. if err != nil {
  12. l.Fatal(err)
  13. }
  14. // 根据 Exporter 和 Resource 共同创建一个 TracerProvider
  15. tp := trace.NewTracerProvider(
  16. trace.WithBatcher(exp),
  17. trace.WithResource(newResource()),
  18. )
  19. defer func() {
  20. // 程序退出时,关闭该 TracerProvider
  21. if err := tp.Shutdown(context.Background()); err != nil {
  22. l.Fatal(err)
  23. }
  24. }()
  25. // 将 TracerProvider 设置为 OpenTelemetry 的全局 TracerProvider
  26. otel.SetTracerProvider(tp)
  27. /* … */
  28. }

看一下上述代码:

  • 首先,我们创建了一个导出到文件中的 Exporter;
  • 然后,我们将 Exporter、Resource 共同创建了一个 TracerProvider 对象。其中,Exporter 对象是通过 WithBatcher 方式传递的,这是一种推荐的模式,可以有效避免下游系统过载;
  • 最后,创建 TracerProvider 后,我们通过一个 defer 函数来在程序结束时进行优雅退出,并将其注册为全局 OpenTelemetry TracerProvider。

你还记得我们之前通过全局 TracerProvider 获取 Tracer 的过程么?我们通过将我们创建的 TracerProvider 设置为全局 TracerProvider 后,新创建的 Tracer 都将会通过我们的 TracerProvider 来生成。从而使得 Exporters 与插桩数据进行了关联。这种使用全局 TracerProvider 的方式是比较简单易用的。但是针对某些更复杂的分布式场景而言可能并不完全适用,需要根据业务的情况进行选择。

整体验证一下

下面,我们就可以整体运行一下代码来看看了:

  1. go run .
  2. # What Fibonacci number would you like to know:
  3. 42
  4. # Fibonacci(42) = 267914296
  5. # What Fibonacci number would you like to know:
  6. ^C
  7. # goodbye

此时,你会看到 fib 目录下已经生成了一个 traces.txt 的文件。在该文件中,你也将会看到在这一过程中生成的 trace 相关的数据。

错误记录

运行的过程中,你可能会发现你的程序存在一些问题:

  1. go run .
  2. # What Fibonacci number would you like to know:
  3. 100
  4. # Fibonacci(100) = 3736710778780434371
  5. # ...

事实上,100 的斐波拉契数为354224848179261915075,而不是3736710778780434371。虽然这个应用示例仅仅是一个 demo,但是既然发现了问题,那么我们也应该对其就是修改,下面,我们来修改 fib.go 文件,对输入参数进行一些校验:

  1. func Fibonacci(n uint) (uint64, error) {
  2. if n <= 1 {
  3. return uint64(n), nil
  4. }
  5. if n > 93 {
  6. return 0, fmt.Errorf("unsupported fibonacci number %d: too large", n)
  7. }
  8. var n2, n1 uint64 = 0, 1
  9. for i := uint(2); i < n; i++ {
  10. n2, n1 = n1, n1+n2
  11. }
  12. return n2 + n1, nil
  13. }

不错,我们已经增加了输入校验并修复了上述问题。那么我们怎么才能将上述错误信息增加到遥测数据中呢?从而可以在进行 Trace 分析的过程中更新清晰的查询到程序运行过程中的异常。我们可以修改一下 Write 方法:

  1. import "go.opentelemetry.io/otel/codes"
  2. // Write writes the n-th Fibonacci number back to the user.
  3. func (a *App) Write(ctx context.Context, n uint) {
  4. var span trace.Span
  5. ctx, span = otel.Tracer(name).Start(ctx, "Write")
  6. defer span.End()
  7. f, err := func(ctx context.Context) (uint64, error) {
  8. _, span := otel.Tracer(name).Start(ctx, "Fibonacci")
  9. defer span.End()
  10. f, err := Fibonacci(n)
  11. if err != nil {
  12. // 如果计算错误,在 span 中记录错误信息并标记 span 状态为 error
  13. span.RecordError(err)
  14. span.SetStatus(codes.Error, err.Error())
  15. }
  16. return f, err
  17. }(ctx)
  18. /* … */
  19. }

可以看到,在 Fibonacci 函数返回 err 的时候,我们在 span 中记录了对应的错误信息并且标记了 span 的状态为 Error。
下面,我们再来运行一次看看:

  1. go run .
  2. # What Fibonacci number would you like to know:
  3. 100
  4. # Fibonacci(100): unsupported fibonacci number 100: too large
  5. # What Fibonacci number would you like to know:
  6. ^C
  7. # goodbye

不错,请求已经被拦截了,下面,我们来看一下 traces.txt 文件中的 span 数据,观察一下是否正常捕获到了错误信息呢?

  1. "Events": [
  2. {
  3. "Name": "exception",
  4. "Attributes": [
  5. {
  6. "Key": "exception.type",
  7. "Value": {
  8. "Type": "STRING",
  9. "Value": "*errors.errorString"
  10. }
  11. },
  12. {
  13. "Key": "exception.message",
  14. "Value": {
  15. "Type": "STRING",
  16. "Value": "unsupported fibonacci number 100: too large"
  17. }
  18. }
  19. ],
  20. ...
  21. }
  22. ],
  23. "Status": {
  24. "Code": "Error",
  25. "Description": "unsupported fibonacci number 100: too large"
  26. },

不错,可以看到,我们已经在 Span 数据中看到了对应的 Events 实践,同时 span Status 也发生了变化。