OpenTelemetry

本指南介绍 AdonisJS 应用中的 OpenTelemetry 集成。你将学习如何:

  • 安装并配置 @adonisjs/otel
  • 理解 trace、span 与 attribute
  • 对 HTTP、数据库和 Redis 使用自动埋点
  • 使用辅助函数与装饰器创建自定义 span
  • 跨服务传播 trace 上下文
  • 使用 Jaeger 在本地测试你的配置

概述

OpenTelemetry 是一个开放标准,用于从你的应用中收集遥测数据:trace、metric 与 log。@adonisjs/otel 包在 AdonisJS 与 OpenTelemetry 之间提供了无缝集成,为你带来分布式追踪与采用合理默认值的自动埋点。

可观测性对于理解应用内部发生的事情至关重要,在生产环境中尤其如此。当用户反馈「结账页面很慢」时,追踪能让你精确看到时间花费在哪里。是数据库查询?外部 API 调用?一个缓慢的服务?没有追踪,你就只能靠猜。

alt text

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

OpenTelemetry 概念

在深入实现之前,你应该先理解几个核心的 OpenTelemetry 概念。有关全面的介绍,请参阅 官方 OpenTelemetry 文档

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

span 是 trace 内的一个工作单位。每个数据库查询、HTTP 请求或函数调用都可以是一个 span。span 拥有开始时间、持续时间、名称和属性(键值形式的元数据)。span 以层级方式嵌套:HTTP 请求的父 span 包含该请求期间每次数据库查询的子 span。

attribute(属性)是附加在 span 上的键值对,用于提供上下文。例如,一个 HTTP span 可能拥有 http.method: GEThttp.route: /users/:idhttp.status_code: 200 这样的属性。

安装

使用以下命令安装并配置该包。

node ace add @adonisjs/otel
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/otel 包。

  2. adonisrc.ts 文件中注册以下服务提供者。

    {
      providers: [
        // ...other providers
        () => import('@adonisjs/otel/otel_provider')
      ]
    }
  3. start/kernel.ts 文件中注册以下中间件。

    router.use([
      () => import('@adonisjs/otel/otel_middleware')
    ])
  4. 创建 config/otel.ts 文件。

  5. 创建带有 OpenTelemetry 初始化的 bin/otel.ts 文件。

  6. bin/server.ts 文件顶部添加 import 语句。

  7. 定义以下环境变量及其校验规则。

    OTEL_EXPORTER_OTLP_ENDPOINT=
    OTEL_EXPORTER_OTLP_HEADERS=

就这样。你的应用现在已具备针对 HTTP 请求、数据库查询等的自动追踪能力。

配置

配置文件位于 config/otel.ts

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'),
})

服务标识

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

serviceName
string

你的服务的名称。该值从 OTEL_SERVICE_NAMEAPP_NAME 环境变量解析而来。

export default defineConfig({
  serviceName: 'my-api'
})
serviceVersion
string

你的服务的版本。该值从 APP_VERSION 环境变量解析而来,默认为 0.0.0

export default defineConfig({
  serviceVersion: '1.2.3'
})
environment
string

你的服务运行所在的环境。该值从 APP_ENV 环境变量解析而来,默认为 development

export default defineConfig({
  environment: 'production'
})

导出器(Exporter)

默认情况下,该包使用基于 gRPC 的 OTLP 将 trace 导出到 localhost:4317。这是标准的 OpenTelemetry Collector 端点。如果你在本地或基础设施中运行了 OpenTelemetry Collector,trace 会自动发送过去。

你可以使用环境变量配置导出器端点,而无需修改任何代码。

# title: .env
OTEL_EXPORTER_OTLP_ENDPOINT=https://otel-collector.example.com:4317

对于身份认证或自定义请求头:

# title: .env
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=your-api-key

有关所有可用选项,请参阅 OpenTelemetry 环境变量规范 ,并查看 进阶配置 以了解更多自定义方式。

多目标导出(扇出)

当你需要同时将遥测数据导出到多个后端时,请使用 destinations 选项。

该包通过 destinations.otlp() 提供了一个通用的 OTLP 目标辅助函数。每个目标可以接收所有信号(tracesmetricslogs)或仅接收其子集。

config/otel.ts
import { defineConfig, destinations } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',

  destinations: {
    grafana: destinations.otlp({
      endpoint: 'https://grafana-otlp.example.com',
      headers: {
        Authorization: `Basic ${process.env.GRAFANA_BASIC_AUTH}`,
      },
      signals: 'all',
    }),

    honeycomb: destinations.otlp({
      endpoint: 'https://api.honeycomb.io',
      headers: {
        'x-honeycomb-team': process.env.HONEYCOMB_API_KEY!,
        'x-honeycomb-dataset': process.env.HONEYCOMB_DATASET!,
      },
      signals: 'all',
    }),
  },
})

当你设置了 endpoint 时,该包会通过追加以下内容自动派生出各信号的端点:

  • /v1/traces
  • /v1/metrics
  • /v1/logs

你也可以为每个信号显式提供端点:

config/otel.ts
import { defineConfig, destinations } from '@adonisjs/otel'

export default defineConfig({
  serviceName: 'my-app',

  destinations: {
    custom: destinations.otlp({
      signals: ['traces', 'logs'],
      endpoints: {
        traces: 'https://collector-a.example.com/v1/traces',
        logs: 'https://collector-b.example.com/v1/logs',
      },
    }),
  },
})
Note

destinations 是可选的。如果你未定义它,该包会保留默认的 OpenTelemetry 行为与环境变量配置(OTEL_EXPORTER_OTLP_*OTEL_TRACES_EXPORTEROTEL_METRICS_EXPORTEROTEL_LOGS_EXPORTER)。

调试模式

启用调试模式可在开发期间将 span 打印到控制台。

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

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

这会添加一个 ConsoleSpanExporter,将 span 输出到你的终端,帮助你在不搭建 collector 的情况下可视化 trace。

启用与禁用

NODE_ENV === 'test' 时,OpenTelemetry 会自动禁用,以避免在测试期间产生噪音。你可以覆盖这一行为。

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

export default defineConfig({
  serviceName: 'my-app',

  /**
   * 在测试中强制启用
   */
  enabled: true,

  /**
   * 或在任何环境中强制禁用
   */
  enabled: false,
})

采样

在高流量的生产环境中,追踪每一个请求会产生海量数据。采样控制收集多少比例的 trace。

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

export default defineConfig({
  serviceName: 'my-app',

  /**
   * 采样 10% 的 trace(高流量生产环境的推荐值)
   */
  samplingRatio: 0.1,

  /**
   * 采样 100% 的 trace(默认值,适合开发环境)
   */
  samplingRatio: 1.0,
})

采样器使用基于父级的采样,意味着子 span 会从父级继承采样决策。这确保你总是获得完整的 trace,而非碎片。

另见: OpenTelemetry 采样文档

Note

如果你提供了自定义的 sampler 选项,samplingRatio 会被忽略。

自定义埋点

该包无需任何代码改动即可自动为常见库埋点。开箱即用,你会获得针对 HTTP 请求(入站与出站)、Lucid 数据库查询(通过 Knex)以及 Redis 操作的追踪。

为了减少噪音,以下端点默认被排除在追踪之外。

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

你可以配置单个埋点,或添加自定义的忽略 URL。

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 },
  },
})

使用 Jaeger 在本地测试

Jaeger 是一个流行的开源分布式追踪平台,实现了 OpenTelemetry 标准。它提供了用于可视化 trace 的 Web UI,非常适合本地开发。

  1. 使用 Docker 启动 Jaeger

    在 Docker 容器中运行 Jaeger。all-in-one 镜像包含了 collector、查询服务和 UI。

    docker run -d --name jaeger \
      -e COLLECTOR_OTLP_ENABLED=true \
      -p 16686:16686 \
      -p 4317:4317 \
      -p 4318:4318 \
      jaegertracing/all-in-one:latest

    该命令会暴露:

    • 端口 16686 用于 Jaeger UI
    • 端口 4317 用于 OTLP gRPC(默认端点)
    • 端口 4318 用于 OTLP HTTP
  2. 启动你的应用

    你的 AdonisJS 应用已经配置为默认向 localhost:4317 发送 trace,因此无需额外配置。

    node ace serve --hmr
  3. 生成一些 trace

    向你的应用发起请求以生成 trace。你可以使用 curl、浏览器或任意 HTTP 客户端。

    curl http://localhost:3333/users
    curl http://localhost:3333/posts/1
  4. 在 Jaeger 中查看 trace

    http://localhost:16686 打开 Jaeger UI。你应该在下拉列表中看到你的服务。选择你的服务并点击「Find Traces」以查看所有捕获的 trace。

    点击任意 trace 即可看到完整的操作时间线:HTTP 请求、数据库查询,以及你创建的任何自定义 span。

Tip

在开发期间保持 Jaeger 运行。它会随时间累积 trace,便于你比较慢请求与快请求,或发现应用行为中的模式。

创建自定义 span

虽然自动埋点涵盖了大多数常见操作,但你经常会想要追踪自定义的业务逻辑。该包为此提供了辅助函数与装饰器。

使用 record 辅助函数

record 辅助函数会围绕一段代码创建一个 span:

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

export default class OrderService {
  async processOrder(orderId: string) {
    /**
     * 将同步或异步代码包裹在一个 span 中
     */
    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) {
    /**
     * 访问 span 以添加自定义属性
     */
    await record('order.validate_inventory', async (span) => {
      span.setAttributes({
        'order.id': order.id,
        'order.items_count': order.items.length,
      })

      // 校验逻辑...
    })
  }
}

使用装饰器

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

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

export default class UserService {
  /**
   * 创建一个名为 "UserService.findById" 的 span
   */
  @span()
  async findById(id: string) {
    return User.find(id)
  }

  /**
   * 自定义 span 名称与属性
   */
  @span({ name: 'user.create', attributes: { operation: 'create' } })
  async create(data: UserData) {
    return User.create(data)
  }
}

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

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

/**
 * 所有方法都会获得 span。
 */
@spanAll()
export default class PaymentService {
  async charge(amount: number) {
    // ...
  }

  async refund(transactionId: string) {
    // ...
  }
}

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

在当前 span 上设置属性

使用 setAttributes 向当前活跃的 span 添加元数据,而无需创建新的 span。

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

export default class OrdersController {
  async store({ request }: HttpContext) {
    const data = request.all()

    /**
     * 向 HTTP span 添加业务上下文
     */
    setAttributes({
      'order.type': data.type,
      'order.total': data.total,
      'customer.tier': data.customerTier,
    })

    // 处理订单...
  }
}

记录事件

事件是 span 内带时间戳的注解。使用它们来标记重要时刻。

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')
  }
}

上下文传播

当你的应用调用其他服务或处理后台任务时,你需要传播 trace 上下文,以便所有操作都出现在同一个 trace 中。

向 HTTP 调用传播

将 trace 上下文注入到出站的 HTTP 请求头中。

app/services/external_api_service.ts
import { injectTraceContext } from '@adonisjs/otel/helpers'

export default class ExternalApiService {
  async fetchUserData(userId: string) {
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
    }

    /**
     * 添加 traceparent 与 tracestate 请求头。
     * 会修改原始对象
     */
    injectTraceContext(headers)

    const response = await fetch(`https://api.example.com/users/${userId}`, {
      headers,
    })

    return response.json()
  }
}

向队列任务传播

在派发后台任务时,包含 trace 上下文。

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())

    /**
     * 在任务元数据中包含 trace 上下文
     */
    const traceHeaders: Record<string, string> = {}
    injectTraceContext(traceHeaders)

    await queue.dispatch('process-order', {
      orderId: order.id,
      traceContext: traceHeaders,
    })

    return order
  }
}

在你的队列 worker 中,提取上下文并继续该 trace。

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> }) {
    /**
     * 从任务负载中提取 trace 上下文
     */
    const extractedContext = extractTraceContext(payload.traceContext)

    /**
     * 在提取出的上下文中运行任务
     */
    await context.with(extractedContext, async () => {
      await record('job.process_order', async () => {
        /**
         * 该 span 将成为原始 HTTP 请求 span 的子级
         */
        const order = await Order.findOrFail(payload.orderId)
        await this.fulfillOrder(order)
      })
    })
  }
}

用户上下文

当安装了 @adonisjs/auth 时,如果用户已通过认证,中间件会自动在 span 上设置用户属性。这包括 user.iduser.email(如果可用)和 user.roles(如果可用)。

你可以自定义这一行为,或添加额外的用户属性。

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

export default defineConfig({
  serviceName: 'my-app',

  /**
   * 禁用自动用户上下文
   */
  userContext: false,

  /**
   * 或使用 resolver 自定义
   */
  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,
      }
    },
  },
})

你也可以在任何代码中手动设置用户上下文。

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()
  }
}

日志集成

该包会自动将 trace 上下文注入到 Pino 日志中,为每个日志条目添加 trace_idspan_id。这让你可以在可观测性平台中将日志与 trace 关联起来。

在开发环境使用 pino-pretty 时,你可以隐藏这些字段以获得更干净的輸出。

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

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

保留特定字段可见:

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

进阶配置

defineConfig 函数接受来自 OpenTelemetry Node SDK 的所有选项,让高级用户获得完全的控制。

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 },
  }),

  /**
   * 带批处理配置的自定义 span 处理器
   */
  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 需要创建 span 对象、记录时序信息,并将数据导出到你的 collector。在大多数应用中,这一开销可以忽略不计,但你应该对此有所了解。

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

  • 使用采样 来减少 trace 的数据量。samplingRatio0.1(10%)通常足以发现问题,同时能显著降低开销与存储成本。

  • 使用批处理(默认)而非立即发送 span。BatchSpanProcessor 会将 span 加入队列并分批发送,从而减少网络开销。

  • 谨慎使用自定义 span。自动埋点已覆盖大多数需求。仅为那些你需要额外可见性的、业务关键的操作添加自定义 span。不要在每个类上都使用 @spanAll 装饰器而过度埋点。

另见: OpenTelemetry 采样文档

辅助函数参考

所有辅助函数都可通过 @adonisjs/otel/helpers 获取。

辅助函数说明
getCurrentSpan()返回当前活跃的 span,如果没有则返回 undefined
setAttributes(attributes)在当前 span 上设置属性
record(name, fn)围绕一个函数创建 span
recordEvent(name, attributes?)在当前 span 上记录一个事件
setUser(user)在当前 span 上设置用户属性
injectTraceContext(carrier)将 trace 上下文注入到一个载体对象(请求头)中
extractTraceContext(carrier)从载体对象中提取 trace 上下文
otelLoggingPreset(options?)返回隐藏 OTEL 字段的 pino-pretty 选项