与 OpenTelemetry 和 Jaeger 集成

概述

为了增强 Golang 程序的可观测性,方便定位问题,我们往往会在代码中集成链路追踪 (tracing) 的能力,Jaeger 是当今比较主流的选择,而 tracing 相关的 API 如今都抽象到了 OpenTelemetry 项目中,涵盖各种实现,也包括 Jaeger 在内。

利用 req 强大的中间件能力,可以轻松为我们涉及 HTTP 调用的代码统一集成链路追踪的能力,且能以最少的代码量进行扩展。

本文将给出一个可运行的程序示例:输入一个 GitHub 用户名,展示用户的简短介绍,包含名字、网站地址以及该用户下的最火开源项目与 star 数量,期间涉及的函数与 API 调用链路追踪信息均上报至 Jaeger,进行可视化展示。

主要包含以下特点:

  • 内置一个基于 req 封装的 GitHub SDK。
  • SDK 中利用 req 的 RequestMiddlewareResponseMiddleware,统一处理 API 异常,对接 API 的实现函数无需关心错误处理。
  • SDK 支持传入 OpenTelemetry 的 Tracer 来开启链路追踪,利用 req 的 Client 中间件能力,在请求前创建 trace span,并记录请求与响应的详细信息到 span 中(URL、Method、请求头、请求体、响应状态码、响应头、响应体等),在响应结束后自动终止 span。
  • 在调用 SDK 的上层函数也使用 trace,层层传递,在 Jaeger UI 上可查看完整且非常详细的调用链路详情。

初始化项目

首先创建一个目录,使用 go mod init 初始化工程:

go mod init opentelemetry-jaeger-tracing

封装支持 Tracing 的 GitHub SDK

在项目根目录下面创建一个名为 github 的目录,作为内置的 GitHub SDK 的 package,在里面创建源文件 github.go,写入代码:

package github

import (
  "context"
  "fmt"
  "github.com/imroc/req/v3"
  "go.opentelemetry.io/otel/attribute"
  "go.opentelemetry.io/otel/codes"
  "go.opentelemetry.io/otel/trace"
  "strconv"
  "strings"
)

// Client is the go client for GitHub API.
type Client struct {
  *req.Client
}

// APIError represents the error message that GitHub API returns.
// GitHub API doc: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors
type APIError struct {
  Message          string `json:"message"`
  DocumentationUrl string `json:"documentation_url,omitempty"`
  Errors           []struct {
    Resource string `json:"resource"`
    Field    string `json:"field"`
    Code     string `json:"code"`
  } `json:"errors,omitempty"`
}

// Error convert APIError to a human readable error and return.
func (e *APIError) Error() string {
  msg := fmt.Sprintf("API error: %s", e.Message)
  if e.DocumentationUrl != "" {
    return fmt.Sprintf("%s (see doc %s)", msg, e.DocumentationUrl)
  }
  if len(e.Errors) == 0 {
    return msg
  }
  errs := []string{}
  for _, err := range e.Errors {
    errs = append(errs, fmt.Sprintf("resource:%s field:%s code:%s", err.Resource, err.Field, err.Code))
  }
  return fmt.Sprintf("%s (%s)", msg, strings.Join(errs, " | "))
}

// NewClient create a GitHub client.
func NewClient() *Client {
  c := req.C().
    // All GitHub API requests need this header.
    SetCommonHeader("Accept", "application/vnd.github.v3+json").
    // All GitHub API requests use the same base URL.
    SetBaseURL("https://api.github.com").
    // Enable dump at the request-level for each request, and only
    // temporarily stores the dump content in memory, so we can call
    // resp.Dump() to get the dump content when needed in response
    // middleware.
    // This is actually a syntax sugar, implemented internally using
    // request middleware
    EnableDumpEachRequest().
    // Unmarshal response body into an APIError struct when status >= 400.
    SetCommonError(&APIError{}).
    // Handle common exceptions in response middleware.
    OnAfterResponse(func(client *req.Client, resp *req.Response) error {
      if resp.Err != nil { // There is an underlying error, e.g. network error or unmarshal error(SetResult or SetError was invoked before).
        if dump := resp.Dump(); dump != "" { // Append dump content to original underlying error to help troubleshoot.
          resp.Err = fmt.Errorf("%s\nraw content:\n%s", resp.Err.Error(), resp.Dump())
        }
        return nil // Skip the following logic if there is an underlying error.
      }
      if err, ok := resp.Error().(*APIError); ok { // Server returns an error message.
        // Convert it to human-readable go error.
        resp.Err = err
        return nil
      }
      // Corner case: neither an error response nor a success response,
      // dump content to help troubleshoot.
      if !resp.IsSuccess() {
        resp.Err = fmt.Errorf("bad response, raw content:\n%s", resp.Dump())
      }
      return nil
    })

  return &Client{
    Client: c,
  }
}
  • 使用 Client 结构体作为 GitHub 的客户端,也是 SDK 的核心结构体,内置了一个 *req.Client
  • 分别使用 SetCommonHeaderSetBaseURL 为 GitHub 所有 API 请求设置统一的 Accept 请求头与 URL 前缀。
  • GitHub API 响应的错误格式是统一的,使用 SetCommonError 告知 req 如果响应了错误(状态码大于等于400),则自动将响应体 Unmarshal 到 APIError 结构体的对象中。
  • APIError 结构体实现了 go 的 error 接口,将 API 层面的错误信息转换成可读的字符串。
  • OnAfterResponse 中设置 ResponseMiddleware,检测到 API 响应错误时,将其写入到 resp.Err,自动会将其作为 go error 抛给上层的调用方。
  • OnBeforeRequest 中设置 RequestMiddleware,为所有请求开启请求级别的 dump (暂存到内存,不打印出来),若遇到底层错误(如超时、dns 解析失败、Unmarshal 失败),或者收到未知的状态码(小于200),在 ResponseMiddleware 中尽可能将有助于定位问题的信息(dump 内容)记录到 error,写入 resp.Err 以便抛给上层的调用方。

下面为 Client 增加 Tracing 的能力:

type apiNameType int

const apiNameKey apiNameType = iota

// SetTracer set the tracer of opentelemetry.
func (c *Client) SetTracer(tracer trace.Tracer) {
    c.WrapRoundTripFunc(func(rt req.RoundTripper) req.RoundTripFunc {
        return func(req *req.Request) (resp *req.Response, err error) {
            ctx := req.Context()
            apiName, ok := ctx.Value(apiNameKey).(string)
            if !ok {
                apiName = req.URL.Path
            }
            _, span := tracer.Start(req.Context(), apiName)
            defer span.End()
            span.SetAttributes(
                attribute.String("http.url", req.URL.String()),
                attribute.String("http.method", req.Method),
                attribute.String("http.req.header", req.HeaderToString()),
            )
            if len(req.Body) > 0 {
                span.SetAttributes(
                    attribute.String("http.req.body", string(req.Body)),
                )
            }
            resp, err = rt.RoundTrip(req)
            if err != nil {
                span.RecordError(err)
                span.SetStatus(codes.Error, err.Error())
            }
            if resp.Response != nil {
                span.SetAttributes(
                    attribute.Int("http.status_code", resp.StatusCode),
                    attribute.String("http.resp.header", resp.HeaderToString()),
                    attribute.String("http.resp.body", resp.String()),
                )
            }
            return
        }
    })
}
  • Client.SetTracer 中传入 OpenTelemetry 的 Tracer 来开启 Tracing 能力。
  • 调用 Client 中内置的 *req.ClientWrapRoundTripFunc 添加 Client 中间件,确保将 rt.RoundTrip(req) 返回的 resp 和 err 最终返回给上层。该行代码之前是发起请求前,可记录请求信息,之后是收到响应后,可记录响应信息。
  • 在中间件实现函数里,为每个请求创建一个 trace span,从 context 中获取 API 名称作为 span 名称,如果 context 中有 parant span,当前 span 也会自动成为其 child span。
  • 使用 defer span.End() 确保在响应结束后再结束 span,以便 tracing 能够正确统计耗时。
  • 将请求与响应的详细信息全都记录到 span 中,如 URL、Method、请求头、请求体、响应状态码、响应头、响应体等。
  • 如果检测到 error,也记录到 span 中并设置 span 的 error 状态。

下面开始对接 GitHub API,第一个实现的是获取 GitHub 用户信息的 API,方法命名为 GetUserProfile:

func withAPIName(ctx context.Context, name string) context.Context {
	if ctx == nil {
		ctx = context.Background()
	}
	return context.WithValue(ctx, apiNameKey, name)
}

type UserProfile struct {
	Name string `json:"name"`
	Blog string `json:"blog"`
}

// GetUserProfile returns the user profile for the specified user.
// Github API doc: https://docs.github.com/en/rest/users/users#get-a-user
func (c *Client) GetUserProfile(ctx context.Context, username string) (user *UserProfile, err error) {
	err = c.Get("/users/{username}").
		SetPathParam("username", username).
		SetResult(&user).
		Do(withAPIName(ctx, "GetUserProfile")).Err
	return
}
  • 链路追踪的 span 都是通过 context 传递,每个方法的第一个参数都用 context。
  • 利用链式调用构造 Request,Get 表示创建一个 GET 请求,并传入 API 路径(之前 Client.SetCommonBaseURL 已设置所有请求的 URL 前缀,这里就可以省略前缀只写路径), 路径中还有 username 路径参数 (REST 风格 API),使用 SetPathParam 传入。
  • 响应体格式是 UserProfile 结构体,直接将返回参数中的空指针变量的地址传入 SetResult,表示如果没有异常,自动创建一个该结构体类型的对象,并让指针变量指向该结构体,这样都不需要自己事先初始化结构体,减少代码量。
  • 利用公共函数 withAPIName 将 API 名称放入 context,然后调用 Do 发起请求时,将 context 传进去,以便让 Client 中间件能够获取到 API 名称并自动将其作为 span 名称。
  • Do 会返回 *req.Response,任何情况它都不为 nil,如果请求过程中返回了 error,会记录到其 Err 字段,将其赋值给返回参数的 err 以便 error 能够层层传递上去。

下面再来增加一个获取指定用户代码仓库列表的 API ListUserRepo:

type Repo struct {
    Name string `json:"name"`
    Star int    `json:"stargazers_count"`
}

// ListUserRepo returns a list of public repositories for the specified user
// Github API doc: https://docs.github.com/en/rest/repos/repos#list-repositories-for-a-user
func (c *Client) ListUserRepo(ctx context.Context, username string, page int) (repos []*Repo, err error) {
    err = c.Get("/users/{username}/repos").
        SetPathParam("username", username).
        SetQueryParamsAnyType(map[string]any{
            "type":      "owner",
            "page":      page,
            "per_page":  "100",
            "sort":      "updated",
            "direction": "desc",
        }).
        SetResult(&repos).
        Do(withAPIName(ctx, "ListUserRepo")).Err
    return
}

  • 该 API 支持分页,需要传入 username 和 page。
  • page 是整数类型,需要将其传入查询参数,使用 SetQueryParamsAnyType 传入所有查询参数,无需提前转成字符串。
  • 其余与上一个 API 实现类似。

可以看到,后续我们每次对接新的 API 都变得非常轻松,因为利用了 req 的中间件能力,对异常与链路追踪都进行了统一处理,对接 API 时,只需传入 API 必要的参数与响应体结构类型即可,没有一点多余的代码,非常直观和简洁。

好了,作为示例我们就只对接这两个 API 就够了,我们还可以再为 Client 增加一些实用的小功能:

// LoginWithToken login with GitHub personal access token.
// GitHub API doc: https://docs.github.com/en/rest/overview/other-authentication-methods#authenticating-for-saml-sso
func (c *Client) LoginWithToken(token string) *Client {
	c.SetCommonHeader("Authorization", "token "+token)
	return c
}

// SetDebug enable debug if set to true, disable debug if set to false.
func (c *Client) SetDebug(enable bool) *Client {
	if enable {
		c.EnableDebugLog()
		c.EnableDumpAll()
	} else {
		c.DisableDebugLog()
		c.DisableDumpAll()
	}
	return c
}
  • 如果是匿名用户调用 GitHub API,会有限频,可以使用 token 来避免被限频,增加 LoginWithToken 以支持为所有请求带上认证的 personal access token
  • 增加 SetDebug 以支持 debug 能力,开启 debug 时,将打印 req 的 debug 日志以及原始的请求与响应内容。

至此,我们的 GitHub SDK 封装完成。

程序示例

下面,正式开始写可运行的示例程序。

在项目根目录下创建 main.go:

package main

import (
	"context"
	"fmt"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/codes"
	"go.opentelemetry.io/otel/exporters/jaeger"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.12.0"
	"log"
	"opentelemetry-jaeger-tracing/github"
	"os"
)

const serviceName = "github-query"

var githubClient *github.Client
  • 定义 serviceName 作为本服务的标识 (通常每个程序都是一个服务,上报 tracing 数据时,需标识服务名),这里就定义为 github-query
  • 本示例程序需要调用 GitHub API 进行查询,使用前面我们封装的 GitHub SDK 作为 client,这里定义一个全局 githubClient 变量,内部函数直接使用该 client 进行调用。

使用 OpenTelemetry 进行链路追踪,需要创建一个 TracerProvider,这里我们定义 traceProvider 函数来创建包含 Jaeger 实现的 TracerProvider:

func traceProvider() (*trace.TracerProvider, error) {
	// Create the Jaeger exporter
	ep := os.Getenv("JAEGER_ENDPOINT")
	if ep == "" {
		ep = "http://localhost:14268/api/traces"
	}
	exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(ep)))
	if err != nil {
		return nil, err
	}

	// Record information about this application in a Resource.
	res, _ := resource.Merge(
		resource.Default(),
		resource.NewWithAttributes(
			semconv.SchemaURL,
			semconv.ServiceNameKey.String(serviceName),
			semconv.ServiceVersionKey.String("v0.1.0"),
			attribute.String("environment", "test"),
		),
	)

	// Create the TraceProvider.
	tp := trace.NewTracerProvider(
		// Always be sure to batch in production.
		trace.WithBatcher(exp),
		// Record information about this application in a Resource.
		trace.WithResource(res),
		trace.WithSampler(trace.AlwaysSample()),
	)
	return tp, nil
}
  • 使用 JAEGER_ENDPOINT 自定义 Jaeger 地址,默认使用本地测试的地址。
  • 传入 serviceName 以便在 tracing 数据对本服务进行标识。

下面来写查询用户信息的主要函数 QueryUser:

// QueryUser queries information for specified GitHub user, and display a
// brief introduction which includes name, blog, and the most popular repo.
func QueryUser(username string) error {
	ctx, span := otel.Tracer("query").Start(context.Background(), "QueryUser")
	defer span.End()

	span.SetAttributes(
		attribute.String("query.username", username),
	)
	profile, err := githubClient.GetUserProfile(ctx, username)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		return err
	}
	span.SetAttributes(
		attribute.String("query.name", profile.Name),
		attribute.String("result.blog", profile.Blog),
	)
	repo, err := findMostPopularRepo(ctx, username)
	if err != nil {
		span.RecordError(err)
		span.SetStatus(codes.Error, err.Error())
		return err
	}
	span.SetAttributes(
		attribute.String("popular.repo.name", repo.Name),
		attribute.Int("popular.repo.star", repo.Star),
	)
	fmt.Printf("The most popular repo of %s (%s) is %s, with %d stars\n", profile.Name, profile.Blog, repo.Name, repo.Star)
	return nil
}

func findMostPopularRepo(ctx context.Context, username string) (repo *github.Repo, err error) {
	ctx, span := otel.Tracer("query").Start(ctx, "findMostPopularRepo")
	defer span.End()

	for page := 1; ; page++ {
    var repos []*github.Repo
		repos, err = githubClient.ListUserRepo(ctx, username, page)
		if err != nil {
			return
		}
		if len(repos) == 0 {
			break
		}
		if repo == nil {
			repo = repos[0]
		}
		for _, rp := range repos[1:] {
			if rp.Star >= repo.Star {
				repo = rp
			}
		}
		if len(repos) == 100 {
			continue
		}
		break
	}

	if repo == nil {
		err = fmt.Errorf("no repo found for %s", username)
	}
	return
}
  • QueryUser 需传入一个 username,以便查询指定 GitHub 用户的信息。
  • 在函数开头创建一个名为 QueryUser 的 root span,作为链路追踪的初始 span。
  • 在 span 中记录查询相关信息,包含查询的 username 以及查询到的昵称、blog 地址(使用 GetUserProfile 接口),也包含该用户最火的开源项目及其 star 数量(使用 ListUserRepo 接口并进行计算对比得出)。
  • 在函数末尾打印最终查询到的信息到控制台。
  • 其中计算用户最火开源项目及其 star 数量由单独的 findMostPopularRepo 函数来实现,该函数也有对应的 span。

主要的实现函数准备就绪,现在我们来写 main 函数:

func main() {
    tp, err := traceProvider()
    if err != nil {
        panic(err)
    }
    otel.SetTracerProvider(tp)

    githubClient = github.NewClient()
    if os.Getenv("DEBUG") == "on" {
        githubClient.SetDebug(true)
    }
    if token := os.Getenv("GITHUB_TOKEN"); token != "" {
        githubClient.LoginWithToken(token)
    }
    githubClient.SetTracer(otel.Tracer("github"))

    sigs := make(chan os.Signal, 1)
    signal.Notify(sigs, syscall.SIGTERM, syscall.SIGINT)
    go func() {
        sig := <-sigs
        fmt.Printf("Caught %s, shutting down\n", sig)
        if err := tp.Shutdown(context.Background()); err != nil {
            log.Fatal(err)
        }
        os.Exit(0)
    }()

    for {
        var name string
        fmt.Printf("Please give a github username: ")
        _, err := fmt.Fscanf(os.Stdin, "%s\n", &name)
        if err != nil {
            panic(err)
        }
        err = QueryUser(name)
        if err != nil {
            fmt.Println(err.Error())
        }
    }
}
  • 调用 traceProvider() 创建一个 TraceProvider,并使用 otel.SetTracerProvider(tp) 设置到全局共享,以便前面其它函数调用 otel.Tracer(xx) 能够使用此 provider 来创建与获取 tracer。
  • 调用 github.NewClient() 为全局的 githubClient 进行初始化。
  • 判断环境变量,如果 DEBUG=on 则开启 Debug,如果提供 GITHUB_TOKEN 则将其设置给所有请求。
  • 使用 githubClient.SetTracer(otel.Tracer("github")) 来为 GitHub 的 Client 启用 Tracing 能力,用名为 gihtub 的 tracer 标识 SDK 中产生的 tracing 信息。
  • 处理 SIGTERMSIGTNT 信号以实现优雅终止,在程序退出前关闭 TraceProvider,确保 trace 数据上报完再退出 (如果程序不是常驻运行,可以在 main 函数中用 defer 语句关闭 TraceProvider)。
  • 主体是一个 for 死循环: 获取用户输入的 username,然后调用 QueryUser 查询并展示用户信息。

大功告成,下面我们来运行一下看看效果。

运行与效果

首先按照 Jaeger 官方文档 Getting Started 在本地启动一个 Jaeger。

然后在项目根目录运行 go run . 运行程序,输入一个 GitHub 用户名(如 spf13),不出意外的话,会自动展示该用户的简短介绍:

$ go run .
Please give a github username: spf13
The most popular repo of Steve Francia (http://spf13.com) is cobra, with 28044 stars

然后使用浏览器进入 Jaeger UI 界面(http://127.0.0.1:16686/)来查看 Tracing 详情:

可以清晰的看到函数调用链路与耗时信息:

QueryUser (3.27s)
   |
   |----> GetUserProfile (1.1s)
   |----> findMostPopularRepo (2.16s)
                  |
                  |----> ListUserRepo (1.17s)
                  |----> ListUserRepo (453.24ms)

ListUserRepo 调用两次是因为分页查询用户 repo 时一页没查询完,分成了两次查询。

点进 QueryUser 的 span 详情,可以看到我们在函数内记录的查询与结果信息:

再点进 GetUserProfile 这个 SDK 产生的 span 详情,可以看到我们在中间件统一记录的 URL、Method、请求头、响应状态码、响应头、响应体等信息全都在这里,非常详细:

不断输入其它 username 测试,经过多次后可能会因 GitHub 的 API 限频导致异常:

$ go run .
Please give a github username: spf13
API error: API rate limit exceeded for 43.132.98.44. (But here's the good news: Authenticated requests get a higher rate limit. Check out the documentation for more details.) (see doc https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting)

检查下 Jaeger UI,可以看到很详细很显眼的错误信息:

此时,你可以将你的 GitHub 账号 personal access token 写到环境变量来避免被限频:

export GITHUB_TOKEN=*******

尝试输入一个不存在的用户:

$ go run .
Please give a github username: kjtlejkdglfjsadhfajfsa
API error: Not Found (see doc https://docs.github.com/rest/reference/users#get-a-user)

检查下 Jaeger UI,同样的也可以看到详细的错误信息:

如果断开公网测试,可能会报 dns 解析失败的错:

$ go run .
Please give a github username: imroc
Get "https://api.github.com/users/imroc": dial tcp: lookup api.github.com: no such host

或者连接超时的错:

$ go run .
Please give a github username: spf13
Get "https://api.github.com/users/spf13": dial tcp 20.205.243.168:443: connect: operation timed out

完整代码

本文涉及的完整代码已放入 req 官方 examples 下的 opentelemetry-jaeger-tracing 目录。

总结

如果业务程序中需要调用其它服务的 API,我们可以利用 req 强大的中间件能力,统一处理所有请求的异常,统一记录所有请求详细信息到 Tracing 系统,写出健壮、可观测性强且极易扩展的 SDK 与业务代码。