Node.js アプリケーションのログとトレースを OTLP エンドポイント経由で CloudWatch Logs と X-Ray に送信してみる

Node.js アプリケーションのログとトレースを OTLP エンドポイント経由で CloudWatch Logs と X-Ray に送信してみる

2026.01.14

概要

2024 年末に OTLP エンドポイント経由で X-Ray にトレースを送信できるようになりました。

https://dev.classmethod.jp/articles/x-ray-support-otlp-endpoint/

また、いつからあったかはわかっていないですが、CloudWatch にはログ送信用の OTLP エンドポイントが存在します。

https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html

これらを利用することで、ログとメトリクスの両方を OpenTelemetry 方式で AWS に連携することが可能です。
OpenTelemetry のログ機能は Development や Beta の言語が多く、まだまだこれからといったステータスです。

https://opentelemetry.io/ja/docs/languages/

とはいえ、ログを OpenTelemetry 標準に沿った形で扱えれば Fluent Bit のようなログ収集管理ツールを減らせる場合もあるでしょうし、各テレメトリの関連付けも行いやすくなります。

スクリーンショット 2026-01-14 11.51.51.png

図は OpenTelemetry Logging | OpenTelemetry より引用

ということで、今回は Node.js アプリケーションのログとトレースを OTLP エンドポイント経由で CloudWatch Logs と X-Ray に送信しつつ、それらを関連付けて確認する所まで行ってみます。

環境セットアップ

アプリケーションは Express.js、OpenTelemetry Collector としては OpenTelemetry Collector Contrib を使用します。

Untitled(67).png

Transaction Search を有効化

OTLP エンドポイント経由でトレースを送信する際、X-Ray の Transaction Search が有効になっている必要があります。

Make sure Transaction Search is enabled in CloudWatch before using the OTLP endpoint for traces.
https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPTroubleshooting.html

有効化していない場合、CloudWatch のサービスページで 設定 > X-Ray トレース > Transaction Search と辿って有効化します。

スクリーンショット 2026-01-14 11.45.16.png

Transaction Search は X-Ray に送信したスパンを自動で CloudWatch Logs に連携する機能です。
詳細は下記ブログをご参照下さい。

https://dev.classmethod.jp/articles/try-xray-transaction-search/

CloudWatch ロググループとログストリームの作成

ログを格納するための CloudWatch ロググループとログストリームを事前に作成しておきます。

aws logs create-log-group --log-group-name otel-test
aws logs create-log-stream --log-group-name otel-test --log-stream-name default

ロググループ名とログストリーム名を OpenTelemetry Collector の設定ファイルで指定するため、控えておきます。

OpenTelemetry Collector の起動

まず、 OpenTelemetry Collector に設定する AWS 認証情報を取得します。
よりセキュアに SSM ハイブリッドアクティベーション等で渡したい所ですが、一旦 IAM Identity Center のアクセスポータルから取得した環境変数を渡します。
権限としては AWSXrayWriteOnlyAccess と下記権限を付与します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CloudWatchLogsAccess",
      "Effect": "Allow",
      "Action": [
        "logs:PutLogEvents",
        "logs:DescribeLogGroups",
        "logs:DescribeLogStreams"
      ],
      "Resource": ["arn:aws:logs:*:*:log-group:*"]
    }
  ]
}

取得した認証情報を otel-collector/compose.yamlenvironment に設定します。

services:
  otel-collector:
    image: otel/opentelemetry-collector-contrib:0.143.0
    command: ["--config", "/etc/otel-collector-config.yaml"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "4317:4317"
      - "4318:4318"
    environment:
      AWS_ACCESS_KEY_ID: xxxxxxxxxxxxxxxx
      AWS_SECRET_ACCESS_KEY: xxxxxxxxxxxxxxxx
      AWS_SESSION_TOKEN: xxxxxxxxxxxxxxxx

AWS 公式ドキュメントの Configure the OpenTelemetry Collector を参考に、OpenTelemetry Collector の設定ファイルを構成します。
変更が必要なのはロググループ名とログリージョン名、リージョンくらいで他はそのまま利用できるはずです。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

exporters:
  otlphttp/logs:
    compression: gzip
    logs_endpoint: https://logs.ap-northeast-1.amazonaws.com/v1/logs
    headers:
      x-aws-log-group: otel-test # ロググループ名
      x-aws-log-stream: default # ログストリーム名
    auth:
      authenticator: sigv4auth/logs

  otlphttp/traces:
    compression: gzip
    traces_endpoint: https://xray.ap-northeast-1.amazonaws.com/v1/traces
    auth:
      authenticator: sigv4auth/traces

extensions:
  sigv4auth/logs:
    region: "ap-northeast-1"
    service: "logs"
  sigv4auth/traces:
    region: "ap-northeast-1"
    service: "xray"

service:
  telemetry:
  extensions: [sigv4auth/logs, sigv4auth/traces]
  pipelines:
    logs:
      receivers: [otlp]
      exporters: [otlphttp/logs]
    traces:
      receivers: [otlp]
      exporters: [otlphttp/traces]

その後、OpenTelemetry Collector を起動します。

docker compose up -d

アプリケーションの起動

OpenTelemetry Collector を起動したシェルとは別シェルに移動して、パッケージのインストールを行います。

pnpm install

利用した package.json は下記です。

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon --exec ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@10.23.0",
  "dependencies": {
    "@opentelemetry/api": "^1.9.0",
    "@opentelemetry/api-logs": "^0.208.0",
    "@opentelemetry/auto-instrumentations-node": "^0.67.3",
    "@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
    "@opentelemetry/exporter-trace-otlp-http": "^0.208.0",
    "@opentelemetry/resources": "^2.2.0",
    "@opentelemetry/sdk-logs": "^0.208.0",
    "@opentelemetry/sdk-node": "^0.208.0",
    "dotenv": "^17.2.3",
    "express": "^5.2.1",
    "morgan": "^1.10.0",
    "winston": "^3.19.0",
    "winston-transport": "^4.9.0"
  },
  "devDependencies": {
    "@types/express": "^5.0.6",
    "@types/morgan": "^1.9.9",
    "@types/node": "^25.0.3",
    "dotenv-cli": "^11.0.0",
    "nodemon": "^3.1.11",
    "ts-node": "^10.9.2",
    "typescript": "^5.9.3"
  }
}

※ dotenv と nodemon は開発時に使っていただけでアプリケーションの動作には不要です。

アプリケーションは下記を利用しました。
HTTP リクエストロガーとして morgan を利用し、winston に連携しています。

index.ts

// 最初にトレーシングを初期化
import "./tracing";

import express, { Request, Response, NextFunction } from "express";
import morgan from "morgan";
import logger from "./logger";
import { trace, SpanStatusCode } from "@opentelemetry/api";

const app = express();
const PORT = process.env.PORT || 3000;

// Morganのストリーム設定(WinstonのロガーにHTTPアクセスログを連携)
const morganStream = {
  write: (message: string) => {
    logger.info(message.trim());
  },
};

app.use(
  morgan("combined", {
    stream: morganStream,
  })
);

app.use(express.json());

app.use((req: Request, res: Response, next: NextFunction) => {
  const span = trace.getActiveSpan();

  logger.info("Incoming request", {
    method: req.method,
    path: req.path,
    query: req.query,
    traceId: span?.spanContext().traceId,
    spanId: span?.spanContext().spanId,
  });

  next();
});

app.get("/", (req: Request, res: Response) => {
  const tracer = trace.getTracer("express-app");
  const span = tracer.startSpan("handle-root-request");

  try {
    logger.info("Processing root endpoint");

    span.setAttributes({
      "http.method": req.method,
      "http.url": req.url,
    });

    res.json({ message: "Hello World!" });

    span.setStatus({ code: SpanStatusCode.OK });
  } catch (error) {
    span.setStatus({
      code: SpanStatusCode.ERROR,
      message: error instanceof Error ? error.message : "Unknown error",
    });
    span.recordException(error as Error);

    logger.error("Error processing request", { error });
    throw error;
  } finally {
    span.end();
  }
});

app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  const span = trace.getActiveSpan();

  if (span) {
    span.recordException(err);
    span.setStatus({
      code: SpanStatusCode.ERROR,
      message: err.message,
    });
  }

  logger.error("Unhandled error", {
    error: err.message,
    stack: err.stack,
    traceId: span?.spanContext().traceId,
  });

  res.status(500).json({ error: "Internal Server Error" });
});

app.listen(PORT, () => {
  logger.info(`Server is running on port ${PORT}`);
});

tracing.ts

リソース属性 aws.log.group.names としてロググループ名を設定しておくことがポイントです。
こちらを設定することで後ほどログとトレースの関連付けが可能になります。

import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
import {
  LoggerProvider,
  SimpleLogRecordProcessor,
} from "@opentelemetry/sdk-logs";
import { resourceFromAttributes } from "@opentelemetry/resources";

// OTelコレクターのエンドポイント
const OTEL_COLLECTOR_URL =
  process.env.OTEL_COLLECTOR_URL || "http://localhost:4318";

// リソース情報の設定
const resource = resourceFromAttributes({
  "service.name": process.env.SERVICE_NAME || "express-app",
  "service.version": process.env.SERVICE_VERSION || "1.0.0",
  "deployment.environment": process.env.ENVIRONMENT || "development",
  "aws.log.group.names": process.env.AWS_LOG_GROUP || "otel-test",
});

// トレースエクスポーターの設定
const traceExporter = new OTLPTraceExporter({
  url: `${OTEL_COLLECTOR_URL}/v1/traces`,
  headers: {},
});

// ログエクスポーターの設定
const logExporter = new OTLPLogExporter({
  url: `${OTEL_COLLECTOR_URL}/v1/logs`,
  headers: {},
});

// ログプロバイダーの設定
const loggerProvider = new LoggerProvider({
  resource,
  processors: [new SimpleLogRecordProcessor(logExporter)],
});

// OpenTelemetry SDKの初期化
const sdk = new NodeSDK({
  resource,
  traceExporter,
  instrumentations: [
    getNodeAutoInstrumentations({
      "@opentelemetry/instrumentation-fs": {
        enabled: false, // ファイルシステムの計装はノイズが多いため無効化
      },
    }),
  ],
});

// SDKの起動
sdk.start();

// Graceful shutdown
process.on("SIGTERM", () => {
  sdk
    .shutdown()
    .then(() => console.log("OpenTelemetry SDK shut down successfully"))
    .catch((error) => console.error("Error shutting down SDK", error))
    .finally(() => process.exit(0));
});

export { loggerProvider };

logger.ts

こちらでは Winston のカスタムトランスポート設定を行います。

import winston from "winston";
import Transport from "winston-transport";
import { loggerProvider } from "./tracing";
import { SeverityNumber } from "@opentelemetry/api-logs";

const otelLogger = loggerProvider.getLogger("default");

// カスタムトランスポート設定(OTel Logs送信用)
class OpenTelemetryTransport extends Transport {
  log(info: any, callback: () => void) {
    setImmediate(() => {
      if (this.emit) {
        this.emit("logged", info);
      }
    });

    const severityMap: { [key: string]: SeverityNumber } = {
      error: SeverityNumber.ERROR,
      warn: SeverityNumber.WARN,
      info: SeverityNumber.INFO,
      debug: SeverityNumber.DEBUG,
    };

    otelLogger.emit({
      severityNumber: severityMap[info.level] || SeverityNumber.INFO,
      severityText: info.level.toUpperCase(),
      body: info.message,
      attributes: {
        ...info.metadata,
        "log.level": info.level,
      },
    });

    callback();
  }
}

// Winstonロガーの作成
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || "info",
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.errors({ stack: true }),
    winston.format.json()
  ),
  defaultMeta: { service: process.env.SERVICE_NAME || "express-app" },
  transports: [
    new winston.transports.Console({
      format: winston.format.combine(
        winston.format.colorize(),
        winston.format.simple()
      ),
    }),
    new OpenTelemetryTransport(),
  ],
});

export default logger;

ビルドします。

pnpm build

アプリケーションを起動します。

pnpm start

localhost:3000 にアクセスすると、レスポンスが返ってくるようになりました。

% curl -I http://localhost:3000
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 26
ETag: W/"1a-iEQ9RXvkycqsT4vWvcdHrxZT8OE"
Date: Wed, 14 Jan 2026 02:13:12 GMT
Connection: keep-alive
Keep-Alive: timeout=5

動作確認

CloudWatch のサービスページから確認すると、無事トレースが X-Ray に連携されていることを確認できました。

スクリーンショット 2026-01-14 11.15.11.png

また、ログが CloudWatch ログに連携されて、トレースと関連付けて確認できるようになっています。

スクリーンショット 2026-01-14 11.16.16.png

traceId と spanId も良い感じに挿入されていますね。
また、X-Ray のコンソール (CloudWatch APM のトレース機能) からトレースに関連付けられたログを検索する際、トレースのリソース属性にロググループ名が設定されている必要があります(aws.log.group.names として設定した部分)。

スクリーンショット 2026-01-14 11.21.05.png

traceId と spanId はあまり深く考えずに実装しても良い感じに挿入されましたが、こちらは忘れがちなので注意が必要です。

最後に

てっきり OTLP エンドポイント経由で AWS に送信できるのはトレースのみだと勘違いしていました...
X-Ray SDK や X-Ray Daemon のサポート終了も決まり、CloudWatch 周りの機能が OpenTelemetry に寄っていくのは避けられない流れです。

https://dev.classmethod.jp/articles/x-ray-sdk-daemon-migration-to-opentelemetry/

これからの AWS 利用に OpenTelemetry は必修科目くらいの気持ちで、しっかりキャッチアップしていきたいですね!

この記事をシェアする

FacebookHatena blogX

関連記事