なつねこメモ

主にプログラミング関連のメモ帳 ♪(✿╹ヮ╹)ノ 書いてあるコードは自己責任でご自由にどうぞ。記事本文の無断転載は禁止です。

Next.js + Vercel + Pino で Mackerel に OpenTelemetry Log を送りたい

Mackerel にて、ログ機能がオープンベータになりました。

mackerel.io

今回は、 Next.js + Vercel 、それに加えてロガーとして Pino を使っている状態でスムーズに Mackerel にログを送信する方法を紹介します。

前提:

  • Next.js 16
  • Pino 9 (※最新ではない)
  • インフラストラクチャーは Vercel

依存関係を追加する

まずは必要な依存関係を追加します:

$ pnpm add @opentelemetry/api @opentelemetry/api-logs @opentelemetry/exporter-logs-otlp-proto @opentelemetry/resources @opentelemetry/sdk-logs @opentelemetry/semantic-conventions

今回は Logger に Pino を使うので、 Pino も入れましょう:

$ pnpm add pino

ログ送信部分を作る

次は Pino の出力先に OpenTelemetry Log を追加します。これには multistream 機能が使えます。 こんな感じのコードを追加することで、ひとまず OpenTelemetry Log に対応した状態になります。

import { Writable } from "node:stream";
import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
import { SeverityNumber } from "@opentelemetry/api-logs";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { resourceFromAttributes } from "@opentelemetry/resources";
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
import { ATTR_SERVER_ADDRESS, ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
import { pino, multistream } from "pino";

// pino のログレベル数値 (trace=10 ... fatal=60) を OpenTelemetry の SeverityNumber に変換する
// https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
const PINO_LEVEL_TO_OTEL_SEVERITY: Record<number, SeverityNumber> = {
  10: SeverityNumber.TRACE,
  20: SeverityNumber.DEBUG,
  30: SeverityNumber.INFO,
  40: SeverityNumber.WARN,
  50: SeverityNumber.ERROR,
  60: SeverityNumber.FATAL,
};

const loggerProvider = new LoggerProvider({
  resource: resourceFromAttributes({
    [ATTR_SERVICE_NAME]: "api.natsuneko.com",
  }),
  processors: [
    new BatchLogRecordProcessor({
      exporter: new OTLPLogExporter({
        url: "https://otlp-vaxila.mackerelio.com/v1/logs",
        headers: {
          Accept: "*/*",
          "Mackerel-Api-Key": process.env.MACKEREL_API_KEY!,
        },
      }),
      maxQueueSize: 5000,
      maxExportBatchSize: 5000,
      scheduledDelayMillis: 10000,
    }),
  ],
});

const otelLogger = loggerProvider.getLogger("api.natsuneko.com", "0.1.0");

// pino-opentelemetry-transport は pino.transport() 経由のワーカースレッド実行を前提としており、
// pino 9 系ではその初期化プロトコルに対応していないため送信が握りつぶされる。そのため、
// pino が書き出す ndjson を横流しして OTel の LogRecord に変換するストリームを自前で用意する。
const otelStream = new Writable({
  write(chunk: Buffer, _encoding, callback) {
    try {
      const { time, level, msg, ...attributes } = JSON.parse(chunk.toString());

      // Mackerel のログ画面は body (メッセージ本文) しか表示せず、OTel の属性 (attributes) を
      // 参照できないため、属性がある場合はメッセージ本文に畳み込んでおく
      const body = Object.keys(attributes).length > 0 ? { message: msg, ...attributes } : msg;

      otelLogger.emit({
        timestamp: time,
        body,
        severityNumber: PINO_LEVEL_TO_OTEL_SEVERITY[level] ?? SeverityNumber.UNSPECIFIED,
        attributes,
      });
    } catch {
      // ログの変換に失敗しても本来の pino のログ出力は継続させる
    }

    callback();
  },
});

// multistream() の戻り値は SonicBoom 互換のマーカーを持たないため、pino() の第1引数として渡すと
// オプションオブジェクトとして誤認識され、標準出力のみへ暗黙にフォールバックしてしまう。
// 必ず第2引数(destination)として渡すこと。
const logger = pino({}, multistream([{ stream: process.stdout }, { stream: otelStream }]));

export const getLogger = (scope: string) => logger.child({ scope });

ひとまずは、これでログを送信することが出来ます。 getLogger はこんな感じで使います。

const logger = getLogger("StatusService#getById");
logger.info("Hello, Mackerel!");

この状態でアプリケーションを起動して、ログを書き出すと......

送れていますね。ただ、これだけだとトレースと紐付かないため、リクエスト単位で見たいときとかに困ってしまいます。 なので、次のコードを追加します:

--- a/packages/api.natsuneko.com/src/lib/logger.ts
+++ b/packages/api.natsuneko.com/src/lib/logger.ts
@@ -1,5 +1,5 @@
 import { Writable } from "node:stream";
-import { diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
+import { context as otelContext, diag, DiagConsoleLogger, DiagLogLevel } from "@opentelemetry/api";
 import { SeverityNumber } from "@opentelemetry/api-logs";
 import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
 import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -62,6 +62,11 @@ const otelStream = new Writable({
           body,
           severityNumber: PINO_LEVEL_TO_OTEL_SEVERITY[level] ?? SeverityNumber.UNSPECIFIED,
           attributes,
+          // Next.js はリクエストごとに root span ([http.method] [next.route]) を自動生成するため、
+          // アクティブな span があればここで trace_id/span_id が LogRecord に付与され、Mackerel 上で
+          // トレースとログが紐付く。
+          context: otelContext.active(),
         });
       } catch {
         // ログの変換に失敗しても本来の pino のログ出力は継続させる

この状態で送信すると、

こんな感じで、ログ画面から、もしくはトレース画面からログを開けるようになります。 ということで、ログを送信してみた記事でした。
ちなみに Pino 10 (最新) の場合はおそらく pino-opentelemetry-transport が使えると思うのですが、 Vercel で動くかどうかは試していません。