🐾

Bun + Hono 環境で Datadog APM を実現する

に公開

こんにちは。技術部 テクニカルイネイブルメントチームの ut61z です。
この記事は MOSH Advent Calendar 2025 の10日目の記事です。

MOSHでは、バックエンドのアプリケーションサーバの技術スタックに Bun + Hono を採用しています。

本番環境および開発環境にてAPMを有効活用し、可観測性を高めることは、障害の早期検知やパフォーマンス把握のためにとても重要です。

しかし、Bun + Hono (TypeScript) の構成で Datadog の Node.js の自動計装を試したところ、トレース情報が送れず正常に動作しませんでした。
調査の結果、ESM 環境では自動計装がうまく機能しないことがわかり、手動計装でAPMを実現することにしました。

本記事では、Bun と Datadog のAPMライブラリである dd-trace の現状を整理し、なぜ ESM 環境で自動計装が動かないのかを技術的に解説した上で、どのように手動計装をしているかをご紹介します。

Bun と dd-trace の現状

Bun v1.1.6 で dd-trace サポートが追加された

2024年4月にリリースされた Bun v1.1.6 で、dd-trace のサポートが追加されました。

The private Node.js API Module._resolveLookupPaths is now implemented in Bun. This API is used by require-in-the-middle to intercept require calls to instrument module loading.

DataDog's dd-trace module now works in Bun.

参考: Bun v1.1.6 | Bun Blog

具体的には、以下の Node.js 内部 API が実装されました。

  • Module._resolveLookupPaths: モジュール検索パスの取得
  • Module._findPath: モジュールのファイルパスを特定
  • node:dgram: UDP ソケット(dd-trace が内部で使用)

これにより、dd-trace が依存している require-in-the-middle ライブラリが Bun で動作するようになりました。

ただし ESM 環境では自動計装が動かない

dd-trace のサポートが入ったとはいえ、すべてのケースで自動計装が動作するわけではありません。

Bun + Hono の構成をとる場合、Bun は ESM を推奨、前提としているため、意識しない限り ESM を採用するかたちになります。

そして、dd-trace の自動計装は CommonJS の require() をフックする仕組みに依存しています。ESM の import 文はこのフックの対象外となるため、Hono を使用している環境では自動計装が機能しません。

実際、Bun + ESM 環境で dd-trace を使用しても、Datadog APM にトレースが送信されないという Issue が報告されています(2025/12 時点ではまだResolveされていません)。

Using dd-trace with or without automatic instrumentation fails to produce any traces in the APM.

参考: Datadog tracing fails in ESM application. · Issue #13027 · oven-sh/bun

つまり、Bun + Hono のような ESM ベースの構成では、手動計装が現実的な選択肢となります。

なぜ ESM 環境で自動計装が動かないのか

ここからは、dd-trace の自動計装の仕組みを解説し、なぜ ESM 環境で動作しないのかを明らかにします。

dd-trace の自動計装の仕組み

dd-trace は、require-in-the-middle(ritm.js)というライブラリを使用して自動計装を実現しています。

参考: https://github.com/DataDog/dd-trace-js/blob/master/packages/dd-trace/src/ritm.js

このライブラリは、Node.js の Module.prototype.require をモンキーパッチすることで、require() が呼び出されるたびにフック処理を挿入します。

以下のコードは概念を理解するためのサンプルコードです。

// 元のrequireを保存
const origRequire = Module.prototype.require

// requireを上書き
Module.prototype.require = function patchedRequire(request) {
  // 1. 元のrequireでモジュールを読み込む
  let exports = origRequire.apply(this, arguments)
  
  // 2. 登録されたフックがあれば実行
  const hooks = moduleHooks[moduleName]
  if (hooks) {
    for (const hook of hooks) {
      exports = hook(exports, moduleName, basedir)
    }
  }
  
  return exports
}

フックが実行されると、対象モジュールのメソッドが tracer でラップされます。

// Express の場合の例
new Hook(['express'], (exports, name, basedir) => {
  const originalUse = Router.prototype.use

  Router.prototype.use = function wrappedUse(...args) {
    const span = tracer.startSpan('express.middleware')
    try {
      return originalUse.apply(this, args)
    } finally {
      span.finish()
    }
  }

  return exports
})

これにより、アプリケーションコードを変更することなく、自動的にトレースが取得できる仕組みになっています。

CommonJS と ESM の根本的な違い

問題は、この仕組みが CommonJS の require() に依存しているという点です。

CommonJS

  • require() による同期的・動的なモジュール読み込み
  • ランタイムでモジュールが解決される
  • Module.prototype.require を上書きすることでフック可能

ESM(ECMAScript Modules)

  • import による静的なモジュール読み込み
  • パース時にモジュールが解決される
  • ランタイムでのフックが困難

ESM の import 文は、コードが実行される前のパース段階でモジュールの依存関係が解決されます。そのため、require() のようにランタイムでフックを挿入することができません。

Node.js での ESM 対応

Node.js では、ESM 環境で自動計装を行うには、--loader dd-trace/loader-hook.mjs(Node < 20.6) あるいは --import dd-trace/register.js(Node ≥ 20.6) といった "Loader Hook / import-flag" を使う方式が公式に案内されています。

https://docs.datadoghq.com/ja/tracing/trace_collection/automatic_instrumentation/dd_libraries/nodejs/

しかし、この仕組みは Node.js 固有の Loader Hook API に依存しており、Bun ではその hook がサポートされていません。

手動計装する

ESM 環境で自動計装が使えない以上、手動計装が最も確実なアプローチとなります。

MOSHでは、Hono の Middleware 機構を活用して、すべてのリクエストにトレーシングを適用しています。

Hono Middleware によるリクエストトレーシング

import { Context, Next } from "hono";
import { matchedRoutes } from "hono/utils/handler";
import tracer from "dd-trace";

export const tracingMiddleware = async (c: Context, next: Next) => {
  const span = tracer.startSpan("hono.request", {
    tags: {
      "http.method": c.req.method,
      "http.url": c.req.url,
      "http.path": c.req.path,
      component: "hono",
    },
  });

  return tracer.scope().activate(span, async () => {
    try {
      await next();

      // next()の後にルートパターンを取得(ルートマッチング後)
      const routes = matchedRoutes(c);
      const routePattern =
        routes.length > 0 ? routes[routes.length - 1].path : c.req.path;

      span.setTag("http.route", routePattern);
      span.setTag("http.status_code", c.res.status);
      // resource.nameを設定(Datadogでフィルタリングできるようにする)
      span.setTag("resource.name", `${c.req.method} ${routePattern}`);
    } catch (error) {
      span.setTag("error", true);
      throw error;
    } finally {
      span.finish();
    }
  });
};

これを全ルートに適用します。

import { Hono } from "hono";
import { tracingMiddleware } from "./middlewares/tracing";

const app = new Hono();

app.use("*", tracingMiddleware);

// 以下、通常のルート定義
app.get("/users", async (c) => {
  // ...
});

参考: Hono Middleware ドキュメント

Prisma Client Extensions によるデータベーストレーシング

データベースクエリもトレーシングしたいケースは多いでしょう。

MOSHでは Prisma を使用しており、Client Extensions($extends)を利用してクエリのトレーシングを実現しています。

import { PrismaClient } from '@prisma/client';
import tracer from 'dd-trace';

const prisma = new PrismaClient().$extends({
  query: {
    $allModels: {
      async $allOperations({ model, operation, args, query }) {
        const span = tracer.startSpan(`prisma.${model}.${operation}`, {
          childOf: tracer.scope().active() || undefined,
          tags: {
            'db.system': 'postgresql',
            'db.operation': operation,
            'prisma.model': model,
          },
        });

        try {
          const result = await query(args);
          return result;
        } catch (e) {
          span.setTag('error', true);
          span.setTag('error.message', String(e));
          throw e;
        } finally {
          span.finish();
        }
      },
    },
  },
});

export { prisma };

これにより、Prisma を使うだけで自動的にスパンが作成されます。

const users = await prisma.user.findMany();
// → span: "prisma.User.findMany"

const post = await prisma.post.create({
  data: { title: 'Hello' }
});
// → span: "prisma.Post.create"

Hono のトレーシングミドルウェアと組み合わせることで、HTTP リクエストを親スパン、データベースクエリを子スパンとして紐付けられます。

hono.request (親span)
  └── prisma.User.findMany (子span)
  └── prisma.Post.create (子span)

参考: Datadog tracing with Prisma ORM | Prisma Documentation

OpenTelemetry も同様の制約がある

dd-trace だけでなく、OpenTelemetry の自動計装も ESM 環境では同様の制約があります。

OpenTelemetry の Node.js 向け自動計装(@opentelemetry/auto-instrumentations-node)も、内部で require-in-the-middleimport-in-the-middle を使用しています。

参考: https://github.com/open-telemetry/opentelemetry-js/blob/main/doc/esm-support.md

Bun コミュニティでも、OpenTelemetry のネイティブサポートが要望されており、本番環境への採用における重要な要素の一つとして議論されていることが伺えます(2025/12 時点ではまだResolveされていません)。

"native OpenTelemetry (traces, metrics, W3C context/OTLP) is a critical blocker for production adoption and is forcing teams to avoid Bun or build fragile workarounds."

参考: Is there (or will be) a official opentelemetry provider library for bun? · Discussion #7185 · oven-sh/bun

まとめ

Bun + Hono 環境で Datadog APM を実現するにあたり、以下のことがわかりました。

  • Bun v1.1.6 で dd-trace の基本的なサポートが追加された
    • Module._resolveLookupPaths などの内部 API が実装された
  • ESM 環境では自動計装がうまく動作しない
    • Bun や Hono は ESM を前提としている
    • dd-trace の自動計装は CommonJS の require() フックに依存
  • 手動計装はフレームワークやライブラリの力を借りればそこまで大きな手間なく導入できる
    • Hono Middleware で Request Tracing
    • Prisma Client Extensions で Database Tracing

手動計装は一見運用コストが高いように感じますが、ランタイムの内部実装に依存せずに、必要なスパンだけを取得できるとも言え、スパンを最適化できるため気に入っています。
また、自動計装であれ手動計装であれ、計装とはイベントをラップしてスパンをセットすることだという理解が進みました。

Bun はまだ発展途上のランタイムですが、手動計装を活用することでAPMの実現は可能です。
ESM 環境でのAPMに課題を感じている方の参考になればうれしいです。

参考リンク

MOSH

Discussion