2024年をちょっとだけ楽にしてくれた改善

:::note info
この記事は、[ラクス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/rakus) シリーズ2の19日目の記事です。裏番組なので気楽にいきます。 :santa_tone2:
:::

いよいよ2024年も終わりが見えてきました。この記事では、今年をふりかえって、私の日々の開発業務をほんの少しだけ楽にしてくれた小さな改善たちを紹介したいと思います。2024年に新しく始めたものしばりで思い出そうとしたのですが、すでに全然思い出せないです…

## logfmt: JSONログの整形

今年は、担当プロダクトのアプリログをJSON化して本番投入しました。今までは人間の読みやすさ重視だったのですが、メトリクスやログを[Grafana](https://grafana.com/grafana/)に一元化できるよう機械の扱いやすさ重視に変わりました。何気に本番環境のログをJSONにするのは初めてです。

…結果、人間としてはめちゃくちゃ読みづらくなりました。特に、複数行のスタックトレースが `\n` や `\t` で1行にまとめられてしまい、横スクロールが破綻するのでお手上げです。

```json
{"datetime": "2024-12-17T15:37:04.548","message": "localhost:5432 への接続が拒絶されました。ホスト名とポート番号が正しいことと、postmaster がTCP/IP接続を受け付けていることを確認してください。","logger_name": "com.zaxxer.hikari.pool.HikariPool","thread_name": "pool-4-thread-2","level": "ERROR","stack_trace": "org.postgresql.util.PSQLException: localhost:5432 への接続が拒絶されました。ホスト名とポート番号が正しいことと、postmaster がTCP/IP接続を受け付けていることを確認してください。\n\tat org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:342)\n\tat org.postgresql.core.ConnectionFactory.openConnection(ConnectionFactory.java:54)\n\tat org.postgresql.jdbc.PgConnection.<init>(PgConnection.java:263)\n\tat org.postgresql.Driver.makeConnection(Driver.java:443)\n\tat org.postgresql.Driver.connect(Driver.java:297)\n\t...\n\tat java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)\n\tat java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)\n\tat java.base/java.lang.Thread.run(Thread.java:840)\nCaused by: java.net.ConnectException: Connection refused\n\t   ...\n\tat org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:132)\n\tat org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:258)\n\t... 61 common frames omitted\n","trace_id": "a3e528e8b59f835967a3cf024de43885","version": "1.0.0"}
```

というわけで、こうです[^1]。

[^1]: `\` を二重にエスケープしなければいけない関係で、ワケがわからない感じなのは認めます。

```shell
$ alias logfmt='jq . | sed "s/\\\\n/\\n/g; s/\\\\t/\\t/g"'

$ pbpaste | logfmt
{
  "datetime": "2024-12-17T15:37:04.548",
  "message": "localhost:5432 への接続が拒絶されました。ホスト名とポート番号が正しいことと、postmaster がTCP/IP接続を受け付けていることを確認してください。",
  "logger_name": "com.zaxxer.hikari.pool.HikariPool",
  "thread_name": "pool-4-thread-2",
  "level": "ERROR",
  "stack_trace": "org.postgresql.util.PSQLException: localhost:5432 への接続が拒絶されました。ホスト名とポート番号が正しいことと、postmaster がTCP/IP接続を受け付けていることを確認してください。
        at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:342)
        at org.postgresql.core.ConnectionFactory.openConnection(ConnectionFactory.java:54)
        at org.postgresql.jdbc.PgConnection.<init>(PgConnection.java:263)
        at org.postgresql.Driver.makeConnection(Driver.java:443)
        at org.postgresql.Driver.connect(Driver.java:297)
        ...
        at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
        at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
        at java.base/java.lang.Thread.run(Thread.java:840)
Caused by: java.net.ConnectException: Connection refused
           ...
        at org.postgresql.core.v3.ConnectionFactoryImpl.tryConnect(ConnectionFactoryImpl.java:132)
        at org.postgresql.core.v3.ConnectionFactoryImpl.openConnectionImpl(ConnectionFactoryImpl.java:258)
        ... 61 common frames omitted
",
  "trace_id": "a3e528e8b59f835967a3cf024de43885",
  "version": "1.0.0"
}
```

多少くずれるけど十分過ぎます。実際には、GitHub Issueに貼ったり、チャットに貼ったりしてチームメンバーに共有することがほとんどなので、こうです[^2]。

```shell
$ pbpaste | logfmt | pbcopy
```

[^2]: `pbcopy`, `pbpaste` はMacのCLIでクリップボードを操作できる最高のコマンドです。


## gh-check: GitHubのあれやこれやのチェック

フロントエンドとバックエンドのリポジトリが分かれたり、運用や補助ツールのためのリポジトリが分かれたりと、開発が進んでいくとマイクロサービスでなくてもリポジトリが増えていきます。そうなってくると、GitHubの1つ1つのリポジトリをブラウザで見にいって、特定のラベルをもつIssueが増えていないかとか、ワークフローが失敗していないかとか、確認するのはいちいち面倒です。

というわけで、こうです。[GitHub CLI](https://cli.github.com/) を使って、シェルスクリプトで一括チェックします。

```shell:gh-check
#!/bin/bash

REPO_BACKEND=awesome-backend
REPO_FRONTEND=awesome-frontend
REPO_OPERATION=awesome-operation

echo "======================================================================"
echo "Closed Issue/PR without milestone"
echo "======================================================================"
for repo in "${REPO_BACKEND}"; do
    gh issue list --repo $repo --search "is:closed no:milestone";
    gh pr list --repo $repo --search "is:closed no:milestone";
done

echo
echo "======================================================================"
echo "Vulnerability Issue"
echo "======================================================================"
for repo in "${REPO_BACKEND}" "${REPO_FRONTEND}" "${REPO_OPERATION}"; do
    gh issue list --repo $repo --label "security"
done

echo
echo "======================================================================"
echo "Vulnerability scan actions"
echo "======================================================================"
for repo in "${REPO_BACKEND}" "${REPO_FRONTEND}" "${REPO_OPERATION}"; do
    echo $repo
    gh run list --repo $repo --workflow "Scan container image daily" --limit 1
done
```

結果はこんなイメージ。

```shell
$ gh-check
======================================================================
Closed Issue/PR without milestone
======================================================================

Showing 1 of 1 issue in awesome-backend that matches your search

ID     TITLE                           LABELS   UPDATED
#3644  bug: エラーメッセージに統一性が無い  discard  about 21 hours ago
no pull requests match your search in awesome-backend

======================================================================
Vulnerability Issue
======================================================================

Showing 2 of 2 issues in awesome-backend that match your search

ID     TITLE                  LABELS    UPDATED
#3616  awesome-backend:0.9.0  security  about 6 hours ago
#3598  awesome-backend:0.8.0  security  about 6 hours ago

Showing 1 of 1 issues in awesome-frontend that match your search

ID     TITLE                   LABELS    UPDATED
#3336  awesome-frontend:0.8.0  security  about 6 hours ago

no pull requests match your search in awesome-operation

======================================================================
Vulnerability scan actions
======================================================================
awesome-backend
STATUS  TITLE                       WORKFLOW                    BRANCH   EVENT     ID           ELAPSED  AGE
✓       Scan container image daily  Scan container image daily  develop  schedule  12382195765  1m28s    about 6 hours ago
awesome-frontend
STATUS  TITLE                       WORKFLOW                    BRANCH   EVENT     ID           ELAPSED  AGE
✓       Scan container image daily  Scan container image daily  develop  schedule  12382156609  1m20s    about 6 hours ago
awesome-operation
STATUS  TITLE                       WORKFLOW                    BRANCH  EVENT     ID           ELAPSED  AGE
✓       Scan container image daily  Scan container image daily  main    schedule  12382173232  1m4s     about 6 hours ago
```

本当はこれ自体をGitHub Actionsに組み込んで成否を自動判定させる方がよいと思いますが、複数リポジトリにアクセスさせるときの認証が面倒なので、いったん自分のトークンでローカル実行する簡易対応にとどめています。


## copy-markdown-url: 完全に自分好みURLコピー

世の中にはURLをコピーするための数多のブラウザ拡張がありますが、タイトル文字列とURLをWebページのどこから抽出するか柔軟に選べるものはありません（嘘です、探してないです）。大抵は、HTMLのtitle要素から抽出すると思いますが、サイト名などの付属情報がいっぱい付いてしまい、チャットなどで連携するとノイズになりがちです。

そしてWebデータベースともなると、データ項目を自由に設定できるため、どこをタイトルとして抽出すべきかは個々のデータベースごとに変わります。Webデータベース側でいい感じに設定できるといいのですが、私が使っているものはできません。

というわけで、user.jsを使って力技で何とかします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/c4527102-78f8-70f0-bb4d-df7d13f853d6.png)

この手のスクリプトを書くときに、トリガーとなるボタンなり何なりをWebサイト上に埋め込むのが面倒だなといつも思っていたのですが、実はコンテキストメニューに追加できることを知りました。結構便利です。

```js:copy-markdown-url.user.js
// ==UserScript==
// @name         Copy Markdown URL
// ...
// @match        *://*/*
// @run-at       context-menu
// @grant        GM_setClipboard
// ==/UserScript==
(function() {
    'use strict';

    // (URL, タイトル文字列) のペアを抽出
    function extractURL() {
        const url = window.location.href;
        const title = document.title;
        if (url.match(/https:\/\/github.com\/.+\/pull\/.+/)) {
            // GitHub Pull Request
            const match = title.match(/^(.+) by (.+) · Pull Request #(\d+) · (.+)\/(.+)$/);
            if (match) {
                return [ url, `${match[5]}#${match[3]}: ${match[1]}` ];
            }
        } else if (url.match(/^https:\/\/webdb.example.com\/.+/)) {
            // Webデータベース
            const mainDocument = parent.document.querySelector('iframe#main').contentDocument;
            const pageTitle = mainDocument.querySelector('.fw-pankuzu-box').innerText;
            const link = mainDocument.querySelector('#js-copy-page-url').getAttribute('data-url');
            if (pageTitle.includes("調査依頼")) {
                // このWebデータベースからハードコーディングで特定項目の情報を抽出
                const id = mainDocument.querySelectorAll('.table-box td')[0].innerText.trim();
                const summary = mainDocument.querySelectorAll('.table-box td')[8].innerText.trim();
                if (id && summary) {
                    return [ link, `調査依頼${id}: ${summary}` ];
                }
            } else if (pageTitle.includes(...)) {
                ...
            }
        } else if (...) {
            ...
        } else if (...) {
            ...
        }
        return [ url, title ];
    }
    // URLを抽出してMarkdown形式でクリップボードにコピー
    const [ url, title ] = extractURL();
    const text = `[${title}](${url})`
    GM_setClipboard(text, "text");
})();
```

私は気づきました。Webデータベースごとにこの分岐を足していくのはつらい、つらすぎる。これは完成しないと。

## broken-ci: 不安定なCIのチャット通知

REST APIを開発するときに、「ワンパスはDBも含めた自動テストを書く」をチーム方針としています。テストコードを頑張って書くのはよいのですが、やはりDBを含めたテストにかかる時間が伸び続け、とうとうPRで全テストが通るのを待てなくなりました。

苦渋の決断として、PRではDBなしのテストコードだけを実行し、DBありのテストコードはマージ後に統合ブランチで実行することにしました。が、案の定マージ後にテストがコケます。しかも、CIエラーがマージ者にしか通知されないため、どうしても気付くのが遅れたり、「CIコケてるよ」連絡を個別にしたりと、いろいろ億劫でした。

というわけで、よくある話ですが、チャットに自動通知します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/33668/2352e07e-360d-38bc-1384-f9ec3a7c2cc9.png)

今回面倒なのは、GitHubはインターネット側、チャットの[Mattermost](https://mattermost.com/)はイントラネット側で稼働しているため、GitHub Actionsから普通には通知を送れないという点です。仕方がないので、テスト実行と通知するジョブを分離し、通知ジョブだけはイントラネット上の [self-hosted runner](https://docs.github.com/en/actions/hosting-your-own-runners) を使って実行することで、疎通の問題を回避します。

```yml:.github/workflows/ci.yml
jobs:
...
  test:
    ...
  notify:
    runs-on: [ self-hosted, awesome-runner ]
    timeout-minutes: 1
    needs: [ test ]
    if: ${{ failure() && (github.ref_name == 'main' || startsWith(github.ref_name, 'develop')) }}
    steps:
      - name: "Notify to Mattermost"
        run: |
          url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
          label="${GITHUB_REPOSITORY} > ${GITHUB_REF_NAME} > actions/runs/${GITHUB_RUN_ID}"
          msg="${{ github.event.head_commit.message }}"
          curl -s -i -k -X POST -H 'Content-Type: application/json' \
            -d "{\"channel\": \"awesome-channel\", \"username\": \"不安定なCI\", \"icon_emoji\": \":fire:\", \
                \"text\": \"@実装チーム CIが失敗しました (triggered by \`@${GITHUB_ACTOR}\`)  #broken-ci\n* [${label}](${url})\n\`\`\`\n${msg}\n\`\`\`\"}" \
            ${{ secrets.MATTERMOST_WEBHOOK_URL }}
```

## まとめ

2024年に取り組んだちょっとした改善を紹介しました。日々の開発で不便に思ったことをどう楽にできるか考えているときほど楽しい瞬間はないですよね。来年もやっていき。