監視強化!Deno アプリに自作 Elastic APM Agent を導入

あいさつ

こんにちは。Product Team の 下川、9sako6、手嶋、藤原です1

本記事では、我々が開発している Deno 製アプリケーションのパフォーマンス監視を Elastic APM で行った方法を紹介します。

背景

Product Team では、パフォーマンス監視を目的として部分的に Elastic APM の導入を進めています。 本記事で監視対象としたアプリケーションは Deno で書かれており、対応する Agent がありませんでしたが、 必要なものは自分たちで用意すべきと考え、Agent を自作することにしました。 (厳密には、Node.js には対応していましたが、Deno で試したところうまく動きませんでした)

Elastic APM と今回実装した Deno 向け Agent の概要

Elastic APM の簡単な説明

Elastic APM とは、Elastic 社が提供しているアプリケーションパフォーマンス監視(APM)です。

アプリケーションのトランザクションやログ、エラーの監視、関係するマイクロサービスの可視化などを行うことができます。 トランザクションとは例えば、API のエンドポイントの結果などを指します。 トランザクション内の各処理 (DB 処理や他 API への通信処理など) の状況を監視することもできます。 計測結果は Kibana で参照することができます。

Elastic APM Agent で計測

今回実装した Deno 向け Agent では以下の情報を計測し、送信できるようにしました。

  • Metadata
    • 監視対象のサービス名
    • 実行環境
    • 言語やそのバージョン
  • Transaction
    • リクエストメソッド
    • リクエスト URI
    • 処理結果 (Success or Failure)
    • リクエスト時のタイムスタンプ
    • 処理にかかった時間
  • Errors
    • エラーメッセージ
    • エラーのスタックトレース
  • Spans
    • トランザクション内での fetch 処理
      • fetch したリクエストメソッド
      • fetch したリクエスト URI
      • fetch 時のタイムスタンプ
      • fetch 処理にかかった時間

Transactions and Errors on Kibana
Transactions and Errors on Kibana

Elastic APM Agent の使い方(簡単なミドルウェアのコード例つき)

// file: main.ts
import { elasticApmAgent } from "./elastic_apm.ts";

// Deno の oak を採用した場合
// 先頭に設定する
app.use(elasticApmAgent);

// 以降の router の設定などに続く
// file: elastic_apm.ts
import { initializeApm, start } from "./elastic_apm_agent/agent.ts";

export const elasticApmAgent: Middleware<Record<string, unknown>> = async (
  ctx,
  next,
) => {
  const serviceName = "your-service-name";
  const serverUrl = "http://your-elastic-apm-server-url";

  // APM Agent の初期化を行う
  const apm = initializeApm({ serviceName, serverUrl });

  // APM Agent の処理を開始する
  await start(
    apm,
    new Request(ctx.request.url, { method: ctx.request.method }),
    next,
  );
};

設計と実装

環境

Middleware として実装

今回、Apm Agent は oak の Middleware として実装しました。 導入したアプリケーションへのリクエストを全て監視したいことが理由です。 各エンドポイントで Apm Agent を使う処理を書く方法も考られますが、同じことを何度も書く必要があるほか、実装漏れのリスクや Apm Agent の仕様が変わった際の修正箇所が多くなるリスクが高く、デメリットの多さから採用しませんでした。 また、以前にJVM で動くアプリケーションに Elastic Apm を導入した時、Middleware にコードを少し書くだけで導入できたので、今回も同様の世界を目指したいと思いました。

今回我々は oak の Middleware として実装しましたが、Apm Agent 自体は特定のフレームワークに依存しないように実装しているので、他のフレームワークの Middleware として導入することも可能です。

Span の計測と fetch へのパッチ

Elastic APM では、Transaction 内の様々な処理についての情報を Span という単位で計測できます。 例えば、データベースや外部 API へのアクセスに関する情報を計測し、Transaction の内訳を知ることができます。

ここでは、Span の計測のために行った工夫を紹介します。

外部 API 呼び出しを計測する方針

外部 API へのリクエストの処理時間を計測するためには、リクエストの開始時刻と終了時刻を知る必要があります。

最初は以下のような案を考えましたが、それぞれ問題があり採用しませんでした。

  • API 呼び出しをコールバックとして渡して計測できるようにする?
    • アプリケーションの内部で都度 APM の処理を呼び出すのはあまりにも使い勝手が悪い
  • Deno でも Service Worker が使えるっぽいぞ?
    • ブラウザ環境ではないので Service Worker で HTTP リクエストを捉えるのは無理

最終的に我々は、fetch にパッチを当てる方法を採用しました。

Span 計測処理の実装

細部の実装は割愛しますが、実際のコードを用いて Span 計測の大まかな方針を説明します。

fetch のパッチバージョンである fetchForApm 関数では、リクエストが始まる前と終わった後にタイムスタンプを取得し、その差分である処理時間を計算します。 さらに、リクエストが完了した時点でカスタムイベントを発火し、タイムスタンプと処理時間を APM Agent のイベントリスナーに送信します。

export const fetchForApm: typeof fetch = new Proxy(globalThis.fetch, {
  apply(target, thisArg, argsArray: Parameters<typeof fetch>) {
    // 開始時刻の計測など、前処理

    // オリジナルの `fetch` 呼び出し
    const result = target.apply(thisArg, argsArray)
      .finally(() => {
        // カスタムイベントの発火
      });

    return result;
  },
});

Span を記録する APM Agent 側の実装です。 まず、オリジナルの fetchfetchForApm で置き換えています。 次に、イベントリスナーを登録し、fetch が完了したタイミングで発火するカスタムイベントを捕捉して Span オブジェクトを構築します。

  // パッチした関数で置き換える
  globalThis.fetch = monitorPort.fetchForApm;

  const eventHandler = (e: CustomEvent<CustomEventParams>) => {
    // カスタムイベントから必要な情報を取り出して
    // Span オブジェクトを構築し、Queue に入れる
  };

  globalThis.addEventListener(
    FETCH_COMPLETED_EVENT,
    eventHandler as EventListenerOrEventListenerObject,
  );

これで、外部 API へのリクエスト情報を Span として収集できるようになりました。 Span の計測によって Transaction 内部の処理をより正確に把握できます。

Spans on Kibana
Spans on Kibana

fetch のパッチバージョンである fetchForApm のテスト

fetchForApm のテストについても言及します。

以下は、fetchForApm によってカスタムイベントが発火することをテストするコードです。 テスト時に実際の fetch によって外部にリクエストが飛ばないように、型付けに敗北しつつも stub しています。

Deno.test("fetchForApm", async (t) => {
  await t.step("emit a custom event", async () => {
    let actualEvent: FetchCompletedEvent | undefined;

    globalThis.addEventListener(FETCH_COMPLETED_EVENT, (event) => {
      actualEvent = event as FetchCompletedEvent;
    });

    stub(performance, "now", returnsNext([1000, 4000]));
    stub(Date, "now", () => 123);

    stub(
      fetch as any, // ここで型付けに敗北した
      "apply",
      () => Promise.resolve(new Response()),
    );

    const response = await fetchForApm(
      "https://example.com",
      {
        method: "GET",
      },
    );

    const expected = {
      duration: 3000,
      timestamp: 123000,
      request: {
        url: "https://example.com",
        method: "GET",
      },
    };
    assertEquals(actualEvent?.detail, expected);
  });
});

ちなみに Deno では、ネットワークアクセスのある処理を --allow-net を有効にせず実行した場合、パーミッションエラーが生じます。 仮に fetchstub し忘れても、テスト実行時にエラーで落ちてくれるので安心です。

今後の展望

現在 Apm Agent が送信している情報は Kibana で見る際に必要な最低限の情報だけです。

例えば、外部通信に関しては fetch のみを監視しており、DB への接続等の外部接続は監視されません。 Transaction の速度面でのパフォーマンス計測はできていますが、Apm Agent を導入したアプリケーションのエンドポイントが返すレスポンス情報を計測することはできていません。 他にも Metrics 情報はそもそも送信していないので、エンドポイント内での処理でメモリーや CPU がどの程度使用されているかも計測できていません。

今後は計測できる項目を増やして、Elastic Apm の公式で公開されている他のランタイムの Agent と遜色ない状態まで開発を進めたいです。

また、現時点ではこの Apm Agent はアプリケーションのディレクトリ内に実装されていますが、今後は Apm Agent を別リポジトリーに切り出して、公開できればと考えています。


  1. 本記事は、4名のエンジニアでモブりながら作成しました
Page top