Grafana と Loki でアプリケーションエラーをグループ化して通知する

SLI/SLO やエラーバジェットに則ってサービスをモニタリングする場合、個別のアプリケーションエラーのモニタリングの重要度は高くないかもしれません。一方で、アプリケーションエラーの先には困っているユーザがいるわけだし、SLI/SLO で全ての事象をカバーできるわけではないので、個別のアプリケーションエラーを全く監視しないというのもまずいでしょう。
アプリケーションエラーの管理には Sentry や Rollbar を利用するのが便利ですが、Grafana に様々な telemetry が集約されている場合はアプリケーションエラーの管理も集約できた方が都合が良いので、Grafana と Loki でアプリケーションエラーを監視するにはどのように通知すれば良さそうかを考え、デモを作成してみました。

どんな感じになったか?

以下のように、エラーメッセージ、発生回数、各種リンク、stack trace 情報などを Slack に通知するようにしてみました。

エラーは stack trace の情報などを使ってグループ化し、同じ箇所で発生した同種のエラーはひとまとめにしています。

利用したアプリケーションや設定は github.com/abicky/grafana-loki-alert-rule-example に置いてあります。

以降、どのような流れでこの通知を実現していったか解説します。

要件を考える

自分の場合、Rollbar に使い慣れているのですが、Rollbar には次のような機能がありました。

  • エラーのグループ化
    • Default Grouping Algorithm にあるように、stack trace などを考慮してよしなに同じエラーをグループ化してくれる
  • エラーの level や発生回数などによる通知の出し分け
    • 例えば critical であれば一発で通知、warning であれば一定期間内に大量に発生したら通知するなどの設定ができる
  • アプリケーションエラーの内容を Slack や PagerDuty などへ通知

Slack の通知は次のようにエラーメッセージがタイトルに含まれているので、どのようなエラーが起きたかひと目でわかります。

他にも様々な機能がありますが、Grafana で実現することを考えると上記の内容をある程度満たせるようにしたいと考えました。

また、次のような機能も求められると考えました。

  • runbook が用意されている場合は通知に runbook へのリンクが含まれている
    • runbook の URL はエラーの attribute として付与したり alert rule 側で管理したりできる
  • エラーが発生した時刻付近のログを確認できるように Explore へのリンクが含まれている
  • stack trace の内容がひと目で確認できる
  • 別の alert rule で監視しているエラーを二重で通知しないようにできる
    • 特別扱いするエラーが増える度に LogQL を変更しなくて済むようにする

要件を踏まえた方針

汎用的な仕組みを構築しようと思うと非常に複雑な alert rule を設定することになるので、次のような制約と方針を取りました。

  • Go アプリケーションのみを対象とし、github.com/cockroachdb/errors の Formattable で stack trace をログの structured metadata として記録する
    • stack trace からエラーメッセージを除外したものをベースにグループ化する(Rollbar と似たような方針)
    • stack trace は配列として記録することも考えられるが、配列で保持してもあまりメリットはなさそうだし、exception.stacktrace が string として定義されているのでそれに従う
  • severity が FATAL なら 5 分間に 1 回以上、ERROR なら 10 分間に 2 回以上、WARN なら 10 分間に 11 回以上で Slack に通知
    • 閾値や時間幅はサービスの特性によって変わるので仮置きした値
    • FATAL は 1 回の発生で通知するものの、ログの到達遅延なども考慮して時間幅に余裕を持たせる
  • severity_text を indexed label にする
    • Grafana Cloud はクエリのスキャン量が書き込みの 100 倍を超えるとコストがかかるので、対象 severity 以外のログをスキャンしないようにしたい (Fair use query ratio)
  • エラーのグループ化ロジックは Grafana alert rule 側に持つ
    • とりあえず試してみる分には既存アプリケーションや Alloy に手を入れなくて良いので手軽

実装

方針自体はシンプルですが、github.com/abicky/grafana-loki-alert-rule-example を見るとわかるとおり、Grafana + Loki で実現しようと思うと、alert rule の基本的な設定に加えて以下のような対応・要素が必要です。

  • FATAL レベルの対応
  • エラーのグループごとに発生回数をカウントする LogQL
  • グループ化したエラーの代表的なエラーメッセージや stack trace を抽出する LogQL
  • LogQL の結果を利用した alert rule の annotation の設定
  • 通知に使うメッセージテンプレート

それぞれ解説していきます。

FATAL レベルの対応

OpenTelemetry では Displaying Severity のとおり、FATAL というログレベルが定義されているんですが、Go で otelslog package を利用する場合、CRITICAL や FATAL のようなログレベルは提供されていません。よって、次のようにログレベルを定義する必要があります。

// The slog bridge maps level 12 to OTLP severity 21; Alloy labels it FATAL.
const levelCritical = slog.LevelError + (slog.LevelError - slog.LevelWarn)

これだけだと severity_text は ERROR+4 になってしまうため、 次のように Grafana Alloy で変換する必要があります。

otelcol.processor.transform "severity" {
  log_statements {
    context = "log"
    statements = [
      // Normalize custom slog texts such as ERROR+4 to the OTLP FATAL range.
      `set(severity_text, "FATAL") where severity_number >= 21 and severity_number <= 24`,
    ]
  }
  output {
    logs = [otelcol.processor.batch.logs.input]
  }
}

エラーの発生回数をカウントする LogQL

エラーの発生回数をカウントする LogQL は次のようなクエリです。以下は ERROR 用の alert rule で、FATAL と WARN では severity_text と時間幅をそれぞれの値に置き換えるだけです。

sum by (service_name, deployment_environment_name, error_group, exception_type, severity) (
  count_over_time(
    {service_name="otel-log-demo", severity_text="ERROR"}
    # Exclude errors handled by dedicated metric- or log-based rules from this general rule.
    | alert_general_rule_exclude!="true"
    # errors.Formattable's first two lines and Wraps messages can contain variable error text,
    # such as IP addresses and timestamps.
    # Remove them only from the fingerprint, preserving frame order and line numbers.
    | label_format error_group=`{{ if and .exception_type .exception_stacktrace }}{{ regexReplaceAll "(?m)^Wraps: [^\\r\\n]*(\\r?\\n|$)" (regexReplaceAll "^[^\\r\\n]*\\r?\\n[^\\r\\n]*(\\r?\\n|$)" .exception_stacktrace "") "" }}{{ end }}`
    # Without a usable typed stack, normalize common variable identifiers in the body.
    # Remove UUIDs such as 550e8400-e29b-41d4-a716-446655440000.
    | label_format normalized_message=`{{ regexReplaceAll "(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b" (__line__) "" }}`
    # Remove IPv6 addresses, including compressed forms.
    | label_format normalized_message=`{{ regexReplaceAll "(?i)[0-9a-f]*:[0-9a-f:]*:[0-9a-f:]*" .normalized_message "" }}`
    # Remove hexadecimal values with a 0x or 0X prefix.
    | label_format normalized_message=`{{ regexReplaceAll "0[xX][0-9a-fA-F]+" .normalized_message "" }}`
    # Remove remaining decimal digits, including those in IPv4 addresses and ports.
    | label_format normalized_message=`{{ regexReplaceAll "[0-9]+" .normalized_message "" }}`
    # Use the normalized body only when no stack-based fingerprint was produced.
    | label_format error_group=`{{ if .error_group }}{{ .error_group }}{{ else }}{{ .normalized_message }}{{ end }}`
    # Use the normalized severity in both alert identity and notification grouping.
    | label_format severity=`{{ .severity_text }}`
    [10m]
  )
)

以下、この LogQL について解説します。

severity_text によるフィルタリング

まず最初に、ログのスキャン量を減らすために log stream の段階で severity_text でフィルタリングしています。

{service_name="otel-log-demo", severity_text="ERROR"}

severity_text はデフォルトで structured metadata として記録されるので、Loki の設定を変更する必要があります。

Grafana Cloud の場合、OTLP label mappings で変更方法が説明されています。厳密には設定変更申請なので、UI 上から変更しても反映されるまでに数営業日かかるらしいです。また、無料版だとこの機能が利用できないらしく、UI すら存在しません。
Loki には severity_text_as_label という設定がありますが、ドキュメントのとおりこの設定を変更することはできないようなので、以下のように log attribute などに severity_text をセットし、その log attribute を index label にするように申請する必要がありそうです。

otelcol.processor.transform "severity" {
  log_statements {
    context = "log"
    statements = [
      // Loki's OTLP attribute mapping indexes attributes, not LogRecord fields.
      `set(attributes["severity_text"], severity_text)`,
    ]
  }
  output {
    logs = [otelcol.processor.batch.logs.input]
  }
}

Loki を自前で運用している場合は次のような設定をすることで Grafana Cloud と挙動を揃えることができるはずです。

limits_config:
  allow_structured_metadata: true
  otlp_config:
    log_attributes:
      - action: index_label
        attributes: [severity_text]

severity_text_as_label を指定できるので、以下の設定をした上で Grafana Alloy から severity_text log attribute を設定する処理を削除することも可能です。

limits_config:
  allow_structured_metadata: true
  otlp_config:
    severity_text_as_label: true

除外対象のログのフィルタリング

次に、alert_general_rule_exclude=true がセットされているログを除外します。これは「別の alert rule で監視しているエラーを二重で通知しないようにできる」という要件を満たすためで、アプリケーション側でログを出力する際にセットする想定です。

# Exclude errors handled by dedicated metric- or log-based rules from this general rule.
| alert_general_rule_exclude!="true"

グループ化

全く同じエラーメッセージ (exception.message) や type (exception.type) でもサービス影響は異なることが予想されるため、Rollbar 同様 stack trace を利用してグループ化しています。以下がそのための処理です。

# errors.Formattable's first two lines and Wraps messages can contain variable error text,
# such as IP addresses and timestamps.
# Remove them only from the fingerprint, preserving frame order and line numbers.
| label_format error_group=`{{ if and .exception_type .exception_stacktrace }}{{ regexReplaceAll "(?m)^Wraps: [^\\r\\n]*(\\r?\\n|$)" (regexReplaceAll "^[^\\r\\n]*\\r?\\n[^\\r\\n]*(\\r?\\n|$)" .exception_stacktrace "") "" }}{{ end }}`

Go には標準でサポートされている stack trace(のフォーマット)が存在しないので、前述のとおり、github.com/cockroachdb/errors の Formattable を利用します。どのような stack trace になるか知らないと正規表現の内容もピンとこないと思うので、以下のサンプルコードで stack trace の例を示します。

package main

import (
	"fmt"
	"net"
	"os"
	"time"

	"github.com/cockroachdb/errors"
)

func connect() error {
	conn, err := net.DialTimeout("tcp", "127.0.0.1:1", time.Second)
	if err != nil {
		return errors.Wrap(err, "failed to connect to 127.0.0.1:1")
	}
	defer conn.Close()
	return nil
}

func run() error {
	if err := connect(); err != nil {
		return fmt.Errorf("failed to connect to database: %w", err)
	}
	return nil
}

func main() {
	if err := run(); err != nil {
		fmt.Printf("%+v\n", errors.Formattable(err))
		os.Exit(1)
	}
}

上記のコードを実行すると、以下のような stack trace が表示されます。

$ go run -trimpath ./stacktraceexample
failed to connect to database: failed to connect to 127.0.0.1:1: dial tcp 127.0.0.1:1: connect: connection refused
(1) failed to connect to database
Wraps: (2) attached stack trace
  -- stack trace:
  | main.connect
  |     github.com/abicky/grafana-loki-alert-rule-example/stacktraceexample/main.go:15
  | main.run
  |     github.com/abicky/grafana-loki-alert-rule-example/stacktraceexample/main.go:22
  | main.main
  |     github.com/abicky/grafana-loki-alert-rule-example/stacktraceexample/main.go:29
  | runtime.main
  |     runtime/proc.go:302
  | runtime.goexit
  |     runtime/asm_arm64.s:1039
Wraps: (3) failed to connect to 127.0.0.1:1
Wraps: (4) dial tcp 127.0.0.1:1
Wraps: (5) connect
Wraps: (6) connection refused
Error types: (1) *fmt.wrapError (2) *withstack.withStack (3) *errutil.withPrefix (4) *net.OpError (5) *os.SyscallError (6) syscall.Errno
exit status 1

上記のとおり、Formattable の出力結果の最初の 2 行は具体的なエラーメッセージで、”Wraps: “ で始まる行も wrap されているエラーのメッセージが表示されていることがわかります。これらのメッセージには IP アドレスやタイムスタンプなど、エラーの発生ごとに変わる情報が含まれる可能性が高いので、除外した上でグループ化しています。
なお、Rollbar は stack trace 情報からバージョン番号や行番号を除外した上でグループ化するので、デプロイによって多少 stack trace が変わっても同じエラーを追跡できるようになっています。
Grafana の alert rule ではデプロイをまたいでまでエラーを追跡し続ける必要はなさそうなので、異なる行で発生しているエラーが誤って同一グループにまとめられてしまうのを防ぐ意味でも行番号などは削除していません。

最後に、stack trace がないケースはエラーメッセージを正規化したものでグループ化しています。正規化処理として、ログごとに変化しやすい UUID、IPv6 アドレス、0x から始まる 16 進数、10 進数を除外しています。10 進数の除外によって IPv4 アドレスや port 番号にも対応しています。

# Without a usable typed stack, normalize common variable identifiers in the body.
# Remove UUIDs such as 550e8400-e29b-41d4-a716-446655440000.
| label_format normalized_message=`{{ regexReplaceAll "(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b" (__line__) "" }}`
# Remove IPv6 addresses, including compressed forms.
| label_format normalized_message=`{{ regexReplaceAll "(?i)[0-9a-f]*:[0-9a-f:]*:[0-9a-f:]*" .normalized_message "" }}`
# Remove hexadecimal values with a 0x or 0X prefix.
| label_format normalized_message=`{{ regexReplaceAll "0[xX][0-9a-fA-F]+" .normalized_message "" }}`
# Remove remaining decimal digits, including those in IPv4 addresses and ports.
| label_format normalized_message=`{{ regexReplaceAll "[0-9]+" .normalized_message "" }}`
| label_format error_group=`{{ if .error_group }}{{ .error_group }}{{ else }}{{ .normalized_message }}{{ end }}`

グループ化したエラーの代表的なエラーメッセージや stack trace を抽出する LogQL

エラーの発生回数をカウントする LogQL の最終結果にはエラーメッセージが含まれていないので、具体的なエラーメッセージを通知に含めるには一工夫必要です。
以下の LogQL は、エラーの発生回数をカウントする LogQL と同じロジックでエラーをグループ化した上で、グループ内で最後に発生したエラーログを抽出しています。このクエリを latest_error という名前で alert rule に追加します。

topk by (service_name, deployment_environment_name, error_group, exception_type, severity) (
  1,
  max by (service_name, deployment_environment_name, error_group, exception_type, severity, error_message, exception_stacktrace, runbook_url, error_time_from, error_time_to) (
    max_over_time(
      {service_name="otel-log-demo", severity_text="ERROR"}
      # Exclude errors handled by dedicated metric- or log-based rules from this general rule.
      | alert_general_rule_exclude!="true"
      # errors.Formattable's first two lines and Wraps messages can contain variable error text,
      # such as IP addresses and timestamps.
      # Remove them only from the fingerprint, preserving frame order and line numbers.
      | label_format error_group=`{{ if and .exception_type .exception_stacktrace }}{{ regexReplaceAll "(?m)^Wraps: [^\\r\\n]*(\\r?\\n|$)" (regexReplaceAll "^[^\\r\\n]*\\r?\\n[^\\r\\n]*(\\r?\\n|$)" .exception_stacktrace "") "" }}{{ end }}`
      # Without a usable typed stack, normalize common variable identifiers in the body.
      # Remove UUIDs such as 550e8400-e29b-41d4-a716-446655440000.
      | label_format normalized_message=`{{ regexReplaceAll "(?i)\\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\b" (__line__) "" }}`
      # Remove IPv6 addresses, including compressed forms.
      | label_format normalized_message=`{{ regexReplaceAll "(?i)[0-9a-f]*:[0-9a-f:]*:[0-9a-f:]*" .normalized_message "" }}`
      # Remove hexadecimal values with a 0x or 0X prefix.
      | label_format normalized_message=`{{ regexReplaceAll "0[xX][0-9a-fA-F]+" .normalized_message "" }}`
      # Remove remaining decimal digits, including those in IPv4 addresses and ports.
      | label_format normalized_message=`{{ regexReplaceAll "[0-9]+" .normalized_message "" }}`
      # Use the normalized body only when no stack-based fingerprint was produced.
      | label_format error_group=`{{ if .error_group }}{{ .error_group }}{{ else }}{{ .normalized_message }}{{ end }}`
      # Use the normalized severity in both alert identity and notification grouping.
      | label_format severity=`{{ .severity_text }}`
      # Include the message for notification display, but not in the labels that identify an alert.
      | label_format error_message=`{{ __line__ }}{{ if and .exception_message (not (contains .exception_message (__line__))) }}: {{ .exception_message }}{{ end }}`
      # Save the Explore time range and expose the timestamp as a numeric value for latest-log selection.
      | label_format error_time_from=`{{ unixEpochMillis ((__timestamp__).Add -10000000000) }}`
      | label_format error_time_to=`{{ unixEpochMillis (__timestamp__) }}`
      | label_format error_epoch=`{{ unixEpochMillis (__timestamp__) }}`
      | unwrap error_epoch
      [10m]
    )
  )
)

初見だと以下の箇所の理解が難しいと思うので解説します。

topk by (service_name, deployment_environment_name, error_group, exception_type, severity) (
  1,
  max by (service_name, deployment_environment_name, error_group, exception_type, severity, error_message, exception_stacktrace, runbook_url, error_time_from, error_time_to) (
    max_over_time(
      ...
      | label_format error_epoch=`{{ unixEpochMillis (__timestamp__) }}`
      | unwrap error_epoch
      [10m]
    )
  )
)

具体例として、error_epoch 付与後の状態が以下の状態になっている場合を考えます。

error_group error_message error_epoch
database database request 100 timed out 1000
database database request 101 timed out 5000
payment payment request 200 failed 3000

上記のログをメトリクスに変換しているのが次の箇所です。

max_over_time(
  -- snip --
  | unwrap error_epoch
  [10m]
)

これによって、次のように error_epoch が value で残りは label のメトリクスに変換されます。

{error_group="database", error_message="database request 100 timed out", ...} 1000
{error_group="database", error_message="database request 101 timed out", ...} 5000
{error_group="payment",  error_message="payment request 200 failed", ...}    3000

あとは PromQL と要領は同じで、max by することで不要な label を落とし、topk でグループごとに最も時刻 (error_epoch) の大きなログを抽出しています。

LogQL のメトリクスクエリの詳細は Unwrapped range aggregations を参照してください。

なお、上記のクエリはエラーが発生していなくても実行されることに注意が必要です。Grafana Cloud では Alert enrichment という仕組みがあり、これを利用することで特定の LogQL にマッチするログを __enrich_logs_lines annotation として付与することができるんですが、どうも alert enrichment の LogQL では {service_name="{{ $labels.service_name }}"} のように alert rule の label を参照することができないようなので、今回求めているエラーメッセージを取得する用途には Alert enrichment は利用できそうにありませんでした。

LogQL の結果を利用した alert rule の annotation の設定

通知用のテンプレートで利用するために、alert rule に annotation の設定をする必要があります。

Summary

まず summary として以下のようなテンプレートを記述します。ここで、「グループ化したエラーの代表的なエラーメッセージや stack trace を抽出する LogQL」(latest_error) で抽出した error_message を参照しています。

{{ with $labels.exception_type -}}
  {{ . }}
{{- end -}}
{{ with $values.latest_error -}}
  {{ with .Labels.error_message -}}
    {{ if $labels.exception_type }}: {{ end }}{{ . }}
  {{- end -}}
{{- end -}}

Runbook

runbook は latest_error の結果に runbook_url があればそれを採用し、なければ予め想定されている exception_type のもの(以下の例では main.databaseError)に対してのみ付与するようにします。

{{ $runbook := "" -}}
{{ with $values.latest_error -}}
  {{ with .Labels.runbook_url -}}
    {{ $runbook = . -}}
  {{ end -}}
{{ end -}}
{{ if not $runbook -}}
  {{ if eq $labels.exception_type "main.databaseError" -}}
    {{ $runbook = "https://example.com/runbooks/database" -}}
  {{ end -}}
{{ end -}}
{{ $runbook -}}

Stack Trace

stack trace は latest_error の結果を採用します。

{{ with $values.latest_error }}{{ with .Labels.exception_stacktrace }}{{ . }}{{ end }}{{ end }}

Explore 用の annotation

Explore 用の annotation として、まずはベースとなる LogQL の annotation を定義します。

explore_query
{{ printf "{service_name=%q, deployment_environment_name=%q}" $labels.service_name $labels.deployment_environment_name }}

次に、最後のエラーの時刻をベースにクエリする範囲となる情報を annotation として付与します。

explore_from
{{ with $values.latest_error }}{{ .Labels.error_time_from }}{{ end }}
explore_to
{{ with $values.latest_error }}{{ .Labels.error_time_to }}{{ end }}

通知に使うメッセージテンプレート

Grafana のデフォルトのテンプレートは汎用的なものなので、良い感じに表示するには自前でテンプレートを用意する必要があります。
冒頭で紹介したように、今回は次のような通知が届くようにテンプレートを用意しました。

Slack のメッセージテンプレートには title と message があるので、それぞれについて解説します。

Slack title

title では summary annotation を参照します。以下のとおりとてもシンプルな内容です。

{{ $summary := "No alerts in notification" -}}
{{ if .Alerts.Firing -}}
  {{ $summary = (index .Alerts.Firing 0).Annotations.summary -}}
{{ else if .Alerts.Resolved -}}
  {{ $summary = (index .Alerts.Resolved 0).Annotations.summary -}}
{{ end -}}
{{ .CommonLabels.service_name }}: {{ $summary -}}

Slack message

message には以下のようなテンプレートを定義しました。

{{ range .Alerts -}}
{{ .Annotations.description }}
  {{ $hasExplore := and .Annotations.explore_query .Annotations.explore_from .Annotations.explore_to -}}
  {{ $hasSilence := and .SilenceURL (le (len .SilenceURL) 4000) -}}
  {{ if or .Annotations.runbook_url .GeneratorURL .DashboardURL $hasExplore $hasSilence }}
*Actions*

    {{ with .Annotations.runbook_url -}}
- <{{ . }}|Go to runbook>
    {{ end -}}
    {{ with .GeneratorURL -}}
- <{{ . }}|Go to alert rule>
    {{ end -}}
    {{ with .DashboardURL -}}
- <{{ . }}|Go to dashboard>
    {{ end -}}
    {{ if $hasExplore -}}
      {{/* Use data.ToJSON instead of printf "%q", which can emit Go-only escapes such as \x01 that are invalid in JSON. */ -}}
      {{ $panes := printf "{\"logs\":{\"datasource\":\"loki\",\"queries\":[{\"datasource\":{\"type\":\"loki\",\"uid\":\"loki\"},\"editorMode\":\"code\",\"expr\":%s,\"queryType\":\"range\",\"refId\":\"logs\"}],\"range\":{\"from\":\"%s\",\"to\":\"%s\"}}}" (data.ToJSON .Annotations.explore_query) .Annotations.explore_from .Annotations.explore_to -}}
- <http://localhost:3000/explore?schemaVersion=1&panes={{ urlquery $panes }}|Explore logs>
    {{ end -}}
    {{ if $hasSilence -}}
- <{{ .SilenceURL }}|Create silence>
    {{ end -}}
  {{ end -}}
  {{ with .Annotations.stacktrace }}
*Stack trace*

```
{{ printf "%.3000s" . }}
```
  {{ end }}
  {{ $firstLabel := true -}}
  {{ range $key, $value := .Labels -}}
    {{ if and (ne $key "error_group") (ne $key "service_name") -}}
      {{ if $firstLabel -}}
*Other labels:*

        {{ $firstLabel = false -}}
      {{ end -}}
- {{ $key }}: {{ $value }}
    {{ end -}}
  {{ end }}
  {{ $firstAnnotation := true -}}
  {{ range $key, $value := .Annotations -}}
    {{ if and (ne $key "summary") (ne $key "description") (ne $key "runbook_url") (ne $key "stacktrace") (ne $key "explore_query") (ne $key "explore_from") (ne $key "explore_to") -}}
      {{ if $firstAnnotation -}}
*Other annotations:*

        {{ $firstAnnotation = false -}}
      {{ end -}}
- {{ $key }}: {{ $value }}
    {{ end -}}
  {{ end }}
{{ end -}}

Explore logs のリンクを生成する際には Explore 用の annotation として付与した explore_query, explore_from, explore_to を利用しています。

  {{ $hasExplore := and .Annotations.explore_query .Annotations.explore_from .Annotations.explore_to -}}
-- snip --
    {{ if $hasExplore -}}
      {{/* Use data.ToJSON instead of printf "%q", which can emit Go-only escapes such as \x01 that are invalid in JSON. */ -}}
      {{ $panes := printf "{\"logs\":{\"datasource\":\"loki\",\"queries\":[{\"datasource\":{\"type\":\"loki\",\"uid\":\"loki\"},\"editorMode\":\"code\",\"expr\":%s,\"queryType\":\"range\",\"refId\":\"logs\"}],\"range\":{\"from\":\"%s\",\"to\":\"%s\"}}}" (data.ToJSON .Annotations.explore_query) .Annotations.explore_from .Annotations.explore_to -}}
- <http://localhost:3000/explore?schemaVersion=1&panes={{ urlquery $panes }}|Explore logs>
    {{ end -}}

メッセージが長大な場合、Slack は 8,000 文字ぐらいで truncate するようになっているようなので silence 用の URL が長過ぎる場合は非表示にしたり、stack trace 情報は以下のように 3,000 文字で切るようにしています。

  {{ $hasSilence := and .SilenceURL (le (len .SilenceURL) 4000) -}}
  {{ if or .Annotations.runbook_url .GeneratorURL .DashboardURL $hasExplore $hasSilence }}
-- snip --
    {{ if $hasSilence -}}
- <{{ .SilenceURL }}|Create silence>
    {{ end -}}
  {{ with .Annotations.stacktrace }}
*Stack trace*

```
{{ printf "%.3000s" . }}
```
  {{ end }}

メッセージの末尾にはこれまでに利用した label や annotation 以外を表示するようにしています。

  {{ $firstLabel := true -}}
  {{ range $key, $value := .Labels -}}
    {{ if and (ne $key "error_group") (ne $key "service_name") -}}
      {{ if $firstLabel -}}
*Other labels:*

        {{ $firstLabel = false -}}
      {{ end -}}
- {{ $key }}: {{ $value }}
    {{ end -}}
  {{ end }}
  {{ $firstAnnotation := true -}}
  {{ range $key, $value := .Annotations -}}
    {{ if and (ne $key "summary") (ne $key "description") (ne $key "runbook_url") (ne $key "stacktrace") (ne $key "explore_query") (ne $key "explore_from") (ne $key "explore_to") -}}
      {{ if $firstAnnotation -}}
*Other annotations:*

        {{ $firstAnnotation = false -}}
      {{ end -}}
- {{ $key }}: {{ $value }}
    {{ end -}}
  {{ end }}

まとめ

以上、Grafana と Loki でアプリケーションエラーを監視するために、Rollbar を参考に通知の仕組みの作り方について解説しました。
複雑な LogQL とメッセージテンプレートを書く前提で、冒頭で挙げた要件とそれに対応する実現方法は以下のとおりです。

要件 実現方法
エラーのグループ化 stack trace から error_group を生成
エラーの level や発生回数などによる通知の出し分け severity ごとに alert rule を作成。スキャン量低減のために severity_text を index label として登録。
エラーメッセージの表示 latest_error クエリでエラーメッセージを取得
Runbook へのリンクの表示 アプリケーションと alert rule の両方で設定
エラーが発生した時刻付近のログの確認 latest_error クエリの結果から Explore リンクを生成
stack trace の表示 メッセージテンプレートで code block を利用
二重通知の防止 alert_general_rule_exclude=true のログを監視対象から除外

なお、グループ化のロジックは 2 つの LogQL で共有し、同じような alert rule を 3 つの severity に対して作成することから、デモでは Terraform で locals を使って動的に生成しています。
cf. terraform/locals.tf

アプリケーションエラー監視の改善の参考になれば幸いです。