Skip to content

OpenTelemetry

本指南介绍 AdonisJS 应用程序中的 OpenTelemetry 集成。您将学习如何安装和配置 @adonisjs/otel 包,理解 OpenTelemetry 概念(如追踪和跨度),使用 HTTP 请求和数据库查询的自动检测,使用助手和装饰器创建自定义跨度,跨服务传播追踪上下文,以及为生产环境配置采样和导出器。

概述

OpenTelemetry 是一个开放标准,用于从应用程序收集遥测数据:追踪、指标和日志。@adonisjs/otel 包提供了 AdonisJS 和 OpenTelemetry 之间的无缝集成,为您提供分布式追踪和自动检测,具有合理的默认值和零配置设置。

可观测性对于理解应用程序内部发生的事情至关重要,特别是在生产环境中。当用户报告"结账页面很慢"时,追踪可以让您确切地看到时间花在哪里:是数据库查询?外部 API 调用?还是慢速服务?没有追踪,您只能猜测。

:::media alt text :::

此包为您处理 OpenTelemetry 设置的复杂性。运行一个命令,您的应用程序就会自动追踪 HTTP 请求、数据库查询、Redis 操作等。

OpenTelemetry 概念

在深入实现之前,您应该了解一些核心 OpenTelemetry 概念。有关全面介绍,请参阅官方 OpenTelemetry 文档

追踪表示请求通过系统的完整旅程。当用户访问您的 API 时,追踪会捕获发生的一切:HTTP 请求、数据库查询、缓存查找、对外部服务的调用以及响应。

跨度是追踪中的单个工作单元。每个数据库查询、HTTP 请求或函数调用都可以是一个跨度。跨度具有开始时间、持续时间、名称和属性(键值元数据)。跨度按层次结构嵌套:HTTP 请求的父跨度包含该请求期间进行的每个数据库查询的子跨度。

属性是附加到跨度的键值对,提供上下文。例如,HTTP 跨度可能具有 http.method: GEThttp.route: /users/:idhttp.status_code: 200 等属性。

安装

使用以下命令安装和配置包:

sh
node ace add @adonisjs/otel

此命令在项目根目录创建 otel.ts 并进行 OpenTelemetry 初始化,在 bin/server.ts 顶部添加导入,注册提供者和中间件,并设置环境变量。

就是这样。您的应用程序现在具有 HTTP 请求、数据库查询等的自动追踪。

WARNING

导入顺序至关重要

OpenTelemetry 必须在任何其他代码加载之前初始化。SDK 需要在 httppgredis 等库被导入之前对其进行修补。这就是为什么 otel.tsbin/server.ts 中作为第一行导入。

如果您移动或删除 import './otel.js' 行,自动检测将不起作用。您仍然可以创建手动跨度,但 HTTP 请求和数据库查询的自动追踪将不会被捕获。

手动设置

如果您不想使用 node ace add,以下是它配置的内容。

首先,在 otel.ts 创建一个文件进行 OpenTelemetry 初始化:

ts
// title: otel.ts
import { init } from '@adonisjs/otel/init'

await init(import.meta.dirname)

然后更新 bin/server.ts 将其作为第一行导入:

ts
// title: bin/server.ts
/**
 * OpenTelemetry 初始化 - 必须是第一个导入
 */
import './otel.js'

import { Ignitor } from '@adonisjs/core'
// ... 其余服务器设置

adonisrc.ts 中添加提供者以挂钩到 AdonisJS 的异常处理程序并在跨度中记录错误:

ts
// title: adonisrc.ts
{
  providers: [
    // ... 其他提供者
    () => import('@adonisjs/otel/otel_provider'),
  ]
}

最后,在 start/kernel.ts 中将中间件添加为第一个路由中间件,以使用路由信息丰富 HTTP 跨度:

ts
// title: start/kernel.ts
router.use([
  () => import('@adonisjs/otel/otel_middleware'),
  // ... 其他中间件
])

配置

配置文件位于 config/otel.ts

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'
import env from '#start/env'

export default defineConfig({
  serviceName: env.get('APP_NAME'),
  serviceVersion: env.get('APP_VERSION'),
  environment: env.get('APP_ENV'),
})

服务标识

该包从多个来源解析服务元数据:

选项环境变量默认值
serviceNameOTEL_SERVICE_NAMEAPP_NAMEunknown_service
serviceVersionAPP_VERSION0.0.0
environmentAPP_ENVdevelopment

导出器

默认情况下,该包使用 OTLP over gRPC 将追踪导出到 localhost:4317。这是标准的 OpenTelemetry Collector 端点。如果您在本地或基础设施中运行 OpenTelemetry Collector,追踪将自动发送到那里。

您可以使用环境变量配置导出器端点而无需更改任何代码:

dotenv
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317

对于身份验证或自定义标头:

dotenv
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=your-api-key

有关所有可用选项,请参阅 OpenTelemetry 环境变量规范,并查看高级配置以获取更多自定义。

调试模式

启用调试模式以在开发期间将跨度打印到控制台:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',
  debug: true,
})

这会添加一个 ConsoleSpanExporter,将跨度输出到您的终端,帮助您在不设置收集器的情况下可视化追踪。

启用和禁用

NODE_ENV === 'test' 时,OpenTelemetry 会自动禁用以避免测试期间的噪音。您可以覆盖此行为:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',
  
  /**
   * 在测试中强制启用
   */
  enabled: true,
  
  /**
   * 或在任何环境中强制禁用
   */
  enabled: false,
})

采样

在高流量生产环境中,追踪每个请求会生成大量数据。采样控制收集多少百分比的追踪:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',
  
  /**
   * 采样 10% 的追踪(建议用于高流量生产)
   */
  samplingRatio: 0.1,
  
  /**
   * 采样 100% 的追踪(默认,适合开发)
   */
  samplingRatio: 1.0,
})

采样器使用基于父级的采样,这意味着子跨度从其父级继承采样决策。这确保您始终获得完整的追踪而不是片段。

另请参阅:OpenTelemetry 采样文档

TIP

如果您提供自定义 sampler 选项,samplingRatio 将被忽略。

自动检测

在底层,@adonisjs/otel 使用官方的 @opentelemetry/auto-instrumentations-node 包。这意味着您无需任何代码更改即可获得广泛库的自动追踪:HTTP 请求(传入和传出)、数据库查询(PostgreSQL、MySQL、MongoDB)、Redis 操作等更多

我们预配置了合理的默认值,因此一切开箱即用。但是,您可以通过配置中的 instrumentations 选项覆盖任何检测设置。

默认忽略的 URL

为了减少噪音,默认情况下排除以下端点的追踪:

  • /health/healthz/ready/readiness
  • /metrics/internal/metrics/internal/healthz
  • /favicon.ico

自定义检测

您可以配置单个检测或添加自定义忽略的 URL:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',
  
  instrumentations: {
    /**
     * 添加自定义忽略的 URL(与默认值合并)
     */
    '@opentelemetry/instrumentation-http': {
      ignoredUrls: ['/internal/*', '/api/ping'],
      mergeIgnoredUrls: true,
    },
    
    /**
     * 禁用特定检测
     */
    '@opentelemetry/instrumentation-pg': { enabled: false },
  },
})

创建自定义跨度

虽然自动检测涵盖了大多数常见操作,但您通常希望追踪自定义业务逻辑。该包为此提供了助手和装饰器。

使用 record 助手

record 助手在代码段周围创建跨度:

ts
// title: app/services/order_service.ts
import { record } from '@adonisjs/otel/helpers'

export default class OrderService {
  async processOrder(orderId: string) {
    /**
     * 在跨度中包装同步或异步代码
     */
    const result = await record('order.process', async () => {
      const order = await Order.findOrFail(orderId)
      await this.validateInventory(order)
      await this.chargePayment(order)
      return order
    })
    
    return result
  }
  
  async validateInventory(order: Order) {
    /**
     * 访问跨度以添加自定义属性
     */
    await record('order.validate_inventory', async (span) => {
      span.setAttributes({
        'order.id': order.id,
        'order.items_count': order.items.length,
      })
      
      // 验证逻辑...
    })
  }
}

使用装饰器

对于类方法,装饰器提供了更简洁的语法:

ts
// title: app/services/user_service.ts
import { span, spanAll } from '@adonisjs/otel/decorators'

export default class UserService {
  /**
   * 创建名为 "UserService.findById" 的跨度
   */
  @span()
  async findById(id: string) {
    return User.find(id)
  }
  
  /**
   * 自定义跨度名称和属性
   */
  @span({ name: 'user.create', attributes: { operation: 'create' } })
  async create(data: UserData) {
    return User.create(data)
  }
}

要自动追踪类的所有方法,请使用 @spanAll 装饰器:

ts
// title: app/services/payment_service.ts
import { spanAll } from '@adonisjs/otel/decorators'

/**
 * 所有方法都获得跨度:"PaymentService.charge"、"PaymentService.refund" 等
 */
@spanAll()
export default class PaymentService {
  async charge(amount: number) {
    // ...
  }
  
  async refund(transactionId: string) {
    // ...
  }
}

/**
 * 自定义前缀:"payment.charge"、"payment.refund" 等
 */
@spanAll({ prefix: 'payment' })
export default class PaymentService {
  // ...
}

在当前跨度上设置属性

使用 setAttributes 向当前活动跨度添加元数据而不创建新跨度:

ts
// title: app/controllers/orders_controller.ts
import { setAttributes } from '@adonisjs/otel/helpers'

export default class OrdersController {
  async store({ request }: HttpContext) {
    const data = request.all()
    
    /**
     * 向 HTTP 跨度添加业务上下文
     */
    setAttributes({
      'order.type': data.type,
      'order.total': data.total,
      'customer.tier': data.customerTier,
    })
    
    // 处理订单...
  }
}

记录事件

事件是跨度内带有时间戳的注释。使用它们标记重要时刻:

ts
// title: app/services/checkout_service.ts
import { recordEvent } from '@adonisjs/otel/helpers'

export default class CheckoutService {
  async process(cart: Cart) {
    recordEvent('checkout.started')
    
    await this.validateCart(cart)
    recordEvent('checkout.cart_validated')
    
    const payment = await this.processPayment(cart)
    recordEvent('checkout.payment_processed', {
      'payment.method': payment.method,
      'payment.amount': payment.amount,
    })
    
    await this.fulfillOrder(cart)
    recordEvent('checkout.completed')
  }
}

上下文传播

当您的应用程序调用其他服务或处理后台作业时,您需要传播追踪上下文,以便所有操作都显示在同一个追踪中。

传播到队列作业

分派后台作业时,包含追踪上下文:

ts
// title: app/controllers/orders_controller.ts
import { injectTraceContext } from '@adonisjs/otel/helpers'

export default class OrdersController {
  async store({ request }: HttpContext) {
    const order = await Order.create(request.all())
    
    /**
     * 在作业元数据中包含追踪上下文
     */
    const traceHeaders: Record<string, string> = {}
    injectTraceContext(traceHeaders)
    
    await queue.dispatch('process-order', {
      orderId: order.id,
      traceContext: traceHeaders,
    })
    
    return order
  }
}

在您的队列 worker 中,提取上下文并继续追踪:

ts
// title: app/jobs/process_order_job.ts
import { extractTraceContext, record } from '@adonisjs/otel/helpers'
import { context } from '@adonisjs/otel'

export default class ProcessOrderJob {
  async handle(payload: { orderId: string; traceContext: Record<string, string> }) {
    /**
     * 从作业有效负载中提取追踪上下文
     */
    const extractedContext = extractTraceContext(payload.traceContext)
    
    /**
     * 在提取的上下文中运行作业
     */
    await context.with(extractedContext, async () => {
      await record('job.process_order', async () => {
        /**
         * 此跨度将是原始 HTTP 请求跨度的子级
         */
        const order = await Order.findOrFail(payload.orderId)
        await this.fulfillOrder(order)
      })
    })
  }
}

用户上下文

安装 @adonisjs/auth 后,如果用户已通过身份验证,中间件会自动在跨度上设置用户属性。这包括 user.iduser.email(如果可用)和 user.roles(如果可用)。

您可以自定义此行为或添加其他用户属性:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',
  
  /**
   * 禁用自动用户上下文
   */
  userContext: false,
  
  /**
   * 或使用解析器自定义
   */
  userContext: {
    resolver: async (ctx) => {
      if (!ctx.auth.user) return null
      
      return {
        id: ctx.auth.user.id,
        tenantId: ctx.auth.user.tenantId,
        plan: ctx.auth.user.plan,
      }
    },
  },
})

您也可以在代码的任何位置手动设置用户上下文:

ts
// title: app/middleware/auth_middleware.ts
import { setUser } from '@adonisjs/otel/helpers'

export default class AuthMiddleware {
  async handle({ auth }: HttpContext, next: NextFn) {
    await auth.authenticate()
    
    setUser({
      id: auth.user!.id,
      email: auth.user!.email,
      role: auth.user!.role,
    })
    
    return next()
  }
}

日志集成

该包自动将追踪上下文注入 Pino 日志,为每个日志条目添加 trace_idspan_id。这允许您在可观测性平台中将日志与追踪关联起来。

使用 pino-pretty 进行开发时,您可以隐藏这些字段以获得更简洁的输出:

ts
// title: config/logger.ts
import { defineConfig, targets } from '@adonisjs/core/logger'
import app from '@adonisjs/core/services/app'
import { otelLoggingPreset } from '@adonisjs/otel/helpers'

export default defineConfig({
  default: 'app',
  loggers: {
    app: {
      transport: {
        targets: targets()
          .pushIf(!app.inProduction, targets.pretty({ ...otelLoggingPreset() }))
          .toArray(),
      },
    },
  },
})

保持特定字段可见:

ts
otelLoggingPreset({ keep: ['trace_id', 'span_id'] })

高级配置

defineConfig 函数接受来自 OpenTelemetry Node SDK 的所有选项,为高级用户提供完全控制:

ts
// title: config/otel.ts
import { defineConfig } from '@adonisjs/otel'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'

export default defineConfig({
  serviceName: 'my-app',
  
  /**
   * 使用 HTTP 代替 gRPC
   */
  traceExporter: new OTLPTraceExporter({
    url: 'https://otel-collector.example.com/v1/traces',
    headers: { 'x-api-key': process.env.OTEL_API_KEY },
  }),
  
  /**
   * 具有批处理配置的自定义跨度处理器
   */
  spanProcessors: [
    new BatchSpanProcessor(new OTLPTraceExporter(), {
      maxQueueSize: 2048,
      scheduledDelayMillis: 5000,
    }),
  ],
  
  /**
   * 自定义资源属性
   */
  resourceAttributes: {
    'deployment.region': 'eu-west-1',
    'k8s.pod.name': process.env.POD_NAME,
  },
})

有关所有可用选项,请参阅 OpenTelemetry JS 文档

性能考虑

OpenTelemetry 会为您的应用程序增加一些开销。SDK 需要创建跨度对象、记录时间信息并将数据导出到您的收集器。在大多数应用程序中,这种开销可以忽略不计,但您应该了解它。

对于高流量生产环境,请考虑以下建议:

  • 使用采样来减少追踪量。samplingRatio0.1(10%)通常足以识别问题,同时大大减少开销和存储成本。

  • 使用批处理(默认)而不是立即发送跨度。BatchSpanProcessor 将跨度排队并批量发送,减少网络开销。

  • 选择性使用自定义跨度。自动检测涵盖了大多数需求。仅为需要额外可见性的关键业务操作添加自定义跨度。不要通过在每个类上使用 @spanAll 装饰器来过度检测。

另请参阅:OpenTelemetry 采样文档

助手参考

所有助手都可从 @adonisjs/otel/helpers 获取:

助手描述
getCurrentSpan()返回当前活动跨度,如果没有则返回 undefined
setAttributes(attributes)在当前跨度上设置属性
record(name, fn)在函数周围创建跨度
recordEvent(name, attributes?)在当前跨度上记录事件
setUser(user)在当前跨度上设置用户属性
injectTraceContext(carrier)将追踪上下文注入载体对象(标头)
extractTraceContext(carrier)从载体对象提取追踪上下文
otelLoggingPreset(options?)返回隐藏 OTEL 字段的 pino-pretty 选项