OpenTelemetry
本指南介绍 AdonisJS 应用中的 OpenTelemetry 集成。你将学习如何:
- 安装并配置
@adonisjs/otel包 - 理解 trace、span 与 attribute
- 对 HTTP、数据库和 Redis 使用自动埋点
- 使用辅助函数与装饰器创建自定义 span
- 跨服务传播 trace 上下文
- 使用 Jaeger 在本地测试你的配置
概述
OpenTelemetry 是一个开放标准,用于从你的应用中收集遥测数据:trace、metric 与 log。@adonisjs/otel 包在 AdonisJS 与 OpenTelemetry 之间提供了无缝集成,为你带来分布式追踪与采用合理默认值的自动埋点。
可观测性对于理解应用内部发生的事情至关重要,在生产环境中尤其如此。当用户反馈「结账页面很慢」时,追踪能让你精确看到时间花费在哪里。是数据库查询?外部 API 调用?一个缓慢的服务?没有追踪,你就只能靠猜。
该包为你处理了 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: GET、http.route: /users/:id 和 http.status_code: 200 这样的属性。
安装
使用以下命令安装并配置该包。
node ace add @adonisjs/otel
查看 add 命令执行的步骤
-
使用检测到的包管理器安装
@adonisjs/otel包。 -
在
adonisrc.ts文件中注册以下服务提供者。{ providers: [ // ...other providers () => import('@adonisjs/otel/otel_provider') ] } -
在
start/kernel.ts文件中注册以下中间件。router.use([ () => import('@adonisjs/otel/otel_middleware') ]) -
创建
config/otel.ts文件。 -
创建带有 OpenTelemetry 初始化的
bin/otel.ts文件。 -
在
bin/server.ts文件顶部添加 import 语句。 -
定义以下环境变量及其校验规则。
OTEL_EXPORTER_OTLP_ENDPOINT= OTEL_EXPORTER_OTLP_HEADERS=
就这样。你的应用现在已具备针对 HTTP 请求、数据库查询等的自动追踪能力。
配置
配置文件位于 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
你的服务的名称。该值从 OTEL_SERVICE_NAME 或 APP_NAME 环境变量解析而来。
export default defineConfig({
serviceName: 'my-api'
})
serviceVersion
你的服务的版本。该值从 APP_VERSION 环境变量解析而来,默认为 0.0.0。
export default defineConfig({
serviceVersion: '1.2.3'
})
environment
你的服务运行所在的环境。该值从 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 目标辅助函数。每个目标可以接收所有信号(traces、metrics、logs)或仅接收其子集。
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
你也可以为每个信号显式提供端点:
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',
},
}),
},
})
destinations 是可选的。如果你未定义它,该包会保留默认的 OpenTelemetry 行为与环境变量配置(OTEL_EXPORTER_OTLP_*、OTEL_TRACES_EXPORTER、OTEL_METRICS_EXPORTER、OTEL_LOGS_EXPORTER)。
调试模式
启用调试模式可在开发期间将 span 打印到控制台。
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
debug: true,
})
这会添加一个 ConsoleSpanExporter,将 span 输出到你的终端,帮助你在不搭建 collector 的情况下可视化 trace。
启用与禁用
当 NODE_ENV === 'test' 时,OpenTelemetry 会自动禁用,以避免在测试期间产生噪音。你可以覆盖这一行为。
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
/**
* 在测试中强制启用
*/
enabled: true,
/**
* 或在任何环境中强制禁用
*/
enabled: false,
})
采样
在高流量的生产环境中,追踪每一个请求会产生海量数据。采样控制收集多少比例的 trace。
import { defineConfig } from '@adonisjs/otel'
export default defineConfig({
serviceName: 'my-app',
/**
* 采样 10% 的 trace(高流量生产环境的推荐值)
*/
samplingRatio: 0.1,
/**
* 采样 100% 的 trace(默认值,适合开发环境)
*/
samplingRatio: 1.0,
})
采样器使用基于父级的采样,意味着子 span 会从父级继承采样决策。这确保你总是获得完整的 trace,而非碎片。
如果你提供了自定义的 sampler 选项,samplingRatio 会被忽略。
自定义埋点
该包无需任何代码改动即可自动为常见库埋点。开箱即用,你会获得针对 HTTP 请求(入站与出站)、Lucid 数据库查询(通过 Knex)以及 Redis 操作的追踪。
为了减少噪音,以下端点默认被排除在追踪之外。
/health、/healthz、/ready、/readiness/metrics、/internal/metrics、/internal/healthz/favicon.ico
你可以配置单个埋点,或添加自定义的忽略 URL。
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,非常适合本地开发。
-
使用 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
- 端口
-
启动你的应用
你的 AdonisJS 应用已经配置为默认向
localhost:4317发送 trace,因此无需额外配置。node ace serve --hmr -
生成一些 trace
向你的应用发起请求以生成 trace。你可以使用 curl、浏览器或任意 HTTP 客户端。
curl http://localhost:3333/users curl http://localhost:3333/posts/1 -
在 Jaeger 中查看 trace
在 http://localhost:16686 打开 Jaeger UI。你应该在下拉列表中看到你的服务。选择你的服务并点击「Find Traces」以查看所有捕获的 trace。
点击任意 trace 即可看到完整的操作时间线:HTTP 请求、数据库查询,以及你创建的任何自定义 span。
在开发期间保持 Jaeger 运行。它会随时间累积 trace,便于你比较慢请求与快请求,或发现应用行为中的模式。
创建自定义 span
虽然自动埋点涵盖了大多数常见操作,但你经常会想要追踪自定义的业务逻辑。该包为此提供了辅助函数与装饰器。
使用 record 辅助函数
record 辅助函数会围绕一段代码创建一个 span:
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,
})
// 校验逻辑...
})
}
}
使用装饰器
对于类方法,装饰器提供了更简洁的语法。
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 装饰器。
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。
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 内带时间戳的注解。使用它们来标记重要时刻。
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 请求头中。
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 上下文。
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。
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.id、user.email(如果可用)和 user.roles(如果可用)。
你可以自定义这一行为,或添加额外的用户属性。
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,
}
},
},
})
你也可以在任何代码中手动设置用户上下文。
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_id 和 span_id。这让你可以在可观测性平台中将日志与 trace 关联起来。
在开发环境使用 pino-pretty 时,你可以隐藏这些字段以获得更干净的輸出。
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
的所有选项,让高级用户获得完全的控制。
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 的数据量。
samplingRatio为0.1(10%)通常足以发现问题,同时能显著降低开销与存储成本。 -
使用批处理(默认)而非立即发送 span。
BatchSpanProcessor会将 span 加入队列并分批发送,从而减少网络开销。 -
谨慎使用自定义 span。自动埋点已覆盖大多数需求。仅为那些你需要额外可见性的、业务关键的操作添加自定义 span。不要在每个类上都使用
@spanAll装饰器而过度埋点。
辅助函数参考
所有辅助函数都可通过 @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 选项 |