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 只导出框架无关工具(createWithSpangetOtelStatus)。

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.jsonvext.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 值行为
未配置任何 endpoint"none"SDK 启动但不导出数据(auto-instrumentation 仍生效,但无遥测输出)
配置了地址但 Collector 不可达配置值SDK 内部 batch 超时后丢弃,控制台无错误
enabled: false完全 no-op,不初始化 SDK

安全默认值——未配置 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() 解析。

创建插件(保持 serviceNamepackage.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 字段遵循相同规则:

格式传输协议适用场景
"host:port"gRPC h2c(明文 HTTP/2)内网/自建 Collector(Jaeger、K8s)
"http://host:port"OTLP HTTP公网或明确需要 HTTP
"none" / 不传不上报本地开发、测试

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 选项:

字段类型说明
serviceNamestring服务名,写入 Resource service.name
endpointstring上报地址(见端点格式说明)
headersRecord<string,string>OTLP 请求头(HTTP 模式时有效)
instrumentationsInstrumentation[]自动检测列表(HTTP/DB/Redis 等)
metricIntervalMsnumber指标上报间隔(默认 15000ms)

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 字段:

字段说明
ctx.trace_idW3C trace ID(无 active span 时为空字符串)
ctx.span_name${method} ${routerPath}
ctx.endpointrouterPath(路由模板)
ctx.latency_ms请求总耗时(ms)

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

框架差异对比

特性VextJSEgg.jsKoa / Express / Hono / Fastify
SDK 初始化--import(自动/手动)--require otel-init.cjs--require otel-init.cjs
exporter 配置plugin optionsinitOtel()initOtel()
中间件opentelemetryPlugin()createEggMiddleware()createXxxMiddleware()
业务字段注入N/AonCtxInit 回调手动
logger bridgelogs.bridgeAppLoggerEgg logger formatter手动
onCtxInit/onRequestDone✅ 专属

/_otel/status 状态检查接口

用于验证 OTel SDK 当前运行状态:

curl http://localhost:3000/_otel/status
{
  "sdk": "initialized",
  "serviceName": "my-app",
  "exportMode": "grpc",
  "endpoint": "otel-collector.internal:4317",
  "autoInstrumentation": true
}
字段说明
sdk"initialized" = SDK 正常 / "noop" = SDK 未初始化
serviceName当前生效的服务名
exportMode"grpc" = h2c gRPC / "otlp" = HTTP OTLP / "file" = 本地文件 / "none" = 未配置
endpoint当前生效的上报目标(未配置时为 "none"
autoInstrumentation是否启用了自动检测(MongoDB/Redis/MySQL 等)

VextJS:启动后自动注册,无需手动配置(可通过 statusEndpoint: false 禁用)。

非 VextJS:在路由层调用 getOtelStatus() 手动注册(见各框架接入示例)。

生产环境建议在网关层限制内网访问。


上报的数据内容

Traces(链路追踪)

每个 HTTP 请求产生一条 Span,包含:

属性示例值说明
http.method"GET"HTTP 方法
http.route"/users/:id"路由模板(低基数,安全用于指标聚合)
http.status_code200响应状态码
http.request_id"my-app-a1b2c3d4"vext 请求 ID
vext.service"my-app"服务名称
http.url"http://localhost:3000/users/42"完整请求 URL
net.peer.ip"127.0.0.1"客户端 IP

安装 @opentelemetry/auto-instrumentations-node 后,数据库操作、HTTP 外部调用等会自动产生子 Span。

Metrics(指标监控)

指标名类型标签说明
http.server.durationHistogram(ms)method, route, status_code请求耗时分布
http.server.request.totalCountermethod, route, status_code请求总数
http.server.active_requestsUpDownCountermethod当前并发请求数
http.server.request.sizeHistogram(bytes)method, route请求体大小分布(Content-Length 存在时记录)
http.server.response.sizeHistogram(bytes)method, status_code响应体大小分布(Content-Length 存在时记录)

ignorePaths 同时抑制 Trace 和 Metrics——被忽略路径(如 /health)不会产生任何 Span 或指标数据,不会在监控面板产生噪声。

Node.js Runtime 指标(通过 @opentelemetry/instrumentation-runtime-node 自动上报):

指标名说明
process.cpu.usage进程 CPU 使用率
process.memory.usage堆内存使用量(heap_used / rss)
nodejs.eventloop.lag事件循环延迟
nodejs.gc.duration / nodejs.gc.countGC 耗时和次数

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/statusendpoint 字段返回值,不控制实际导出。建议与第一层保持一致,方便运维核查。

// 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 配置。

变量默认值说明
OTEL_TRACES_SAMPLER"parentbased_always_on"采样策略
OTEL_TRACES_SAMPLER_ARG"1"采样率(如 0.1 = 10%)
OTEL_METRIC_EXPORT_INTERVAL15000指标导出间隔(毫秒)
OTEL_LOG_LEVEL"info"SDK 日志级别

接入后端

本地开发

后端启动方式endpoint 配置
无(文件导出)不需要 Dockerpackage.json vext.otel.endpoint: "./otel-data"
Jaegerdocker run -d -p 4318:4318 -p 16686:16686 -e COLLECTOR_OTLP_ENABLED=true jaegertracing/all-in-onepackage.json vext.otel.endpoint: "http://localhost:4318"
Grafana LGTMdocker run -d -p 3000:3000 -p 4318:4318 grafana/otel-lgtmpackage.json vext.otel.endpoint: "http://localhost:4318"

云厂商

厂商endpointheaders
New Relichttps://otlp.nr-data.net:4318{ "api-key": "LICENSE_KEY" }
Grafana Cloudhttps://otlp-gateway-....grafana.net/otlp{ "Authorization": "Basic TOKEN" }
Datadoghttp://dd-agent-host:4318
阿里云 ARMS参考阿里云 OTLP 接入文档参考文档

云厂商 token 建议通过环境变量注入(K8s Secret),不要硬编码到代码中。


自动检测(Auto-Instrumentation)

安装 @opentelemetry/auto-instrumentations-node 后,SDK 自动 patch 常见库,无需修改任何业务代码即可获得数据库查询、HTTP 外调、消息队列等的链路追踪。

安装

npm install @opentelemetry/auto-instrumentations-node

使用 vext start / vext dev 启动后,自动生效。

支持的库

类别自动追踪内容
数据库MongoDB(mongodb / mongoose查询操作、集合名、耗时
PostgreSQL(pgSQL 语句、表名、耗时
MySQL(mysql / mysql2SQL 语句、表名、耗时
Redis(ioredis / redis命令、key、耗时
HTTPNode.js http / https外部 HTTP 调用、URL、状态码
undici / fetch同上,Node.js 18+ 内置 fetch
消息队列amqplib(RabbitMQ)队列名、消息发送/消费
kafkajsTopic、消息发送/消费
缓存memcached操作命令、key
RPC@grpc/grpc-js方法名、状态码
其他dnsDNS 解析
netTCP 连接

完整列表见 @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 了 mongodbioredishttp 等模块。

禁用特定检测

如果某个自动检测引起问题或不需要,可在插件中覆盖 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;
  }
}

行为说明

场景自动行为
回调正常返回span.end() 自动调用
回调抛出异常span.recordException(err) + span.setStatus(ERROR) + span.end() + re-throw
SDK 未初始化Noop span,零 overhead(OTel API 契约保证)

底层 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"
}

requestIdtrace_idspan_id 由框架内置 mixin 自动注入,不需要在用户 mixin 中重复配置。

字段对照表

字段来源配置方式
timestamppino 自动无需配置
levelpino 自动无需配置
msglogger.info("...")无需配置
requestId框架 ALS → mixin 自动无需配置
trace_idotel 中间件 → ALS → mixin 自动无需配置
span_idotel 中间件 → ALS → mixin 自动无需配置
service_nameconfig.logger.mixin用户 mixin 注入
envconfig.logger.mixin用户 mixin 注入
hostconfig.logger.mixin用户 mixin 注入
spantrace.getActiveSpan().name用户 mixin 注入
endpointaccess log 中的 req.route自动包含在请求日志 msg 中
latency_msaccess log自动包含在请求日志 msg 中
user_id业务代码logger.info({ user_id: "..." }, msg)
feature.flag业务代码logger.info({ "feature.flag": "..." }, msg)
exception.*logger.error(err)pino serializer 自动展开

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_namehostspan)会自动出现在 LogRecord.attributes 中。

OTel Logs 最佳实践

避免在 LogRecord attributes 中放入所有落地日志字段。OTel Logs 通过 trace_id 关联 Trace 即可看到 endpointlatency_msuser.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)  ← 自动
字段来源出现位置
db.statementDB instrumentation 自动数据库子 Span attributes
db.systemDB instrumentation 自动数据库子 Span attributes
cache.systemRedis/Memcached instrumentation 自动缓存子 Span attributes
http.urlHTTP instrumentation 自动外部调用子 Span attributes

通过 trace_id 在 Jaeger / Grafana Tempo 中查看完整调用链路即可关联这些深层字段。


生产最佳实践

  1. 配置上报地址 — 未配置时不会上报(安全默认值),但也意味着无可观测性数据
  2. shutdown.timeout: 60 — 确保 SDK 有足够时间 flush 数据
  3. 限制 /_otel/status — 网关层限内网,或 statusEndpoint: false
  4. 不要在 Span 中记录敏感信息 — 密码、Token、身份证号等
  5. 采样 — 高并发服务用 OTEL_TRACES_SAMPLER=traceidratio
  6. 部署 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.jsonscripts 中加入 --require 参数。

Q: endpoint 显示 localhost 但我配了其他地址

① 检查插件参数 otlpEndpoint / initOtelendpoint 配置 ② 确认 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-instrumentationcreateKoaMiddleware 会自动创建 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" });