发布日期

第 34 讲:Instrumentation、OpenTelemetry、日志与可观测性

instrumentation.ts 钩子、OpenTelemetry 集成与生产可观测性实践

第 33 讲讲了如何处理错误。本讲讲生产里真的能用的可观测性:让你不用 SSH 进容器看日志,而是在 Datadog / Honeycomb / Grafana 里直接看到 "/products/iphone P99=200ms,其中 30% 来自 db.query"。Next.js 自带 OpenTelemetry tracing,本讲会讲清楚它的内部 span 名约定、instrumentation.ts 的 register 生命周期、以及如何对接外部 collector。

学习目标

  1. 看懂 NextTracerpackages/next/src/server/lib/trace/tracer.ts)的实现:基于 OpenTelemetry API。
  2. 列出 Next.js 内置 span 命名约定,会读 trace 找瓶颈。
  3. instrumentation.ts 里初始化 OTel SDK,把 trace 发到 Jaeger/Honeycomb/Datadog。
  4. 区分 4 类信号:traces / metrics / logs / events,知道分别什么时候用。
  5. 用 trace 排查 4 类常见问题:慢 page / 频繁 cache miss / Server Action 慢 / 上游 fetch 慢。

一、4 类可观测性信号

信号数据模型Next.js 关注点
Traces树形 span,记录请求经过哪些操作一条请求的延迟分布
Metrics时序数值(counter/gauge/histogram)QPS、P99 latency、错误率、内存
Logs结构化文本事件业务日志、stack trace
Events一次性结构化记录deploy、feature flag 改

Next.js 内置 trace 能力(OpenTelemetry),其它信号需要自己接(pino、prom-client、Datadog SDK 等)。

二、Next.js 的 OTel 实现

230:packages/next/src/server/lib/trace/tracer.ts
class NextTracerImpl implements NextTracer {
  private getTracerInstance(): Tracer {
    return trace.getTracer('next.js', '0.0.1')
  }
  // ...
}

Next.js 使用标准 @opentelemetry/api 接口,意味着:

  • 它不绑定到任何具体的 OTel SDK
  • 你在 instrumentation.ts 里 init 哪个 SDK,next.js 的 span 就发到哪里
  • 不 init SDK 的话,trace 是 no-op,零开销

内置 span 命名

55:packages/next/src/server/lib/trace/constants.ts
export enum BaseServerSpan {
  handleRequest = 'BaseServer.handleRequest',
  run = 'BaseServer.run',
  pipe = 'BaseServer.pipe',
  render = 'BaseServer.render',
  renderToResponseWithComponents = 'BaseServer.renderToResponseWithComponents',
  renderToHTML = 'BaseServer.renderToHTML',
  // ...
}

export enum NextNodeServerSpan {
  createComponentTree = 'NextNodeServer.createComponentTree',
  clientComponentLoading = 'NextNodeServer.clientComponentLoading',
  // ...
}

每个请求会生成一棵 span 树,大致结构:

BaseServer.handleRequest         总耗时
├── BaseServer.run               路由匹配
└── BaseServer.renderToResponseWithComponents
    └── BaseServer.renderToHTML
        ├── NextNodeServer.createComponentTree
        │   ├── NextNodeServer.getLayoutOrPageModule
        │   └── NextNodeServer.clientComponentLoading
        ├── AppRender.renderToStream
        ├── AppRender.fetch (× N)
        ├── AppRender.getDynamicHTML
        └── AppRender.useCache (× N)

记住这棵树的形状,能让你打开 Jaeger UI 一眼定位瓶颈。

启用方式

next.config.js 不需要任何配置。只要:

  1. 安装 @vercel/otel 或手动 init OTel SDK
  2. instrumentation.tsregister() 里启动 SDK
// instrumentation.ts
import { registerOTel } from "@vercel/otel";

export function register() {
  registerOTel({
    serviceName: "my-next-app",
    // 默认走 OTLP exporter,可以发到 collector
  });
}

或手写:

// instrumentation-node.ts (Node runtime only)
import { NodeSDK } from "@opentelemetry/sdk-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: "my-next-app",
    [SemanticResourceAttributes.SERVICE_VERSION]: process.env.GIT_SHA,
  }),
  traceExporter: new OTLPTraceExporter({
    url: process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
  }),
});
sdk.start();

三、instrumentation.ts 生命周期

// instrumentation.ts
export async function register() {
  // 在 server 启动时执行一次(每个 worker 各一次)
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    await import('./instrumentation-node')
  }
  if (process.env.NEXT_RUNTIME === 'edge') {
    await import('./instrumentation-edge')
  }
}

export const onRequestError = (...) => { /* 第 33 讲 */ }

register 执行时机:

  • next dev:dev server 启动时
  • next start:每个 render worker / router server 启动时
  • next build --debug-prerender:build 时也跑(用于 prerender 阶段抓 trace)

注意:

  • 必须按 runtime 分支加载:edge runtime 没法 require @opentelemetry/sdk-node,会 import 报错
  • 用动态 import 而非 top-level import,避免边缘 bundle 被污染

四、自定义 span

业务代码可以自己加 span:

import { context, trace } from "@opentelemetry/api";

async function getProducts() {
  const tracer = trace.getTracer("my-app");
  return tracer.startActiveSpan("db.products.findMany", async (span) => {
    try {
      span.setAttribute("db.system", "postgresql");
      const result = await db.products.findMany();
      span.setAttribute("result.count", result.length);
      return result;
    } catch (err) {
      span.recordException(err as Error);
      span.setStatus({ code: 2, message: (err as Error).message });
      throw err;
    } finally {
      span.end();
    }
  });
}

或用便捷库:

import { traced } from "@/lib/tracing";

const result = await traced(
  "db.products.findMany",
  { "db.system": "postgresql" },
  async () => {
    return db.products.findMany();
  },
);

业务 span 自动嵌套在 Next.js 的 request span 下,trace UI 一棵完整树。

五、Trace 排障实战

案例 1:某 page P99 200ms 但 P50 30ms

打开慢 trace 看 span 树。可能看到:

BaseServer.handleRequest [200ms]
├── BaseServer.run [5ms]
└── AppRender.renderToStream [195ms]
    ├── AppRender.fetch /api/products [180ms]  ← 元凶
    └── ...

定位到上游 /api/products 慢,去查上游服务。

案例 2:cache miss 多

Next.js fetch / use cache 有专门的 span:

AppRender.fetch /api/products
  attributes:
    http.url: ...
    http.method: GET
    next.span_name: AppRender.fetch
    next.fetch_cache: MISS

next.fetch_cache=MISS 过滤,看是哪些 page / 哪些 fetch 没命中。

案例 3:Server Action 慢

HandleAction
├── HandleAction.deserialize [2ms]
├── action invocation [500ms]
│   └── db.users.update [480ms]
└── HandleAction.serialize [3ms]

定位到 db 慢 → 加索引。

案例 4:上游 fetch 慢

OTel HTTP instrumentation 自动给所有 fetch 加 span。结合 @opentelemetry/instrumentation-http,能看到完整的 outbound 请求树。

六、Trace 后端选择

后端特点exporter
Jaeger开源,自托管OTLP / Jaeger native
HoneycombSaaS,高基数 queryOTLP
Datadog APMSaaS,与 logs/metrics 整合好DD agent
Tempo + Grafana开源,Loki/Prometheus 整合OTLP
VercelVercel 项目内置自动
New RelicSaaSOTLP
AWS X-RayAWS 原生X-Ray exporter

OTLP exporter 是事实标准,写一份配置可以切换后端。

七、Logs:结构化日志

Next.js 自身用 console.log,没有内置日志库。生产建议:

// lib/logger.ts
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  base: {
    service: "my-next-app",
    env: process.env.NODE_ENV,
    region: process.env.AWS_REGION,
  },
  redact: {
    paths: [
      "req.headers.cookie",
      "req.headers.authorization",
      "password",
      "*.token",
    ],
    censor: "[REDACTED]",
  },
});

业务代码:

logger.info({ userId, orderId }, "order created");

输出(JSON Lines):

{
  "level": "info",
  "time": 1700000000,
  "service": "my-next-app",
  "userId": "u1",
  "orderId": "o1",
  "msg": "order created"
}

Fluentd / Vector 直接收 JSON Lines 进 ES / Loki / Datadog。

关联 trace 与 log

在每条 log 里带上 trace ID 和 span ID:

import { trace } from "@opentelemetry/api";

const span = trace.getActiveSpan();
const traceId = span?.spanContext().traceId;
const spanId = span?.spanContext().spanId;

logger.info({ traceId, spanId, userId }, "something happened");

Datadog / Honeycomb 会把这条日志关联到对应 trace,点 log 一秒跳到 trace 上下文。

八、Metrics:业务关键指标

虽然 Next.js 默认不出 metrics,但你能自己加:

import { metrics } from '@opentelemetry/api'

const meter = metrics.getMeter('my-app')
const ordersCounter = meter.createCounter('orders_total', {
  description: 'Total orders placed',
})
const orderLatency = meter.createHistogram('order_latency_ms', {
  description: 'Order processing latency',
  unit: 'ms',
})

async function placeOrder(...) {
  const t0 = Date.now()
  try {
    const result = await ...
    ordersCounter.add(1, { status: 'success', region: 'us-east' })
    orderLatency.record(Date.now() - t0, { status: 'success' })
    return result
  } catch (err) {
    ordersCounter.add(1, { status: 'error' })
    throw err
  }
}

OTel SDK 会定期推到 Prometheus / Datadog / OTLP collector。

Web Vitals 上报

useReportWebVitals 收集前端性能数据:

"use client";
import { useReportWebVitals } from "next/web-vitals";

export function WebVitalsReporter() {
  useReportWebVitals((metric) => {
    fetch("/api/web-vitals", {
      method: "POST",
      body: JSON.stringify(metric),
      keepalive: true,
    });
  });
  return null;
}

后端 route handler 接收:

export async function POST(req: NextRequest) {
  const metric = await req.json();
  // 上报 metric 到 Prometheus / DD
  return new Response(null, { status: 204 });
}

九、对接 Datadog / Vercel 的样板

Datadog

// instrumentation.ts
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    // 用 dd-trace 一站式
    const { tracer } = await import("dd-trace");
    tracer.init({
      service: "my-next-app",
      env: process.env.DD_ENV,
      version: process.env.GIT_SHA,
      logInjection: true, // 自动给 pino log 注 traceId
      runtimeMetrics: true, // CPU / heap 自动 metrics
    });
  }
}

Vercel

import { registerOTel } from "@vercel/otel";

export function register() {
  registerOTel({ serviceName: "my-next-app" });
}

Vercel 自动把 trace 接到 Observability 面板,零配置可看。

十、Performance 监控的金字塔

        ┌──────────┐
Synthetic │     用户体验真实模拟(Datadog Synthetic)
        ├──────────┤
RUM      │     真实用户监控(前端 web-vitals + 错误)
        ├──────────┤
APM      │     后端 traces + spans
        ├──────────┤
Logs     │     原始事件
        └──────────┘
  • 最上层最贵但最贴近用户
  • 最下层最全但最难查
  • 任何一层缺都会让排障变成"猜"

十一、配套 fixture

fixtures/lecture-34/ 提供:

  • instrumentation.ts:自定义 console-only tracer(无需 OTel 后端)
  • app/slow/page.tsx:用业务 span 包了一个 800ms 慢操作
  • app/api/web-vitals/route.ts:接收前端 web-vitals 数据
  • lib/logger.ts:pino 风格的结构化日志(实现成 console,避免增加依赖)

启动后访问 /slow,终端打 fake trace 树,能看到每个 span 的耗时。

十二、本讲小结

  1. Next.js 内置 OTel tracer,无需配置就能产出 span;只需在 instrumentation.ts init SDK 就能看 trace。
  2. span 命名约定记熟,能让 trace UI 一目了然。
  3. 4 类信号:traces / metrics / logs / events,互补使用。
  4. 关联 traceId 到 log,能在 log 一键跳 trace。
  5. 生产可观测性的金字塔:synthetic / RUM / APM / logs,缺一不可。

阶段五完结

至此阶段五"运行时与部署"全部完成。下个阶段六(第 35-38 讲)进入"性能优化与生产问题排查":

  • 35 讲:Next.js 应用的性能优化清单(Bundle Size、TTFB、CLS、INP)
  • 36 讲:高并发与冷启动的优化
  • 37 讲:调试技巧大全(dev / Node inspector / source map / production debug)
  • 38 讲:5 个真实生产案例复盘