OpenTelemetry 可观测性
vextjs-opentelemetry 提供完整的 OpenTelemetry 可观测性支持,覆盖 VextJS、Express、Koa、Egg.js、Hono、Fastify 等主流框架,统一输出 Traces + Metrics + Logs 三大支柱。
快速开始(VextJS 框架)
1. 安装
npm install vextjs-opentelemetry @opentelemetry/api \
@opentelemetry/sdk-node \
@opentelemetry/exporter-trace-otlp-http \
@opentelemetry/exporter-metrics-otlp-http
2. 创建插件
// src/plugins/otel.ts
import { opentelemetryPlugin } from "vextjs-opentelemetry/vextjs";
export default opentelemetryPlugin({ serviceName: "my-app" });
注意:opentelemetryPlugin 通过 vextjs-opentelemetry/vextjs 子路径导入(VextJS 专属)。
主入口 vextjs-opentelemetry 只导出框架无关工具(createWithSpan、getOtelStatus)。
3. 启动
vext start # 生产模式
vext dev # 开发模式
vext start / vext dev 自动运行 OTel SDK 初始化脚本(vextjs-opentelemetry 已在其 package.json 声明 "vext.preload": "./dist/instrumentation.js",VextJS CLI 自动扫描并以 --import 注入,无需手动配置)。
默认不上报——SDK 初始化脚本在启动时读取项目自身 package.json 的 vext.otel.endpoint 字段来决定上报地址,未配置时安全 noop(数据被丢弃,不会发送到任何地址)。
4. 验证
curl http://localhost:3000/_otel/status
{
"sdk": "initialized",
"serviceName": "my-app",
"exportMode": "otlp",
"endpoint": "http://otel-collector.internal:4318",
"autoInstrumentation": true
}
Done. 所有遥测功能已自动启用。
不配置上报地址会怎样?
✅ 安全默认值——未配置 endpoint 时不会向任何地址发送数据,也不会写入本地文件。要启用上报,请在插件配置或 package.json vext.otel.endpoint 中显式指定上报地址。
本地测试(无需 Docker)
不想装 Jaeger/Collector?可以将数据导出到本地文件,直接看原始数据格式。
方案一:导出到本地文件(推荐)
在项目 package.json 中配置上报地址(由 SDK 初始化脚本读取,控制实际导出):
{
"vext": {
"otel": {
"endpoint": "./otel-data"
}
}
}
package.json vext.otel.endpoint 是 VextJS 模式下唯一控制实际导出的配置。相对路径基于 process.cwd() 解析。
创建插件(保持 serviceName 与 package.json 一致,otlpEndpoint 可选,仅影响 /_otel/status 显示):
// src/plugins/otel.ts
import { opentelemetryPlugin } from "vextjs-opentelemetry/vextjs";
export default opentelemetryPlugin({ serviceName: "my-app" });
vext dev
# 发起几个请求后查看文件
cat ./otel-data/traces.jsonl
cat ./otel-data/metrics.jsonl
插件自动创建目录,每次请求的 Span 追加到 traces.jsonl(每行一个 JSON),指标定期追加到 metrics.jsonl。
traces.jsonl 示例(每行一条 Span):
{
"traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
"parentId": undefined,
"name": "GET /users/:id",
"id": "00f067aa0ba902b7",
"kind": 1,
"timestamp": 1743431641234000,
"duration": 45230,
"attributes": {
"http.method": "GET",
"http.route": "/users/:id",
"http.status_code": 200,
"http.request_id": "my-app-a1b2c3d4",
"vext.service": "my-app",
"http.url": "http://localhost:3000/users/42",
"net.peer.ip": "127.0.0.1"
},
"status": { "code": 0 },
"events": [],
"resource": {
"service.name": "my-app",
"service.version": "1.0.0",
"deployment.environment": "development"
}
}
metrics.jsonl 示例(每行一批指标):
{
"timestamp": "2026-04-02T10:30:00.000Z",
"metrics": [
{
"descriptor": { "name": "http.server.duration", "unit": "ms" },
"dataPointType": "HISTOGRAM",
"dataPoints": [
{
"attributes": { "http.method": "GET", "http.route": "/users/:id", "http.status_code": 200 },
"count": 5,
"sum": 225,
"min": 12,
"max": 89
}
]
},
{
"descriptor": { "name": "http.server.request.total" },
"dataPointType": "SUM",
"dataPoints": [
{
"attributes": { "http.method": "GET", "http.route": "/users/:id", "http.status_code": 200 },
"value": 5
}
]
}
]
}
方案二:本地 Jaeger(有 Docker 时)
docker run -d --name jaeger -p 4318:4318 -p 16686:16686 \
-e COLLECTOR_OTLP_ENABLED=true jaegertracing/all-in-one:latest
在插件中配置上报地址为本地 Jaeger:
// src/plugins/otel.ts
export default opentelemetryPlugin({
serviceName: "my-app",
otlpEndpoint: "http://localhost:4318",
});
同时在 package.json 中配置:
{ "vext": { "otel": { "endpoint": "http://localhost:4318" } } }
vext dev
curl http://localhost:3000/users
open http://localhost:16686 # Jaeger UI
多框架接入(非 VextJS)
安装
npm install vextjs-opentelemetry @opentelemetry/api @opentelemetry/sdk-node
端点格式说明
所有框架的 endpoint 字段遵循相同规则:
gRPC h2c 使用原生 node:http2 实现,绕开 @grpc/grpc-js 与自建 Collector h2c 握手不兼容的问题。
SDK 初始化(initOtel)
非 VextJS 框架通过 vextjs-opentelemetry/koa 子路径的 initOtel 函数完成 SDK 初始化,在 --require CJS 预加载文件中调用。
// app/otel-init.cjs(通过 --require 预加载)
'use strict';
const { initOtel } = require('vextjs-opentelemetry/koa');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici');
const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
const { RuntimeNodeInstrumentation } = require('@opentelemetry/instrumentation-runtime-node');
initOtel({
serviceName: 'my-app',
endpoint: process.env.OTEL_COLLECTOR_ENDPOINT || 'otel-collector.internal:4317', // host:port → gRPC h2c
instrumentations: [
new HttpInstrumentation(),
new UndiciInstrumentation(),
new MongoDBInstrumentation(),
// Node.js 运行时指标(CPU / 内存 / 事件循环 / GC)
new RuntimeNodeInstrumentation(),
],
});
initOtel 选项:
Express
import express from "express";
import { createExpressMiddleware } from "vextjs-opentelemetry/express";
import { getOtelStatus } from "vextjs-opentelemetry";
const app = express();
app.use(createExpressMiddleware({
serviceName: "my-express-app",
tracing: {
ignorePaths: ["/health", "/_otel/status"],
spanNameResolver: (ctx) => `${ctx.method} ${ctx.route ?? ctx.path}`,
},
metrics: {
customLabels: (ctx) => ({ "http.path": ctx.route ?? ctx.path }),
},
}));
app.get("/_otel/status", (_req, res) => res.json(getOtelStatus()));
启动命令(CJS 项目):
{
"scripts": {
"start": "node --require ./otel-init.cjs dist/server.js"
}
}
Koa
import Koa from "koa";
import { createKoaMiddleware } from "vextjs-opentelemetry/koa";
import { getOtelStatus } from "vextjs-opentelemetry";
const app = new Koa();
app.use(createKoaMiddleware({
serviceName: "my-koa-app",
tracing: {
ignorePaths: ["/health", "/_otel/status"],
spanNameResolver: (ctx) => `${ctx.method} ${ctx.route ?? ctx.path}`,
},
}));
app.use(async (ctx, next) => {
if (ctx.path === "/_otel/status") { ctx.body = getOtelStatus(); return; }
await next();
});
Egg.js
Egg.js 采用 CJS --require 预加载,SDK 必须在任何模块加载前初始化。
Step 1:otel-init.cjs
// app/otel-init.cjs
'use strict';
const { initOtel } = require('vextjs-opentelemetry/koa');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { UndiciInstrumentation } = require('@opentelemetry/instrumentation-undici');
const { MongoDBInstrumentation } = require('@opentelemetry/instrumentation-mongodb');
const { IORedisInstrumentation } = require('@opentelemetry/instrumentation-ioredis');
const { MySQL2Instrumentation } = require('@opentelemetry/instrumentation-mysql2');
const { RuntimeNodeInstrumentation } = require('@opentelemetry/instrumentation-runtime-node');
initOtel({
serviceName: 'my-service',
endpoint: process.env.OTEL_COLLECTOR_ENDPOINT || 'otel-collector.internal:4317',
instrumentations: [
new HttpInstrumentation(),
new UndiciInstrumentation(),
new MongoDBInstrumentation(),
new IORedisInstrumentation(),
new MySQL2Instrumentation(),
// Node.js 运行时指标(CPU / 内存 / 事件循环 / GC)
new RuntimeNodeInstrumentation(),
],
});
Step 2:package.json scripts
{
"scripts": {
"dev": "egg-bin dev --require ./app/otel-init.cjs",
"start": "egg-scripts start --require ./app/otel-init.cjs"
}
}
Step 3:OTel 中间件(app/middleware/otel.ts)
使用 createEggMiddleware,内置 trace_id / span_name / endpoint / latency_ms 自动注入,并通过回调暴露业务扩展点:
import { createEggMiddleware } from 'vextjs-opentelemetry/egg';
export default createEggMiddleware({
serviceName: 'my-service',
tracing: {
ignorePaths: [/^\/favicon/, /^\/_/, '/health'],
spanNameResolver: (ctx) => `${ctx.method} ${ctx.route ?? ctx.path}`,
},
metrics: {
customLabels: (ctx) => ({ 'http.path': ctx.route ?? ctx.path }),
},
// 业务字段注入(span 创建前,各服务自行扩展)
onCtxInit: (ctx) => {
ctx.user_id = ctx.state?.userId ?? ctx.state?.user?.id ?? '';
ctx.feature_flag = ctx.get('x-feature-flag') || '';
},
// 自定义 access log(请求完成后,finally 块)
onRequestDone: (ctx, info) => {
ctx.logger.info(`${info.method} ${ctx.status} ${info.route} ${info.latencyMs}ms`);
},
});
createEggMiddleware 自动注入的 ctx 字段:
TypeScript 类型声明(typings/index.d.ts):
declare module 'egg' {
interface Context {
trace_id: string;
span_name: string;
endpoint: string;
latency_ms: number;
user_id: string; // 由 onCtxInit 注入
feature_flag: string; // 由 onCtxInit 注入
}
}
Step 4:注册中间件
// config/config.default.ts
config.middleware = ['otel', /* 其他中间件 */];
Step 5:/_otel/status 路由
// app/router.ts
import { getOtelStatus } from 'vextjs-opentelemetry';
router.get('/_otel/status', async (ctx) => {
ctx.body = getOtelStatus(); // 无参,自动读取环境变量
});
Step 6:ctx.withSpan 扩展(可选)
// app/extend/context.ts
import { createWithSpan } from 'vextjs-opentelemetry';
export default { withSpan: createWithSpan('my-service') };
使用:
// app/controller/userController.ts
export default class UserController extends Controller {
async findById() {
const { ctx } = this;
const user = await ctx.withSpan('db.user.findById', async (span) => {
span.setAttribute('user.id', ctx.params.id);
return ctx.service.user.findById(ctx.params.id);
});
ctx.body = user;
}
}
Hono
import { Hono } from "hono";
import { createHonoMiddleware } from "vextjs-opentelemetry/hono";
import { getOtelStatus } from "vextjs-opentelemetry";
const app = new Hono();
app.use(createHonoMiddleware({ serviceName: "my-hono-app" }));
app.get("/_otel/status", (c) => c.json(getOtelStatus()));
Fastify
import Fastify from "fastify";
import { createFastifyPlugin } from "vextjs-opentelemetry/fastify";
import { getOtelStatus } from "vextjs-opentelemetry";
const fastify = Fastify();
await fastify.register(createFastifyPlugin({ serviceName: "my-fastify-app" }));
fastify.get("/_otel/status", () => getOtelStatus());
框架差异对比
/_otel/status 状态检查接口
用于验证 OTel SDK 当前运行状态:
curl http://localhost:3000/_otel/status
{
"sdk": "initialized",
"serviceName": "my-app",
"exportMode": "grpc",
"endpoint": "otel-collector.internal:4317",
"autoInstrumentation": true
}
VextJS:启动后自动注册,无需手动配置(可通过 statusEndpoint: false 禁用)。
非 VextJS:在路由层调用 getOtelStatus() 手动注册(见各框架接入示例)。
生产环境建议在网关层限制内网访问。
上报的数据内容
Traces(链路追踪)
每个 HTTP 请求产生一条 Span,包含:
安装 @opentelemetry/auto-instrumentations-node 后,数据库操作、HTTP 外部调用等会自动产生子 Span。
Metrics(指标监控)
ignorePaths 同时抑制 Trace 和 Metrics——被忽略路径(如 /health)不会产生任何 Span 或指标数据,不会在监控面板产生噪声。
Node.js Runtime 指标(通过 @opentelemetry/instrumentation-runtime-node 自动上报):
Logs(日志关联)
每条请求日志自动注入 trace_id + span_id:
{
"msg": "GET /users/42 200 45ms | 127.0.0.1",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"requestId": "my-app-a1b2c3d4"
}
通过 trace_id 可在 Grafana Loki / ELK 中关联日志与链路。
结构化日志(Schema A + Schema B)
当日志需同时落地(Schema A)并上报至 OTLP Collector(Schema B)时,使用 vextjs-opentelemetry/log 提供的两个工厂函数:
createStructuredLogFormatter — Schema A 结构化 JSON 格式化器(固定字段顺序)
createOtelLogBridge — Schema B OTel LogRecord 桥接(通过 globalThis._otelLogger)
Schema A — 落地日志 JSON(完整字段)
{
"timestamp": "2026-04-03 10:00:00",
"level": "INFO",
"message": "用户创建成功",
"service_name": "my-app",
"env": "production",
"host": "pod-abc123",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span": "POST /users",
"endpoint": "/users",
"latency_ms": 45,
"user_id": "u_123",
"feature_flag": "new-checkout",
"exception.type": "",
"exception.message": "",
"exception.stacktrace": ""
}
Egg.js 配置示例(config/config.default.ts)
import {
createStructuredLogFormatter,
createOtelLogBridge,
} from 'vextjs-opentelemetry/log';
// Schema B 桥接:延迟求值,SDK 初始化前返回 null(noop)
const bridge = createOtelLogBridge(() => (globalThis as any)._otelLogger);
// Schema A 格式化器
const formatter = createStructuredLogFormatter({
serviceName: 'my-service',
getTraceFields: (meta: any) => ({
trace_id: meta.ctx?.trace_id ?? '',
span: meta.ctx?.span_name ?? '', // ctx.span_name 由 createEggMiddleware 注入
endpoint: meta.ctx?.endpoint ?? '',
latency_ms: meta.ctx?.latency_ms ?? 0,
user_id: meta.ctx?.user_id ?? '',
}),
getCustomFields: (meta: any) => ({
'feature.flag': meta.ctx?.feature_flag ?? '',
}),
});
// logger 配置
config.logger = {
formatter(meta: any) {
// Schema B — OTel LogRecord(trace_id/span_id 从 AsyncLocalStorage 自动注入)
bridge.emit(meta.level ?? 'info', meta.message ?? '', {
...(meta.ctx?.endpoint ? { endpoint: meta.ctx.endpoint } : {}),
...(meta.ctx?.user_id ? { 'user.id': meta.ctx.user_id } : {}),
});
// Schema A — 落地日志 JSON
return formatter(meta);
},
};
initOtel() 已将 LoggerProvider.getLogger(serviceName) 写入 globalThis._otelLogger(默认 key),createOtelLogBridge 通过工厂函数延迟求值,SDK 初始化前调用时安全 noop。
配置方式(VextJS)
VextJS 的 OTel 配置分两层,目的不同:
第一层:实际上报地址(package.json,必须)
由 SDK 初始化脚本(instrumentation.ts,通过 vext.preload 在应用代码前执行)读取,唯一控制遥测数据实际发往何处。插件参数和 vext.config.ts 均不影响导出行为。
{
"vext": {
"otel": {
"endpoint": "http://otel-collector.internal:4318",
"headers": { "api-key": "YOUR_KEY" },
"sampling": { "ratio": 1.0 }
}
}
}
第二层:状态显示(插件参数 / vext.config.ts,可选)
仅影响 /_otel/status 的 endpoint 字段返回值,不控制实际导出。建议与第一层保持一致,方便运维核查。
// src/plugins/otel.ts(otlpEndpoint 仅用于状态显示)
export default opentelemetryPlugin({
serviceName: "my-app",
otlpEndpoint: "http://otel-collector.internal:4318", // 与 package.json 保持一致
otlpHeaders: { "api-key": "YOUR_KEY" },
});
或通过 vext.config.ts:
// src/config/default.ts
export default {
otel: {
serviceName: "my-app",
enabled: true,
endpoint: "http://otel-collector:4318",
},
};
不再支持通过 OTEL_EXPORTER_OTLP_ENDPOINT 环境变量覆盖上报地址,请统一通过 package.json vext.otel 配置。
完整配置参考
opentelemetryPlugin() 选项
opentelemetryPlugin({
// ── 基础 ───────────────────────────────────────────
serviceName: "my-app", // 服务名称
enabled: true, // false 时完全 no-op
// ── 状态显示(不控制实际导出,仅影响 /_otel/status 响应)──────────
otlpEndpoint: "http://collector:4318", // 建议与 package.json vext.otel.endpoint 一致
otlpHeaders: { "api-key": "KEY" }, // 同上
// ── 状态检查 ───────────────────────────────────────
statusEndpoint: "/_otel/status", // 自定义路径,false 禁用
// ── 追踪 ───────────────────────────────────────────
tracing: {
enabled: true,
ignorePaths: ["/health", "/_otel/status", /^\/internal\//], // 忽略的路径
spanNameResolver: (req) => `${req.method} ${req.route ?? req.path}`, // Span 名称
extraAttributes: (req) => ({ // 自定义 Span 属性
"user.id": req.headers["x-user-id"] ?? "",
"tenant.id": req.headers["x-tenant-id"] ?? "",
}),
},
// ── 指标 ───────────────────────────────────────────
metrics: {
enabled: true,
durationBuckets: [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000],
customLabels: (req) => ({ // 自定义指标标签(避免高基数!)
"tenant.id": req.headers["x-tenant-id"] ?? "default",
}),
},
});
vext.config.ts
// src/config/default.ts
export default {
otel: {
serviceName: "my-app",
enabled: true,
endpoint: "http://collector:4318",
headers: { "api-key": "KEY" }, sampling: { ratio: 1.0 }, // 采样率 0.0~1.0,默认全量 },
};
// src/config/test.ts
export default {
otel: { enabled: false },
};
环境变量
以下环境变量由 OpenTelemetry SDK 原生支持,但本插件不再自动读取 endpoint/headers 相关的环境变量。
上报地址和鉴权请求头请统一通过插件参数或 package.json vext.otel 配置。
接入后端
本地开发
云厂商
云厂商 token 建议通过环境变量注入(K8s Secret),不要硬编码到代码中。
自动检测(Auto-Instrumentation)
安装 @opentelemetry/auto-instrumentations-node 后,SDK 自动 patch 常见库,无需修改任何业务代码即可获得数据库查询、HTTP 外调、消息队列等的链路追踪。
安装
npm install @opentelemetry/auto-instrumentations-node
使用 vext start / vext dev 启动后,自动生效。
支持的库
完整列表见 @opentelemetry/auto-instrumentations-node。
效果示例
安装后,一次 GET /users/:id 请求在 Jaeger 中可能产生如下 Span 树:
GET /users/:id (http, 45ms)
├── mongodb.find users (db, 12ms)
├── redis.GET user:cache:42 (cache, 2ms)
└── HTTP GET https://api.xxx/verify (http, 28ms)
无需任何代码改动——SDK 在进程启动时(--import)自动 patch 了 mongodb、ioredis、http 等模块。
禁用特定检测
如果某个自动检测引起问题或不需要,可在插件中覆盖 instrumentation.ts 配置:
// src/instrumentation.ts(自定义,替代内置版本)
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
const sdk = new NodeSDK({
instrumentations: getNodeAutoInstrumentations({
// 禁用 fs 检测(日志噪音大)
"@opentelemetry/instrumentation-fs": { enabled: false },
// 禁用 dns 检测
"@opentelemetry/instrumentation-dns": { enabled: false },
}),
});
sdk.start();
export {};
然后在 package.json 中指向自定义的 instrumentation:
{ "vext": { "preload": "./dist/instrumentation.js" } }
未安装时的行为
如果未安装 @opentelemetry/auto-instrumentations-node:
- 控制台输出一行 warning 提示
- HTTP 中间件层的追踪(Span 属性标注、指标统计、日志关联)仍然正常
- 仅缺失数据库 / 外部 HTTP 等深层 Span(不影响应用运行)
[vextjs-opentelemetry/instrumentation] @opentelemetry/auto-instrumentations-node is not installed.
npm install @opentelemetry/auto-instrumentations-node
高级用法
手动追踪业务操作(withSpan)
withSpan() 是追踪自定义业务操作的推荐方式。它对 tracer.startActiveSpan() 做了 try/catch/finally 封装,自动处理 span.end()、span.recordException()、span.setStatus() 三件最容易遗漏的事。
VextJS 插件(通过 app.otel.withSpan)
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.post("/payments", async (req, res) => {
// ① 最简:完全不接触 span(仅追踪生命周期)
const result = await req.app.otel!.withSpan(
"payment.process",
() => processPayment(req.body.id),
);
// ② 带静态初始属性
const result = await req.app.otel!.withSpan(
"payment.process",
() => processPayment(req.body.id),
{ attributes: { "payment.provider": "stripe", "payment.currency": "USD" } },
);
// ③ 动态属性(依赖执行结果时,通过回调参数访问 span)
const result = await req.app.otel!.withSpan("payment.process", async (span) => {
const res = await processPayment(req.body.id);
span.setAttribute("payment.result", res.status);
return res;
});
res.json(result);
});
});
非 VextJS 框架(通过 createWithSpan() 工厂)
import { createWithSpan } from "vextjs-opentelemetry";
// 应用启动时初始化(绑定 tracer 名称,通常与 serviceName 一致)
const withSpan = createWithSpan("payment");
// Express / Koa / Hono / Fastify 路由中使用
router.post("/charge", async (req, res) => {
const result = await withSpan("payment.charge", async (span) => {
span.setAttribute("payment.amount", req.body.amount);
return await chargeService.run(req.body);
});
res.json(result);
});
Egg.js(通过 ctx.withSpan,由 app/extend/context.ts 注入)
// app/controller/paymentController.ts
export default class PaymentController extends Controller {
async charge() {
const { ctx } = this;
// ctx.withSpan 由 app/extend/context.ts 注入,直接调用
const result = await ctx.withSpan("payment.charge", async (span) => {
span.setAttribute("payment.amount", ctx.request.body.amount);
return ctx.service.payment.charge(ctx.request.body);
});
ctx.body = result;
}
}
行为说明:
底层 API(自定义 SpanKind / Processor 等高级场景)
需要精细控制 span 类型或自定义处理时,可直接使用 tracer:
import { SpanStatusCode } from "@opentelemetry/api";
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get("/users/:id", { validate: { param: { id: "string" } } }, async (req, res) => {
const span = req.app.otel!.tracer.startSpan("db.user.findById", {
attributes: { "db.system": "mongodb", "user.id": req.valid("param").id },
});
try {
const user = await app.services.user.findById(req.valid("param").id);
span.setStatus({ code: SpanStatusCode.OK });
res.json(user);
} catch (err) {
span.recordException(err as Error);
span.setStatus({ code: SpanStatusCode.ERROR, message: (err as Error).message });
throw err;
} finally {
span.end();
}
});
});
自定义业务指标
import { definePlugin } from "vextjs";
export default definePlugin({
name: "business-metrics",
dependencies: ["opentelemetry"],
setup(app) {
const meter = app.otel!.meter;
app.extend("businessMetrics", {
orderCreated: meter.createCounter("business.order.created"),
orderAmount: meter.createHistogram("business.order.amount", { unit: "cents" }),
});
},
});
采样(降低开销)
方式一:package.json 代码级配置(推荐)
instrumentation 在 SDK 初始化时读取 vext.otel.sampling.ratio,
自动使用 ParentBasedSampler(TraceIdRatioBasedSampler(ratio)):
{
"vext": {
"otel": {
"endpoint": "http://collector:4318",
"sampling": { "ratio": 0.1 }
}
}
}
方式二:环境变量(运行时覆盖)
# 无需改代码,可在 CI/CD 或部署脚本中注入
OTEL_TRACES_SAMPLER=traceidratio OTEL_TRACES_SAMPLER_ARG=0.1 vext start
Cluster 多进程
VEXT_CLUSTER=1 vext start # vext 自动为每个 Worker 注入 OTel
自定义 instrumentation
完全替换内置 SDK 初始化:
// src/instrumentation.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-node";
const sdk = new NodeSDK({
sampler: new TraceIdRatioBasedSampler(0.1),
// ... 自定义配置
});
sdk.start();
export {};
{ "vext": { "preload": "./dist/instrumentation.js" } }
日志字段规划
VextJS + vextjs-opentelemetry 支持两层日志输出,各有侧重:
- A. 落地日志(stdout / file JSON):业务字段清晰可读,便于人工排查和日志聚合(ELK/Loki)
- B. OTel Logs(LogRecord → Collector):轻量级,通过
trace_id 关联完整链路
A. 落地日志字段(stdout / file JSON)
通过 config.logger.mixin 注入 Resource 级和 Span 级上下文:
// src/config/default.ts
import os from "node:os";
let getActiveSpan: (() => unknown) | undefined;
try {
const api = await import("@opentelemetry/api");
getActiveSpan = api.trace.getActiveSpan.bind(api.trace);
} catch {}
export default {
logger: {
level: "info",
mixin() {
const fields: Record<string, unknown> = {
// Resource 级字段(每条日志都有)
service_name: "my-app",
env: process.env.NODE_ENV ?? "development",
host: os.hostname(),
};
// Span 级字段(请求上下文中有值时注入)
if (getActiveSpan) {
const span = getActiveSpan() as
| { isRecording?: () => boolean; name?: string }
| undefined;
if (span?.isRecording?.()) {
fields.span = span.name; // "GET", "mongodb.find", "redis.GET" 等
}
}
return fields;
},
},
};
输出示例:
{
"level": 30,
"time": 1743431641234,
"service_name": "my-app",
"env": "production",
"host": "web-pod-a1b2c3",
"requestId": "my-app-19f8d0dd",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"span": "GET",
"msg": "→ GET /users/42 200 45ms"
}
requestId、trace_id、span_id 由框架内置 mixin 自动注入,不需要在用户 mixin 中重复配置。
字段对照表
B. OTel Logs(LogRecord → Collector)
由 @opentelemetry/instrumentation-pino(auto-instrumentations-node 包含)自动完成:
trace_id / span_id:自动从 active span 注入到 LogRecord
severity_text:从 pino level 自动映射
body:日志消息内容
service.name:来自 Resource(instrumentation.ts 已配置)
attributes:pino 日志的自定义字段自动映射为 LogRecord attributes
用户 mixin 注入的字段(如 service_name、host、span)会自动出现在 LogRecord.attributes 中。
OTel Logs 最佳实践
避免在 LogRecord attributes 中放入所有落地日志字段。OTel Logs 通过 trace_id 关联 Trace 即可看到 endpoint、latency_ms、user.id 等完整上下文。保持 LogRecord 轻量有助于控制 Collector 流量。
C. 深层字段(自动出现在子 Span 中)
以下字段由 @opentelemetry/auto-instrumentations-node 自动采集,无需手动配置:
GET /users/:id (http, 45ms) ← user.id, tenant.id 在此
├── mongodb.find users (db, 12ms) ← db.statement 自动
├── redis.GET user:cache:42 (cache, 2ms) ← cache.system 自动
└── HTTP GET https://api.xxx/verify (http, 28ms) ← 自动
通过 trace_id 在 Jaeger / Grafana Tempo 中查看完整调用链路即可关联这些深层字段。
生产最佳实践
- 配置上报地址 — 未配置时不会上报(安全默认值),但也意味着无可观测性数据
shutdown.timeout: 60 — 确保 SDK 有足够时间 flush 数据
- 限制
/_otel/status — 网关层限内网,或 statusEndpoint: false
- 不要在 Span 中记录敏感信息 — 密码、Token、身份证号等
- 采样 — 高并发服务用
OTEL_TRACES_SAMPLER=traceidratio
- 部署 Collector — 应用 → Collector → 后端,解耦 + 缓冲
应用(N 个) ──OTLP──► Collector ──► Jaeger / Prometheus / Grafana
常见问题
Q: /_otel/status 返回 "sdk": "noop"
VextJS:① 使用 vext start/dev 启动 ② vextjs-opentelemetry 在 dependencies 中 ③ SDK 包已安装
非 VextJS(initOtel 模式):确认 --require ./app/otel-init.cjs 在所有业务模块加载前执行。Egg.js 需在 package.json 的 scripts 中加入 --require 参数。
Q: endpoint 显示 localhost 但我配了其他地址
① 检查插件参数 otlpEndpoint / initOtel 的 endpoint 配置 ② 确认 package.json vext.otel.endpoint 与插件参数一致 ③ 确认用 vext start/dev 启动
Q: 日志没有 trace_id
先确认 /_otel/status 返回 "sdk": "initialized",SDK 必须正常才有 trace。
非 VextJS 框架:createKoaMiddleware / createExpressMiddleware 等适配器从 OTel active span 读取 trace_id,需确认中间件已注册且在路由之前执行。
Koa / Egg.js 未使用 HTTP auto-instrumentation:createKoaMiddleware 会自动创建 SERVER span,无需额外安装 @opentelemetry/instrumentation-http。
Q: 后端收不到数据
① /_otel/status 确认 sdk: "initialized" + endpoint 正确 ② 服务日志中确认 [otel] ... export SUCCESS (grpc-status:0) ③ 等 30 秒(批量上报延迟)④ 用接 Jaeger/LGTM 本地调试确认数据格式
Q: [otel] ... export FAILED: grpcSend timeout
服务器到采集器的 h2c gRPC 连接受阻。检查:① 采集器地址和端口可达 ② 采集器服务正常运行 ③ 网络防火墙/安全组规则 ④ 如在 Docker/K8s 内,使用 Service DNS 而非 localhost
Q: Egg.js 热重载后 SDK 不报数
Egg.js 文件监听触发 Worker 重启时,--require 会在新 Worker 进程中重新执行,SDK 会重新初始化。若 Worker 启动过程中因外部依赖(如 Nacos、MySQL)超时导致崩溃,是启动依赖问题,与 OTel 无关。
Q: 测试环境如何禁用
// VextJS —— src/config/test.ts
export default { otel: { enabled: false } };
// 非 VextJS —— 测试入口不调用 initOtel(),或传入 endpoint: "none"
initOtel({ serviceName: "my-app", endpoint: "none" });