Next.js で AWS X-Ray の有効化を目指す

## はじめに

仕事で AWS X-Ray を利用してアプリケーションの性能検証を行うことになり、いろいろやったのでその経験を記録として残したいと思います。

今回検証を行いたいアプリケーションの作りは以下の通りです。Next.js の Route Handler 機能を使用し Client と Service の間には BFF が存在します。

![{77861E57-1817-4C6C-BE11-7D32EA2E8B27}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/636b010b-fdd8-d32c-7879-8589a45dcd3c.png)


普段担当しているのがフロントエンドなので X-Ray 有効化を目指すのは Client(Next.js)⇒ServiceA(Spring)の部分です。ちなみに X-Ray の使用経験はありません。

## AWS X-Ray SDK for Node.js

**AWS** X-Ray を有効にしたい訳なのでまずは公式を探します。

https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-sdk-nodejs.html

ありました。早速読んでみると・・・

> X-Ray SDK for Node.js は、Express ウェブアプリケーションと Node.js Lambda 関数用のライブラリで、トレースデータを生成して X-Ray デーモンに送信するためのクラスとメソッドを提供します。
> 

上図の通りフロントエンドのフレームワークはNext.jsなのでサーバーもNext.js製です。ExpressでもLambdaでもないのでこのSDKは使用できない説が浮上しましたが、とりあえず使い方を見てみます。

https://github.com/aws/aws-xray-sdk-node

https://docs.aws.amazon.com/xray-sdk-for-nodejs/latest/reference/

> 現在、[Express](https://github.com/aws/aws-xray-sdk-node/tree/master/packages/express)および[Restify](https://github.com/aws/aws-xray-sdk-node/tree/master/packages/restify) アプリケーションは、ミドルウェア経由の自動キャプチャがサポートされています。
> 

ということは自動モードが動かないだけで手動モードなら動かせるのでは？？と思い（ここで夏休みに入ったため）その後の調査をメンバーに押し付けます。

（夏休みが明けるとSDKを利用してトレースする事が出来ていました。）

---

> 自動モードは、Express、Restify、Lambda アプリケーションで使用するために設計されていますが、これらのアプリケーション以外でも使用できます。独自のミドルウェアの開発、またはミドルウェアなしでの自動モードの使用の詳細については、以下の[「自動モードを使用したカスタム ソリューションの開発」](https://github.com/aws/aws-xray-sdk-node/tree/master/packages/core#developing-custom-solutions-without-middleware)セクションを参照してください。
> 

どうやらNext.jsでも自動モードで動かすことが可能なようです。

> アプリケーションがサポートされているフレームワークを使用していない場合、新しいセグメントを作成し、これを SDK に設定する必要があります。CLS を使用して新しいコンテキストを作成し、そこにセグメントを保存して、SDK がそれを取得して自動キャプチャできるようにする必要があります。これを行うには、CLS 名前空間オブジェクトを使用します。これは、次の API を介して公開されます。
> 
> 
> ```
> AWSXRay.getNamespace();
> ```
> 
> ライブラリ`cls-hooked`はコンテキストを設定するためのいくつかの方法を提供します。以下に使用例を示します。
> 
> ```jsx
> var segment = new AWSXRay.Segment(name, [optional root ID], [optional parent ID]);
> var ns = AWSXRay.getNamespace();
> 
> ns.run(function () {
>   AWSXRay.setSegment(segment);
>   
>   // Requests using AWS SDK, HTTP calls, SQL queries...
>   
>   segment.close();
> });
> ```
> 

最終的に出来上がっていたのがこちら ※抜粋

```jsx
import * as AWSXRay from 'aws-xray-sdk/lib';
import { NextRequest, NextResponse } from 'next/server';

const xrayDaemonAddress = process.env.AWS_XRAY_DAEMON_ADDRESS;
const xraySegmentName = process.env.AWS_XRAY_SEGMENT_NAME;

// 自動モード
AWSXRay.enableAutomaticMode();

// SDKに接続先を設定
AWSXRay.setDaemonAddress(xrayDaemonAddress);

/**
 * トレース情報を収集する
 * デーモンを介して、AWS X-Rayコンソールにトレース情報が連携される
 *
 * @param request APIへのリクエスト
 * @param handler APIの実処理
 * @returns {Promise<NextResponse>} レスポンスをPromiseで返却する
 * @throws {Error}
 */
export function xrayTrace(
    request: NextRequest,
    handler: (trace_id: string) => Promise<NextResponse>,
): Promise<NextResponse> {
		// セグメントを生成
    const segment = new AWSXRay.Segment(xraySegmentName);
		// CLS 名前空間
    const ns = AWSXRay.getNamespace();

		// route handlerの結果としてPromiseを返せるように
    return new Promise<NextResponse>((resolve, reject) => {
        ns.run(() => {
            AWSXRay.setSegment(segment);
            handler(segment.trace_id)
                .then((nextResponse) => {
                    segment.close();
                    resolve(nextResponse);
                })
                .catch((error: Error) => {
                    segment.close();
                    reject(error);
                });
        });
    });
}
```

```tsx
// route.ts
export const dynamic = 'force-dynamic';

import { NextRequest, NextResponse } from 'next/server';
import { xrayTrace } from '@/util/app/x-ray/awsXrayUtils';

export async function POST(request: NextRequest): Promise<NextResponse> {
    return xrayTrace(request, async (trace_id: string) => {
        const requestBody = (await request.json());

        const sendBody= {
            // request body
        };
				
				// Service の API を呼び出し
        const purchaseorderResponse = await fetch(
            `${process.env.SERVER_HOST_URL}/xxx`,
            {
                method: 'POST',
                headers: {
		                // その後のSNSやSQSまでトレースを繋げるためトレースIDを渡す
                    'X-Amzn-Trace-Id': `Root=${trace_id}`, 
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify(sendBody),
                cache: 'no-store',
            },
        );

        if (purchaseorderResponse.status === 201) {
            return new NextResponse(null, {
                status: 200,
            });
        }

        return new NextResponse(`failed request`, { status: 500 });
    });
}
```

## @vercel/otel

AWS X-Ray SDK for Node.js を利用してトレース情報の収集ができるようになりましたが、 自力でセグメントの開始終了を記載するやり方となっていたため、今後APIが増えるたびに `xrayTrace` を噛ませなければいけなくなりました。これでは今後の開発が面倒だったり見通しが悪いため別の方法を探ります。

https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry

Next.js が AWS X-Ray SDK for Node.js とは違う OpenTeremetry のライブラリを用意しているようです。こちらを使用する方法を試してみます。

> To get started, install the following packages:
> 
> 
> ```tsx
> npm install @vercel/otel @opentelemetry/sdk-logs @opentelemetry/api-logs @opentelemetry/instrumentation
> ```
> 
> Next, create a custom [`instrumentation.ts`](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation) (or `.js`) file in the **root directory** of the project (or inside `src` folder if using one):
> 
> ```tsx
> import { registerOTel } from '@vercel/otel'
>  
> export function register() {
>   registerOTel({ serviceName: 'next-app' })
> }
> ```
> 

ドキュメント通りにパッケージをインストールし `instrumentation.ts` を作成しました。

https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation

> Instrumentation is currently an experimental feature, to use the `instrumentation.js` file, you must explicitly opt-in by defining [`experimental.instrumentationHook = true;`](https://nextjs.org/docs/app/api-reference/next-config-js/instrumentationHook) in your `next.config.js`:
> 
> 
> 
> ```tsx
> import type { NextConfig } from 'next'
>  
> const nextConfig: NextConfig = {
>   experimental: {
>     instrumentationHook: true,
>   },
> }
>  
> export default nextConfig
> ```
>

`instrumentation.ts` のページによると instrumentation の機能を有効にするには設定で明示的に指定する必要があるようなのでこれも対応しておきます。


これでトレース情報の生成はライブラリが勝手にやってくれるはずなので、次は収集するための OpenTelemetry コレクターを用意します。最終的に AWS X-Ray へデータを送信するため、AWSが用意している https://github.com/aws-observability/aws-otel-collector を利用してみます。

### ローカル otel-collector

本来アプリケーションはECSにデプロイされますが、動作確認の段階ではデプロイの手間が多くて面倒なのでまずはローカルにコレクターを起動して試してみます。

https://github.com/aws-observability/aws-otel-collector/blob/main/docs/developers/docker-demo.md

ドキュメントは aws-otel-collector のサンプルアプリを使用するようになっているのでコレクターの起動部分だけを拝借して試します。

`config-test.yaml` は https://github.com/aws-observability/aws-otel-collector/blob/main/examples/docker/config-test.yaml を元に微調整を加えた以下のものを用意、

```yaml
extensions:
    health_check:
    pprof:
        endpoint: 0.0.0.0:1777

receivers:
    otlp:
        protocols:
            grpc:
                endpoint: 0.0.0.0:4317 
            http:
                endpoint: 0.0.0.0:4318 # 追加した

processors:
    batch:

exporters:
    logging:
        loglevel: debug
    awsxray:
        region: 'ap-northeast-1' # 変更した
    awsemf:
        region: 'ap-northeast-1' # 変更した

service:
    pipelines:
        traces:
            receivers: [otlp]
            exporters: [awsxray]
        metrics:
            receivers: [otlp]
            exporters: [awsemf]

    extensions: [pprof]
    telemetry:
        logs:
            level: debug

```

コレクター起動はssoで作成したprofileを指定してもうまくいかなかったので `AWS_ACCESS_KEY_ID` `AWS_SECRET_ACCESS_KEY`  `AWS_SESSION_TOKEN` を利用する方法を取りました。

```tsx
docker run --rm -p 4317:4317 -p 4318:4318 -p 55680:55680 -p 8889:8888 \
    -e AWS_REGION=ap-northeast-1 \
    -e AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID \
    -e AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY \
    -e AWS_SESSION_TOKEN=$AWS_SESSION_TOKEN \
    -v ~/.aws:/home/aoc/.aws \
    -v "${PWD}/config-test.yaml":/otel-local-config.yaml \
    --name awscollector public.ecr.aws/aws-observability/aws-otel-collector:latest \
    --config otel-local-config.yaml;
```

先ほど修正を加えたアプリケーションを起動し適当に画面をポチポチしてみます。

![{04752C36-36A6-4382-A85E-D78E34E5960B}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/54be0914-e673-9160-e7b6-082970ccf6e7.png)


トレース情報がAWSコンソールから確認できるようになりました😀

### ECS aws-otel-collector

ECSで動作するアプリケーションのトレースを収集するためにどうすれば良いか、AWSのドキュメントを色々読んだ結果ECSのサイドカーとしてコンテナを起動すれば良いとの結論にたどり着きました。https://aws-otel.github.io/docs/setup/ecs を参考にセットアップします。

![Inkedスクリーンショット 2024-09-17 171222_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/15c2cbb7-dff2-86b5-31da-243aa0264331.jpeg)

しました。早速アプリケーションをポチポチしてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/9fb62866-a2d2-b86d-f6ee-67b327f118a7.png)

トレースマップでは分かりませんがECSで動作しているアプリケーションのトレース情報を取る事ができました。next-appが最初の図のclient, xxx:8080がServiceAに当たるアプリケーションです。

### イベント駆動アーキテクチャのトレースマップ

ECSで動作しているアプリケーションのトレース情報を取る事ができましたが、システムの構成ではServiceAの後にSNS, SQS, Lambda, ServiceBと続いている部分にトレースマップが続いていません。対応しましょう。

https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-concepts.html#xray-concepts-tracingheader

ServiceAには既にAWS X-Ray SDK for Java が組み込まれており、トレースヘッダーをリクエストに付与する事で自動的にトレースIDを引き継いでくれるはずです。その辺りは `@vercel/otel` パッケージが良しなにやってくれるはずなので[設定](https://www.npmjs.com/package/@vercel/otel)を探してみます。

> • `propagators`: A set of propagators that may extend inbound and outbound contexts. By default, `@vercel/otel` configures [W3C Trace Context](https://www.w3.org/TR/trace-context/) propagator.
> 

よく分からないですが [W3C Trace Context](https://www.w3.org/TR/trace-context/) を見てみるとトレースヘッダーが AWS X-Ray のものとは違うようです。

https://www.npmjs.com/package/@opentelemetry/propagator-aws-xray

世の中には便利なものがあるもので X-Ray 対応の propagator があるようですね。使ってみましょう。

```tsx
import { AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray'; // 追加
import { registerOTel } from '@vercel/otel'
 
export function register() {
  registerOTel({
    serviceName: 'next-app',
    propagators: [new AWSXRayPropagator()], // 追加
  });
}
```

![{5314F15B-B180-4CCE-A412-EDA2207A5CAA}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/78d9d881-c949-69de-256e-9d39d13d609d.png)


ローカルでリクエストヘッダーをログ出力してみると `x-amzn-trace-id` が付与されていました。勝ちましたね。
ECSへデプロイしてポチポチします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/0b679d09-bd6e-77e4-f0f8-e723a17ecaf1.png)



おかしいですね。先ほどと同じ結果です。（面倒だったので本当に同じ画像を使用していますがSNS以降がトレースマップに出てこないという意です）

~~ 6時間くらいあっちこっちでログ出力したり原因調査 ~~

結局ローカルで確認した時には付与されていた `x-amzn-trace-id` が無いことが原因で、何故無いのかというと設定でした。

 https://www.npmjs.com/package/@vercel/otel

> • `propagateContextUrls`: A set of URL matchers (string prefix or regex) for which the tracing context should be propagated (see `propagators`). By default the context is propagated *only* for the [deployment URLs](https://vercel.com/docs/deployments/generated-urls), all other URLs should be enabled explicitly. Example: `fetch: { propagateContextUrls: [ /my.api/ ] }`.
> 

```tsx
import { AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray';
import { registerOTel } from '@vercel/otel'
 
export function register() {
  registerOTel({
    serviceName: 'next-app',
    propagators: [new AWSXRayPropagator()],
    instrumentationConfig: {
      fetch: {
        propagateContextUrls: [new RegExp(`${process.env.SERVER_HOST_URL}/*`)], // 追加
      }
    }
  });
}
```

伝搬先にServiceAを指定して再チャレンジです。

![image2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889009/d27d5a1b-5684-3892-9a4a-29ef9f5ae256.png)


SNSやlambdaがトレースマップに表示されています。やりました🎉

## 組み込み方まとめ
大きく分けると2つだけですね。AWSの設定はIAMロールの作成など色々やっていますがドキュメント通りに進めれば出来るので特に難しい事はありません。

1. アプリケーションに `instrumentation.ts` を作成しinstrumentationの機能を有効化する
    ```typescript
    import { AWSXRayPropagator } from '@opentelemetry/propagator-aws-xray';
    import { registerOTel } from '@vercel/otel'
     
    export function register() {
      registerOTel({
        serviceName: 'next-app',
        propagators: [new AWSXRayPropagator()],
        instrumentationConfig: {
          fetch: {
            propagateContextUrls: [new RegExp(`${process.env.SERVER_HOST_URL}/*`)], // 追加
          }
        }
      });
    }
    ```
    ```tsx
    import type { NextConfig } from 'next'
     
    const nextConfig: NextConfig = {
      experimental: {
        instrumentationHook: true,
      },
    }
     
    export default nextConfig
    ```
    
2. aws-otel-collector をアプリケーションのECSサイドカーコンテナとして起動
    https://aws-otel.github.io/docs/setup/ecs

## おわりに
Next.js製アプリケーションのトレース情報をX-Rayに送信する事に成功しました。AWS X-Ray SDK for Node.js を見ていた時はもう無理かも知れないと思いましたが `@vercel/otel` を情報提供してくれたSさんのおかげで何とかなりました、本当にありがとうございます。

## 参考資料
[AWS X-Ray の概念](https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-concepts.html)
[AWS Node.js SDK用 X-Ray](https://docs.aws.amazon.com/ja_jp/xray/latest/devguide/xray-sdk-nodejs.html)
[aws-xray-sdk-node](https://github.com/aws/aws-xray-sdk-node)
[Setting up AWS Distro for OpenTelemetry Collector in Amazon Elastic Container Service](https://aws-otel.github.io/docs/setup/ecs)
[Next.js - instrumentation](https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation)
[Next.js - OpenTelemetry](https://nextjs.org/docs/app/building-your-application/optimizing/open-telemetry)
[@vercel/otel](https://github.com/vercel/otel)
[Next.jsのサーバーサイド処理をX-Rayでトレースしてみた](https://dev.classmethod.jp/articles/trace-nextjs-with-aws-xray/)