/var/log/jsoizo

メモ帳 技術とか趣味とか

JR東の首都圏の駅名でベストイレブン組んでみた

首都圏を走るJRの電車で通勤通学してるとイヤでも覚える↓の図

https://www.jreast.co.jp/map/pdf/map_tokyo.pdf

大学生の頃に横浜線で通学していて、「苗字みたいな駅多いな〜」って思ってたことがあり、これをみながら打線組んだりしていた。

今回は思いつきでサッカーチーム11人を選出をしてみる。 選考基準は特にないが、なんか乗降客数多い駅とか、サッカー的実績や所属クラブ有名な感じでチョイス。

GK

渋谷 飛翔(山形 - 山手線, 埼京線, 湘南新宿ライン)

駅名ベストイレブンの守護神にしてキャプテン。
乗降客数世界第2位の渋谷駅。メッシが新宿なら渋谷はクリロナ。
世界でもトップクラスに知名度のある駅前のスクランブル交差点があり、東京に来たインバウンドが一度は訪れる駅。

あとGKなのになんか苗字のせいでやたらチャラい印象。
グランパス時代の彼を知る限り落ち着いた人柄なのでギャップがすごい。

www.jleague.jp

サッカー的実績でいうと鹿島の早川(鹿島 - 東海道線)一択だろうが、
それで選んじゃったら面白くない。駅の格が違いすぎる。

DF

千葉 和彦(琉球 - 総武線)

デジっち枠。そっちもいけちゃうこのイレブン最高なのでは。

千葉駅、千葉県の代表駅ではあるものの、実は乗降客数的には千葉県内では5位と思ったより高くない。
西船橋や柏みたいに乗り換えがあまりないからね。懸垂式モノレールがいるのは良いよね。

なお、こないだ長谷川アーリアジャスール氏のトークイベント行ったとき、
出てきた軽食をつまみながら「グルテンフリーはやめました」って言ってて、 千葉ちゃんがグランパスに残したものを思い出した。

www.jleague.jp

大﨑 玲央(札幌 - 山手線)

通勤してるときに使ってるので選出。3人目にして選考基準が曖昧になってきた。 2010年ワールドカップでフランス代表監督のドメネクが星占いでメンバー選考したのは有名だが、それよりはマシ。

昔は山手線乗ろうとして大崎行きが来ると舌打ちしたものである。
大崎駅のキャラクターのおうさきは推せる。

www.jleague.jp

町田 浩樹(ホッフェンハイム - 横浜線)

ACL断裂中の現役日本代表CB。左利きCBという貴重な選手を入れられるのはデカい。

サッカー的実績もさることながら駅としてもなかなか大きい駅なのもよい。
町田駅、乗降客数が都内で14位となかなかに多く、大半の人間がJR小田急間のデッキを早歩きで駆け抜けていく。
淵野辺に通っていた大学生時代、暇があれば町田で降りてブックオフで古本を立ち読みするか、リブロで技術書を立ち読みして、どみそ食べて帰っていた。

www.jfa.jp

MF

宮原 和也(東京V - 高崎線)

イケメン枠。いい感じのMFが選べなかったから選出。 名古屋時代にDFにコンバートされたデビュー時はボランチだったからね。

路線図的には特徴ない。うん。埼玉というか北関東は本当に何も知らないから書けることない。
大宮の近くってことで思い出したが久しぶりに鉄博いきたい。
氷川神社の参道をあるいてNACK5もいきたい。

www.jleague.jp

橋本 拳人(F東京 - 横浜線)

同チームのどっちのけんとにするか悩んだが流石に実績重視。
町田 - 橋本ってラインが作れてるのが元横浜線ユーザ的にはアツい。

ベテランの域にかかった同選手ではあるものの、
橋本駅としてはリニアの神奈川県駅ができる予定であり未来しかない。
とはいえ、現状は橋本駅の近くにはまともな施設はなく、相模原市の代表駅としても相模大野のほうが大きいしなんとなく物足りないターミナル駅という印象。

www.jleague.jp

川﨑 颯太(マインツ - 東海道線, 京浜東北線, 南武線)

海外組でU-23代表、政令指定都市の代表駅と、本チームの選考基準で一番欠点ないんじゃないか。
強いて言うなら川崎駅の南側の治安の悪さくらいか。
北のラゾーナ中心のきれいな川崎と南の伝統的な川崎の対比はいつ訪れても面白い。

川崎選手、京都からレンタルでマインツに行ってたけど買取決まったんですね。
次回のワールドカップで主力張っているかもしれないよねー。

www.jfa.jp

中野 就斗(広島 - 中央線)

あまり知らない。こないだの100年構想リーグ最終節のゴールはうまかった。
ミシャ式にめっちゃ合いそう。ください。

中野駅といえば、去年に中野セントラルパークいってめっちゃいいじゃん!ってなった。
あれだけ広い公園があると子育て環境としても良さそうなんだよな。
それとは裏腹に中野サンプラザの再開発はいったいどうなるのかと暗雲が立ち込める駅前。

www.jleague.jp

FW

宇佐美 貴史(G大阪 - 伊東線)

このネタを思いつくきっかけになった大天才。
路線図的には端っこも端っこであるが、流石にこの天才をチョイスしないのはおかしい。

バイエルン時代のプレシーズンマッチで途中出場したあたりまでは夢を見た。
ACL2の決勝戦で奥抜が無理矢理ドリブルで突破しようとしたのをかっさらわれたあと鬼プレスバックしてたのは感動した。

宇佐美駅がどんなところかは想像もついてないですが、たぶん僻地でしょう。

www.jleague.jp

神田 奏馬(川崎 - 山手線, 京浜東北線, 中央線快速)

駅的にも都会だし、サッカー実績的にもU-21代表のホープなので選出。
どちらにも思い入れはないので何も書くことない。
同じフロンターレつながりでより実績のある小林 悠(川崎 - 成田線)も選べたが、
森保一の言葉を借りるならば「同じ能力なら若い選手を選ぶ」である。

なお、川崎フロンターレが得点決めたときの電光掲示板の演出、本当にトラウマ。
フロンターレがイヤで蕁麻疹出ちゃうので、義実家が近いのだが川崎市だけには住みたくない。

www.jleague.jp

浅野 拓磨(マジョルカ - 鶴見線)

浅野駅、鶴見線とかいう鉄ヲタ好みな路線かつ海芝浦支線との分岐駅でホームのかたちが独特という、
なんか大舞台で決めちゃった浅野選手となんとなく重なるものがある。

Was ist das?

www.jfa.jp

最終的にこうなる

こうやって並べるとなかなか強くね?
若手から大ベテランまで幅広く、代表またはJベストイレブン級を揃えつつ駅知名度的にも抜群。
それぞれの全盛期で考えたらJ1は軽く優勝できそう。

 宇佐美 神田 浅野

川崎 宮原  橋本 中野

  町田 千葉 大﨑

     渋谷

蛇足

引退選手を含めると、川口とか戸田とか中山とか伊東とかA代表経験者が結構いるんだよね。
そこまで広げたらどうなるかは思いついたらやる(やらない)

久しぶりにAI使わずに好き勝手に文章を書いた。

追記

栃木に佐野駅あったな。現役W杯プレーヤー選べたじゃんw 宮原好きだし更新はしないでおこう。

MySQLコンテナで初期データの投入が完了してからヘルスチェックが通ってほしい

想定ケースとして、mysqlを使ったアプリケーション開発をしているとして、こんな compose.yml を用意している場合を考える。

  db:
    container_name: awesome-db
    image: mysql:8.4
    ports:
      - "3306:3306"
    environment:
      MYSQL_ROOT_PASSWORD: awesome_root_password
      MYSQL_DATABASE: awesome_database
      MYSQL_USER: awesome_user
      MYSQL_PASSWORD: awesome_password
    volumes:
      - ./local-containers/db/scripts/init_data.sql:/docker-entrypoint-initdb.d/init_data.sql:ro
      - data-volume:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "--silent"]
      interval: 5s
      timeout: 3s
      start_period: 30s
      retries: 20
    restart: always
  app:
    depends_on:
      db:
        condition: service_healthy

期待と問題

この場合、 ヘルスチェックは 初期データ投入SQLの init_data.sql の実行が終わってから HEALTHY となってほしい

後続のコンテナの起動制御、たとえば

  • アプリケーションサーバによるDBマイグレーション
  • e2eテストの実行のためのデータセットアップ

などが自動的に確実に行うことができて、 e2eテストをCI環境で安定して動かすことができるから。

ところが、ヘルスチェックの書き方がこう

    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "--silent"]

だと、初期化SQLファイル init_data.sql が実行される前 にヘルスチェックを返し始めるので若干厄介。

解決策

これをこのようにするだけで解決する。

差分はホストとポートの指定と --protocol=TCP オプション。

    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-P", "3306", "--protocol=TCP"]

なぜそうなるか

MySQLコンテナの起動時の entrypointの仕様を元に確認する。

https://github.com/docker-library/mysql/blob/master/8.4/docker-entrypoint.sh#L362-L414

MySQLデータベースが初期化がまだ = データファイルが存在しない場合、以下の処理を通る。

_main() {

    ### 中略 ###

    if []; then
        # there's no database, so it needs to be initialized
        if [ -z "$DATABASE_ALREADY_EXISTS" ]; then
            docker_verify_minimum_env

            # check dir permissions to reduce likelihood of half-initialized database
            ls /docker-entrypoint-initdb.d/ > /dev/null

            docker_init_database_dir "$@"

            mysql_note "Starting temporary server"
            docker_temp_server_start "$@"
            mysql_note "Temporary server started."

            mysql_socket_fix
            docker_setup_db
            docker_process_init_files /docker-entrypoint-initdb.d/*

            mysql_expire_root_user

            mysql_note "Stopping temporary server"
            docker_temp_server_stop
            mysql_note "Temporary server stopped"

            echo
            mysql_note "MySQL init process done. Ready for start up."
            echo
        fi
    fi
    exec "$@"
}

要約するとこのような順序で処理を行っている。

  1. データディレクトリを初期化する docker_init_database_dir "$@"
  2. 初期化用の一時的な mysqld を起動する docker_temp_server_start "$@"
  3. MYSQL_DATABASEMYSQL_USER などを反映する docker_setup_db
  4. /docker-entrypoint-initdb.d/* を処理する docker_process_init_files
  5. 一時的な mysqld を停止する docker_temp_server_stop
  6. 最後に exec "$@" で本来の mysqld を起動する

ポイントは docker_temp_server_start で、ここでは --skip-networking--socket="${SOCKET}" を指定して mysqld を起動している。つまり、この時点の temporary server は TCP/IP 接続を受け付けず、Unix socket 経由でのみ接続される。

docker_temp_server_start() {
    # For 5.7+ the server is ready for use as soon as startup command unblocks
    if ! "$@" --daemonize --skip-networking --default-time-zone=SYSTEM --socket="${SOCKET}"; then
        mysql_error "Unable to start server."
    fi
}

そのため、ヘルスチェックで mysqladmin ping -h localhost を使うと、MySQL クライアントの localhost が Unix socket 接続として扱われ、初期化用の temporary server に接続できてしまう可能性がある。

一方で、mysqladmin ping -h 127.0.0.1 -P 3306 --protocol=TCP とすれば TCP 接続に限定される。temporary server は --skip-networking で起動しているためこの段階では接続できず、/docker-entrypoint-initdb.d/*.sql の実行完了後、最後に exec "$@" で起動された本来の mysqld が TCP listen し始めてから HEALTHY になる。

これでMySQLが確実に起動したことを保証してから後続のアプリケーションの処理が動かせる。

なお、 mysqladmin ping はあくまでサーバの接続チェックしかしないので、 user/passwordを使って実際のデータベースにアクセスできることを確認したほうがより厳格ではある。

はてなブックマークをWebhookで通知するサービスを作った

こういうサービスを作った。

hatebu-webhook.jskmt.workers.dev

何ができるか

はてなブックマークの公開ブックマークを定期的に監視し、変更があったら任意のURLにWebhookでPOSTする。

検知できるイベントは以下の3種類。

  • new - 新規ブックマーク追加
  • tag_changed - 既存ブックマークのタグ変更
  • comment_changed - 既存ブックマークのコメント変更

タグでフィルタリングもできて、例えば「tech タグがついたブックマークだけ通知」みたいな設定も可能。AND条件・OR条件を選べる。

Webhookの送信先は任意のURLを指定できるので、SlackのIncoming WebhookでもDiscordでも自前のサーバでも何でもいける。

Webhookの作成画面

作った動機

自分のブクマをSlackやDiscordに流して「あとで読む」用にストックしたり、ブクマしたURLをクロールしてスナップショットを取る、みたいな自動化をやりたかった。

以前ははてなブックマークの設定画面からWebhookのURLを登録できる機能があった気がするのだが、今は見当たらない。IFTTTのはてなブックマーク連携も2024年に終了しており、代替手段が見つからなかったので自分で作ることにした。

仕組み

1時間ごとにCloudflare Workers上でCronが動き、登録ユーザーのRSSフィードを取得する。最新100件のブックマークについて、前回取得時のスナップショット(URLとタグとコメントのハッシュ)と比較して、差分があればWebhookを発火する。

なお、削除の検知はしていない。RSSには削除イベントが含まれないので、やるならユーザーページのスクレイピングが必要になってちょっと面倒だった。

Webhookペイロード

こんな感じのJSONがPOSTされる。

{
  "event": "new",
  "timestamp": "2024-01-15T12:34:56.789Z",
  "webhook": {
    "id": "abc123",
    "name": "My Webhook"
  },
  "bookmark": {
    "url": "https://example.com/article",
    "title": "記事タイトル",
    "comment": "コメント内容",
    "tags": ["tech", "programming"],
    "bookmarked_at": "2024-01-15T12:30:00.000Z"
  },
  "user": {
    "hatena_id": "username"
  }
}

詳細な仕様はサービス内の「Webhook仕様」ページに書いてある。

技術スタック

  • Cloudflare Workers
  • Cloudflare D1(SQLiteベースのDB)
  • Cloudflare Cron Triggers(定期実行)
  • HonoX(Webフレームワーク)
  • drizzle-orm
  • valibot

全部Cloudflareで完結しているので運用コストはほぼゼロ。D1の無料枠で十分収まる規模だし。

今後

一通りやりたいことはできたので、特にこれ以上発展させる予定はない。

ブクマの削除検知は技術的にはできるが、RSSでは取れないのでスクレイピングが必要になり、はてな側の負荷を考えると微妙だなと思ってやめた。

ひとまず自分のユースケースは満たせているので、もし使いたい人がいたらどうぞという感じ。

PHPで型安全なエラーハンドリングをやりたくてResult型ライブラリを作った

こういうライブラリを作った。

packagist.org

PHPでResult型が必要な理由

PHPの標準的なエラーハンドリングは例外に依存している。ただ、例外ベースのアプローチにはいくつか問題がある。

まず、例外は関数シグネチャに現れない。function getUser(int $id): User というシグネチャからは、この関数が UserNotFoundExceptionDatabaseException を投げる可能性があることが読み取れない。PHPDocの @throws アノテーションは書けるが、強制力がない。

また、例外のハンドリングを強制できない。try-catchを書き忘れても、コンパイラ(や静的解析ツール)は警告しない。結果として、想定外の例外がアプリケーションの上位層まで伝播し、500エラーになる。

Result型はこれらの問題に対する解決策で、エラーを「値」として扱い、関数の戻り値型に明示することで、エラーハンドリングを型システムで強制できるようになる。

// 例外ベース: エラーの可能性がシグネチャから見えない
function getUser(int $id): User { ... }

// Result型: 失敗する可能性が型で明示される
/** @return Result<User, UserNotFound|DatabaseError> */
function getUser(int $id): Result { ... }

呼び出し側は Result を受け取った時点で、成功・失敗の両方を処理する責任を負う。PHPStanを使えば、この処理漏れを静的解析で検出できる。

PHPでのResult型の必要性などは以下のスライドがわかりやすい。

Result型で“失敗”を型にするPHPコードの書き方 - Speaker Deck

なお、PHPに限らずResult型の必要性はあらゆるところで議論されているものの、ジェネリクスが言語レベルで存在しないPHPにおいてはまだまだ一般的になっているとは感じない。仕事で書いているPHPコードにもまだ入れられていないし。

自作したモチベーション

既存のPHPでResult型を実装したライブラリ、例えば最も普及しているgraham-campbell/result-typeは存在するのだが、以下のような課題を抱えている。

  • graham-campbell/result-typeはmap/flatMap/mapError程度で、recoverfoldがない
  • 基本的な@templateによる型付けはあるが、match式の網羅性チェックがない
  • phpoption/phpoptionに依存している
  • PHP 7.2+対応のため、never型や共変性が活用できない

Issue #3API拡張のリクエストがあるが対応されておらず、自分で作ることにした。

このライブラリで出来ること

実用的なAPIの提供

ScalaのEither/Try、RustのResult、Kotlinのarrow-ktを参考に、実際の開発で必要になるメソッドを一通り実装した。

メソッド 説明
map($fn) 成功値を変換
mapError($fn) エラー値を変換
flatMap($fn) Resultを返す操作をチェーン
fold($onFailure, $onSuccess) 両方のケースを処理して値を返す
recover($fn) 値でエラーから回復
recoverWith($fn) Resultでエラーから回復
tap($fn) / tapError($fn) 副作用を実行(ログ出力等)
getOrElse($default) 値またはデフォルトを取得
getOrNull() 成功なら値、失敗ならnull

ネストを防ぐbindingブロック

複数の処理を flatMap で合成すると、ネストが深くなる。

$result = Result::catch(fn() => $orderRepo->find($orderId))
    ->flatMap(fn($order) =>
        Result::catch(fn() => $order->loadItems())
            ->flatMap(fn($items) =>
                Result::catch(fn() => $this->validate($items))
                    ->flatMap(fn($validated) =>
                        Result::catch(fn() => $this->process($validated))
                    )
            )
    );

バリデーションなど複数のResultを処理する必要があるときに厄介である。

ここで、arrow-ktのEitherでいうところのeither {}ブロックのように、ネストを防ぐためにResult::bindingというstatic関数を用意している。途中でFailureが発生した場合、そこで処理が短絡(early return)される。

$result = Result::binding(function () use ($orderId) {
    /** @var Order $order */
    $order = yield Result::catch(fn() => $orderRepo->find($orderId));
    /** @var list<Item> $items */
    $items = yield Result::catch(fn() => $order->loadItems());
    /** @var list<Item> $validated */
    $validated = yield Result::catch(fn() => $this->validate($items));
    return $this->process($validated);
});

PHPのジェネレータの制約上、yieldの戻り値の型推論が効かないのでPHPDocコメントが多くなるのが欠点。このぱっとみたときのわかりづらさと、インデントが深くなることの辛さのどちらを取るかが悩ましい。

PHPStanとのインテグレーション

静的解析ツールPHPStanのアノテーション機能を極力使うようにして、安全に扱えるようにしている。PHPStanのlevel設定は9〜10くらいの厳しさで設定したときに適切にエラーが出てくれる。

Sealed Class

Result型が@phpstan-sealedを使用してsealedクラスとしてマークされているので、Success/Failure以外から継承したときにPHPStanに怒られるようになっている。

ただ@phpstan-sealedアノテーションはあくまで「指定したクラス以外からの継承を許さない」ことしか検知しないので、ScalaやKotlinで代数的データ型の実装のためにsealedを利用している身としては足りなさを感じる。

Match式の網羅性チェック(カスタムルール)

このライブラリとしてカスタムのルールを設けており、match式の中でSuccess/Failureの片方しか網羅できていない場合にエラーが吐かれるようにしている。

// PHPStanエラー: Match expression on Result type is not exhaustive. Missing: Failure.
match (true) {
    $result instanceof Success => 'success',
};

型ナローイング

isSuccess()/isFailure()の結果に応じて、PHPStanが型を適切に推論する。

if ($result->isSuccess()) {
    // PHPStanがSuccess<T, E>と推論 → get()が安全に呼べる
    $value = $result->get();
} else {
    // PHPStanがFailure<T, E>と推論 → getError()が安全に呼べる
    $error = $result->getError();
}

Zero Dependency

Result型という基盤的な型のために、他のライブラリを引き込みたくない。

graham-campbell/result-typeはOption型を提供するphpoption/phpoptionに依存するが、本ライブラリは依存ゼロで実装している。シンプルなエラーハンドリングをしたいだけなのに、Composer依存ツリーが肥大化することを避けられる。

他言語との比較と設計判断

本ライブラリはRustのResult<T, E>ScalaのEither/Try、Kotlinのarrow-ktを参考にしている。ただし、PHPの言語仕様の制約により、完全に同じ体験を提供できない部分がある。

パターンマッチがない

Rustではこう書ける:

match result {
    Ok(value) => println!("Success: {}", value),
    Err(e) => println!("Error: {}", e),
}

PHPには言語レベルのパターンマッチがないため、match式とinstanceofの組み合わせで代替している:

match (true) {
    $result instanceof Success => "Success: " . $result->get(),
    $result instanceof Failure => "Error: " . $result->getError(),
};

あるいはfoldメソッドで両方のケースを処理する:

$result->fold(
    fn($error) => "Error: " . $error,
    fn($value) => "Success: " . $value,
);

match (true) という書き方はなかなか気持ちが悪いし、このブロックの中に好きに判定条件を書けてしまうのがつらいところ。

ジェネリクスがランタイムに存在しない

Rustやその他の言語ではジェネリクスコンパイル時に検証される。PHPにはランタイムのジェネリクスがないため、型安全性はPHPStanのPHPDocアノテーションに完全に依存している。

PHPStanを使わないプロジェクトでは、この型情報は単なるコメントになってしまう。逆に言えば、PHPStanを導入していれば他言語と遜色ない型チェックが得られる。ただ、PHPStanのlevelを上げるとメモリをドカ喰いするのでマシンスペックを要求するのが欠点。

never型による精密な型表現

共変性(@template-covariant)を活かして、ファクトリメソッドではnever型を使った精密な型を返すようにしている:

// Successは絶対にエラーを持たない → E = never
public static function success(mixed $value): Success<TValue, never>

// Failureは絶対に成功値を持たない → T = never
public static function failure(mixed $error): Failure<never, TError>

neverは「この型の値は存在しない」ことを表す。neverはすべての型のサブタイプなので、Success<string, never>Result<string, Exception>に代入可能になる。

このあたりがPHPの古いバージョンを切ることで得られる恩恵となっている。

共変性とrecoverの型安全性のトレードオフ

正直なところ、現状の実装は妥協の産物である。

共変性(@template-covariant)を導入した理由は、Success<T, never>Result<T, E> に自然に代入できるようにするためだった。しかし、これにより recover の型シグネチャで問題が発生した。

/**
 * @param callable(E): T $fn
 * @return Result<T, E>
 * @phpstan-ignore generics.variance
 */
abstract public function recover(callable $fn): Result;

Tは共変(出力位置のみで使用可能)として宣言しているが、callable(E): T の戻り値型としてTを使っている。callableの戻り値は呼び出し側から見ると「入力」にあたるため、これは反変位置での使用となり、共変性に違反する。PHPStanはScalaのような下限境界(lower bound)をサポートしていないため、@phpstan-ignore で抑制せざるを得なかった。

代替案として、共変性を諦めることを考えている。

// 共変性なしの場合、こういうメソッドで明示的に型を広げる
/** @return Result<T, E2> */
public function widenError(): Result { return $this; }

より良いアプローチがあれば模索したい。

まとめ

PHPという型で守ることが難しい言語の枠組みの中で、今回作ったライブラリはこういうアプローチでエラーハンドリングを安全に扱えるようにしている。

  • PHPStan Level 9-10での型チェック
  • カスタムルールによる網羅性チェック
  • 他言語のResult型とほぼ同等のAPI
  • 依存なし

PHPStanを導入しているプロジェクトで、例外の伝播範囲を明確に制御したい場合に効果を発揮するんじゃないかなー。

ecscheduleでtaskDefinitionのrevision指定したい場合どうしたら良いの?

お悩み

echscheduleを利用してECS Scheduled Taskの管理をしているのだが、configのymlファイルにはrevisionを指定して起動することができるかどうかはわからない。少なくともymlの項目としては存在していない

region: us-east-1
cluster: clusterName
rules:
trackingId: trackingId1
- name: taskName1
  description: task 1
  scheduleExpression: cron(30 15 ? * * *)
  taskDefinition: taskDefName

例えばWeb APIバッチ処理で同一のtaskdef(とそれが参照するコンテナイメージ)を利用しているような場合において、言語バージョンアップ等でリスクのあるリリースをしたい場合などで、一時的にバッチはrevisionを固定することで異なるtaskdefを変えておきたい場合がある。 なお、そういうtaskdef運用が良いか?と言われると良し悪しあると思うが本筋ではないので置いておく。

結論

ymlファイルの taskDefinition フィールドに対して awesomeTaskDef:123 のようにリビジョンを末尾へつければ良さそう。
もしくは arn:~~~:awesomeTaskDef:123 のようにarnをフルに記載するのでも良い

コードを読み取っていく

rule.go で以下通り設定ファイルから構造体Ruleにバインド?される

type Rule struct {
    Name               string `yaml:"name" json:"name"`
    // 略
    *Target            `yaml:",inline" json:",inline"`
}

type Target struct {
    TargetID            string    `yaml:"targetId,omitempty" json:"targetId,omitempty"`
    TaskDefinition      string    `yaml:"taskDefinition" json:"taskDefinition"`
    // 略
}

同じく rule.gotaskDefinitionArn 関数でarnの文字列を組み立てているが、ここでTaskDefinitionが arn: で始まるならTaskDefinitionをそのまま、違う場合はarnをアカウントやリージョンを取得しつつ組み立てている。

func (ta *Target) taskDefinitionArn(r *Rule) string {
    if strings.HasPrefix(r.TaskDefinition, "arn:") {
        return r.TaskDefinition
    }
    return fmt.Sprintf("arn:aws:ecs:%s:%s:task-definition/%s", r.Region, r.AccountID, r.TaskDefinition)
}

ゆえに、arnの組み立てをイメージしながらTaskDefinitionの値を書けば良いってだけなのがわかる。

まだ実験はできていないがほぼ間違いなくコレで大丈夫でしょう。

React RouterなSPAに対するGoogle Tag Managerの導入

直近でGoogle Tag Manager(GTM)をReact Routerで作ったSPAにいれる作業をやっており、
繰り返しやることになりそうなので書き殴りメモ。

react-gtm-moduleのインストール

pnpm install react-gtm-module
pnpm install --save-dev @types/react-gtm-module

GTM呼び出し用関数の定義

ローカル環境などでGTMを設定していない場合の加味をしつつ、GTMへ送る典型的なイベント発生用の関数を用意する。
react-gtm-moduleの処理を凝集させる意図も含む。

import TagManager from "react-gtm-module";

const VITE_GTM_ID = import.meta.env.VITE_GTM_ID;

export const initializeGTM = () => {
  if (VITE_GTM_ID) {
    TagManager.initialize({
        gtmId: VITE_GTM_ID,
      }
    )
  }
}

export const trackPageView = (pagePath: String) => {
  console.log(`Tracking page view: ${pagePath}`);
  trackCustomEvent('pageview', {
    page: pagePath
  })
}

export const trackCustomEvent = (eventName: string, eventData: Record<string, any>) => {
  if (VITE_GTM_ID) {
    TagManager.dataLayer({
      dataLayer: {
        event: eventName,
        ...eventData
      }
    })
  }
}

React Routerのページ遷移時にpageviewイベントを発火する

location.pathnameの変化をトラッキングし、↑で用意した `trackPageView 関数を呼び出す。

import {useEffect} from "react";
import {useLocation} from "react-router-dom";
import * as GTM from "@/lib/gtm/gtm";

export const useTrackPageView = () => {
  const location = useLocation();
  useEffect(() => {
    GTM.trackPageView(location.pathname);
  }, [location.pathname]);
}

React Routerの内側でuseTrackPageViewをフック

Reactc Rooterの要素内でないとuseLocationが使えないので、Routerの内側のトップレベルでuseTrackPageViewを宣言するのが理想的である。
アプリケーションによってはProvider類がルーターの外側にあるかもしれないが、そうだとしてもuseTrackPageViewはRouterの内側で宣言しないといけないので留意する。

// RootProviderは諸々のProviderを重ねたもの
const RootProvider = ({ children }: { children?: ReactNode }) => {
  useTrackPageView()

  return (
     <AwesomeProvider>
        {children}
     </AwesomeProvider>
  )
}

const router = createBrowserRouter([
  {
    element: (
      <RootProvider>
        use
        <PageTemplate />
      </RootProvider>
    ),
    errorElement: <ErrorBoundary />,
    children: [
        { path: "/login", element <LoginPage />
    ],
  }
]);

デバッグ

react-gtm-moduleのTagManager.dataLayer関数は実際のところはwindow.dataLayerに値をpushするだけである。

つまりコンソールでwindow.dataLayerを確認して、ページ遷移時に期待通りにpageviewイベントを発火できているかがわかるので、期待通りに動いているかはそこで確認できる。

GTMでの設定が適切ならばGTMのサーバへの通信もネットワークインスペクタで確認しておくとなお良い。

スタックトレースが長すぎてDatadogでログがパースできない問題の解消(Log4j2)

最近、仕事で開発しているKotlinアプリケーションでDatadogに送信されるログが崩れるという問題に遭遇し、原因を調べていくと巨大なスタックトレースによるものだった。

やったことなどメモしておく。

問題の背景

Spring WebFluxベースのWebアプリケーションで、アプリケーションログをLog4j2で吐き出してDatadogに送信し、監視やデバッグに活用している。ログはJSON形式で出力され、Datadogで構造化されたデータとして扱えるようにしている。

ところが、例外が発生したときのログがDatadog上で正しくパースされず、単なるテキストとして表示される事象が発生した。調査してみると、スタックトレースが非常に長い場合にJSONファイルの文字列長が長くなりDatadog側で途中で切ってしまい、切ったログを無理くりパースしようとして崩れていることがわかった。

なお、なぜログが長くなってしまうかはそこまで追えてないが、長いスタックトレースの大半がWebFlux系だったのとぱっと調べた感じそのあたりをアプリケーション側で短くするのがむずそうだったので考えるのをやめた。

修正前のロギング設定

もともとはNewRelic用のログフォーマッターである NewRelicLayout を使っていた:

// build.gradle.kts
implementation("com.newrelic.logging:log4j2:3.1.0")
<!-- log4j2.xml -->
<Console name="Console" target="SYSTEM_OUT">
    <NewRelicLayout/>
</Console>

このレイアウトは、スタックトレースの長さに関する制御ができず、深いスタックトレースがそのまま出力されてしまう。結果として、Datadogのログパーサーが処理しきれない長さのJSONログとなっていた。

修正内容

Log4j2標準の JsonTemplateLayout を使うことで、この問題を解決した。

まず、依存関係を変更。

// build.gradle.kts
// implementation("com.newrelic.logging:log4j2:3.1.0") を削除
runtimeOnly("org.apache.logging.log4j:log4j-layout-template-json")

次に、ログ設定を更新。

<!-- log4j2.xml -->
<Console name="Console" target="SYSTEM_OUT">
    <JsonTemplateLayout
            eventTemplateUri="classpath:logging-template.json"
            maxStringLength="5000"
            truncatedStringSuffix=" ...[truncated because of maxStringLength]"
    />
</Console>

ログのテンプレートを定義。

// logging-template.json
{
  "instant": {
    "$resolver": "timestamp",
    "pattern": {
      "format": "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
      "timeZone": "UTC"
    }
  },
  "thrown": {
    "$resolver": "exception",
    "field": "stackTrace",
    "stackTrace": {
      "stringified": true
    }
  },
  // ... 他のフィールド定義
}

maxStringLengthの設定

この件のポイントは log4j2.xmlに記載したJsonTemplateLayoutの maxStringLength="5000" の設定。
これによりスタックトレースを含むすべての文字列フィールドが最大5000文字で切り詰められる。

出力されるスタックトレースはこのようになっている。

java.lang.RuntimeException: Example error happens
    at com.jsoizo.example.controller.Example$get$2.invokeSuspend(Example.kt:38)
        
        (...中略)

    at reactor.core.publisher.FluxConcatArray$ConcatArraySubscriber.onSubscribe(FluxConcatArray.java:172)
    at reactor.core.publisher.MonoNext$NextSubscriber.onSubscribe(MonoNext.java:70)
    at reactor.core.publisher.FluxConcatMapN ...[truncated because of maxStringLength]

切り詰められた場合は ...[truncated because of maxStringLength] というサフィックスが付くので、ログを見たときに切り詰めが発生したことがわかる。

Appendix: Datadogのログサイズ制限

Datadogのログ収集ドキュメントによるとHTTP APIで1MBまでのログを送れると書いてある。ただ実際のところ、1MBということは100万文字くらいってことになるのだがそんな大きなログを送ってはいなくて、ログのJSONが切られてしまう上限は数万文字とかのところにあるっぽそうな挙動だった。

そのあたりは実際にちゃんと追わないとわからないが面倒くさかったので調べられてない。

あと、今回の5000文字という設定は、Datadogの制限内に収まるように、かつスタックトレースの重要な部分が確認できるバランスを考慮して決定した。