构建从Zustand到Ktor的全栈可观测性 整合Sentry与Pulumi实现分布式追踪


一个棘手问题的排查,往往始于信息断层。前端团队反馈用户操作偶发性失败,但从Zustand状态变更和网络请求来看,一切正常。后端团队检查Ktor服务的日志,对应时间戳的请求也成功处理,没有异常。问题出在前端操作与后端日志之间的“黑暗隧道”里,我们无法将用户的某一次点击,与其在后端触发的一系列数据库调用、缓存读写和第三方API请求完整地关联起来。这种场景下,独立的日志系统价值骤减,我们需要的是一个能贯穿整个技术栈的统一视图。

我们的目标是构建一个全栈分布式追踪系统。当用户在基于Turbopack构建的React应用中触发一个Zustand action时,我们希望能生成一个唯一的追踪ID。这个ID将随着API请求传递到后端的Ktor服务,并在整个请求生命周期中,包括任何异步协程或下游调用,都能被一致地传递。所有这一切,从基础设施的配置(Sentry项目本身)到应用的埋点,都应通过代码进行管理,以保证环境的一致性和可重复性。Pulumi是这项工作的理想工具,它能让我们用TypeScript来定义和管理Sentry资源。

第一步:用Pulumi将可观测性基础设施化

手动在Sentry后台创建项目、团队、获取DSN,是一种不可靠且难以规模化的方式。在真实项目中,我们会有开发、预发、生产等多个环境,每个环境都应该有独立的Sentry项目。使用Pulumi,我们可以将这些配置声明为代码,纳入版本控制。

首先,需要安装Sentry的Pulumi provider。
npm install @pulumiverse/sentry

接下来,我们编写Pulumi程序来创建组织、团队和两个项目:一个用于Ktor后端,一个用于React前端。

// pulumi/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as sentry from "@pulumiverse/sentry";

// Sentry 组织 slug,通常在你的 Sentry URL 中可以找到
const orgSlug = "your-sentry-organization-slug";

// 创建一个专门用于此应用的团队
const appTeam = new sentry.SentryTeam("app-team", {
    organization: orgSlug,
    name: "FullstackAppTeam",
    slug: "fullstack-app-team",
});

// 为 Ktor 后端服务创建 Sentry 项目
const ktorProject = new sentry.SentryProject("ktor-backend-project", {
    organization: orgSlug,
    teams: [appTeam.slug],
    name: "Ktor-Backend-Service",
    platform: "kotlin",
    // 这里的 slug 必须是唯一的
    slug: "ktor-backend-service",
});

// 为 React 前端应用创建 Sentry 项目
const reactProject = new sentry.SentryProject("react-frontend-project", {
    organization: orgSlug,
    teams: [appTeam.slug],
    name: "React-Frontend-App",
    platform: "javascript-react",
    slug: "react-frontend-app",
});

// 从项目中获取 DSN (Data Source Name)。
// DSN 是机密信息,我们将其标记为 secret。
const ktorDsn = pulumi.secret(ktorProject.dsn);
const reactDsn = pulumi.secret(reactProject.dsn);

// 导出 DSN,以便在 CI/CD 流程中注入到应用的环境变量中
export const backendSentryDsn = ktorDsn;
export const frontendSentryDsn = reactDsn;

// 导出项目ID,方便后续进行其他自动化操作
export const backendProjectId = ktorProject.id;
export const frontendProjectId = reactProject.id;

在真实项目中,Pulumi的堆栈(stack)会对应不同的环境(dev, staging, prod)。运行pulumi up后,我们就能得到每个环境独立的Sentry DSN。这里的关键点在于,DSN被pulumi.secret()标记,确保了它在状态文件和输出中是加密的。后续CI/CD流程可以从Pulumi的输出中读取这些值,并作为环境变量(如SENTRY_DSN)注入到应用运行时,从而避免了硬编码。

第二步:为Ktor后端植入追踪探针

后端的追踪是整个链路的核心。我们需要在Ktor中捕获前端传递过来的追踪头(sentry-tracebaggage),并基于此开启一个事务(Transaction)。如果请求中没有这些头,就创建一个新的事务。

首先,在build.gradle.kts中添加Sentry的Ktor集成依赖:

// build.gradle.kts
dependencies {
    // ... 其他 Ktor 依赖
    implementation("io.sentry:sentry-ktor-server:6.33.1") // 使用最新稳定版
    implementation("ch.qos.logback:logback-classic:1.4.11") // Sentry 需要日志后端
}

接下来,我们创建一个Ktor插件来自动化处理追踪逻辑。这个插件会在请求管道的早期阶段介入,确保每个请求都被包裹在Sentry的事务中。

// src/main/kotlin/com/example/plugins/SentryTracing.kt
package com.example.plugins

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.sentry.Sentry
import io.sentry.SentryOptions
import io.sentry.SentryTracer
import io.sentry.TransactionContext
import io.sentry.kotlin.SentryContext
import kotlinx.coroutines.withContext

// 定义一个自定义的 Ktor 插件
val SentryTracingPlugin = createApplicationPlugin(name = "SentryTracingPlugin") {
    // 插件初始化逻辑
    on(MonitoringEvent(ApplicationStarting)) { application ->
        // 从环境变量或配置文件中读取 DSN
        val dsn = application.environment.config.property("sentry.dsn").getString()
        Sentry.init { options: SentryOptions ->
            options.dsn = dsn
            options.tracesSampleRate = 1.0 // 在生产中,应该使用更低的值,例如 0.2
            options.isEnableTracing = true
            options.setTag("service", "ktor-backend")
        }
        application.log.info("Sentry tracing initialized.")
    }

    // 核心逻辑:拦截每个请求
    onCall { call ->
        // 从请求头中提取 Sentry 追踪信息
        val sentryTraceHeader = call.request.header("sentry-trace")
        val baggageHeader = call.request.header("baggage")

        // 根据是否存在追踪头来创建或延续一个事务
        val transactionContext = if (sentryTraceHeader != null) {
            TransactionContext.fromSentryTrace(
                "http.server",
                "ktor.request",
                sentryTraceHeader,
                baggageHeader
            )
        } else {
            TransactionContext(
                "${call.request.httpMethod.value} ${call.request.path()}",
                "http.server"
            )
        }
        
        // 为整个请求生命周期启动一个事务
        val transaction = Sentry.startTransaction(transactionContext)
        
        // 将事务设置到协程上下文中,这是关键一步。
        // SentryContext.transaction.asContextElement() 利用了 Kotlin 协程的 ContextElement,
        // 使得在请求处理的任何挂起函数中,都能访问到这个事务。
        withContext(SentryContext(transaction)) {
            try {
                // 执行请求管道的剩余部分
                proceed()
            } catch (e: Exception) {
                // 捕获未处理的异常,并关联到当前事务
                Sentry.captureException(e)
                // 标记事务为失败状态
                transaction.status = io.sentry.protocol.TransactionStatus.INTERNAL_ERROR
                throw e // 重新抛出异常,让Ktor的StatusPages等机制处理
            } finally {
                // 确保事务总是被结束
                if (!transaction.isFinished) {
                    // 如果没有明确设置状态,则根据HTTP状态码判断
                    if (transaction.status == null) {
                        transaction.status = io.sentry.protocol.TransactionStatus.fromHttpStatusCode(call.response.status()?.value)
                    }
                    transaction.finish()
                }
            }
        }
    }
}

// 模拟一个需要一些时间处理的业务逻辑
suspend fun performDatabaseQuery(): String {
    // 获取当前协程上下文中的事务,在其下创建一个子 Span
    val parentSpan = Sentry.getCurrentHub().span
    val dbSpan = parentSpan?.startChild("db.query", "SELECT * FROM users")
    
    try {
        kotlinx.coroutines.delay(150) // 模拟数据库查询耗时
        dbSpan?.status = io.sentry.protocol.SpanStatus.OK
        return "Query Result"
    } catch (e: Exception) {
        dbSpan?.status = io.sentry.protocol.SpanStatus.INTERNAL_ERROR
        dbSpan?.throwable = e
        throw e
    } finally {
        dbSpan?.finish()
    }
}

然后在主应用中安装这个插件:

// src/main/kotlin/com/example/Application.kt
package com.example

import com.example.plugins.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.sentry.Sentry

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        // 在所有路由之前安装插件
        install(SentryTracingPlugin)

        routing {
            get("/process") {
                // 在路由处理函数中,我们可以创建更细粒度的 Span
                val businessLogicSpan = Sentry.getSpan()?.startChild("business.logic", "Processing user data")
                try {
                    val result = performDatabaseQuery()
                    call.respondText("Processed: $result")
                    businessLogicSpan?.status = io.sentry.protocol.SpanStatus.OK
                } finally {
                    businessLogicSpan?.finish()
                }
            }
        }
    }.start(wait = true)
}

这里的核心在于withContext(SentryContext(transaction))。它利用了kotlinx.coroutines的结构化并发和上下文传递能力,确保了无论我们的业务逻辑中有多少suspend函数调用,Sentry.getSpan()总能拿到正确的当前事务。这是在JVM上实现可靠追踪的关键,避免了基于ThreadLocal的传统方案在协程切换时失效的问题。

第三步:改造Zustand,让状态变更可追踪

前端的挑战在于如何将用户的交互(通常体现为状态变更)与后续的API调用关联起来。一个常见的错误是只在API调用层面进行追踪,这样会丢失“用户为什么会发起这个API调用”的上下文。我们将通过创建一个Zustand中间件来解决这个问题。

首先,初始化Sentry for React。Turbopack能极快地处理这些模块。

// pages/_app.tsx (Next.js with Turbopack)
import * as Sentry from "@sentry/react";
import { BrowserTracing } from "@sentry/tracing";

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, // DSN通过环境变量注入
  integrations: [new BrowserTracing()],
  tracesSampleRate: 1.0,
  environment: process.env.NODE_ENV,
});

// ... App component

现在,创建Zustand store和我们的自定义追踪中间件。

// store/userStore.ts
import { create } from 'zustand';
import * as Sentry from '@sentry/react';

// State and Action types for our store
interface UserState {
  userId: string | null;
  userData: Record<string, any> | null;
  isLoading: boolean;
  error: string | null;
  fetchUserData: (id: string) => Promise<void>;
}

// Our custom middleware for Sentry tracing
// It's a function that takes a config function and returns a new config function
const sentryTracingMiddleware = (config) => (set, get, api) => {
  // Wrap original 'set' to log state changes to Sentry breadcrumbs
  const originalSet = api.setState;
  api.setState = (...args) => {
    // Add a breadcrumb for every state change
    Sentry.addBreadcrumb({
      category: 'zustand',
      message: `State updated`,
      data: {
        newState: args[0], // The new state or updater function
      },
      level: 'debug',
    });
    return originalSet(...args);
  };
  
  const newConfig = config(
    // The wrapped set function
    api.setState, 
    get, 
    api
  );

  // Intercept actions to create Sentry spans
  const interceptedActions = Object.keys(newConfig).reduce((acc, key) => {
    const action = newConfig[key];
    if (typeof action === 'function') {
      // We are only interested in async actions that likely perform API calls
      acc[key] = async (...args) => {
        const transaction = Sentry.getCurrentHub().getTransaction();
        if (!transaction) {
          // If there's no active transaction (e.g. from page load), just run the action
          return action(...args);
        }

        // Create a span for the Zustand action itself
        const span = transaction.startChild({
          op: 'zustand.action',
          description: `Zustand Action: ${key}`,
        });

        // Add action arguments as context
        span.setData('action_args', args);

        try {
          // Sentry's BrowserTracing integration automatically patches `fetch` and `XMLHttpRequest`
          // So any API call inside `action` will be a child span of `zustand.action`
          // and will carry the `sentry-trace` header.
          await action(...args);
          span.setStatus('ok');
        } catch (error) {
          span.setStatus('internal_error');
          Sentry.captureException(error, {
            tags: { "zustand.action": key },
          });
          throw error;
        } finally {
          span.finish();
        }
      };
    } else {
      acc[key] = action;
    }
    return acc;
  }, {});

  return { ...newConfig, ...interceptedActions };
};


// Create the store using the middleware
export const useUserStore = create<UserState>()(
  sentryTracingMiddleware((set) => ({
    userId: null,
    userData: null,
    isLoading: false,
    error: null,
    
    fetchUserData: async (id: string) => {
      set({ isLoading: true, error: null, userId: id });
      
      try {
        // This fetch call is automatically instrumented by Sentry
        const response = await fetch(`http://localhost:8080/process?userId=${id}`);
        
        if (!response.ok) {
          throw new Error(`API call failed with status ${response.status}`);
        }
        
        const data = await response.text();
        set({ userData: { message: data }, isLoading: false });
      } catch (err) {
        const errorMessage = err instanceof Error ? err.message : 'Unknown error';
        set({ error: errorMessage, isLoading: false });
        // Re-throw to be caught by the middleware
        throw err;
      }
    },
  }))
);

这个中间件做了两件核心事情:

  1. 包裹setState: 每次状态变更都会被记录为一个Sentry的“面包屑”(Breadcrumb)。当错误发生时,这些面包屑会附在错误报告上,我们可以清晰地看到错误发生前的一系列状态变化。
  2. 拦截异步Action: 它识别出fetchUserData这样的异步action,并为其创建一个zustand.action的Span。由于Sentry的BrowserTracing集成已经自动patch了fetch,所以fetchUserData内部的fetch调用会自动成为这个action Span的子Span,并且携带正确的追踪头信息发送到Ktor后端。这就完成了从前端交互到后端请求的链路串联。

第四步:观察完整的调用链路

现在,当用户在UI上触发fetchUserData调用时,整个链路被完整地记录下来。

sequenceDiagram
    participant User
    participant ReactComponent as React Component
    participant ZustandStore as Zustand Store (with Middleware)
    participant SentryFrontend as Sentry (Frontend)
    participant KtorBackend as Ktor Backend (with Plugin)
    participant SentryBackend as Sentry (Backend)

    User->>ReactComponent: Clicks 'Fetch Data' button
    ReactComponent->>ZustandStore: calls fetchUserData('user-123')
    
    ZustandStore->>SentryFrontend: transaction.startChild(op: 'zustand.action')
    activate ZustandStore
    
    ZustandStore->>ZustandStore: set({ isLoading: true })
    ZustandStore->>SentryFrontend: addBreadcrumb('State updated')
    
    ZustandStore->>KtorBackend: fetch('/process?userId=123') with 'sentry-trace' header
    activate KtorBackend
    
    KtorBackend->>SentryBackend: Sentry.startTransaction(from headers)
    activate SentryBackend
    
    KtorBackend->>KtorBackend: performDatabaseQuery()
    KtorBackend->>SentryBackend: transaction.startChild(op: 'db.query')
    KtorBackend-->>SentryBackend: finish 'db.query' span
    
    KtorBackend-->>ZustandStore: returns HTTP 200 OK
    deactivate KtorBackend
    SentryBackend-->>SentryBackend: finish 'http.server' transaction
    deactivate SentryBackend
    
    ZustandStore->>ZustandStore: set({ userData: ..., isLoading: false })
    ZustandStore->>SentryFrontend: addBreadcrumb('State updated')
    
    ZustandStore-->>SentryFrontend: finish 'zustand.action' span
    deactivate ZustandStore
    
    ZustandStore-->>ReactComponent: Promise resolves
    ReactComponent->>ReactComponent: Re-renders with new data

在Sentry的性能监控页面,我们会看到一个完整的分布式追踪瀑布图。它会以pageloadnavigation事务开始,紧接着是我们的zustand.action Span,这个Span内部嵌套着一个fetch Span,然后这个追踪会无缝地延续到后端的http.server事务,该事务内部又包含了business.logicdb.query等更细粒度的Span。如果任何一个环节出错,例如Ktor的数据库查询超时,该错误会被捕获并精确地关联到这次完整的用户操作上,同时附带前端的Zustand状态变更历史。信息断层被彻底消除。

方案的局限性与后续迭代

这套方案虽然解决了核心的追踪问题,但在生产环境中仍有需要完善之处。首先,tracesSampleRate设置为1.0仅适用于开发和调试。在生产环境,为了控制性能开销和Sentry的费用,必须降到一个合理的数值,例如0.1。但这会带来采样丢失问题,对于低流量但重要的接口,可能需要动态采样或在前端和后端配置一致的采样决策。

其次,当前的Ktor插件只实现了基本的事务和异常捕获。对于更复杂的场景,比如追踪对Redis、Kafka等中间件的调用,需要引入Sentry对应的instrumentation库或手动创建Span。同样,performDatabaseQuery中的Span是手动创建的,在真实项目中,应该通过集成Sentry的JDBC或JDBI驱动来实现数据库调用的自动追踪。

最后,Pulumi的管理也需要进一步细化。例如,使用Pulumi的自动化API来动态创建告警规则,当某个事务的延迟(P95)超过阈值或错误率飙升时自动通知对应的团队。这才是将基础设施即代码的能力发挥到极致,实现了从资源创建到运维监控的全生命周期自动化。


  目录