ファストフードのテイクアウトのイラスト

Hono のログを Pino と OpenTelemetry で計装する

OpenTelemetry を使用すると、アプリケーションのログをベンダーに依存しない形式で収集し、トレースと関連付けることができます。この記事では Node.js で動作する Hono バックエンドの構造化ログを Pino で出力し、OpenTelemetry Collector を通じて Loki と Tempo に送信する方法を紹介します。

OpenTelemetry は、トレース・メトリクス・ログといったテレメトリーデータを作成、収集、エクスポートするためのオブザーバビリティフレームワークです。ベンダーに依存しない API、SDK、プロトコルを提供しており、アプリケーションの実装と監視バックエンドを分離できることが特徴です。

OpenTelemetry は分散トレーシングの文脈でよく知られていますが、ログを表現するためのデータモデルも定義されています。アプリケーションのログを OpenTelemetry の形式で送信すると、ログに現在のスパンの Trace ID と Span ID を付与できます。これにより、エラーログから関連するトレースを開き、どの処理で問題が発生したのか調査できます。

この記事では Node.js で動作する Hono バックエンドで Pino を使って構造化ログを出力します。ログとトレースは OTLP(OpenTelemetry Protocol)を使って OpenTelemetry Collector に送信し、ログを Grafana Loki、トレースを Grafana Tempo で確認します。

Hono + Pino
  ├── Logs   ── OTLP/HTTP ──┐
  └── Traces ── OTLP/HTTP ──┘

                 OpenTelemetry Collector
                   ├── Loki(ログ)
                   └── Tempo(トレース)

                         Grafana

Note

2026 年 8 月現在、OpenTelemetry JavaScript では Traces と Metrics が Stable である一方、Logs は Development とされています。Logs に関する API は今後変更される可能性があるため、実際のプロダクトで利用する場合は最新の対応状況を確認してください。

既存のライブラリを使用して OpenTelemetry へログを送信する

OpenTelemetry には、既存のライブラリやフレームワークからテレメトリを自動生成する Instrumentation Library があるため、既存のコードを大きく書き換えずに OpenTelemetry へログを送信できます。Pino では @opentelemetry/instrumentation-pino パッケージが提供されており、以下の 2 つの機能を追加します。

  • Log sending:Pino のログを OpenTelemetry Logs SDK に渡す
  • Log correlation:アクティブなスパンの trace_idspan_idtrace_flags を Pino のログへ追加する

Pino はこれまでどおり JSON を標準出力へ書き出します。それに加えて Pino Instrumentation が同じログイベントを OpenTelemetry Logs SDK に渡し、エクスポーターが OpenTelemetry Collector へ送信します。

アプリケーションのコードでは、Trace ID や Span ID を logger.info() へ明示的に渡していません。HTTP Instrumentation がリクエストのスパンを作成し、処理中の OpenTelemetry Context へアクティブなスパンとして設定します。Pino Instrumentationlogger.info() などの呼び出しを計装し、その時点のアクティブなスパンから Trace ID と Span ID を取得します。

tracer.startActiveSpan("userRepository.findById", async (span) => {
  // この時点では span がアクティブ
  logger.info("finding user");
  // Pino Instrumentation が span の Trace ID・Span ID を自動付与
});

Hono プロジェクトを作成する

ここからは実際にサンプルアプリケーションを実装していきます。create-hono コマンドで Node.js 向けのプロジェクトを作成します。

npm create hono@latest hono-otel-logs -- --template nodejs --install --pm npm
cd hono-otel-logs

続いて Pino と OpenTelemetry のパッケージをインストールします。すべての Node.js 向け計装を含む @opentelemetry/auto-instrumentations-node は使用せず、HTTP と Pino の計装のみを個別にインストールします。

npm install \
  [email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected] \
  @opentelemetry/[email protected]

OpenTelemetry SDK を設定する

src/instrumentation.ts ファイルを作成し、OpenTelemetry SDK を初期化します。

src/instrumentation.ts
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
import { PinoInstrumentation } from "@opentelemetry/instrumentation-pino";
import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
import { NodeSDK } from "@opentelemetry/sdk-node";
 
// トレースとログを OTLP/HTTP で Collector へ送信する
const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter(),
  logRecordProcessors: [
    new BatchLogRecordProcessor({
      exporter: new OTLPLogExporter(),
    }),
  ],
  // 必要なライブラリだけを個別に計装する
  instrumentations: [
    new HttpInstrumentation(),
    new PinoInstrumentation(),
  ],
});
 
sdk.start();
 
export const shutdownTelemetry = () => sdk.shutdown();

OTLPTraceExporterOTLPLogExporter は、それぞれトレースとログを OTLP/HTTP で送信するエクスポーターです。送信先をコンストラクターへ直接指定していないため、後ほど設定する OTEL_EXPORTER_OTLP_ENDPOINT 環境変数が使用されます。シグナルごとのパスは自動で補われ、トレースは /v1/traces、ログは /v1/logs に送信されます。

BatchLogRecordProcessor はログをバッファリングし、一定数(既定では 512 件)に達したときか、一定時間(既定では 1 秒)が経過したときにまとめてエクスポートします。アプリケーションの処理をログの送信完了まで待たせないため、本番環境では通常 SimpleLogRecordProcessor ではなくバッチ処理を使用します。

instrumentations には HttpInstrumentationPinoInstrumentation のみを登録しています。HttpInstrumentation は Hono の背後で動作する Node.js の HTTP サーバーを計装してリクエストごとのスパンを作成します。PinoInstrumentation は Pino のログを送信し、トレースコンテキストを注入します。

PinoInstrumentationdisableLogSendingdisableLogCorrelation オプションで、Log sending と Log correlation を個別に無効化できます。既存のログ収集基盤をそのまま使い、トレースコンテキストの付与だけを追加するといった使い分けもできます。

Pino で構造化ログを出力する

src/logger.ts ファイルで Pino のロガーを作成します。

src/logger.ts
import pino from "pino";
 
export const logger = pino({
  // 環境変数がなければ info 以上のログを出力する
  level: process.env.LOG_LEVEL ?? "info",
});

Pino ではログメッセージだけでなく、オブジェクトを第 1 引数に渡すことで任意の属性を JSON として出力できます。ここで渡した属性は OpenTelemetry の LogRecord の属性としても送信されます。たとえばユーザー ID を属性として渡す場合は以下のようにします。

logger.info({ "app.user.id": "1" }, "finding user");

標準化されている属性には、可能な限り OpenTelemetry Semantic Conventions の名前を使用します。共通の名前と値を使うことで、言語やライブラリごとに異なる名前がつけられるのを避けられます。複数のサービスから集めたテレメトリーも同じ意味で検索・集計でき、クエリやダッシュボードを再利用しやすくなります。

たとえば HTTP メソッドには http.request.method、マッチしたルートには http.route、リクエストパスには url.path、レスポンスのステータスコードには http.response.status_code、サービス名には service.name を使用します。一方、ユーザー ID や処理時間など、アプリケーション固有の属性には app.user.idapp.duration_ms のように app.* という名前空間を使用します。

アプリケーションログとカスタムスパンを実装する

データベースからユーザーを取得する処理を模した findUserById() 関数を src/userRepository.ts に実装します。データベースの取得前後に finding useruser founduser not found などのログを出力し、例外が発生した場合には failed to find user ログを出力します。それぞれのログにはユーザー ID を属性として渡します。

src/userRepository.ts
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { setTimeout } from "node:timers/promises";
import { logger } from "./logger.js";
 
type User = {
  id: string;
  name: string;
};
 
const users = new Map<string, User>([
  ["1", { id: "1", name: "Alice" }],
]);
const tracer = trace.getTracer("user-repository");
 
export const findUserById = (id: string) =>
  // 現在アクティブな HTTP スパンが親として使用される
  tracer.startActiveSpan("userRepository.findById", async (span) => {
    span.setAttribute("app.user.id", id);
 
    try {
      logger.info({ "app.user.id": id }, "finding user");
      // データベースアクセスにかかる時間を再現する
      await setTimeout(50);
 
      if (id === "error") {
        throw new Error("database is unavailable");
      }
 
      const user = users.get(id);
      if (!user) {
        logger.warn({ "app.user.id": id }, "user not found");
        return undefined;
      }
 
      logger.info({ "app.user.id": id }, "user found");
      return user;
    } catch (error) {
      const exception =
        error instanceof Error ? error : new Error(String(error));
 
      // 例外の内容とエラーステータスをスパンに記録する
      span.recordException(exception);
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: exception.message,
      });
      logger.error(
        { err: exception, "app.user.id": id },
        "failed to find user",
      );
      throw exception;
    } finally {
      span.end();
    }
  });

trace.getTracer() で Tracer を取得し、startActiveSpan()userRepository.findById という子スパンを作成しています。親 Span は通常、現在の OpenTelemetry Context から決まります。ここでは HTTP Instrumentation が作成した HTTP リクエストのスパンが親として使用されます。親子スパンは子スパンの parent_span_id 属性で関連付けられます。

GET /users/:id 親:HTTPスパン
└── userRepository.findById 子:アプリケーションスパン

このコールバック内で出力したログには、子スパンの Trace ID と Span ID が自動で追加されます。

例外が発生した場合には、recordException() でスパンに例外イベントを記録し、setStatus() でスパンのステータスをエラーに設定します。Pino に err プロパティとして Error オブジェクトを渡すと、エラーの種類、メッセージ、スタックトレースが構造化されて出力されます。

Hono のリクエストログを実装する

続いて Hono のルーティングを実装し、HTTP リクエストのログを出力します。src/index.ts を以下のように書き換え、以下の GET エンドポイントを実装します。

  • /users/:id:ユーザーを取得する。存在しない ID の場合は 404 を返し warn ログを出力する。
  • /users/errorerror という ID を指定した場合は例外を発生させる。500 を返し error ログを出力する。
src/index.ts
import { serve } from "@hono/node-server";
import { trace } from "@opentelemetry/api";
import { Hono } from "hono";
import { routePath } from "hono/route";
import { shutdownTelemetry } from "./instrumentation.js";
import { logger } from "./logger.js";
import { findUserById } from "./userRepository.js";
 
const app = new Hono();
 
// "*" はすべてのリクエストにマッチするワイルドカード
// レスポンスの完了後にリクエストログを 1 件出力する
app.use("*", async (c, next) => {
  const startedAt = performance.now();
 
  await next();
 
  // 最後にマッチしたエンドポイントのルートを取得する
  const route = routePath(c, -1);
  const span = trace.getActiveSpan();
  span?.setAttribute("http.route", route);
  span?.updateName(`${c.req.method} ${route}`);
 
  const attributes = {
    "http.request.method": c.req.method,
    "http.route": route,
    "url.path": c.req.path,
    "http.response.status_code": c.res.status,
    "app.duration_ms": performance.now() - startedAt,
  };
 
  // HTTP ステータスコードに応じてログレベルを切り替える
  if (c.res.status >= 500) {
    logger.error(attributes, "request completed");
  } else if (c.res.status >= 400) {
    logger.warn(attributes, "request completed");
  } else {
    logger.info(attributes, "request completed");
  }
});
 
app.get("/users/:id", async (c) => {
  const user = await findUserById(c.req.param("id"));
 
  if (!user) {
    return c.json({ message: "User not found" }, 404);
  }
 
  return c.json(user);
});
 
app.onError((_error, c) => {
  return c.json({ message: "Internal Server Error" }, 500);
});
 
const server = serve(
  {
    fetch: app.fetch,
    port: 3001,
  },
  (info) => {
    logger.info({ port: info.port }, "server started");
  },
);
 
// graceful shutdown
const shutdown = (signal: NodeJS.Signals) => {
  logger.info({ signal }, "shutting down");
  server.close(async () => {
    // バッファに残っているログとトレースを送信して終了する
    await shutdownTelemetry();
    process.exit(0);
  });
};
 
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);

リクエストログはレスポンスが完了した時点で出力します。HTTP メソッド, ルート. URL パス, ステータスコード, 処理時間を個別の属性として渡しているため、Loki 上で値を使ってログを絞り込めます。例えば app_duration_ms > 100 のように処理時間が 100ms を超えるリクエストのログだけを検索するといったことも可能です(Loki では属性名のピリオドがアンダースコアへ変換されます)。ログレベルは 2xx・3xx を info、4xx を warn、5xx を error としています。

なお Grafana が 3000 番ポートを使用するため、Hono のポートはテンプレートの既定値から 3001 へ変更しています。

HTTP Instrumentation は Hono のルーティングを認識しないため、自動計装だけではスパン名が GET のようになります。これでは異なる GET エンドポイントをスパン名で区別できません。HTTP の Semantic Conventions では、ルートのようなカーディナリティの低い対象が利用できる場合、HTTP サーバースパンの名前を {method} {target} とすることが推奨されています。

そこでミドルウェアの処理が完了した後に routePath(c, -1) を使って最後にマッチしたルートを取得し、http.route 属性を設定します。さらに updateName() でスパン名を GET /users/:id へ更新します。これにより /users/1/users/unknown は同じルートの処理として集計でき、エンドポイントごとの検索や比較もしやすくなります。実際のパスをスパン名に使うと、ユーザー ID ごとに異なるスパン名が作られてしまいます。

終了時には HTTP サーバーを閉じてから sdk.shutdown() を呼び出します。BatchLogRecordProcessor やトレースエクスポーターのバッファに残っているデータを送信してからプロセスを終了するためです。

OpenTelemetry SDK をアプリケーションより先に初期化する

Pino Instrumentation は、Pino モジュールが読み込まれるときに処理を差し込み、ログ送信とトレースコンテキストの注入を追加します。そのため、アプリケーションが Pino を読み込むより先に OpenTelemetry SDK を初期化し、Instrumentation を登録する必要があります。OpenTelemetry の Node.js 向けガイドでも、Instrumentation の設定をアプリケーションコードより先に実行するよう説明されています。

このサンプルは ES Modules を使用しているため、モジュールの読み込みへ処理を差し込むESM 用のローダーフックも必要です。

package.json のスクリプトを以下のように変更します。

package.json
{
  "scripts": {
    "dev": "node --experimental-loader=@opentelemetry/instrumentation/hook.mjs --import tsx --import ./src/instrumentation.ts --watch src/index.ts",
    "build": "tsc",
    "start": "node --experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./dist/instrumentation.js dist/index.js"
  }
}

--import ./src/instrumentation.ts により、アプリケーション本体より先に OpenTelemetry SDK を初期化します。--experimental-loader=@opentelemetry/instrumentation/hook.mjs は ES Modules として読み込まれる HTTP や Pino を計装するためのフックです。

:::warning Node.js 20.6 以降で --experimental-loader を使用すると、以下のように「将来削除される可能性がある」という警告が表示されます。動作に影響はありませんが、代わりに node:moduleregister() を使う方法も案内されています。

(node:12345) ExperimentalWarning: `--experimental-loader` may be removed in the future; instead use `register()`:
--import 'data:text/javascript,import { register } from "node:module"; import { pathToFileURL } from "node:url"; register("%40opentelemetry/instrumentation/hook.mjs", pathToFileURL("./"));'

:::

OpenTelemetry Collector と監視バックエンドを起動する

ログとトレースを受信する環境を用意します。ここでは開発・デモ・テスト向けに OpenTelemetry Collector、Loki、Tempo、Grafana などを 1 つのコンテナにまとめた grafana/otel-lgtm を使用します。

docker run --rm \
  --name otel-lgtm \
  -p 3000:3000 \
  -p 4317:4317 \
  -p 4318:4318 \
  grafana/otel-lgtm:0.29.2

Note

grafana/otel-lgtm は開発・デモ・テスト向けのイメージです。本番環境向けの構成ではない点に注意してください。

grafana/otel-lgtm に組み込まれている OpenTelemetry Collector の設定は以下のようになっています。OTLP Receiver が 4317(gRPC)と 4318(HTTP)で待ち受け、トレースを Tempo、ログを Loki へ送信します。

otelcol-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
 
processors:
  # データをまとめてバックエンドへ送信する
  batch:
 
exporters:
  # トレースを Tempo へ送信する
  otlphttp/traces:
    endpoint: http://127.0.0.1:4418
    tls:
      insecure: true
 
  # ログを Loki へ送信する
  otlphttp/logs:
    endpoint: http://127.0.0.1:3100/otlp
    tls:
      insecure: true
 
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/traces]
 
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/logs]

Hono アプリケーションを起動してログを送信する

ここまでの準備が整ったら、実際に Hono アプリケーションにリクエストを送信してログとトレースを送信してみましょう。

OpenTelemetry の計装のために必要な環境変数を設定して Hono アプリケーションを起動します。

OTEL_SERVICE_NAME=hono-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
npm run dev

OTEL_SERVICE_NAME は、ログとトレースの Resource に共通の service.name を設定します。OTEL_EXPORTER_OTLP_ENDPOINT には OpenTelemetry Collector の OTLP/HTTP エンドポイントを指定します。

別のターミナルから、正常、404、例外の 3 種類のリクエストをいくつか送信してみましょう。

curl -i http://127.0.0.1:3001/users/1
curl -i http://127.0.0.1:3001/users/unknown
curl -i http://127.0.0.1:3001/users/error

標準出力では、以下のような Pino の JSON ログを確認できます。/users/error へのリクエストで出力される 2 件のログを、見やすいように整形して並べています。

{
  "level": 50,
  "time": 1785644315215,
  "pid": 86258,
  "hostname": "localhost",
  "trace_id": "631f628fcedda17b3ddfdb209c5dbade",
  "span_id": "17e6f76c36eaf37a",
  "trace_flags": "01",
  "err": {
    "type": "Error",
    "message": "database is unavailable",
    "stack": "Error: database is unavailable\n    at <anonymous> (/app/src/userRepository.ts:19:15)\n    ..."
  },
  "app.user.id": "error",
  "msg": "failed to find user"
}
{
  "level": 50,
  "time": 1785644315215,
  "pid": 86258,
  "hostname": "localhost",
  "trace_id": "631f628fcedda17b3ddfdb209c5dbade",
  "span_id": "34e27910106cc378",
  "trace_flags": "01",
  "http.request.method": "GET",
  "http.route": "/users/:id",
  "url.path": "/users/error",
  "http.response.status_code": 500,
  "app.duration_ms": 53.476,
  "msg": "request completed"
}

err プロパティに渡した Error オブジェクトが、typemessagestack に分解されて出力されている点にも注目してください。

failed to find user ログの Span ID(17e6f76c...)は userRepository.findById の子スパンを指します。一方、同じリクエストの request completed ログには親となる HTTP スパンの Span ID(34e27910...)が設定されます。両者の Trace ID は共通(631f628f...)なので、1 つのリクエストで発生したログをまとめて検索できます。

Grafana でログを確認する

送信されたログは Grafana から確認できます。Grafana ではログを UI 上で検索・絞り込み・集計できるほか、Loki の LogQL を使ったクエリでも検索できます。

ブラウザで http://127.0.0.1:3000 にアクセスします。初期ユーザー名とパスワードはともに admin です。左側のメニューから「Drilldown」→「Logs」を開き、service_namehono-api のログを選択します。

Pino に渡した http.request.methodapp.user.id といった属性は、Loki では http_request_methodapp_user_id のようにピリオドがアンダースコアへ変換されて表示されます。

LogQL でエラーログだけを表示するには、次のように detected_level で絞り込みます。

{service_name="hono-api"} | detected_level = "error"

ログからトレースを開く

OpenTelemetry でログを送信する利点の 1 つは、ログをトレースと関連付けられることです。Pino Instrumentation はログの出力時にアクティブなスパンの Trace ID と Span ID を付与します。ログとトレースを同じ Trace ID で結び付けることで、エラーメッセージを確認するだけでなく、そのエラーが発生するまでにどの処理を通り、どのスパンで失敗したのかを続けて調査できます。

failed to find user ログを展開すると、trace_id の横に Tempo のトレースを開くリンクが表示されます。

トレースを開くと、GET /users/:id という HTTP スパンの子として userRepository.findById スパンが表示されます。子スパンはエラーステータスで、database is unavailable という例外イベントも記録されています。

反対に Tempo のトレースから「Logs for this span」を選択すると、同じ Trace ID を持つ Loki のログを検索できます。トレースでレイテンシーや失敗した処理を特定した後に、その処理中に出力されたアプリケーションログを確認するという調査も可能です。

Note

ログとトレースは別々のシグナルとして送信されるため、ログに Trace ID が記録されていても、対応するトレースが必ず保存されているとは限りません。本番環境ではコストを抑えるためにトレースをサンプリングすることが一般的で、サンプリングされなかったリクエストのトレースは Tempo に保存されません。この場合、ログからトレースを開こうとしても該当するトレースが見つからないことがあります。

まとめ

  • @opentelemetry/instrumentation-pino は Pino のログを OpenTelemetry Logs SDK へ渡し、アクティブなスパンの Trace ID と Span ID をログに追加する
  • HttpInstrumentation で Hono の HTTP リクエストを自動計装し、trace.getTracer() でアプリケーション固有の子スパンを作成できる
  • ログとトレースを OTLP/HTTP で OpenTelemetry Collector へ送信し、Loki と Tempo に保存できる
  • 共通の Trace ID により、Grafana でログからトレース、トレースから関連ログの両方向に移動できる

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

Pino Instrumentation が行う処理として、記事の説明と一致するものはどれですか?

  • Pino のログをメトリクスへ変換し、Prometheus に送信する

    もう一度考えてみましょう

    記事ではメトリクスへの変換を扱っていません。Pino のログは OpenTelemetry LogRecord として Logs SDK に渡されます。

  • Pino のログを Logs SDK に渡し、アクティブなスパンの相関情報を追加する

    正解!

    記事で説明している通り、Log sending と Log correlation の 2 つが Pino Instrumentation の主な役割です。

  • Pino の標準出力を無効にし、ログを Tempo だけに保存する

    もう一度考えてみましょう

    Pino の JSON ログは標準出力に残ります。ログの監視バックエンドには Loki を使用しています。

  • Hono のルート定義を解析し、HTTP スパンを自動で作成する

    もう一度考えてみましょう

    HTTP スパンを作成する役割は HttpInstrumentation です。Pino Instrumentation はログを対象とします。