基于 Tyk 网关和 Sentry 实现 Android 到 Solr 的分布式追踪与错误诊断


我们面临的第一个难题,并非来自崩溃,而是来自“慢”。用户在 Android 应用内执行一次复杂的商品搜索,偶尔会卡顿 5 到 8 秒。Sentry 在客户端捕获到了超时的网络请求事务,后端 Sentry 也记录了一些处理时间较长的 API 调用,但两者之间是割裂的。问题出在哪?是客户端网络环境、CDN、Tyk API 网关的抖动、后端服务GC、还是底层 Solr 集群的索引合并?日志散落在各处,没有一条唯一的线索能将它们串联起来。

这种跨越多层系统的故障排查,成本极高。我们的目标是建立一个轻量级的关联机制,用一个唯一的请求ID,将用户在 Android 上的一次点击,贯穿到 Tyk 网关、后端 Go 服务、对 MongoDB 的查询,直至最终对 Solr 的精确查询。所有这一切的日志和错误报告,都必须能在 Sentry 中通过这个 ID 聚合起来。

第一步:在 Android 客户端生成并传递追踪信标

一切的源头在客户端。我们需要在每次网络请求发出时,生成一个全局唯一的 ID,并将其注入到 HTTP 请求头中。在真实项目中,直接修改散落在各处的网络请求代码是不可行的。最佳实践是使用网络库的拦截器(Interceptor)来统一处理。

这里以 OkHttp 为例,实现一个 CorrelationIdInterceptor

// file: com/example/app/api/CorrelationIdInterceptor.kt
package com.example.app.api

import io.sentry.Sentry
import okhttp3.Interceptor
import okhttp3.Response
import java.io.IOException
import java.util.UUID

/**
 * 一个 OkHttp 拦截器,负责三项关键任务:
 * 1. 为每个发出的请求生成一个唯一的 X-Correlation-ID。
 * 2. 将此 ID 附加到 Sentry 的当前作用域 (Scope),以便所有在此请求生命周期内捕获的
 *    错误或事件都自动带上这个标签。
 * 3. 将此 ID 作为请求头发送到后端,用于全链路追踪。
 */
class CorrelationIdInterceptor : Interceptor {

    companion object {
        // 定义标准的请求头 key,与后端和网关保持一致
        const val CORRELATION_ID_HEADER = "X-Correlation-ID"
    }

    @Throws(IOException::class)
    override fun intercept(chain: Interceptor.Chain): Response {
        // 1. 生成一个唯一的 UUID 作为本次请求的关联 ID
        val correlationId = UUID.randomUUID().toString()

        // 2. 将此 ID 设置到 Sentry 的 Scope 中。
        // 这是一个关键步骤。从此以后,任何由 Sentry SDK 自动或手动捕获的事件
        // (崩溃, 错误, 性能事务) 都会附带这个 correlation_id 标签。
        Sentry.configureScope { scope ->
            scope.setTag("correlation_id", correlationId)
            // 同样可以设置到上下文中,提供更多调试信息
            scope.setContext("request_details", mapOf(
                "correlation_id" to correlationId
            ))
        }

        // 3. 将 ID 添加到原始请求的请求头中
        val originalRequest = chain.request()
        val requestWithHeader = originalRequest.newBuilder()
            .header(CORRELATION_ID_HEADER, correlationId)
            .build()
        
        // 执行请求并返回响应
        // 这里不做任何响应处理,仅关注请求的注入
        // 实际项目中可能还需处理响应中的错误码,并与 Sentry 关联
        return chain.proceed(requestWithHeader)
    }
}

// 在构建 OkHttpClient 时应用此拦截器
// file: com/example/app/api/ApiClient.kt
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit

object ApiClient {
    val instance: OkHttpClient by lazy {
        OkHttpClient.Builder()
            .addInterceptor(CorrelationIdInterceptor()) // 在这里添加拦截器
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            // ... 其他配置,如日志、缓存等
            .build()
    }
}

这段代码的核心价值在于,它不仅发送了 ID,还主动将这个 ID 与客户端的 Sentry 上下文关联起来。现在,如果这个网络请求导致了客户端的任何异常(例如,解析响应失败),Sentry 捕获的这条错误报告会天然携带 correlation_id 这个标签。

第二步:配置 Tyk 网关,确保追踪 ID 的透传与加固

请求到达的第一站是 Tyk API 网关。我们需要确保 X-Correlation-ID 能被正确地传递到后端的上游服务。更重要的是,我们要考虑到并非所有客户端(如旧版 App、Web 前端)都实现了这个逻辑。因此,网关层需要一个“加固”策略:如果请求头中存在 X-Correlation-ID,则透传;如果不存在,则由网关生成一个,确保链路追踪的完整性。

Tyk 可以通过其强大的中间件功能实现这一点。我们可以使用“请求转换”中间件中的 Header Injection 功能,并结合 Tyk 的内置变量。

以下是一个 Tyk API 定义(JSON 格式)的关键部分:

{
  "name": "Product-Search-API",
  "api_id": "product-search-api-123",
  "org_id": "my-org",
  "use_keyless": true,
  "proxy": {
    "listen_path": "/search/",
    "target_url": "http://product-service.internal:8080/",
    "strip_listen_path": true
  },
  "version_data": {
    "not_versioned": true,
    "versions": {
      "Default": {
        "name": "Default",
        "use_extended_paths": true,
        "extended_paths": {
          "transform_headers": [
            {
              "path": "{path:.*}",
              "method": "GET",
              "act_on": true,
              "add_headers": {
                // 这是关键:将追踪 ID 注入到发往上游服务的请求中
                // Tyk 的 $tyk_context.request_headers.X-Correlation-ID 会尝试获取入站请求的同名 header
                // 如果客户端传了,就用客户端的。
                // $tyk_context.request_id 是 Tyk 为每个请求生成的唯一 ID,可以作为备选。
                // 这种写法虽然直观,但在 Tyk CE 中直接用变量作为回退并不支持。
                // 更可靠的方式是使用 JavaScript Middleware。
                "X-Correlation-ID": "$tyk_context.request_id"
              },
              "delete_headers": []
            }
          ]
        }
      }
    }
  }
}

上述 transform_headers 的方式很简单,但它会用 Tyk 的请求ID覆盖客户端传来的ID。一个更健壮的方案是使用 Tyk 的 JavaScript 中间件(需要 Pro 或自行构建包含 JSV M 的网关)。

// Tyk JSVM Middleware (pre-request)
// file: ensureCorrelationId.js

var ensureCorrelationIdMiddleware = new TykJS.TykMiddleware.NewMiddleware({});

ensureCorrelationIdMiddleware.NewProcessRequest(function(request, session, spec) {
    // 检查客户端是否已经提供了 X-Correlation-ID
    var correlationId = request.Headers['X-Correlation-ID'];

    // 如果 Header 为空或未定义
    if (correlationId === undefined || correlationId === null || correlationId.length === 0) {
        // 如果不存在,使用 Tyk 自身的请求 ID 作为 Correlation ID。
        // tyk_context.request_id 是一个稳定且唯一的ID。
        correlationId = Tyk.Context().Get("request_id");
        log("X-Correlation-ID not found in request, generated by gateway: " + correlationId);
    } else {
        log("X-Correlation-ID found in request: " + correlationId);
        // 如果存在,取第一个值(Header 值是数组)
        correlationId = correlationId[0];
    }
    
    // 无论来源如何,都将最终确定的 ID 设置到发往上游的请求头中
    request.SetHeaders['X-Correlation-ID'] = correlationId;

    // 这一步也很有用:将 ID 存入 session 元数据,以便后续中间件(如日志记录)使用
    session.MetaData["correlation_id"] = correlationId;

    return ensureCorrelationIdMiddleware.ReturnData(request, session.MetaData);
});

log("Ensure Correlation ID middleware initialized.");

通过这个 JS 中间件,我们保证了每一笔到达后端服务的请求,都必然携带一个有效的 X-Correlation-ID

第三步:在 Go 后端服务中接收、使用并传递追踪 ID

后端服务是整个链路的核心。我们需要在 Go 服务中:

  1. 编写一个 HTTP 中间件,从请求头中提取 X-Correlation-ID
  2. 将此 ID 注入到请求的 context.Context 中,使其在整个请求处理函数调用链中可用。
  3. 配置结构化日志库(如 slog),使其自动将此 ID 添加到每一行日志中。
  4. 配置 Sentry Go SDK,将此 ID 作为标签附加到所有后端事件。
  5. 在调用下游服务(如 Solr)时,想办法把这个 ID 传递过去。
// file: internal/middleware/tracing.go
package middleware

import (
	"context"
	"net/http"

	"github.com/getsentry/sentry-go"
	"github.com/google/uuid"
)

type contextKey string

const CorrelationIDKey contextKey = "correlationID"
const CorrelationIDHeader = "X-Correlation-ID"

// WithCorrelationID 是一个 HTTP 中间件,它负责处理追踪 ID。
func WithCorrelationID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// 1. 从请求头中获取 ID
		id := r.Header.Get(CorrelationIDHeader)
		if id == "" {
			// 如果网关或客户端没有提供,作为最后防线,后端自己生成一个
			id = uuid.New().String()
		}

		// 2. 将 ID 注入到请求的 context 中
		ctx := context.WithValue(r.Context(), CorrelationIDKey, id)
		
		// 3. 将 ID 绑定到 Sentry 的当前 Hub (Scope)
		if hub := sentry.GetHubFromContext(ctx); hub != nil {
			hub.Scope().SetTag("correlation_id", id)
		}

		// 将带有新 context 的请求传递给下一个处理器
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// GetCorrelationIDFromContext 是一个辅助函数,方便在业务逻辑中获取 ID
func GetCorrelationIDFromContext(ctx context.Context) string {
	if id, ok := ctx.Value(CorrelationIDKey).(string); ok {
		return id
	}
	return ""
}

接下来,配置结构化日志。Go 1.21+ 内置的 slog 库非常适合这个场景。

// file: internal/logger/logger.go
package logger

import (
	"context"
	"log/slog"
	"os"

	"your_project/internal/middleware"
)

type ContextHandler struct {
	slog.Handler
}

// Handle 方法会拦截所有日志记录调用,并在这里添加 context 中的值
func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
	if id := middleware.GetCorrelationIDFromContext(ctx); id != "" {
		r.AddAttrs(slog.String("correlation_id", id))
	}
	return h.Handler.Handle(ctx, r)
}

func New(level slog.Level) *slog.Logger {
	handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: level,
	})
	// 使用自定义的 Handler 包装器
	contextHandler := &ContextHandler{Handler: handler}
	return slog.New(contextHandler)
}

// main.go 中初始化
var log = logger.New(slog.LevelDebug)

现在,任何使用这个 log 实例的地方,只要传入了 context,日志就会自动带上 correlation_id
例如:log.InfoContext(r.Context(), "User search request received")

第四步:将追踪ID传递给 Solr 和 NoSQL

这是最棘手也最体现价值的一步。

对于 Solr:
Solr 自身没有原生的分布式追踪集成。但是,它提供了一个鲜为人知但非常有用的功能:comment 查询参数。我们可以在查询 q 的同时,附带一个 comment 参数,这个参数的内容会被 Solr 记录到它的慢查询日志中。

// file: internal/search/solr_client.go
package search

import (
	"context"
	"fmt"
	"net/http"
	"net/url"
	
	"your_project/internal/middleware"
)

type SolrClient struct {
	// ... httpClient, baseURL 等
}

func (c *SolrClient) QueryProducts(ctx context.Context, query string) (string, error) {
	correlationID := middleware.GetCorrelationIDFromContext(ctx)

	// 构建 Solr 查询参数
	params := url.Values{}
	params.Add("q", query)
	params.Add("wt", "json")

	// 关键一步:将 correlation_id 作为 comment 注入查询
	// 这样在 Solr 的 slow query 日志中,我们就能看到是哪个请求触发了慢查询
	if correlationID != "" {
		comment := fmt.Sprintf("trace_id:%s", correlationID)
		params.Add("comment", comment)
	}

	fullURL := fmt.Sprintf("%s/select?%s", c.baseURL, params.Encode())

	// ... 执行 http.Get 请求
	
	return "results", nil
}

当 Solr 出现慢查询时,其日志会像这样:
... o.a.s.c.SolrCore [c:products s:shard1 r:core_node1 x:products_shard1_replica_n1] webapp=/solr path=/select params={q=...&wt=json&comment=trace_id:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx} hits=123 status=0 QTime=1534
现在,我们可以直接 grep xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx 来定位到是哪个具体的用户请求导致了 Solr 的性能问题。

对于 NoSQL (以 MongoDB 为例):
与 Solr 类似,直接将追踪 ID 注入到 MongoDB 的查询协议中很困难。但是,我们的结构化日志已经解决了这个问题。任何与 MongoDB 交互的代码前后,我们都可以记录日志。

// file: internal/datastore/mongo_repo.go
package datastore

func (r *MongoRepository) GetProductDetails(ctx context.Context, productIDs []string) {
    log := logger.FromContext(ctx) // 假设我们有这样一个辅助函数

    log.Info("Fetching product details from MongoDB", "count", len(productIDs))
    
    // ... 执行 mongoDB 查询的代码 ...
    // collection.Find(ctx, bson.M{"_id": bson.M{"$in": productIDs}})
    
    // 如果有错误
    if err != nil {
        log.Error("Failed to fetch from MongoDB", "error", err)
        sentry.GetHubFromContext(ctx).CaptureException(err) // Sentry 事件也会带上 correlation_id
        return
    }
    
    log.Info("Successfully fetched product details")
}

由于我们的日志系统是 context-aware 的,所有这些日志行都会自动包含正确的 correlation_id。这就足够我们将应用日志与特定的请求关联起来。

最终成果:在 Sentry 中串联一切

至此,我们的系统已经具备了端到端追踪的能力。当一个完整的请求流程走完后:

  1. 如果 Android 端发生崩溃,Sentry 会有一个带有 correlation_id: A 的错误报告。
  2. 如果后端 Go 服务发生 panic,Sentry 会有另一个带有 correlation_id: A 的错误报告。
  3. Sentry 的性能监控(Transactions)会同时记录到来自 Android 的前端事务和来自 Go 的后端事务,它们都带有 correlation_id: A 标签。

现在,当收到用户反馈搜索慢时,我们的排查流程变成了:

  1. 向用户获取大致操作时间,在 Sentry (Discover) 中找到对应的 Android 性能事务。
  2. 从该事务中复制 correlation_id 的值。
  3. 在 Sentry 的全局搜索或 Discover 查询中,使用 correlation_id: A 作为过滤条件。
  4. 所有结果会立刻呈现:
    • Android 端的完整事务,包含每个 UI 阶段和网络请求的耗时。
    • 后端服务的对应事务,展示了 API 处理、数据库查询、Solr 查询的耗时分解。
    • 任何在此过程中发生的错误事件(无论前端还是后端)。
  5. 如果发现是 Solr 查询耗时过长,我们拿着这个 correlation_id 去 Solr 的日志服务器上搜索,就能精确定位到是哪个查询语句、在哪个 shard 上、消耗了多少时间。

我们用 Mermaid 图来描绘这个数据流:

sequenceDiagram
    participant AndroidClient as Android 客户端
    participant TykGateway as Tyk API 网关
    participant GoService as Go 后端服务
    participant Sentry
    participant Solr
    participant MongoDB

    AndroidClient->>+TykGateway: GET /search?q=... (H: X-Correlation-ID=A)
    Note over AndroidClient, Sentry: Sentry.setTag("correlation_id", "A")

    TykGateway-->>TykGateway: JS 中间件验证/生成 ID
    TykGateway->>+GoService: GET /?q=... (H: X-Correlation-ID=A)

    GoService-->>GoService: 中间件提取 ID "A", 注入 Context
    Note over GoService, Sentry: Sentry.setTag("correlation_id", "A")
    GoService-->>MongoDB: 查询商品信息 (日志含 ID "A")
    MongoDB-->>GoService: 返回结果

    GoService->>+Solr: GET /select?q=...&comment=trace_id:A
    Note over GoService: 结构化日志记录 (含 ID "A")
    Solr-->>-GoService: 返回搜索结果

    GoService->>-TykGateway: Response
    TykGateway->>-AndroidClient: Response
    
    alt 发生异常
        AndroidClient->>Sentry: 上报错误 (tag: correlation_id=A)
        GoService->>Sentry: 上报错误 (tag: correlation_id=A)
    end

局限性与未来展望

这个方案并非银弹,它本质上是一个“手动”的、基于关联 ID 的追踪系统。它的优点是轻量、易于理解、对现有架构侵入性小,并且能快速解决 80% 的跨系统调试问题。

其局限性也显而易见:

  1. 缺乏标准化的 Span 模型:我们只有关联ID,没有像 OpenTelemetry 或 Jaeger 那样标准的 Span(跨度)概念,无法自动生成服务依赖拓扑图和精确到函数级别的火焰图。每个服务内部的耗时分解依赖于手动的日志记录。
  2. 对下游服务的侵入性:向 Solr 注入 comment 是一个巧妙的技巧,但不是所有数据库或第三方服务都提供类似的机制。对于完全不透明的黑盒服务,追踪就中断了。
  3. 上下文传递的维护成本:在 Go 服务中,我们需要确保 context.Context 在整个调用链中被正确传递。在复杂的异步或 goroutine 场景中,这可能会被遗忘,导致追踪信息丢失。

未来的优化路径是明确的:逐步引入 OpenTelemetry 标准。我们可以将当前的 X-Correlation-ID 替换为标准的 W3C Trace Context Headers (traceparent, tracestate)。在 Go 服务中,使用 OpenTelemetry SDK 自动创建 Span,并通过 Instrumentation Libraries(如 otelhttp, otelmongo)自动追踪对下游服务的调用。这会将我们的手动系统,平滑地升级为一个功能更强大、生态更完善的标准化分布式追踪系统,而我们今天建立的这个关联 ID 体系,将成为向更成熟的可观测性架构演进的坚实基础。


  目录