前回はアラートポリシーを使って検知しました
Loggingにある情報をそのままチケットに掲載するだけならログシンクでもいいのでは?
Cloud Monitoringのアラートポリシーを使わなくてもCloud Loggingはログシンクを使ってPub/Subに一部のログを連携できます。
ログシンクを使えばアラートポリシーなしでもエラー発生時に自動的にJIRAチケット起票できるのではないかと思い、やってみました。
システム構成
構成はこんな感じです。
なお、図上は省略していますが、JIRA起票に必要なユーザー名やアクセストークンはSecretManagerで管理しています。
やってみた
冒頭の前回記事と同じ部分
検証用のアプリケーション
SpringBoot + Spring Security + Thymeleafでログインするだけのアプリを用意しました。
Spring Initializrで作ったひな型プロジェクトをベースにAntigravity CLIで作ってもらいました。
このアプリですが、なんと 「ユーザー名に数字以外が含まれていたらログイン時にシステムエラーになる」 という致命的なバグがあります。
(検証のためにわざと埋め込みました)
埋め込んだバグは以下の個所です。
@Controller
public class LoginController {
// 割愛
@GetMapping("/")
public String home(Model model, Authentication authentication) {
if (authentication != null) {
model.addAttribute(
"username",
Integer.parseInt(authentication.getName()) // ★検証用に仕込んだバグ!ユーザー名に数字以外が含まれていたらエラー
);
}
return "index";
}
}
ログシンクの設定
今回のポイントであるログシンクは以下のTerraformコードで作りました。
resource "google_logging_project_sink" "cloud_run_error_sink" {
name = "cloud-run-error-to-pubsub"
project = var.project_id
destination = "pubsub.googleapis.com/${google_pubsub_topic.cloud_run_error_logs.id}"
filter = <<-EOT
resource.type="cloud_run_revision"
severity>=${var.error_log_severity}
resource.labels.service_name!="${var.jira_ticket_creator_service_name}"
logName="projects/${var.project_id}/logs/run.googleapis.com%2Fstdout"
EOT
unique_writer_identity = true
depends_on = [google_project_service.required]
}
前回記事のフィルター条件と同じですが、重要なのは以下の2点です。
resource.labels.service_name!="${var.jira_ticket_creator_service_name}"が前述の無限エラー地獄を避ける除外条件です。
logName="projects/${var.project_id}/logs/run.googleapis.com%2Fstdout"は、SpringBootアプリケーションが出力したエラーログだけを検知するための条件です。
500応答を返すとCloud Runのリクエストログもseverity = ERRORでログ出力するため、1つの事象で複数チケット起票を避けるために入れています。
JIRAチケット起票をするアプリケーション
前回記事同様にexpress(TypeScript)で開発しました。
ログシンクでPub/Subにメッセージを詰める場合、Pub/Subから受け取るのはCloud Loggingに出力されているログエントリそのものです。
そこで、ログエントリを読み取ってJIRAチケットを組み立てる必要があります。
ログエントリを読み取る処理
export interface CloudLogEntry {
logName?: string;
severity?: string;
timestamp?: string;
insertId?: string;
trace?: string;
textPayload?: string;
jsonPayload?: Record<string, unknown>;
protoPayload?: Record<string, unknown>;
resource?: {
type?: string;
labels?: Record<string, string>;
};
}
export interface ErrorSummary {
serviceName: string;
revisionName?: string;
severity: string;
timestamp: string;
insertId: string;
message: string;
logName: string;
}
const MAX_MESSAGE_LENGTH = 4000;
function extractMessage(entry: CloudLogEntry): string {
if (entry.textPayload) {
return entry.textPayload;
}
// 今回のアプリケーションはjsonPayloadを出力するように準備したのでここで読る
if (entry.jsonPayload) {
const payload = entry.jsonPayload;
if (typeof payload.message === 'string') {
return payload.message;
}
if (typeof payload.error === 'string') {
return payload.error;
}
return JSON.stringify(payload);
}
if (entry.protoPayload) {
return JSON.stringify(entry.protoPayload);
}
return '(ログ本文を取得できませんでした)';
}
/** LogEntryからJIRAチケット起票に必要な情報を正規化して取り出す */
export function toErrorSummary(entry: CloudLogEntry): ErrorSummary {
const message = extractMessage(entry);
return {
serviceName: entry.resource?.labels?.service_name ?? 'unknown-service',
revisionName: entry.resource?.labels?.revision_name,
severity: entry.severity ?? 'ERROR',
timestamp: entry.timestamp ?? new Date().toISOString(),
insertId: entry.insertId ?? 'unknown',
message: message.length > MAX_MESSAGE_LENGTH ? `${message.slice(0, MAX_MESSAGE_LENGTH)}...(truncated)` : message,
logName: entry.logName ?? 'unknown',
};
}
JIRAチケット起票する処理
function buildDescription(summary: ErrorSummary) {
// Atlassian Document Format (ADF)
return {
type: 'doc',
version: 1,
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: `Cloud Runサービス「${summary.serviceName}」でエラーログを検知しました。`,
},
],
},
{
type: 'codeBlock',
content: [{ type: 'text', text: summary.message }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text:
`severity: ${summary.severity} / revision: ${summary.revisionName ?? 'N/A'} / ` +
`timestamp: ${summary.timestamp} / logName: ${summary.logName} / insertId: ${summary.insertId}`,
},
],
},
],
};
}
export async function createIssue(config: Config, summary: ErrorSummary): Promise<string> {
const url = `${config.jiraBaseUrl}/rest/api/3/issue`;
const payload = {
fields: {
project: { key: config.jiraProjectKey },
issuetype: { name: config.jiraIssueType },
summary: `[${summary.severity}] ${summary.serviceName}: ${truncate(summary.message, 120)}`,
labels: [`log-${summary.insertId}`, 'auto-created', 'cloud-run-error'],
description: buildDescription(summary),
},
};
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: authHeader(config),
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(payload),
});
if (!res.ok) {
throw new Error(`JIRAチケット作成に失敗しました: ${res.status} ${await res.text()}`);
}
const body = (await res.json()) as JiraCreateIssueResponse;
return body.key;
}
descriptionの組み立てが地味にめんどくさいですね…
paragraphを並べるのが面倒でちょっと雑になっています。
エラーを発生させてみた結果
無事、下記のようにJIRAチケットが起票されました!
スタックとレースが1行のコードブロックになってしまっていて見づらいですね…
もう少しdescriptionを工夫すれば見やすく起票できそうです。
今回の仕組みだとエラーが発生するたびにJIRAチケットが起票されます。
実運用を考えると起票済みのエラーと一致する場合は起票せず、起票済みのチケットに発生時刻を追記するような仕組みの方が良いと思います。
そうしなければ、多数のエラーが発生した際にJIRAチケットを見切れなくなってしまうためです。
まとめ
今回はアラートポリシーを利用せず、ログシンクでPub/SubにつないでJIRAチケット起票をしてみました。
Google Cloudリソースの構築数を減らせはしますが、その分JIRAチケット起票のアプリケーションに複雑さが寄りました。
JIRAでの見せ方を一か所にまとめたい場合はこの作り方も良いと思います。
また、アラートポリシーを利用した場合と比べてログシンクはJIRAチケット起票までの時間が非常に短いです。
速度を求めるなら今回の作り方がよさそうです。
一方、アラートポリシーが担っていた「同一アラートのログが複数発生した場合のアラート抑制」については、ログシンクを使った場合は独自に作りこむ必要がある点に要注意です。




