Next.jsのアプリケーションにおいて、App Router と Pages Router の両方で共通の prom-client メトリクス(例: Counter)を共有しようとすると、いくつかの課題に直面します。特に、global オブジェクトにメトリクスレジストリを登録しようとした際に問題が発生します。
発生した問題
T3 StackのようなNext.jsプロジェクトで、ログのメトリクス化のために prom-client の Counter を Logger クラスで共有することを検討しました。具体的には、prom-client のデフォルトレジストリを global オブジェクトに登録し、アプリケーション全体で単一のメトリクスインスタンスを使用しようとしました。
しかし、このアプローチでは、Pages Router のAPIルートから global のレジストリに Counter を登録しようとすると、ビルドエラーが発生しました。
import { Counter } from 'prom-client';
class Logger {
private static instance: Logger;
private errorCounter: Counter<string>;
private warnCounter: Counter<string>;
private constructor() {
// ここでCounterを初期化し、デフォルトレジストリに登録しようとすると問題が発生
this.errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
});
this.warnCounter = new Counter({
name: 'warnings_total',
help: 'Total number of warnings',
});
// prom-clientのデフォルトレジストリはグローバルに管理されるが、
// Next.jsの環境ではApp RouterとPages Routerで異なるコンテキストを持つため、
// グローバルオブジェクトの共有が期待通りにいかない場合がある。
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
// ... logging methods ...
}
回避策と残る課題
この問題を回避するために、prom-client の register インスタンスを Logger クラスに持たせる方法を試しました。
import { Counter, register } from 'prom-client';
class Logger {
private static instance: Logger;
private errorCounter: Counter<string>;
private warnCounter: Counter<string>;
private registerInstance: typeof register; // registerインスタンスを保持
private constructor() {
this.registerInstance = register; // デフォルトレジストリのインスタンスを取得
this.errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [this.registerInstance], // このレジストリに登録
});
this.warnCounter = new Counter({
name: 'warnings_total',
help: 'Total number of warnings',
registers: [this.registerInstance], // このレジストリに登録
});
}
public static getInstance(): Logger {
if (!Logger.instance) {
Logger.instance = new Logger();
}
return Logger.instance;
}
// メトリクスを登録するメソッド (必要であれば)
public registerMetric(metric: Counter<string>) {
this.registerInstance.registerMetric(metric);
}
// ... logging methods ...
}
export default Logger;
この方法ではビルドエラーは解消されましたが、根本的な問題は解決されませんでした。App Router と Pages Router のAPIルートは、Next.jsのビルドおよび実行環境において、それぞれ異なるJavaScriptコンテキストで動作するようです。そのため、たとえ register インスタンスを共有しようとしても、実際には別々の prom-client レジストリインスタンスが生成されてしまい、メトリクスを真に共有することができませんでした。
この問題は、Next.jsのGitHub Discussionsでも言及されており、2023年11月の時点では明確な解決策は提示されていませんでした。
以下では、この現象が起きる正確なメカニズムと、実際に手元の環境で再現・検証した結果を整理する。
根本原因:なぜモジュールが分離されるのか
prom-client の register(デフォルトレジストリ)は、prom-client パッケージのモジュールスコープに保持されるシングルトン状態である。Node.jsのCommonJS/ESMモジュールシステムは、同一の解決済みモジュールパスに対して1度だけ評価を行い、以降の require/import はキャッシュされたモジュールオブジェクトを返す。この「モジュールごとに1インスタンス」という前提があるからこそ、new Counter(...) を複数箇所から呼んでも通常は同じ register に集約される。
ところが、Next.jsのビルドシステム(webpackおよびTurbopack)は、ルートハンドラごとに独立したモジュールグラフ(バンドル) を生成する。App Routerの route.ts と Pages Routerの pages/api/*.ts は、たとえ同じ lib/metrics.ts を import していても、それぞれ別のチャンク・別の実行コンテキストとしてコンパイルされる。その結果、prom-client モジュール自体もバンドルごとに個別に評価され、バンドルの数だけ Registry インスタンスが生成される。これが2023年時点で観測された「メトリクスが共有されない」問題の実体であり、global オブジェクトへ手動で登録し直しても、そもそも global を参照している prom-client モジュールのインスタンスがバンドルごとに異なるため解決しない。
さらに、この構造は開発モードでもう一つの症状を引き起こす。Fast Refresh(webpack HMR / Turbopackの差分再コンパイル)は、変更されたファイルとその依存元だけを再評価し、変更されていない依存先(node_modules/prom-client など)はキャッシュされたモジュールインスタンスをそのまま使い続ける。つまり、あるバンドルの中で:
- 初回リクエスト時に
lib/metrics.tsが評価され、new Counter({ name: 'errors_total' })がそのバンドル内のregister(1回だけ生成されたインスタンス)に登録される。 - 開発者が
lib/metrics.tsを編集すると、Fast Refreshは そのファイルだけ を再評価する。prom-clientモジュール自体は変更されていないので再評価されず、registerは生きたまま(=errors_totalを登録済みの状態のまま)残る。 - 再評価された
lib/metrics.tsが再びnew Counter({ name: 'errors_total' })を実行し、同じ名前を同じregisterに登録しようとしてError: A metric with the name errors_total has already been registered.が投げられる。
つまり「多重登録エラー」は、モジュール分離そのものではなく、同一バンドル内でメトリクス定義ファイルだけが再評価され、レジストリを保持するファイルは再評価されない非対称性から生じる。App RouterとPages Routerの分離(2023年当時の課題)と、Fast Refreshによる多重登録エラー(本節で検証する課題)は、「モジュールがどこまで独立して評価されるか」という同じ軸の上にある、隣接した2つの症状である。
図解

左: バンドルごとに独立したモジュールスコープ(Registry AとRegistry Bは別インスタンス)。metrics.jsの再評価が同一バンドル内の生きたレジストリに衝突する。右: globalThisでCounterをプロセスごとにキャッシュし、再評価時は既存インスタンスを再利用することで多重登録を防ぐ(バンドル間の共有そのものは依然として発生しない)。
実機検証:どの層で何を確認したか
推測だけで終わらせず、実際に検証した。検証は明確に区別される2つの層で行った。
層1: Node.jsによる機械的アナログ(モジュール再評価の挙動そのもの)
Next.jsを起動せずに、Node.jsの require.cache を手動で操作することで、「メトリクス定義ファイルだけが再評価され、prom-client 本体は再評価されない」状況を再現した。これはNext.jsの挙動を「模したもの」であり、Next.js自体の実機再現ではない点に注意する。
// メトリクス定義ファイル(Fast Refreshで再評価される側)
const { Counter, register } = require('prom-client');
const errorCounter = new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [register],
});
module.exports = { errorCounter, register };
const metricsPath = require.resolve('./metrics.js');
// 1回目の評価(初回コンパイル相当)
const first = require(metricsPath);
// Fast Refreshのアナログ: metrics.jsだけをキャッシュから削除する。
// prom-client自体のキャッシュは意図的に削除しない
// (安定した依存はHMRで再評価されないことを模している)。
delete require.cache[metricsPath];
try {
require(metricsPath); // new Counter(...) が再実行される
} catch (err) {
console.log(err.name + ':', err.message);
}
実行結果:
$ node repro_duplicate.js
Error: A metric with the name errors_total has already been registered.
prom-client@15.1.3(この検証時点の最新版)で確認した。同じ手法で、require.cache から prom-client のサブモジュールも含めた全体(グローバルレジストリの実体は prom-client/lib/registry.js にあるため、エントリポイントの prom-client 本体だけを消してもこのサブモジュールがキャッシュに残っていれば効果がない点に注意)を同時に削除する(=完全に独立したモジュールグラフを模す)と、エラーは発生せず、register が別インスタンスになるだけであることも確認した。これはバンドル間分離(App Router/Pages Router、あるいはサーバーレス関数間)のアナログであり、下記「開発時 vs 本番・サーバーレス」で扱う。
層2: 実際のNext.js(Turbopack)による実機再現
さらに、最小構成のNext.jsプロジェクトを新規に作成し、next dev(Next.js 16.2.10, Turbopack)を実際に起動して、App RouterとPages Routerの両方に同じ lib/metrics.js を読み込むルートハンドラを用意した。
import { errorCounter, register } from '../../../lib/metrics.js';
export async function GET() {
errorCounter.inc();
const body = await register.metrics();
return new Response(body, { headers: { 'Content-Type': register.contentType } });
}
import { errorCounter, register } from '../../lib/metrics.js';
export default async function handler(req, res) {
errorCounter.inc();
const body = await register.metrics();
res.setHeader('Content-Type', register.contentType);
res.status(200).send(body);
}
まず両エンドポイントを複数回叩くと、それぞれが独立してカウントアップした(errors_total が 1, 2, 3, ... と個別に増加)。これは2023年当時の課題、すなわちApp RouterとPages Routerが別々の Registry インスタンスを持つことを、Next.js 16でも実機で確認できたことを意味する。
次に、サーバーを起動したまま lib/metrics.js の末尾にコメントを1行追記し、Fast Refreshによる再コンパイルを発生させてから再度APIを叩いた。結果、両方のルートで実際に次のエラーが発生した(サーバーログからの抜粋、加工なし):
⨯ Error: A metric with the name errors_total has already been registered.
at Module.eval (lib/metrics.js:6:29)
at module evaluation (app/api/app-metrics/route.js:1:1)
...
at new Counter (node_modules/prom-client/lib/counter.js:20:3)
GET /api/app-metrics 500 in 80ms
これは層1のNode.jsアナログと完全に一致する挙動であり、「レジストリを保持するバンドルは、変更されていないファイル(lib/metrics.js)を再評価しただけで、既存の Registry に対して二重登録を試みる」という機構が、実際のNext.js dev サーバー上で発生することを確認した。なお、この状態になると当該ルートは(プロセスを再起動しない限り)エラーを返し続けた。
さらに next build && next start で本番ビルドを起動し、同じエンドポイントを複数回叩いたところ、エラーは一切発生せず、App Router / Pages Routerそれぞれで正しくカウントアップし続けた(Fast Refreshが存在しないため、モジュールは起動時に1度だけ評価される)。この違いは次節のエッジケースで扱う。
回避策の実装と検証
register.getSingleMetric() で既存メトリクスの有無を確認し、さらに globalThis にインスタンスをキャッシュすることで、同一プロセス内でのモジュール再評価に対して耐性を持たせる。
import { Counter, register } from 'prom-client';
declare global {
// eslint-disable-next-line no-var
var __errorCounter: Counter<string> | undefined;
}
function getOrCreateCounter(): Counter<string> {
const existing = register.getSingleMetric('errors_total') as Counter<string> | undefined;
if (existing) return existing;
return new Counter({
name: 'errors_total',
help: 'Total number of errors',
registers: [register],
});
}
export const errorCounter = globalThis.__errorCounter ?? getOrCreateCounter();
globalThis.__errorCounter = errorCounter;
export { register };
層1のNode.jsアナログで、metrics.js の代わりにこの実装を使い、require.cache からの削除・再requireを3回繰り返したところ、いずれもエラーは発生せず、errorCounter === 前回のerrorCounter が常に true になり、inc() の累積値も再評価をまたいで正しく維持された(4回の inc() 呼び出しに対して最終値 4)。
$ node repro_fixed.js
no throw; same Counter instance reused? true (×3回)
final errors_total value (should be 4: survives all reloads): 4
エッジケース
(a) 開発モードのFast Refresh vs 本番ビルド・サーバーレスのコールドスタート
前節の実機検証で確認した通り、この「多重登録エラー」は開発モードでのみ頻発する。Fast Refreshが変更されたファイルを繰り返し再評価するため、モジュールスコープの状態(prom-client の register)と再評価されるコード(メトリクス定義)の非対称性が繰り返し露呈するからだ。本番ビルド(next build && next start)ではモジュールはプロセス起動時に1度しか評価されないため、ガードなしのコードでもこのエラー自体は発生しない。
一方で、Vercelのようなサーバーレス/エッジ環境では、リクエストごとに異なる関数インスタンス(コールドスタート) が呼ばれることがあり、各インスタンスは自分のモジュールスコープを持つため register も個別になる。これは層1のNode.jsアナログで、prom-client 自身も含めて require.cache を全消去した場合と同じ状況で、エラーにはならないが、メトリクスがインスタンス間で共有されない。これはバグではなく、サーバーレス環境の設計上正しい挙動である。この場合に「なんとかグローバルに共有させよう」と無理に globalThis や外部ストレージへの同期を試みるのは、プラットフォームの実行モデルに逆らう過剰対応になりやすい。複数インスタンスにまたがる集計が必要なら、後述のPushgatewayのような外部集約の方が筋が良い。
(b) register.clear() の落とし穴
「多重登録エラー」への対症療法として、メトリクス定義の直前で無条件に register.clear() を呼ぶ実装を見かけることがある。これは登録エラーそのものは消えるが、リクエストをまたいで蓄積されているべきカウンタの値もろとも消し飛ばすという別の問題を引き起こす。実際に検証すると:
function defineAndBumpNaively() {
register.clear(); // 呼ぶたびに全メトリクスの値がリセットされる
const c = new Counter({
name: "errors_total",
help: "Total number of errors",
});
return c;
}
let counter = defineAndBumpNaively();
counter.inc();
counter.inc(); // ここまでで errors_total = 2
counter = defineAndBumpNaively(); // clear()が再度呼ばれ、2は失われる
counter.inc(); // errors_total = 1 (本来3であるべき値が1に)
value after 2 incs: 2
value after clear()+1 inc (expected 3 if counters should persist, but clear() reset it): 1
register.clear() は「モジュール評価のたびに実行される場所」(ファイルのトップレベルなど)に置くと、意図せず頻繁に呼ばれてしまう。使うのであれば、プロセス起動時に一度だけ実行されることが保証された場所(後述の instrumentation.ts の register() フックなど)に限定するべきで、getSingleMetric による存在チェックの方が安全な既定の選択肢になる。
最新動向(2026年7月時点で確認)
prom-clientv15.1.3 で検証。register.getSingleMetric(name)は既存メトリクスを取得するための公開APIとして提供されている。- Next.jsの
instrumentation.ts(register()フック)は、Next.js 15以降experimental.instrumentationHookの指定なしに自動検出されるようになっており、プロセス起動時に一度だけ実行されることが保証された初期化コードの置き場所として、モジュールスコールでの直接初期化よりも推奨される。ただし、Edge Runtimeでは動作しないためprocess.env.NEXT_RUNTIME === 'nodejs'によるガードが必要になる。 - Fast Refresh下でのモジュールレベルの状態多重登録は
prom-client特有の問題ではなく、webpack HMR時代から報告されている既知の課題である( siimon/prom-client#196 )。Turbopackへの移行後も同じ非対称性(変更ファイルのみ再評価、安定した依存は再評価されない)は変わらないため、本記事の再現手順はTurbopack(Next.js 16.2.10、next dev既定)でも成立することを確認済みである。
結論
Next.jsの App Router と Pages Router のAPIルート間で prom-client のメトリクスを完全に共有することは、依然として困難である。これはNext.jsのビルドシステムがルートごとに独立したモジュールグラフを生成するという設計に起因し、global/globalThis を経由しても解消しない。加えて、開発モードのFast Refreshは、メトリクス定義ファイルとレジストリ本体の再評価タイミングのズレによって「多重登録エラー」という別の症状を引き起こす。この2つは実機(Next.js 16.2.10, Turbopack)で個別に再現・確認した。
対策としては、register.getSingleMetric() によるガードと globalThis へのキャッシュを組み合わせることで、少なくとも同一プロセス内での多重登録エラーは解消できる(バンドル間・インスタンス間の完全な共有までは実現しない)。バンドルをまたいだ真の共有が必要な場合は、それぞれのルーターで独立したメトリクス収集を行うか、Prometheus Pushgatewayのような外部サービスを利用してメトリクスを集約することを検討する必要がある。
関連ツール
- JSON整形ツール(DevToolBox) - JSONデータの整形・検証ツール
- JSON→TypeScript変換(DevToolBox) - JSONからTypeScript型定義を自動生成