我们面临的第一个难题,并非来自崩溃,而是来自“慢”。用户在 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 服务中:
- 编写一个 HTTP 中间件,从请求头中提取
X-Correlation-ID
。 - 将此 ID 注入到请求的
context.Context
中,使其在整个请求处理函数调用链中可用。 - 配置结构化日志库(如
slog
),使其自动将此 ID 添加到每一行日志中。 - 配置 Sentry Go SDK,将此 ID 作为标签附加到所有后端事件。
- 在调用下游服务(如 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 中串联一切
至此,我们的系统已经具备了端到端追踪的能力。当一个完整的请求流程走完后:
- 如果 Android 端发生崩溃,Sentry 会有一个带有
correlation_id: A
的错误报告。 - 如果后端 Go 服务发生 panic,Sentry 会有另一个带有
correlation_id: A
的错误报告。 - Sentry 的性能监控(Transactions)会同时记录到来自 Android 的前端事务和来自 Go 的后端事务,它们都带有
correlation_id: A
标签。
现在,当收到用户反馈搜索慢时,我们的排查流程变成了:
- 向用户获取大致操作时间,在 Sentry (Discover) 中找到对应的 Android 性能事务。
- 从该事务中复制
correlation_id
的值。 - 在 Sentry 的全局搜索或 Discover 查询中,使用
correlation_id: A
作为过滤条件。 - 所有结果会立刻呈现:
- Android 端的完整事务,包含每个 UI 阶段和网络请求的耗时。
- 后端服务的对应事务,展示了 API 处理、数据库查询、Solr 查询的耗时分解。
- 任何在此过程中发生的错误事件(无论前端还是后端)。
- 如果发现是 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% 的跨系统调试问题。
其局限性也显而易见:
- 缺乏标准化的 Span 模型:我们只有关联ID,没有像 OpenTelemetry 或 Jaeger 那样标准的 Span(跨度)概念,无法自动生成服务依赖拓扑图和精确到函数级别的火焰图。每个服务内部的耗时分解依赖于手动的日志记录。
- 对下游服务的侵入性:向 Solr 注入
comment
是一个巧妙的技巧,但不是所有数据库或第三方服务都提供类似的机制。对于完全不透明的黑盒服务,追踪就中断了。 - 上下文传递的维护成本:在 Go 服务中,我们需要确保
context.Context
在整个调用链中被正确传递。在复杂的异步或 goroutine 场景中,这可能会被遗忘,导致追踪信息丢失。
未来的优化路径是明确的:逐步引入 OpenTelemetry 标准。我们可以将当前的 X-Correlation-ID
替换为标准的 W3C Trace Context Headers (traceparent
, tracestate
)。在 Go 服务中,使用 OpenTelemetry SDK 自动创建 Span,并通过 Instrumentation Libraries(如 otelhttp
, otelmongo
)自动追踪对下游服务的调用。这会将我们的手动系统,平滑地升级为一个功能更强大、生态更完善的标准化分布式追踪系统,而我们今天建立的这个关联 ID 体系,将成为向更成熟的可观测性架构演进的坚实基础。