つい先日リニューアルされた スタディサプリ 中学講座 の開発チームでインターン生をしている @YutaUra です。今回はインターンの中で New Relic と Prisma を連携させる業務に取り組んだのでそれに関してご紹介したいと思います!
スタディサプリ中学講座では、バックエンドの一部に TypeScript x GraphQL x Prisma を採用しており、パフォーマンスの計測などに New Relic を用いています。New Relic は Prisma を正式にサポートしていないため、導入するためには自分たちで実装する必要がありました。 そこで今回はライブラリを作成し、最終的に OSS(newrelic-node-prisma)として公開したので、それらについて実装と合わせて紹介します。
New Relic とは
Web サービスにおけるさまざまなパフォーマンスを計測・監視・分析するためのツールです。リクエストごとにボトルネックとなっている箇所を特定したりすることができます。
背景について
New Relic は標準で Express のような Web フレームワーク内の処理や、 PostgreSQL, MySQL などのデータベースのクエリ等のパフォーマンスの計測が可能となっており、設定ファイルを作成することで自動的にそれらの計測を行うことができます。
しかし、 Prisma については標準でのサポートがされていなく(newrelic/node-newrelic#991)、計測を行いたい場合は各自で New Relic と Prisma を連携するための記述をする必要があります。
今回のプロジェクトでは、GraphQL を採用しているため N+1 が発生していないかというのを継続的に監視していくための仕組みが必要で、そのためには New Relic で Prisma を計測することが必要でした。
プラグインの作成について
冒頭で紹介したように、今回はライブラリを作成し、OSS(newrelic/node-newrelic#991)として公開しました。ここでは作る上でのポイントなど紹介します。
New Relic の Prisma プラグインを作成する上で必要となる知識は以下の通りです。
- New Relic の Instrument・Segment について
- Prisma でのクエリを取得する方法について
Instrument・Segment について
New Relic ではパフォーマンスを計測する対象を Instrument と呼んでいます。Instrument にはいくつか種類があり、Datastore, Message Broker, Web Framework などがあります。 そして、これらの Instrument は Segment という単位でさまざまな計測を行います。
上記の画像の例では prisma-sample という Instrument と Prisma という Instrument があり、 複数の Segment が計測できていることがわかります。
また、 Datastore として登録をすると、データベース関連のボトルネックを特定するためのさまざまな計測・可視化が行われるようになります。
Prisma でクエリを取得する方法
Prisma は Rust で実装された独自エンジンを内部に持っていて、それに対して GraphQL のクエリを投げることで、SQL へと変換されデータを取得・更新することができるようになっています。 Prisma でデータベースへのリクエストを取得する場合には 2 つの方法があり、1 つ目は GraphQL として解釈される直前の情報を使う方法で、2 つ目は実行された後の SQL をイベントとして受け取る方法です。
大雑把に実装すると、以下のようになります。
// 方法 1 const prisma = new PrismaClient(); // _executeRequest は内部メソッドのため、将来的に変更される可能性があります。 const _executeRequest = PrismaClient.prototype._executeRequest; prisma.prototype._executeRequest = async (params) => { console.log("START", new Date(), params); const result = await _executeRequest.call(prisma, params); console.log("END", new Date(), params); return result; };
// 方法 2 const prisma = new PrismaClient({ log: [{ level: "query", emit: "event" }], }); prisma.$on("query", (event) => { console.log("SQL", event.query); console.log("実行時間", event.duration); });
方法 1 は内部メソッドに依存している方法となっており、アナウンスなしに利用できなくなる可能性があります。一方で、方法 2 は Prisma で Event-based logging する際の方法なので、すぐに利用できなくなるということは考えにくいです。しかし、 New Relic では実際にクエリさせる前後に Segment に関する処理を行う必要があるため、2 つ目の方法ではなく、1 つ目の方法を使うことで Prisma の計測を行うことができるようになります。
New Relic agent と Prisma を連携させる
詳細については 公式のドキュメント を一度読まれることをオススメします。
Node.js の New Relic agent には Datastore を計測するためのヘルパー関数が多数用意されていて、それらを使うことで簡単に計測をするためのコードを書くことができます。
具体的な実装を交えながら、 New Relic と Prisma を連携する方法を紹介します。
まず、 New Relic agent では先程の方法 1 のようにモジュールの prototype を書き換えることで計測を行うようにしていて、そのためアプリケーションでそのモジュールが呼び出されるよりも前に New Relic agent がモジュールの prototype を書き換えられるようにしてあげる必要があります。
今回は PrismaClient
の prototype を書き換えるために @prisma/client
モジュールに対して New Relic agent に関する処理を行なっていきます。
// register-newrelic.js const newrelic = require("newrelic"); const { instrumentPrisma } = require("./newrelic-prisma"); newrelic.instrumentDatastore("@prisma/client", instrumentPrisma);
とすると、後ほど作成する instrumentPrisma
という関数で @prisma/client
モジュールに対して処理を行えるようになります。
instrumentPrisma
関数は、第 1 引数に DatastoreShim という New Relic agent のヘルパー的なオブジェクトを受け取り、第 2 引数に @prisma/client
モジュールそのものを受け取ります。
// newrelic-prisma.js /** * @param {*} shim DatastoreShim オブジェクトとなっている * @param {import("@prisma/client")} prisma * * @see https://newrelic.github.io/node-newrelic/docs/DatastoreShim.html */ export const instrumentPrisma = (shim, prisma) => { // ... };
DatastoreShim にあるいくつかのメソッドのうち、 recordQuery
というメソッドを使用することでデータベースへのリクエストを計測することができます。
今回は PrismaClient の _executeRequest に対して、セグメントの作成を行えるようにしたいので
// newrelic-prisma.js export const instrumentPrisma = (shim, prisma) => { shim.recordQuery(prisma.PrismaClient.prototype, "_executeRequest", { // ... }); };
と指定します。
次に、 PrismaClient の _executeRequest には SQL ではなく Prisma 独自のリクエストオブジェクトが渡されるので、 newrelic がそれを解釈できるように設定をしてあげる必要があります。
New Relic agent が解釈するためには collection
(users
などのテーブル名・コレクション名)とoperation
(SELECT
や UPDATE
などの操作種別)、 query
(個人情報やコメントなどが取り除かれた文字列または SQL そのもの。任意項目)を指定する必要があります。
PrismaClient の _executeRequest に渡される引数のうち、これらに使えそうな情報を探したところ、以下のようにすることができました。
// newrelic-prisma.js export const instrumentPrisma = (shim, prisma) => { shim.recordQuery(prisma.PrismaClient.prototype, "_executeRequest", { /** * @param {[*]} args _executeRequest の引数の配列で、第 1 引数に InternalRequestParams という型のオブジェクトが渡されます。 * * @see https://github.com/prisma/prisma/blob/6797519fd5c5fc7f523d965e9a55a56c80dc2ee1/packages/client/src/runtime/getPrismaClient.ts#L145-L160 * @see https://github.com/prisma/prisma/blob/6797519fd5c5fc7f523d965e9a55a56c80dc2ee1/packages/client/src/runtime/MiddlewareHandler.ts#L9-L20 */ query: (_0, _1, _2, args) => { const params = args[0]; const query = { // Prismaのモデル名を取得できる collection: params.model, // Prismaの場合は findMany とか findOne などの情報が取得できる operation: params.action, // SQL の代わりとなりそうな情報を取得する query: `${params.clientMethod} ${JSON.stringify(params.args)}`, }; // 文字列として返す必要があるため、一度 JSON 文字列へ変換しています。 return JSON.stringify(query); }, }); };
最後に、 New Relic agent ではデフォルトで SQL-like なクエリをパースする仕組みがあるのですが、それを使わずにただ JSON.parse するように設定します。
// newrelic-prisma.js export const instrumentPrisma = (shim, prisma) => { shim.recordQuery(prisma.PrismaClient.prototype, "_executeRequest", { // ... }); shim.setParser((query) => { // query は 先ほど JSON.stringify で生成した文字列となっている return JSON.parse(query); }); };
ここまででほとんどの設定が完了です。最後にファイルの全体を紹介します。
// register-newrelic.js const newrelic = require("newrelic"); const { instrumentPrisma } = require("./newrelic-prisma"); newrelic.instrumentDatastore("@prisma/client", instrumentPrisma);
// newrelic-prisma.js /** * @param {*} shim DatastoreShim オブジェクトとなっている * @param {import("@prisma/client")} prisma * * @see https://newrelic.github.io/node-newrelic/docs/DatastoreShim.html */ export const instrumentPrisma = (shim, prisma) => { // ここで New Relic 上で表示される文字列を設定することができます。任意の文字列を設定することもできます。 shim.setDatastore(shim.POSTGRES); shim.recordQuery(prisma.PrismaClient.prototype, "_executeRequest", { query: (_shim, _fn, _name, args) => { const params = args[0]; const query = { collection: params.model, operation: params.action, // SQL の代わりとなりそうな情報を取得する query: `${params.clientMethod} ${JSON.stringify(params.args)}`, }; return JSON.stringify(query); }, // New Relic上でデータベースとしての情報として記録してもらうために指定をします。 record: true, // _executeRequest が非同期関数のため、それに関する指定をしています。 promise: true, }); shim.setParser((query) => { return JSON.parse(query); }); };
そして、 register-newrelic.js ファイルを PrismaClient を初期化するよりも前に読み込んでおく必要があります。一番簡単な方法は
$ node -r register-newrelic.js index.js
のような感じで、アプリケーション起動前に読み込まれるようしておく方法です。
ここまで行うことで、以下の画像のように New Relic で Prisma の計測を行うことができるようになります!
最後に
New Relic と Prisma を連携させることでデータベースアクセスを可視化し、N+1 の検出や遅いクエリの発見につなげることができました。 スタディサプリでは、サービスを安定して継続していくためにこれらの内部改善に取り組んでいます。
そして教育、GraphQL、TypeScript などに興味があればぜひ一度、カジュアル面談でお話しましょう!