Lambda Web Functionsのログの挙動を調べてみた

Lambda Web Functionsのログの挙動を調べてみた

Lambda Web Functionsのログ挙動について調べてみました。consoleメソッドが自動的に構造化ログに変換される仕組みは便利ですが、logtapeなどのロギングライブラリを使う場合は工夫が必要です。その対応方法をご紹介します。
2026.10.20

こんばんは、情報システム室の夏目です。

Lambda Web Functionsがリリースされてから少々遊んでいたのですが、ログの挙動で気になったことがあるので調べてみました。

基本

https://docs.aws.amazon.com/lambda/latest/dg/web-functions-logging.html

標準出力と標準エラー出力に書き出された内容をキャプチャし、Lambda自身が生成するシステムログとともにCloudWatch Logsに送信します。
また、Node.jsでは console.log(), console.info(), console.error() など、すべてのconsoleメソッドからの出力がキャプチャされ構造化ログに加工して出力を行います。

一元管理するロググループとコンパニオンロググループ

Web Functionではログを一元管理するロググループと、リージョン毎に作成されるコンパニオンロググループがあります。
これはエンドポイントタイプが MultiRegion や PerRegion の場合複数のリージョンでWeb Functionが実行されるためです。
コンパニオンロググループにログが書き込まれ、その後一元管理するロググループにログが集約されます。

デフォルト設定では次の名前のロググループを使用します。

  • 一元管理するロググループ: /aws/lambda/web/<Function Name>
  • コンパニオンロググループ: /aws/lambda/web/<Function Name>.local

端的に言うと、各リージョンに作成されるコンパニオンロググループの名前は一元管理するロググループの名前に .local というサフィックスを着けたものです。

Web FunctionのRevisionConfigにおいて、logGroupの名前を指定することもできます。
このとき指定しているのは一元管理するロググループです。
コンパニオンロググループは指定した名前に .local というサフィックスを着けたものになります。


boto3でWeb Functionを作成するときにロググループ名を指定するサンプル

これらのロググループはWeb Functionの実行時に存在しなければ自動で作成されます。
しかしログの保持期間などは設定されていません。
もし設定したい場合は事後に保持期間を変更したり、Web Functionと同時にロググループを明示的に作成したりしてください。

Web FunctionのExecution Role

Web FunctionsではログをCloudWatchに送信する際に、①ロググループがなければ作成し、②ロググループにログストリームを作成し、③ログストリームにPutEventを実行します。

そのためWeb FunctionのExecution Roleには logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents などの権限が必要です。

awscliでdeployしたときに自動で作成されたIAMロールについていたポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": "*"
        }
    ]
}

これらの権限が足りていなかったとしてもエラーにはなりません。
なりませんが、ログ出力が行われないだけです。

applicationLogLevel と systemLogLevel

Web FunctionsではrevisionConfigの中で applicationLogLevel と systemLogLevel を設定できます。
applicationLogLevel はアプリケーションが出力するログのレベルで、 systemLogLevel はLambdaランタイムが出力するシステムログのレベルです。

どちらもデフォルトでは INFO となっています。

特に applicationLogLevel には注意が必要です。
applicationLogLevel はNode.jsのconsoleメソッドによる出力も弾くからです。

例えば、 applicationLogLevel: "INFO" のとき console.debug() でログ出力させるように書いたとしても、CloudWatch Logsに記録されません。

構造化ログ

Node.jsのconsoleメソッドを使うとJSONログイベントとして書き込みます。
また、各イベントに requestId, endpointName, region フィールドを追加します

さらに、consoleメソッドにオブジェクトを渡すと、message フィールドにJSONオブジェクトとしてログ出力します。

ドキュメントの例(コード)
function logRequest(req, statusCode, durationMs) {
  console.info({
    method: req.method,
    path: req.url,
    statusCode: statusCode,
    durationMs: durationMs
  });
}
ドキュメントの例(出力)
{
  "timestamp": "2026-09-25T11:29:02.711Z",
  "level": "INFO",
  "message": {
    "method": "GET",
    "path": "/hello-world",
    "statusCode": 200,
    "durationMs": 100
  },
  "requestId": "US3NHLM77IRXDFLBADYHGIZZNM",
  "endpointName": "prod",
  "region": "us-east-1"
}

これにより、CloudWatch Logs InsightsやFilterPatternなどで個々のフィールドをクエリできるようになります。

console.info() でログ出力を試してみる

アプリケーションのログがどうなるのか、実際に試してみます。
特に構造化ログまわりを試していきます。

今回はコード全体は省きます。
consoleメソッドの呼び出しとログを併記した方が見やすいと思うので。

// console.info(111)
{
    "timestamp": "2026-10-20T08:50:46.515Z",
    "level": "INFO",
    "message": 111,
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info(222, 333)
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": "222 333",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info("first")
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": "first",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info("second", "third")
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": "second third",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info(undefined)
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info(null)
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": null,
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info({ first: "1st" })
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": {
        "first": "1st"
    },
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info({ second: "2nd" }, { third: "3rd" })
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": "{ second: '2nd' } { third: '3rd' }",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info([1])
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": [
        1
    ],
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info([1], [2, 3])
{
    "timestamp": "2026-10-20T08:50:46.524Z",
    "level": "INFO",
    "message": "[ 1 ] [ 2, 3 ]",
    "requestId": "MW3TFN2SUKR7BM5OKBKRIYP22Q",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info(JSON.stringify({ first: "1st" }))
{
    "timestamp": "2026-10-20T08:59:28.802Z",
    "level": "INFO",
    "message": "{\"first\":\"1st\"}",
    "requestId": "S32GQWUQ57MPSXFB2GZFATYUBM",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}
// console.info(new Error("outer", { cause: new Error("inner") }))
{
    "timestamp": "2026-10-20T09:06:24.913Z",
    "level": "INFO",
    "message": {
        "errorType": "Error",
        "errorMessage": "outer",
        "stackTrace": [
            "Error: outer",
            "    at Array.<anonymous> (file:///var/task/index.js:8:27833)",
            "    at #a (file:///var/task/index.js:8:19801)",
            "    at fetch (file:///var/task/index.js:8:20246)",
            "    at Server.<anonymous> (file:///var/task/index.js:1:17870)",
            "    at Server.emit (node:events:514:28)",
            "    at file:///var/runtime/index.js:641:35",
            "    at AsyncLocalStorage.run (node:internal/async_local_storage/async_context_frame:65:14)",
            "    at _InvokeStoreMulti.run (file:///var/runtime/index.js:582:21)",
            "    at http.Server.emit (file:///var/runtime/index.js:636:28)",
            "    at parserOnIncoming (node:_http_server:1293:12)"
        ]
    },
    "requestId": "7ACTCVMZG4AQGZOSFMPZ3G5QGY",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}

以上を踏まえて考えると、次の特徴がありそうです。

  • 引数の数が1つだけなら、渡した値の型に従って出力をする
  • 引数の数が2つなら、文字列にされて出力をする (3つ以上でもおそらく同様になると予想)
  • JSON.stringify() をすると文字列として出力される
  • Errorインスタンスを渡すと、ある程度詳細に出力してくれるが cause の中身は出してくれない

ロギングライブラリで構造化ログを出力したい

実際の開発ではロギングライブラリを使用していることも多いかと思います。
特に自分はErrorインスタンスのcauseをきちんと記録したいということでロギングライブラリで構造化することがあります。

今回は個人的に何度か使っているlogtapeで試してみます。

logtapeそのものについては以前の投稿を見てもらえればと思います。

https://dev.classmethod.jp/articles/using-js-ts-logging-library-logtape/

基本の構造化ログのまま使う

logtapeのjsonLinesFormatterを使って出力してみます。

src/index.ts
import { serve } from "@hono/node-server";
import { configure, getLogger, getConsoleSink, jsonLinesFormatter } from "@logtape/logtape";
import { Hono } from "hono";

await configure({
  sinks: {
    console: getConsoleSink({ formatter: jsonLinesFormatter }),
  },
  loggers: [
    { category: ["logtape", "meta"], sinks: ["console"], lowestLevel: "warning" },
    { category: ["app"], sinks: ["console"], lowestLevel: "debug" },
  ],
});

const logger = getLogger(["app"]);

const app = new Hono();

app.get("/", (c) => {
  logger.info("called", { unixtimeMs: new Date().getTime() });

  return c.json({ ok: true });
});

if (process.env.NODE_ENV !== "development") {
  const address = process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? "0.0.0.0:3000";
  const [hostname, port] = address.split(":");

  serve({
    fetch: app.fetch,
    port: Number(port),
    hostname,
  });
}

export default app;
ログ出力
{
    "timestamp": "2026-10-20T09:15:49.459Z",
    "level": "INFO",
    "message": "{\"@timestamp\":\"2026-10-20T09:15:49.451Z\",\"level\":\"INFO\",\"message\":\"called\",\"logger\":\"app\",\"properties\":{\"unixtimeMs\":1792487749451}}",
    "requestId": "BGPJ2NYJOMMITFSXLSW7V6U73M",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}

ログを見ると、messageフィールドが文字列になっています。
ここだけを見るとjsonLineFormatterが JSON.stringify() しているのではないかと予想できます。

結論

自分でカスタムFormatterを書くことで対応できました。

src/index.ts
import { serve } from "@hono/node-server";
import { configure, getLogger, getConsoleSink, jsonLinesFormatter } from "@logtape/logtape";
import type { LogRecord, ConsoleFormatter } from "@logtape/logtape";
import { Hono } from "hono";

const customJsonLogger: ConsoleFormatter = (record: LogRecord) => {
  const text = jsonLinesFormatter(record);
  const data = JSON.parse(text);
  return [data];
};

await configure({
  sinks: {
    console: getConsoleSink({ formatter: customJsonLogger }),
  },
  loggers: [
    { category: ["logtape", "meta"], sinks: ["console"], lowestLevel: "warning" },
    { category: ["app"], sinks: ["console"], lowestLevel: "debug" },
  ],
});

const logger = getLogger(["app"]);

const app = new Hono();

app.get("/", (c) => {
  logger.info("called", { unixtimeMs: new Date().getTime() });

  return c.json({ ok: true });
});

if (process.env.NODE_ENV !== "development") {
  const address = process.env.AWS_LAMBDA_HTTP_ENDPOINT ?? "0.0.0.0:3000";
  const [hostname, port] = address.split(":");

  serve({
    fetch: app.fetch,
    port: Number(port),
    hostname,
  });
}

export default app;
ログ出力
{
    "timestamp": "2026-10-20T09:24:18.229Z",
    "level": "INFO",
    "message": {
        "@timestamp": "2026-10-20T09:24:18.217Z",
        "level": "INFO",
        "message": "called",
        "logger": "app",
        "properties": {
            "unixtimeMs": 1792488258217
        }
    },
    "requestId": "NZIVNJ4DISZGUO2OAZ5JCFKZWE",
    "endpointName": "dev",
    "region": "ap-northeast-1"
}

今回はlogtapeでの対応方法ですが、他のロギングライブラリでも対処法はあるかと思います。

まとめ

以上、Lambda Web Functionsのログの挙動を調べてみました。

consoleメソッドで自動的に構造化ログになるのは便利ですが、もともとロギングライブラリを使っている場合など工夫が必要そうです。

何かのお役に立てたら幸いです。

おまけ: logtapeの対応をどうやったのか

logtapeによる構造化ログを渡してCloudWatch Logsで見たい、というのが自分の目的です。

なので最終的には、consoleメソッドにオブジェクトを一つだけ渡すことができれば実現できそうです。

最初に確認したのは、 getConsoleSink() の周りです。
特にログ出力を具体的にどうやっているのかとformatterの型を見ていきました。
その際に見つけたのが次のコードです。

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/sink.ts#L697-L715

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/sink.ts#L587-L592

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/formatter.ts#L1764-L1772

writeToConsole() という関数がconsoleメソッドを使ってログ出力を行う関数で、第二引数にFormatterを通した値が渡されます。

文字列ではない場合は console[method](...args) が実行されるので、ログ出力したいオブジェクトのみを持つ配列をFormatterで渡すことができればできそうです。

次にjsonLineFormatterを調べてみます。
結果だけ言うとこちらは割とシンプルでわかりやすいものでした。

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/formatter.ts#L1446-L1447

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/formatter.ts#L13-L20

https://github.com/dahlia/logtape/blob/main/packages/logtape/src/formatter.ts#L1411-L1427

jsonLineFormatterはLogRecordを受け取って文字列を返す関数であることがわかります。
jsonLineFormatter = getJsonLineFormatter() なので、getJsonLineFormatter() の返り値を確認して、(何か関数を挟んでいますが) JSON.stringify() していることがわかりました。

これで作るべきものがわかりました。

  • (record: LogRecord) => [any] という型の関数であるFormatter
  • jsonLineFormatterでJSON文字列化したものを、 JSON.parse() でオブジェクトにする
  • パースしたオブジェクトだけをもつ配列を返す

以上を踏まえて次のような関数になりました。

const customJsonLogger: ConsoleFormatter = (record: LogRecord) => {
  const text = jsonLinesFormatter(record);
  const data = JSON.parse(text);
  return [data];
};

今回はlogtapeでの対応をどうしたという話ですが、別のロギングライブラリでも同様に調査していくことになると思います。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事