
この記事では、Railsのログをトレースと紐付けながらMackerelに送る方法を、ファイル・Fluentd・CloudWatch Logsの3つの収集経路で紹介します。
こんにちは、Mackerel CREチームの
id:kmuto です。先日発表したMackerelのログ機能β版、皆さんはもうお試しになりましたか?
ただ、ログ機能のヘルプドキュメントではGo言語アプリケーション、nginx、MySQLのログをMackerelに投稿する設定例を紹介していますが、「ほかの言語の場合はどうしたら……?」とお悩みになったかもしれません。
そこでこの記事では、Ruby on Railsアプリケーションを例に、Mackerelへログを送る方法を紹介します。なお、アプリケーションにはMackerelのAPM機能を利用するためのOpenTelemetryトレース計装がすでに行われているものとします(参考:RubyのアプリケーションからMackerelにトレースを送信する)。
目次
OTelログ送信の王道はログブリッジ!……なのですが
Mackerelのログ機能が受け付けるのは、OpenTelemetry(OTel)形式のログのみです。では、Railsアプリケーションが出力するログをどうやってOTel形式に変換すればよいのでしょうか。
他の言語では、ログブリッジという仕組みでこの変換を行うのが王道です。
OTelにおけるログブリッジとは、各言語の既存ロギングライブラリ(Pythonのlogging、JavaのLogbackなど)とOTel SDKを接続するアダプターです。ブリッジを組み込むと、アプリケーションコードの logger.info(...) がそのままOTel形式のログレコードに変換され、OTLPでコレクターに送信されます。トレースとの紐付けも自動です。
たとえばPythonでは opentelemetry-python の LoggingHandler、Javaでは opentelemetry-logback-appender といった公式のブリッジが提供されており、既存のログ出力コードをほぼ変更せずにOTelに統合できます。
「ではRubyは?」というと、Ruby向けのOTel Logs SDKは opentelemetry-logs-sdk gem として存在します。しかし、これはあくまで低レベルのAPIです。Railsの Rails.logger や ActiveSupport::Logger に透過的に接続するブリッジgemは、現時点(2026年7月)では提供されていません。
OTel Logs SDKを直接使ってログを送ること自体は可能ですが、ログ1件ごとに on_emit でbody、severity、attributes、trace contextなどをすべて個別に指定する必要があります。
# OTel Logs SDKの直接利用例(セットアップは省略) otel_logger.on_emit( body: "#{request.request_method} #{request.path_info} #{response.status}", severity_number: OpenTelemetry::Logs::SeverityNumber::SEVERITY_NUMBER_INFO, severity_text: 'INFO', attributes: { 'http.request.method' => request.request_method, ... }, trace_id: span_context.trace_id, span_id: span_context.span_id, trace_flags: span_context.trace_flags, )
小規模なアプリケーションであれば使えますが、Railsアプリケーションの既存コードにある Rails.logger.info(...) やlogrageの出力をすべてこの形式に書き換えるのは現実的ではありません。
そこで以降では、Railsの既存のロギング機構はそのまま活かし、JSON形式で出力したログをOTelコレクターで収集するというアプローチを紹介します。ログブリッジが担う「OTel形式への変換」と「トレースとの紐付け」を、RailsのログフォーマッタとOTelコレクターの組み合わせで実現する方法です。
方法1:ログファイルをコレクターで読み取る
最初に紹介するのは、最もシンプルな方法です。RailsがファイルにJSON形式でログを書き出し、OTelコレクターの file_log レシーバーがそれを読み取ります。
Rails側:ログをJSONにする
OTelコレクターでログを構造的に扱うには、Railsのログ出力を1行JSONに変換することが出発点です。
Railsのデフォルトのログ形式は人間が読むことを前提とした複数行テキストですが、これをそのまま収集すると、パースが困難で、フィールド単位のフィルタリングもできません。カスタムフォーマッタを設定し、すべてのログを1行のJSONとして出力するようにします。
# config/environments/production.rb logger = ActiveSupport::Logger.new(Rails.root.join("log", "production.jsonl")) logger.formatter = proc do |severity, time, _progname, msg| entry = { "timestamp" => time.utc.strftime("%Y-%m-%dT%H:%M:%S.%3NZ"), "severity" => severity } case msg when Hash # logrageはHashでリクエスト情報を渡してくる entry.merge!(msg.stringify_keys) when Exception # rescue_from等で捕捉されなかった例外 entry["message"] = msg.message entry["backtrace"] = (msg.backtrace || []).first(30).join("\n") when String next "" if msg.start_with?(" \n") # DebugExceptionsの重複出力を除去 entry["message"] = msg else entry["message"] = msg.to_s end # トレースコンテキストを埋め込む span = OpenTelemetry::Trace.current_span if span.context.valid? entry["trace_id"] = span.context.hex_trace_id entry["span_id"] = span.context.hex_span_id end "#{entry.to_json}\n" end config.logger = logger
ポイントは2つあります。
- すべてのログが1行JSON:timestamp、severity、メッセージ、エラー情報がすべて1つのJSONオブジェクトに収まります。OTelコレクターの
json_parserで直接パースできます - trace_id / span_id の埋め込み:OTel SDKの現在のスパンからトレースコンテキストを取得してJSONに含めます。コレクター側でこれをOTelネイティブのフィールドに昇格させることで、Mackerel上でログとトレースが自動的に紐付きます
また、Railsのリクエストログは通常複数行にわたりますが、lograge gemで1行に集約させます。
config.lograge.enabled = true config.lograge.base_controller_class = "ActionController::API" # APIモードの場合のみ必要 config.lograge.formatter = Lograge::Formatters::Raw.new # Hash のまま渡す
ここでは Lograge::Formatters::Raw.new を指定してHashのままロガーに渡すようにしました。JSON化はフォーマッタが一元的に行うため、logrageには任せません。
コレクターからログファイルを読む
Railsとコレクターが同一ホストで動いている場合は、コレクターの file_log レシーバーでログファイルのパスを直接指定するだけで済みます。ECSではタスク定義でアプリケーションコンテナとコレクターコンテナをサイドカー構成にし、ボリュームを共有します。Kubernetesでも同様に、同じPod内でログディレクトリを emptyDir で共有できます。
Docker Composeでローカル開発している場合は、Docker named volumeでログファイルを共有するとよいでしょう。
# compose.yaml services: sample-app: ... volumes: - app-logs:/app/log # Rails が書き出すログディレクトリ otel-collector: image: ghcr.io/mackerelio/opentelemetry-collector-mackerel/otelcol-mackerel:latest volumes: - ./otel-collector-config.yaml:/home/nonroot/config.yaml - app-logs:/app/log # 同じボリュームをマウント command: ["--config", "/home/nonroot/config.yaml"] environment: MACKEREL_APIKEY: "${MACKEREL_APIKEY}" OTELCOL_MACKEREL_HOST: "otel-collector" volumes: app-logs:
OTelコレクター:ログの読み取りとトレース紐付け
OTelコレクターには、Mackerelへの送信に必要なエクスポーター設定が組み込まれているMDOT(Mackerel Distro of OpenTelemetry)コレクターを使うことにします。APIキーの設定は環境変数で注入します。
# otel-collector-config.yaml receivers: file_log/app: include: ["/app/log/*.jsonl"] start_at: end operators: - type: json_parser parse_to: body on_error: send timestamp: parse_from: body.timestamp layout: '%Y-%m-%dT%H:%M:%S.%LZ' severity: parse_from: body.severity processors: resource: attributes: - key: service.name value: "sample-app" action: insert - key: service.namespace value: "my-namespace" action: upsert transform/log_common: log_statements: - context: log statements: # JSON内のtrace_id/span_idをOTelの属性に昇格 - set(trace_id.string, body["trace_id"]) where IsMap(body) and body["trace_id"] != nil - delete_key(body, "trace_id") where IsMap(body) - set(span_id.string, body["span_id"]) where IsMap(body) and body["span_id"] != nil - delete_key(body, "span_id") where IsMap(body) exporters: mackerel_otlp: # これだけでMackerelへの送信設定は完了 service: pipelines: logs: receivers: [file_log/app] processors: [resource, transform/log_common] exporters: [mackerel_otlp]
file_log レシーバーがログファイルをウォッチし、json_parser オペレーターでJSONをパースします。parse_to: body を指定しているため、JSONのフィールドは body.* に展開されます。
resource プロセッサでは、ログに service.name と service.namespace を付与しています。ファイルから読み取ったログにはこれらの情報が含まれないため、コレクター側で明示的に設定する必要があります。insert は属性がまだ存在しない場合のみ追加し、upsert は既存の値があっても上書きします。service.name はトレースなど他のパイプラインと resource プロセッサを共有する場合に既存の値を尊重できるよう insert にしています。一方 service.namespace はコレクター側で一元管理したい属性なので、常に上書きする upsert を使っています。
on_error: send により、JSON形式でない行(Railsの起動メッセージなど)もパースエラーで廃棄せず、非構造化ログとしてそのまま取り込みます(on_error: sendはデフォルト値なので、この行は省略可能です)。
transform/log_common プロセッサでは、Railsのフォーマッタが埋め込んだ trace_id / span_id をOTelの属性に昇格させています。これにより、Mackerel上でログ画面のトレース列をクリックすると対応するトレースを表示できます。
方法2:Fluentd経由で転送する
すでにFluentdを使ったログ収集基盤がある場合は、FluentdからOTelコレクターに転送する方法が自然です。Fluent Bitでも同様の構成が可能です。
Fluentd自体はDockerに限らずあらゆる環境で利用できます。すでにFluentdでログを収集している既存のインフラがあれば、その転送先にOTelコレクターを追加するだけで同じ構成を実現できます。
Rails側:標準出力に切り替える
Rails側のログフォーマッタやlogrageの設定はファイル経由の場合と同じです。出力先だけを標準出力に切り替えます。
logger = ActiveSupport::Logger.new(STDOUT)
アプリケーションの標準出力をFluentdに取り込む方法は環境によって異なります。ECSであればFireLensやログドライバで、Kubernetesであればサイドカーやノードレベルのログ収集で、VMであればsystemdのジャーナルから転送するなど、さまざまな方法があります。
Fluentd:コレクターへの転送設定
Fluentdで受け取ったログをOTelコレクターに転送します。以下は最小限のfluent.confの例です。
<!-- fluentd/fluent.conf --> <source> @type forward port 24224 bind 0.0.0.0 </source> <match **> @type copy <store> @type stdout </store> <store> @type forward <server> host otel-collector port 8006 </server> </store> </match>
<source> でFluentdの入力を受け付け、<match> でOTelコレクターに転送しています。@type stdout にもコピーしておくと、Fluentdのログでメッセージの中身を確認できて便利です。
Docker Composeでローカル開発している場合は、Dockerのfluentdログドライバでアプリケーションの標準出力をFluentdに転送できます。
# compose.yaml services: sample-app: ... depends_on: - otel-collector - fluentd logging: driver: "fluentd" options: fluentd-address: "localhost:24224" tag: "sample-app" fluentd-async: "true" # fluentd停止時にアプリケーションをブロックしない fluentd: image: public.ecr.aws/docker/library/fluentd:v1.18-1 volumes: - ./fluentd/fluent.conf:/fluentd/etc/fluent.conf ports: - "127.0.0.1:24224:24224" - "127.0.0.1:24224:24224/udp"
OTelコレクター:JSONパースとseverity変換
ファイル経由との大きな違いは、Fluentdから届くログはJSON文字列のままという点です。file_log の json_parser のように自動パースされないため、コレクター側でJSONパースとseverityマッピングを行う transform/fluent_forward プロセッサを前段に挟みます。
# otel-collector-config.yaml # ※ exporters, resource, transform/log_common は方法1と同じためここでは記載を省略 receivers: fluent_forward: endpoint: 0.0.0.0:8006 processors: transform/fluent_forward: error_mode: silent log_statements: - context: log statements: # 文字列のままのbodyをJSONにパース - set(body, ParseJSON(body)) where IsString(body) # severity文字列をOTelのseverityに変換 - set(severity_text, body["severity"]) where body["severity"] != nil - set(severity_number, SEVERITY_NUMBER_DEBUG) where body["severity"] == "DEBUG" - set(severity_number, SEVERITY_NUMBER_INFO) where body["severity"] == "INFO" - set(severity_number, SEVERITY_NUMBER_WARN) where body["severity"] == "WARN" or body["severity"] == "WARNING" - set(severity_number, SEVERITY_NUMBER_ERROR) where body["severity"] == "ERROR" - set(severity_number, SEVERITY_NUMBER_FATAL) where body["severity"] == "FATAL" service: pipelines: logs/fluentforward: receivers: [fluent_forward] processors: [resource, transform/fluent_forward, transform/log_common] exporters: [mackerel_otlp]
file_log 経由のログパイプライン(logs)とは別に、logs/fluentforward パイプラインとして独立させています。これは、Fluentd経由では transform/fluent_forward(JSONパース + severity変換)を追加で通す必要があるためです。その後の transform/log_common(trace_id/span_idの昇格)は共通で使います。
方法3:CloudWatch Logs経由で収集する
AWS環境ですでにCloudWatch Logsにログを集約している場合は、既存のログ収集経路を活かしつつ、awscloudwatch レシーバーでOTelコレクターにPullする方法が有効です。
Rails側の設定はFluentd経由と同じく標準出力にJSONを書き出すだけです。フォーマッタやlogrageの設定に変更はありません。
CloudWatch Logsへのログ送信
アプリケーションの標準出力をCloudWatch Logsに送る方法は環境によって異なります。
ECSの場合は、タスク定義の logConfiguration で awslogs ログドライバを設定するのが一般的です。
"logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-region": "ap-northeast-1", "awslogs-group": "/ecs/sample-app", "awslogs-stream-prefix": "app" } }
EC2やオンプレミスの場合は、CloudWatch Agentでログファイルや標準出力をCloudWatch Logsに送信できます。
OTelコレクター:CloudWatch Logsからのポーリング
OTelコレクターがCloudWatch Logs APIで定期的にポーリングしてログを取得します。
# otel-collector-config.yaml # ※ exporters, resource, transform/log_common は方法1と同じためここでは記載を省略 receivers: awscloudwatch: region: "ap-northeast-1" logs: poll_interval: "30s" groups: named: "/ecs/sample-app": processors: transform/cwl: error_mode: silent log_statements: - context: log statements: # CloudWatch Logsから届くbodyはJSON文字列のままなのでパースする - set(body, ParseJSON(body)) where IsString(body) service: pipelines: logs: receivers: [awscloudwatch] processors: [resource, transform/cwl, transform/log_common] exporters: [mackerel_otlp]
CloudWatch Logsから取得したログのbodyはJSON文字列のままなので、transform/cwl プロセッサでパースしたうえで、transform/log_common でtrace_id/span_idの昇格を行います。
なお、OTelコレクターがCloudWatch Logs APIを呼び出すため、IAMの認証設定が必要です。ECSの場合はタスクロール、EC2の場合はインスタンスプロファイルで logs:GetLogEvents / logs:FilterLogEvents などの権限を付与します。
まとめ
RailsのログをMackerelに送るにあたって、おさえるべきポイントをまとめます。
- ログは1行JSONで出力する:OTelコレクターで構造的に扱うための大前提です。カスタムフォーマッタですべてのログをJSON化し、logrageでリクエストログを1行に集約します
- trace_id / span_idをログに埋め込む:フォーマッタ内で
OpenTelemetry::Trace.current_spanから取得してJSONに含めます。コレクター側でOTelネイティブのフィールドに昇格させることで、Mackerel上でログとトレースが紐付きます - 収集経路はインフラに合わせて選ぶ:ファイル経由(
file_log)、Fluentd経由(fluent_forward)、CloudWatch Logs経由(awscloudwatchlogs)のいずれも、Rails側の設定は共通です。出力先とコレクターのレシーバーだけが変わります - コレクターにはMDOTコレクターを使う:MDOT(Mackerel Distro of OpenTelemetry)コレクターにはMackerel向けのエクスポーターが組み込まれており、
mackerel_otlp:の1行でエクスポーター設定が完了します - Ruby向けのOTelログブリッジはまだない:現時点ではRailsの
Rails.loggerをOTel SDKに透過的に接続するブリッジgemは提供されていません。本記事のように既存のロガーを活かしてコレクターで収集するアプローチが、今のRuby/Railsでは最も現実的な方法です