Node.js アプリケーションのログとトレースを OTLP エンドポイント経由で CloudWatch Logs と X-Ray に送信してみる
概要
2024 年末に OTLP エンドポイント経由で X-Ray にトレースを送信できるようになりました。
また、いつからあったかはわかっていないですが、CloudWatch にはログ送信用の OTLP エンドポイントが存在します。
これらを利用することで、ログとメトリクスの両方を OpenTelemetry 方式で AWS に連携することが可能です。
OpenTelemetry のログ機能は Development や Beta の言語が多く、まだまだこれからといったステータスです。
とはいえ、ログを OpenTelemetry 標準に沿った形で扱えれば Fluent Bit のようなログ収集管理ツールを減らせる場合もあるでしょうし、各テレメトリの関連付けも行いやすくなります。

図は OpenTelemetry Logging | OpenTelemetry より引用
ということで、今回は Node.js アプリケーションのログとトレースを OTLP エンドポイント経由で CloudWatch Logs と X-Ray に送信しつつ、それらを関連付けて確認する所まで行ってみます。
環境セットアップ
アプリケーションは Express.js、OpenTelemetry Collector としては OpenTelemetry Collector Contrib を使用します。

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 と辿って有効化します。

Transaction Search は X-Ray に送信したスパンを自動で CloudWatch Logs に連携する機能です。
詳細は下記ブログをご参照下さい。
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.yaml の environment に設定します。
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 に連携されていることを確認できました。

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

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

traceId と spanId はあまり深く考えずに実装しても良い感じに挿入されましたが、こちらは忘れがちなので注意が必要です。
最後に
てっきり OTLP エンドポイント経由で AWS に送信できるのはトレースのみだと勘違いしていました...
X-Ray SDK や X-Ray Daemon のサポート終了も決まり、CloudWatch 周りの機能が OpenTelemetry に寄っていくのは避けられない流れです。
これからの AWS 利用に OpenTelemetry は必修科目くらいの気持ちで、しっかりキャッチアップしていきたいですね!








