CloudWatch Logs Insights のクエリを使う際にハマったこと①


# はじめに
CloudWatch Logs Insgihtsを使う上で欠かせないのが、クエリの作成とログ分析！
マネコンから気軽にログのクエリができるため、トラブルシューティングや運用監視で活躍する機能です！

ただ、このクエリが独特でなかなか難しくAWS初心者泣かせなところもありました…
そこで、私が躓いた点を記載していきますので参考にしていただければと思います！


# CloudWatch Logs Insightsとは？
そもそも何それ？という方向けに簡単に説明します！
ご存じの方は次のセクションへお進みください！

`CloudWatch Logs Insights`は、ログデータを簡単に検索・分析できる便利なツールです。
エラーログの特定やパフォーマンスのトラブルシューティングに非常に役立ちます。
SQLに似たシンプルな構文でログに対してクエリを行い、
必要な情報を検索・分析できる機能です。

基本構文の例としては下記のようなものがあります。

```sql
fields @timestamp, @message
| filter @message like /ERROR/
| sort @timestamp desc
| limit 20
```
:::note info
fields: 表示したいフィールドを指定します（例：@timestamp, @message）。
filter: ログメッセージの中から特定の条件に一致するものを抽出します。
sort: 結果を並び替えます（例：タイムスタンプで降順）。
limit: 表示する結果の数を制限します。
:::


# こいつを使う上で私が困ったこと
CloudWatch Logs Insightのクエリ構文はシンプルな構文でわかりやすいはずなのですが
使ってみるとうまくいかない！？(つД`)ということが多くあったので
それらを順に解説します。
どなたかの参考になりましたら嬉しいです…！

## parseした後のフィールドの中身が空なのだが？

### 事象
__フィールドの霊圧が…消えた…?__
ログメッセージを見て必要なところだけを切り出すようparseで指定したはずなのに
クエリのエラーは出ていないのに何も抽出できていない…

### parseとは？
parseというはログメッセージや特定のフィールドから構造化データを抽出するために使用されるクエリコマンドの１つです。
基本構文は下記の通りです。
```SQL
parse <field> "パターン" as <フィールド名1>, <フィールド名2>, ...
```
:::note info
<"field">: パース対象のフィールド。通常は @message が指定されます。
"パターン": 抽出したいデータの形式を指定。文字列中の特定の値を抽出するためにワイルドカード（*）を使用します。
as <フィールド名>: パース結果を一時的なフィールド（エフェメラルフィールド）として定義します。

（※えふぇめらるふぃーるど？という方は末尾の `付録１：エフェメラルフィールド` もご確認ください！）

:::

<details><summary>details</summary>

</details>


例)
下記のようなログメッセージからUserIDとAction名を抽出したい場合はこのようなクエリになります。
ログメッセージ：
```
2024-12-15T12:34:56Z ERROR UserID:12345 Action:LoginFailed
```
クエリ：
```sql
fields @message
| parse @message "* UserID:* Action:*" as timestamp, userId, action
| display timestamp, userId, action
```

### 原因
__CloudWatch Logsのログ上は半角スペースとタブ文字[/t]の見分けがつかない__
これなんです…初心者はわからないですよ、こんなの泣

`CloudWatch Logs`のコンソールでは、タブ文字は視覚的にスペースのように表示されていますが、
実際のデータでは見た目上、スペースに見えたものがタブ文字である場合もあります。

また`parse`コマンドは、指定した文字列パターンに __厳密に__ 一致する部分を抽出します。
そのため、パターン内のスペース` `とタブ`\t`は別の文字として扱われるため、タブが含まれている場合はクエリ上でも正確にタブを指定しない限り一致せず、抽出することができません。

### 対策
対策としてはいくつか方法があります。

#### #1 ログメッセージ内のタブ文字の存在を確認
ログメッセージをエクスポートして、テキストエディタ（例：`VSCode`、`Notepad++`）や正規表現ツールを使ってタブ文字が含まれているかを確認します。

タブ文字は通常、`\t` として表現されますので、正規表現も可視化される設定でログメッセージを開くことで、
当該箇所が`\t` になっているかどうかを確認できます。
この確認結果に基づき、クエリを作成しましょう！

もし先ほどの例のログの半角スペースに見えるものが、タブ文字であった場合は下記のようにクエリを作成することで適切に抽出することができます。

```sql
fields @message
| parse @message "*\t*\tUserID:*\tAction:*" as timestamp, logLevel, userId, action
| display timestamp, logLevel, userId, action
```
明示的に`\t`を指定することで解決できます。

#### #2 正規表現を使ってタブもスペースも許容する
どっちかわからないし、考えたくもないからどっちでもうまく抽出してくれ！
という強欲な方には正規表現でクエリを作成するのがおすすめです！笑

```sql
fields @message
| parse @message /(?<timestamp>.*?)\s+(?<logLevel>.*?)\s+UserID:(?<userId>\d+)\s+Action:(?<action>\S+)/ 
| display timestamp, logLevel, userId, action

```
こちらの例のように正規表現で`timestamp`, `logLevel`, `userId`, `action` という4つの値を抽出するコマンドを記述します。
重要な点としては、`\s+`を用いることで任意の空白文字（スペース、タブなど）を1つ以上マッチさせられるので、初めからこのように書いておけば、タブ文字でもスペースでも問題なく処理できます！
ただし、正規表現で記述するのには慣れが必要であり、私も悪戦苦闘しながら書いていたので、ちょろっとログをクエリしたいだけの時は、方法１の方が良いかもしれないです。
（上記の正規表現のコマンドを詳細に知りたいよという方は 付録２：正規表現の解説 もご覧ください！）

# 終わりに
今回は`parse`コマンドの使い方とちょっとした落とし穴について解説しました！
スペースとタブ文字、知ってしればなんのことはないのですが
わかるまではクエリ構文に何かミスがあるんじゃないのか？使い方がおかしいんじゃないかなど色々と調査に時間がかかってしまったため、無駄にした時間が悔しくて記事にしました…
私の失敗がどなたかのお役に立てればとっても嬉しいです！

また、本当はいくつも落とし穴にはまったため全て解説したかったのですが
長くなってしまったため、他の落とし穴については後続の記事で記載しようと思います。
よろしければ、ぜひまた覗いてやってください。
（後続のタイトル（未定）
- 作成したフィールドが後続のクエリコマンドで使えない！？
- 単純すぎるが、よくやるログとクエリの記法の揺れ
- Json形式ログのネストが深すぎる時の指定方法
などなど）

ここまでお読みいただきありがとうございました！

## 付録１：エフェメラルフィールド
エフェメラルフィールドとは、クエリの中で一時的に生成されるフィールドのことです。
ログ自体には含まれていないが、利用した要素があった際に
自分で作成し、クエリの実行中にのみ存在させ一時的に利用することができるフィールドのことです！

### 特徴
- parse や他のクエリ操作（例：stats や fields）で生成される。
- クエリ結果として表示したり、他のクエリ操作に利用可能。
- クエリが終了すると消失する。

### 例
```sql
fields @message
| parse @message "* UserID:* Action:*" as userId, action
| filter action = "LoginFailed"
| stats count(*) by userId
```
上記のクエリでは、parseコマンドにより、`userId`と`Action`という自作のフィールド（エフェメラルフィールド）を作成しています。
また、作成したエフェメラルフィールドを用いてfilterコマンドで絞り込みやstatsコマンドで集計を行うことも可能です！

## 付録２：正規表現の解説

例として提示した正規表現について詳しく解説します！

### 使用した正規表現
```regex
/(?<timestamp>.*?)\s+(?<logLevel>.*?)\s+UserID:(?<userId>\d+)\s+Action:(?<action>\S+)/
```

この正規表現は、ログメッセージから `timestamp`, `logLevel`, `userId`, `action` という4つの値を抽出するために使用されます。それぞれの値は**名前付きキャプチャグループ**で定義され、クエリや後続の処理で簡単に参照できます。


### **正規表現の部分ごとの解説**

#### **1. `/`**
- **意味**: 正規表現の開始と終了を示すデリミタ（区切り記号）
- **詳細**:
  - 多くのツールや言語では、 `/` で囲むことで正規表現として扱われます。

---

#### **2. `(?<timestamp>.*?)`**
- **意味**: `timestamp` という名前付きキャプチャグループを定義
- **詳細**:
  - **`(?<timestamp>...)`**:
    - 名前付きキャプチャグループ。抽出された値は `timestamp` という名前で参照可能になります。
  - **`.*?`**:
    - 任意の文字列を最短一致でキャプチャします。
    - `.`: 任意の1文字（改行を除く）
    - `*`: 直前の文字の0回以上繰り返し
    - `?`: 最短一致（必要最小限の文字列をキャプチャ）
- **例**:
  - 入力: `2024-12-15T12:34:56Z ERROR UserID:12345 Action:LoginFailed`
  - 抽出: `2024-12-15T12:34:56Z`

---

#### **3. `\s+`**
- **意味**: 1つ以上の空白文字（スペース、タブ、改行など）に一致
- **詳細**:
  - **`\s`**:
    - 空白文字（スペース、タブ、改行など）に一致
  - **`+`**:
    - 直前の文字（この場合は空白文字）が1回以上繰り返される部分に一致
- **例**:
  - 入力: `2024-12-15T12:34:56Z ERROR`
  - 抽出: `    `（スペース）

---

#### **4. `(?<logLevel>.*?)`**
- **意味**: `logLevel` という名前付きキャプチャグループを定義
- **詳細**:
  - 名前付きキャプチャグループで、ログレベル（例: `INFO`, `ERROR`, `DEBUG`）を抽出
  - **`.*?`**: 任意の文字列を最短一致でキャプチャ
- **例**:
  - 入力: `2024-12-15T12:34:56Z ERROR UserID:12345 Action:LoginFailed`
  - 抽出: `ERROR`

---

#### **5. `UserID:(?<userId>\d+)`**
- **意味**: `UserID:` の後に続く数値を `userId` という名前のキャプチャグループとして抽出
- **詳細**:
  - **`UserID:`**:
    - ログメッセージに固定で含まれる文字列 `UserID:` に一致
  - **`(?<userId>...)`**:
    - `userId` という名前を付ける名前付きキャプチャグループを定義
  - **`\d+`**:
    - 数字（`0-9`）が1回以上繰り返される部分に一致
- **例**:
  - 入力: `UserID:12345`
  - 抽出: `12345`

---

#### **6. `\s+`**
- **意味**: 再び1つ以上の空白文字に一致
- **詳細**:
  - この部分は、`UserID` と `Action` の間の空白を処理

---

#### **7. `Action:(?<action>\S+)`**
- **意味**: `Action:` の後に続く非空白文字列を `action` という名前のキャプチャグループとして抽出
- **詳細**:
  - **`Action:`**:
    - ログメッセージに固定で含まれる文字列 `Action:` に一致
  - **`(?<action>...)`**:
    - `action` という名前付きキャプチャグループを定義
  - **`\S+`**:
    - 非空白文字（スペース以外の文字）が1回以上繰り返される部分に一致
- **例**:
  - 入力: `Action:LoginFailed`
  - 抽出: `LoginFailed`

---

### **全体の動作例**

#### **入力ログメッセージ**
```
2024-12-15T12:34:56Z ERROR UserID:12345 Action:LoginFailed
```

#### **正規表現の動作**
1. **`(?<timestamp>.*?)`**:
   - 抽出: `2024-12-15T12:34:56Z`
2. **`\s+`**:
   - 抽出: （空白文字1つ以上、例: スペース）
3. **`(?<logLevel>.*?)`**:
   - 抽出: `ERROR`
4. **`\s+`**:
   - 抽出: （空白文字1つ以上）
5. **`UserID:(?<userId>\d+)`**:
   - 抽出: `12345`
6. **`\s+`**:
   - 抽出: （空白文字1つ以上）
7. **`Action:(?<action>\S+)`**:
   - 抽出: `LoginFailed`

---

### **最終的な抽出結果**
| フィールド名   | 抽出結果               |
|----------------|------------------------|
| `timestamp`    | `2024-12-15T12:34:56Z` |
| `logLevel`     | `ERROR`                |
| `userId`       | `12345`                |
| `action`       | `LoginFailed`          |

---

### **ポイント**
1. **名前付きキャプチャグループ**:
   - 各部分の値にわかりやすい名前を付けることで、後続の処理で使いやすくなります。
2. **柔軟性**:
   - 非貪欲マッチ（`.*?`）や空白文字マッチ（`\s+`）を使うことで、フォーマットが多少異なるログにも対応できます。
3. **固定文字列の活用**:
   - `UserID:` や `Action:` のような固定部分を含めることで、正確な抽出が可能です。