RailsのログをMackerelに送る3つの方法

この記事では、Railsのログをトレースと紐付けながらMackerelに送る方法を、ファイル・Fluentd・CloudWatch Logsの3つの収集経路で紹介します。

こんにちは、Mackerel CREチームの id:kmuto です。先日発表したMackerelのログ機能β版、皆さんはもうお試しになりましたか?

mackerel.io

ただ、ログ機能のヘルプドキュメントでは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-pythonLoggingHandler、Javaでは opentelemetry-logback-appender といった公式のブリッジが提供されており、既存のログ出力コードをほぼ変更せずにOTelに統合できます。

「ではRubyは?」というと、Ruby向けのOTel Logs SDKは opentelemetry-logs-sdk gem として存在します。しかし、これはあくまで低レベルのAPIです。Railsの Rails.loggerActiveSupport::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.nameservice.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上でログ画面のトレース列をクリックすると対応するトレースを表示できます。

ログ画面でRailsのログを確認する

トレース列から対応するトレースを表示する

方法2:Fluentd経由で転送する

すでにFluentdを使ったログ収集基盤がある場合は、FluentdからOTelコレクターに転送する方法が自然です。Fluent Bitでも同様の構成が可能です。

Fluentd自体はDockerに限らずあらゆる環境で利用できます。すでにFluentdでログを収集している既存のインフラがあれば、その転送先にOTelコレクターを追加するだけで同じ構成を実現できます。

Fluentd経由で転送する

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_logjson_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する方法が有効です。

CloudWatch Logs経由で収集する

Rails側の設定はFluentd経由と同じく標準出力にJSONを書き出すだけです。フォーマッタやlogrageの設定に変更はありません。

CloudWatch Logsへのログ送信

アプリケーションの標準出力をCloudWatch Logsに送る方法は環境によって異なります。

ECSの場合は、タスク定義の logConfigurationawslogs ログドライバを設定するのが一般的です。

"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では最も現実的な方法です