dora-team/fourkeysの中身を読んで理解する
まずはREADMEを読むか。
Background
DORAがソフトウェアデリバリーのパフォーマンスを示す4つのKeyを特定した
- Deployment Frequency
- Lead Time For Changes
- Time to Restore Services
- Change Failure Rate
例えば新ツールの導入効果を測定したかったり、そもそも開発チームのパフォーマンスを測定したかったりする場合にFour Keysを使うことができる
How it works
- Google Cloud Run上のWebhookターゲットに向かってイベント(例えばGitHubのPRやissueに関する)が送信される
- CloudRunは受けたすべてのイベントを
Pub/Sub
にパブリッシュする - CloudRunインスタンスは
Pub/Sub
をサブスクライブ
処理を読む
アーキテクチャ的にはこんな感じ。
- GitHub(など)のイベントをwebhookで取得
- パース
- BigQueryに保存
- Viewに対してGrafanaがSQLを実行し、UIとして表示
1. EventHandler
event-handler/event_handler.py
一番最初の処理。webhookイベントから発信元を特定し、Pub/Subの適切なトピックに送信する。
- source (webhookの発信元)をheaderから判定 = 今回はgithub
- 署名(webhook secret)の検証
- Pub / Sub のトピックにwebhookのボディとヘッダーを送信
※ちなみに Pub/Sub の topic_path はgithubの場合こんな感じになる:
projects/__GOOGLE_CLOUD_PROJECT_NAME__/topics/github
2. Pub / Subのサブスクライバー(Data Parsers)
bq-workers/github-parser/main.py
Pub / SubのサブスクライバーはCloudRunサービスである。
余談だが terraform apply
したときに選択した source
の分だけCloudRunサービスが生成される。
今回は github を選択している。
- サポートされた
github webhook event
かどうかを確認(push, pull_request...etc) - webhook eventの中身を以下の形式に分解する
キー | 説明 | 具体的な値 |
---|---|---|
event_type | どのWebhook Eventか | push(githubの場合) |
id | 今回のイベントの識別ID | 1345678 |
metadata | webhook eventのbody | json |
time_created | イベントが生成された時間 | 2023-07-19 09:10:10 UTC |
signature | 署名 | algo=abcdefghijk... |
msg_id | Pub/Subメッセージの識別ID | 134567 |
source | Webhook発信元 | github |
- それをBigQueryの
events_row
テーブルに挿入する
3. Viewの中身
BigQueryのevents_row
テーブルは生データを扱うが、Grafanaが使いやすいようにViewが3つ用意されている。
- changes
- deployments
- incidents
これらをそれぞれ見ていく。
3-1. changes
queries/changes.sql
SELECT
source,
event_type,
JSON_EXTRACT_SCALAR(commit, '$.id') change_id,
TIMESTAMP_TRUNC(TIMESTAMP(JSON_EXTRACT_SCALAR(commit, '$.timestamp')),second) as time_created,
FROM
four_keys.events_raw e,
UNNEST(JSON_EXTRACT_ARRAY(e.metadata, '$.commits')) as commit
WHERE
event_type = "push"
GROUP BY
1,2,3,4
JSON_EXTRACT_SCALAR(json_field, key)
: jsonから単一の値を取得する
TIMESTAMP_TRANC(timestamp, unit)
: タイムスタンプの値から、指定した単位より小さい単位を切り捨てる
JSON_EXTRACT_ARRAY(json_field, key)
: jsonを持つフィールド(カラム)から配列を取得する
UNNEST(array)
: 配列を1行ずつ展開してテーブルにくっつける
スキーマはこうなる
フィールド名 | 種類 | 意味 |
---|---|---|
source | STRING | webhook event発信元 |
event_type | STRING | どのwebhook eventか |
change_id | STRING | コミットID |
time_created | TIMESTAMP | コミットが生成された時間 |
長くなったが、ようは changes
ビューはどのような変更(コミット)が加えられたか。を記録している。
Changesが入るタイミングを調査
SQLを読む限り、push
イベントのみにこのテーブルは反応する。
気になるのは…
- mainブランチ以外のpushにも反応する?
- 反応した
- ローカルで時間を変えて3つのコミットし、それをpushした場合、どのように保存される?
- pushした際に含まれた新しいコミットはすべて配列でイベント送信される。
- つまり、BigQuery側では
events_row
は1行のレコードになる。 - changesでは3行のレコードになる
- change_idもバラバラ = コミットに紐づく
- そしてtime_createdはそのコミットがいつ作られたかの日付が入る。
- そのブランチでさらにコミットを1つ増やしてpushするとどうなる?
-
events_raw
は2レコードになる。 - changesも同様に増える。
-
- そのブランチをGH上でマージした時、changesに何か保存される?
- マージコミットがChangesに入った。
よってこうなる。
- マージ後、4つ(5つ)の変更が入ったmainブランチのデプロイメントを生成するとどうなる?
- deploymentビューに変化は現れるが、changesビューは特になし
よって、
- 1レコード1コミットで格納する
- pushに反応
- ブランチは何でも
- マージ時にもpushが発生しているのでマージコミットも格納される
3-2. deployments
長すぎるので分割して読む
長すぎる全SQL
# Deployments Table
WITH deploys_cloudbuild_github_gitlab AS (# Cloud Build, Github, Gitlab pipelines
SELECT
source,
id as deploy_id,
time_created,
CASE WHEN source = "cloud_build" then JSON_EXTRACT_SCALAR(metadata, '$.substitutions.COMMIT_SHA')
WHEN source like "github%" then JSON_EXTRACT_SCALAR(metadata, '$.deployment.sha')
WHEN source like "gitlab%" then COALESCE(
# Data structure from GitLab Pipelines
JSON_EXTRACT_SCALAR(metadata, '$.commit.id'),
# Data structure from GitLab Deployments
# REGEX to get the commit sha from the URL
REGEXP_EXTRACT(
JSON_EXTRACT_SCALAR(metadata, '$.commit_url'), r".*commit\/(.*)")
)
WHEN source = "argocd" then JSON_EXTRACT_SCALAR(metadata, '$.commit_sha') end as main_commit,
CASE WHEN source LIKE "github%" THEN ARRAY(
SELECT JSON_EXTRACT_SCALAR(string_element, '$')
FROM UNNEST(JSON_EXTRACT_ARRAY(metadata, '$.deployment.additional_sha')) AS string_element)
ELSE ARRAY<string>[] end as additional_commits
FROM four_keys.events_raw
WHERE (
# Cloud Build Deployments
(source = "cloud_build" AND JSON_EXTRACT_SCALAR(metadata, '$.status') = "SUCCESS")
# GitHub Deployments
OR (source LIKE "github%" and event_type = "deployment_status" and JSON_EXTRACT_SCALAR(metadata, '$.deployment_status.state') = "success")
# GitLab Pipelines
OR (source LIKE "gitlab%" AND event_type = "pipeline" AND JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.status') = "success")
# GitLab Deployments
OR (source LIKE "gitlab%" AND event_type = "deployment" AND JSON_EXTRACT_SCALAR(metadata, '$.status') = "success")
# ArgoCD Deployments
OR (source = "argocd" AND JSON_EXTRACT_SCALAR(metadata, '$.status') = "SUCCESS")
)
),
deploys_tekton AS (# Tekton Pipelines
SELECT
source,
id as deploy_id,
time_created,
IF(JSON_EXTRACT_SCALAR(param, '$.name') = "gitrevision", JSON_EXTRACT_SCALAR(param, '$.value'), Null) as main_commit,
ARRAY<string>[] AS additional_commits
FROM (
SELECT
id,
TIMESTAMP_TRUNC(time_created, second) as time_created,
source,
four_keys.json2array(JSON_EXTRACT(metadata, '$.data.pipelineRun.spec.params')) params
FROM four_keys.events_raw
WHERE event_type = "dev.tekton.event.pipelinerun.successful.v1"
AND metadata like "%gitrevision%") e, e.params as param
),
deploys_circleci AS (# CircleCI pipelines
SELECT
source,
id AS deploy_id,
time_created,
JSON_EXTRACT_SCALAR(metadata, '$.pipeline.vcs.revision') AS main_commit,
ARRAY<string>[] AS additional_commits
FROM four_keys.events_raw
WHERE (source = "circleci" AND event_type = "workflow-completed" AND JSON_EXTRACT_SCALAR(metadata, '$.workflow.name') LIKE "%deploy%" AND JSON_EXTRACT_SCALAR(metadata, '$.workflow.status') = "success")
),
deploys AS (
SELECT * FROM
deploys_cloudbuild_github_gitlab
UNION ALL
SELECT * FROM deploys_tekton
UNION ALL
SELECT * FROM deploys_circleci
),
changes_raw AS (
SELECT
id,
metadata as change_metadata
FROM four_keys.events_raw
),
deployment_changes as (
SELECT
source,
deploy_id,
deploys.time_created time_created,
change_metadata,
four_keys.json2array(JSON_EXTRACT(change_metadata, '$.commits')) as array_commits,
main_commit
FROM deploys
JOIN
changes_raw on (
changes_raw.id = deploys.main_commit
or changes_raw.id in unnest(deploys.additional_commits)
)
)
SELECT
source,
deploy_id,
time_created,
main_commit,
ARRAY_AGG(DISTINCT JSON_EXTRACT_SCALAR(array_commits, '$.id')) changes,
FROM deployment_changes
CROSS JOIN deployment_changes.array_commits
GROUP BY 1,2,3,4;
WITH
-
deploys
-
deployment_status = success
のものを抜き出して、作成当時の最新コミットをmain_commit
とした一時テーブル
-
-
changes_raw
:- 過去のすべての
event_raw
からid(eventごとに何がIDが決まっている)とmetadata(webhook payload)を抜き出した一時テーブル
- 過去のすべての
-
deployment_changes
- deploysテーブルの
main_commit
とchanges_rawテーブルのidをINNER JOINし(=deploymentのmain_commitのpush(コミットをまとめて持っている)のmetadataを集められるようになる)、
- deploysテーブルの
スキーマ
フィールド名 | 種類 | 意味 |
---|---|---|
source | STRING | webhook event発信元 |
deploy_id | STRING | githubのdeployment_status 生成時に振られるID |
time_created | TIMESTAMP | いつdeploymentが生成されたか |
main_commit | STRING | deployment生成時の最新コミット? |
changes | STRING | 以前のdeployment生成時からの差分? |
要調査
- deploymentを生成した後に、何も変更を加えずdeploymentを生成したらどうなるか
- changesが全く同じ5個入ったレコードが登録された。
- deploymentのmain_commitは最新のcommitハッシュで間違いないか
- コミットA,コミットBを別ブランチでpushしてmainにマージ && その後mainに直接push && deployment生成でどうなる?
- 最新のcommit_hash(最後の直接push)でした。
- deploymentテーブルのchangesには、コミットAやコミットBやマージコミットは含まれていなかった。
- これは「コミット〇〇がいつデプロイされたか」が取れない。穴。
- → よって、deploymentはつねにマージコミットである必要がある。
- コミットA,コミットBを別ブランチでpushしてmainにマージ && その後mainに直接push && deployment生成でどうなる?
3-3. incidents
長いSQL
# Incidents Table
SELECT
source,
incident_id,
MIN(IF(root.time_created < issue.time_created, root.time_created, issue.time_created)) as time_created,
MAX(time_resolved) as time_resolved,
ARRAY_AGG(root_cause IGNORE NULLS) changes,
FROM
(
SELECT
source,
CASE
WHEN source LIKE "github%" THEN JSON_EXTRACT_SCALAR(metadata, '$.issue.number')
WHEN source LIKE "gitlab%" AND event_type = "note" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_id')
WHEN source LIKE "gitlab%" AND event_type = "issue" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.id')
WHEN source LIKE "pagerduty%" THEN JSON_EXTRACT_SCALAR(metadata, '$.event.data.id')
END AS incident_id,
CASE
WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.created_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.created_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_created,
CASE
WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.closed_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.closed_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_resolved,
REGEXP_EXTRACT(metadata, r"root cause: ([[:alnum:]]*)") as root_cause,
CASE
WHEN source LIKE "github%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.issue.labels'), '"name":"Incident"')
WHEN source LIKE "gitlab%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.object_attributes.labels'), '"title":"Incident"')
WHEN source LIKE "pagerduty%" THEN TRUE # All Pager Duty events are incident-related
END AS bug,
FROM
four_keys.events_raw
WHERE
event_type LIKE "issue%"
OR
event_type LIKE "incident%"
OR
(event_type = "note" and JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_type') = 'Issue')
) issue
LEFT JOIN
(
SELECT
time_created, changes
FROM
four_keys.deployments d, d.changes
) root
ON root.changes = root_cause
GROUP BY
1,2
HAVING
max(bug) is True
;
CASE句1
$.issue.number
を取り出していることから、issueの識別IDをincident_id
と定義している。
github以外の場合も同様だと考える。
CASE WHEN source LIKE "github%" THEN JSON_EXTRACT_SCALAR(metadata, '$.issue.number')
WHEN source LIKE "gitlab%" AND event_type = "note" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_id')
WHEN source LIKE "gitlab%" AND event_type = "issue" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.id')
WHEN source LIKE "pagerduty%" THEN JSON_EXTRACT_SCALAR(metadata, '$.event.data.id')
END AS incident_id,
CASE句2
$.issue.created_at
を取り出していることから、issueオープン時のタイムスタンプを time_created
と定義している。
github以外の場合も同様だと考える。
CASE WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.created_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.created_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_created,
CASE句3
$.issue.closed_at
を取り出していることから、issueクローズ時のタイムスタンプを time_resolved
と定義している。
github以外の場合も同様だと考える。
CASE WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.closed_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.closed_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_resolved,
root_cause
-
r
: raw文字列として扱う(\をエスケープではなくそのままとする) -
[[:alnum:]]*
: アルファベットと数字( = \w)の0回以上の繰り返し
よって、 root cause: xxxxx
というパターンマッチする部分を抽出する。issue生成時に内容にこの文字列が書かれてあることを期待している。後から分かるが、xxxxxはコミットハッシュであるべきである。
REGEXP_EXTRACT(metadata, r"root cause: ([[:alnum:]]*)") as root_cause,
CASE句4
$.issue.labels
を取り出していることから、issueのラベルが name: Incident
であるものを bug
カラムに入れる。
github以外の場合も似たようなもの。
CASE WHEN source LIKE "github%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.issue.labels'), '"name":"Incident"')
WHEN source LIKE "gitlab%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.object_attributes.labels'), '"title":"Incident"')
WHEN source LIKE "pagerduty%" THEN TRUE # All Pager Duty events are incident-related
END AS bug,
WHERE句以降
-
event_type
がissue%
か、incident%
か、note
かつ'$.object_attributes.noteable_type') = 'Issue')
か- おそらくgithub以外のイベントのことも考えているのだと思う。
- d.changesはARRAY(複数の値が入っている)なのですべての組み合わせを持つ表が作られる。そのうち、root_causeとコミットハッシュ値が一致するものだけを抽出する
- 1,2行目(source & incident_id)に基づいてグループ化し、その中にbug = TRUEがあるものだけを抽出する
WHERE event_type LIKE "issue%" OR event_type LIKE "incident%" OR (event_type = "note" and JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_type') = 'Issue')
) issue
LEFT JOIN (SELECT time_created, changes FROM four_keys.deployments d, d.changes) root on root.changes = root_cause
GROUP BY 1,2
HAVING max(bug) is True
issueやincidentのうちIncidentラベルがついているものの情報を取得する。time_createdは issueのオープン時刻とdeploymentの時刻のうち、早い方をそれとする。
4. GrafanaダッシュボードのSQL
現状だとどんな項目がどのように取れているのか。デフォルトの設定を見ていく。
4-1. Lead Time for Changes
sql
SELECT
day,
IFNULL(ANY_VALUE(med_time_to_change)/60, 0) AS median_time_to_change, # Hours
FROM (
SELECT
d.deploy_id,
TIMESTAMP_TRUNC(d.time_created, DAY) AS day,
PERCENTILE_CONT(
IF(TIMESTAMP_DIFF(d.time_created, c.time_created, MINUTE) > 0, TIMESTAMP_DIFF(d.time_created, c.time_created, MINUTE), NULL), # Ignore automated pushes
0.5) # Median
OVER (PARTITION BY TIMESTAMP_TRUNC(d.time_created, DAY)) AS med_time_to_change, # Minutes
FROM four_keys.deployments d, d.changes
LEFT JOIN four_keys.changes c ON changes = c.change_id
)
GROUP BY
day
ORDER BY
day
deployments
テーブルとdeployments
テーブルのchangesの直積(changesは「STRINGのREPEATING型」
ON changes = c.change_id
のchangesは何かというと、d.changesのことだった。
FROM four_keys.deployments d, d.changes as abcde
LEFT JOIN four_keys.changes c ON abcde = c.change_id
で同じ結果が返ってきたのでそう判断した。
なぜ結合したかというと、changesのtime_createdの値を使いたいため。(deploymentテーブルにはコミットの生成時間がないので、changesビューを使わざるを得ないのさ。)
要は最終的には、deploymentビューが持っているコミットに対して、changesビューの情報をくっつけて、
変更のリードタイム。
デプロイメントの作成時刻とは、ステータスがsuccessになった瞬間のこと。
変更の作成時刻はローカルコミットの時刻。
それらの差分の分を出した後、日毎にグルーピング。グループ内で中央値を出している。
4-2 Lead Time to Change Bucket
無視する。
4-3 Daily Deployments
SELECT
TIMESTAMP_TRUNC(time_created, DAY) AS day,
COUNT(distinct deploy_id) AS deployments
FROM
four_keys.deployments
GROUP BY day
ORDER BY day
デプロイメントテーブルの数を日毎にグルーピングして数えているだけ。
deployment_status
を生成すればいいってことだ。
4-4 Deployment Frequency
sql
WITH last_three_months AS
(SELECT
TIMESTAMP(day) AS day
FROM
UNNEST(
GENERATE_DATE_ARRAY(
DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH), CURRENT_DATE(),
INTERVAL 1 DAY)) AS day
# FROM the start of the data
WHERE day > (SELECT date(min(time_created)) FROM four_keys.events_raw)
)
SELECT
CASE WHEN daily THEN "Daily"
WHEN weekly THEN "Weekly"
# If at least one per month, then Monthly
WHEN PERCENTILE_CONT(monthly_deploys, 0.5) OVER () >= 1 THEN "Monthly"
ELSE "Yearly"
END as deployment_frequency
FROM (
SELECT
# If the median number of days per week is more than 3, then Daily
PERCENTILE_CONT(days_deployed, 0.5) OVER() >= 3 AS daily,
# If most weeks have a deployment, then Weekly
PERCENTILE_CONT(week_deployed, 0.5) OVER() >= 1 AS weekly,
# Count the number of deployments per month.
# Cannot mix aggregate and analytic functions, so calculate the median in the outer select statement
SUM(week_deployed) OVER(partition by TIMESTAMP_TRUNC(week, MONTH)) monthly_deploys
FROM(
SELECT
TIMESTAMP_TRUNC(last_three_months.day, WEEK) as week,
MAX(if(deployments.day is not null, 1, 0)) as week_deployed,
COUNT(distinct deployments.day) as days_deployed
FROM last_three_months
LEFT JOIN(
SELECT
TIMESTAMP_TRUNC(time_created, DAY) AS day,
deploy_id
FROM four_keys.deployments) deployments ON deployments.day = last_three_months.day
GROUP BY week)
)
LIMIT 1
とりあえず、過去3ヶ月のデプロイ頻度を独自の計算式で計算し、daily, weekly, monthly, yearly
で計算している。
個人的な思いとしては、ここは平均デプロイ数/(日、週、月、年)があれば十分だと考える。
4-5 Daily Median Time to Restore Services
SELECT
TIMESTAMP_TRUNC(time_created, DAY) as day,
#### Median time to resolve
PERCENTILE_CONT(
TIMESTAMP_DIFF(time_resolved, time_created, HOUR), 0.5)
OVER(PARTITION BY TIMESTAMP_TRUNC(time_created, DAY)
) as daily_med_time_to_restore,
FROM four_keys.incidents
ORDER BY day
各issueのオープン日とクローズ日の差をHOUR単位でだし、オープン日で計算結果をグルーピングしたときの中央値を出す。
…これ何か意味ある?
例えば、issueA, B, Cが 2023-07-25日にオープンし、
- issueA は 1 hours
- issueB は 15 hours
- issueC は 135 hours
で解決した時、グラフは「2023-07-25日は15時間でissueが解決した」という情報を持った点だけが打ち込まれる。
…日ごとに中央値を取る理由が一切わからないし、重要な情報(issueCがかなり時間がかかった)が抜け落ちている。
4-6 Median Time to Restore Service
SELECT
CASE WHEN med_time_to_resolve < 24 then "One day"
WHEN med_time_to_resolve < 168 then "One week"
WHEN med_time_to_resolve < 730 then "One month"
WHEN med_time_to_resolve < 730 * 6 then "Six months"
ELSE "One year"
END as med_time_to_resolve,
FROM (
SELECT
#### Median time to resolve
PERCENTILE_CONT(
TIMESTAMP_DIFF(time_resolved, time_created, HOUR), 0.5)
OVER() as med_time_to_resolve,
FROM four_keys.incidents
# Limit to 3 months
WHERE time_created > TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH)))
LIMIT 1
すべてのissueのオープン〜クローズ時間の中央値をとり、それを表示する感じ。
これも中央値に意味があるかというと分からんので、中央・平均・95%など、柔軟に見たい。
何なら時間がかかったissueはみたいはず。
4-7 Daily Change Failure Rate
SELECT
TIMESTAMP_TRUNC(d.time_created, DAY) as day,
IF(COUNT(DISTINCT change_id) = 0,0, SUM(IF(i.incident_id is NULL, 0, 1)) / COUNT(DISTINCT deploy_id)) as change_fail_rate
FROM four_keys.deployments d, d.changes
LEFT JOIN four_keys.changes c ON changes = c.change_id
LEFT JOIN(SELECT
DISTINCT incident_id,
change,
time_resolved
FROM four_keys.incidents i,
i.changes change) i ON i.change = changes
GROUP BY day
deploymentsで管理されているchangesをキーとして、changesビュートincidentビューをJOIN。
各日ごとにchange_idを集計し、数が0個ならばレートは0にする。
1個以上ある場合は、incidentなら1、そうでないなら0として、各日ごとのincident発生数をカウントする。
分母はdeployの数である。
ところで、FourKeys基盤における incident
は issueの作成であり、作成時には一意のIDが振られる。root cause
でコミットハッシュを指定することもでき、その場合はincident
ビューにchanges
として記録される。
また、deploymentは複数のchangeを持つ
以上から、1 deployment, 最大 1 issueが守られる限り、変更障害率は100%以内に収まる。
守られない場合は超える。
※rateと言いながら ×100
の百分率になっていない。
4-8 Change Falifure Rate
SELECT
CASE WHEN change_fail_rate <= .15 then "0-15%"
WHEN change_fail_rate < .46 then "16-45%"
ELSE "46-60%" end as change_fail_rate
FROM
(SELECT
IF(COUNT(DISTINCT change_id) = 0,0, SUM(IF(i.incident_id is NULL, 0, 1)) / COUNT(DISTINCT deploy_id)) as change_fail_rate
FROM four_keys.deployments d, d.changes
LEFT JOIN four_keys.changes c ON changes = c.change_id
LEFT JOIN(SELECT
incident_id,
change,
time_resolved
FROM four_keys.incidents i,
i.changes change) i ON i.change = changes
# Limit to 3 months
WHERE d.time_created > TIMESTAMP(DATE_SUB(CURRENT_DATE(), INTERVAL 3 MONTH))
)
LIMIT 1
3ヶ月間の変更障害率を出している。計算はGroup by DayをGroup by 3ヶ月にするイメージか。
うーん。daily change failure rate
って意味あるんかな?
daily change failure count
じゃだめだろうか、カウントだとデプロイしなかった日のスコアが良くなって、たくさんデプロイした日にたくさんのインシデントが発生したらスコアが悪くなるから、レートのほうが良いんかな。
デフォルトのSQLでは怪しいが、やりたいこと
全体的に
対象リポジトリの切り替え
メトリクスの対象リポジトリを単一にしたり、複数にしたり、全てにしたり。
デプロイ頻度
特定期間のデプロイ数の取得
GROUP BY day
がガッツリ書かれているので、日ごとにしか見れない。
おそらく変数機能を使えばいけるはず。
何をデプロイしたか
デプロイ数が多い・少ない月には一体どのようなものをデプロイしたのかを見たくなるはず。
変更のリードタイム
どこに時間がかかったか
- コミットからPR
- PRからレビュー
- レビューからマージ
- マージからデプロイ
あたりを改善のためには知りたいはず。
リードタイムが長かったコミットは何か
リードタイムが長かったコミットは何か
変更障害率
変更障害が起きたコミット
変更障害が起きたコミットの特徴は何だったか。例えばテストケースが不十分だとか。
障害復旧時間
どこに時間がかかったか
- 検知から起票
- コミットからPR
- PRからレビュー
- レビューからマージ
- マージからデプロイ
あたりを改善のためには知りたいはず。
このあたりを実現する方法を見ていくか。(Findy Team +さんでできることが気になるぞ。)
デプロイ頻度改善1: 特定期間でグルーピングしたデプロイ数の取得
GROUP BY dayがガッツリ書かれているので、日ごとにしか見れない。例えば、月ごととかにほしい。
もうちょっと深掘り。
まず、GrafanaにはそもそもAbsolute time range
が存在する。これを変更すると、グラフの横軸が変更される。
例えば、Last 1 hour
を選択すると、X軸の間隔は15分とかに自動的に変更される。これは基本的に自動みたい。(特定のVisualizeタイプであれば詳細な設定が可能だという話もある)
で、自分がやりたいことは、例えば2yearsを選んだときに、group by day をしてなんの意味があるんだ?ということ。2年のデータを追いたいときに、必ずしも間隔がDayである必要はないと思う。もしかするとWEEKくらいに集約してデータを追いたいかもしれない。
というわけでできた。
画像
↑ day, week, monthごとに集約してみた。
やりかた
なんかAPIが有効化されていないので有効化。
dashboard settings > variables で変数を作成。
SELECT
CASE
WHEN 'month' = 'day' THEN TIMESTAMP_TRUNC(time_created, DAY)
WHEN 'month' = 'week' THEN TIMESTAMP_TRUNC(time_created, WEEK)
WHEN 'month' = 'month' THEN TIMESTAMP_TRUNC(time_created, MONTH)
END AS period,
COUNT(distinct deploy_id) AS deployments
FROM
four_keys.deployments
GROUP BY period
ORDER BY period
デプロイ頻度改善2: 何をデプロイしたか。
デプロイ数が多い・少ない月には、それはなぜか?一体どのようなものをデプロイしたのか?
を見たくなるはず。
理想:例えばDAYのグラフをクリックしたら、その日に行われたデプロイ・コミットIDが表示できる。そしてGitHubにリンクで飛べるようにしたい。
Grafanaにクリックしたらなにかアクションを起こす機能はあるのかしら。
→ リンクを使えばいけそう?
リンクの場合、パネルをクリックすると別ダッシュボードへ遷移することができるぽい。
しかしVariablesくらいしか引き継げず、クリックしたポイントの情報を渡す、などと言った細かいことはできないらしい。
うーん。それだともはや「コミットID / コミットメッセージ / 日付」をリストにしたVisualがあったほうがマシかも知れないな。。いったんその方向性で行く。
PoC方針
何を取りたいかは別に決めていないので、いったん適当に「コミットID / コミットメッセージ / 日付」の3つを表示する。
- metadataに入っているか確認
- changes viewを修正し、コミットメッセージを入れる
- Grafanaでパネルを作る。SQLを用意する
手順を書いてみたら簡単だった。。
そもそも複数リポジトリを連携した場合、現在はどうなるんだ…
やってみる。
→ 当たり前のようにすべてのリポジトリからのWebhookが単一の指標として記録された。
つまり、リポジトリごとのメトリクスを取れないということ。
プロダクトのコードはリポジトリごとに別れているのが基本なので、これはなんとしてでも解決したい。
- 方法1: リポジトリごとにインフラ基盤を作成する
- コストがN倍になる。さすがにひどい。
- 方法2: データは単一の場所に記録し、クエリをうまく使ってリポジトリを分ける
- これだろう。データが膨張する問題に関しては今後の対応ということで。
すべてのWebhookEventは events_raw
テーブルに挿入されるが、この中身を見てみるとmetadata
カラムの中にリポジトリIDやリポジトリNameが含まれている。Nameは変わりがちだと思うので、IDをまさしくUniqueなキーとして、Nameをエイリアスとして紐づけてあげる。そしてNameをGrafanaダッシュボードの変数としてあげて、ユーザは変数を変更してあげればリポジトリごとのメトリクスを表示できる。
こんな感じのものを作ってみたいところだ。
※今回はリポジトリの情報がmetadataに入っていたけど、入っていない場合は追加データとして保存できるようにする必要もあるかもなー。「独自ラベルを追加したい」みたいな思いを叶えるために。まぁそれは優先度低で。
リポジトリごとにメトリクスを見るぞ
いったん、1View単体で完結するViewでPoCしてみよう。それはつまりIncidentだな。
- Incident ViewのSQLを修正し、リポジトリIDカラムを追加する
- GrafanaのSQLを直接修正し、リポジトリごとのメトリクスが取れるか確認する
- GrafanaのSQLをDynamicなものにし、ユーザのプルダウン選択によってそのリポジトリのメトリクスを見れるように修正する
- リポジトリIDではなくリポジトリNameでプルダウン選択できるように修正する
- すべてのViewとクエリにApplyする
こんな感じか。
1. Incident ViewのSQLを修正し、リポジトリIDカラムを追加する
IssueのWebhook Eventの場合は、
JSON_EXTRACT_SCALAR(metadata, '$.repository.id')
でリポジトリIDを取れる。なのでViewのSQLを修正する。
修正した
# Incidents Table
SELECT
source,
incident_id,
MIN(IF(root.time_created < issue.time_created, root.time_created, issue.time_created)) as time_created,
MAX(time_resolved) as time_resolved,
ARRAY_AGG(root_cause IGNORE NULLS) changes,
max(repository_id) # 追加
FROM
(
SELECT
source,
CASE WHEN source LIKE "github%" THEN JSON_EXTRACT_SCALAR(metadata, '$.issue.number')
WHEN source LIKE "gitlab%" AND event_type = "note" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_id')
WHEN source LIKE "gitlab%" AND event_type = "issue" THEN JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.id')
WHEN source LIKE "pagerduty%" THEN JSON_EXTRACT_SCALAR(metadata, '$.event.data.id')
END AS incident_id,
CASE WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.created_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.created_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_created,
CASE WHEN source LIKE "github%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.issue.closed_at'))
WHEN source LIKE "gitlab%" THEN four_keys.multiFormatParseTimestamp(JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.closed_at'))
WHEN source LIKE "pagerduty%" THEN TIMESTAMP(JSON_EXTRACT_SCALAR(metadata, '$.event.occurred_at'))
END AS time_resolved,
REGEXP_EXTRACT(metadata, r"root cause: ([[:alnum:]]*)") as root_cause,
CASE WHEN source LIKE "github%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.issue.labels'), '"name":"Incident"')
WHEN source LIKE "gitlab%" THEN REGEXP_CONTAINS(JSON_EXTRACT(metadata, '$.object_attributes.labels'), '"title":"Incident"')
WHEN source LIKE "pagerduty%" THEN TRUE # All Pager Duty events are incident-related
END AS bug,
JSON_EXTRACT_SCALAR(metadata, '$.repository.id') AS repository_id. # 追加
FROM four_keys.events_raw
WHERE event_type LIKE "issue%" OR event_type LIKE "incident%" OR (event_type = "note" and JSON_EXTRACT_SCALAR(metadata, '$.object_attributes.noteable_type') = 'Issue')
) issue
LEFT JOIN (SELECT time_created, changes FROM four_keys.deployments d, d.changes) root on root.changes = root_cause
GROUP BY 1,2
HAVING max(bug) is True
;
完
2. GrafanaのSQLを直接修正し、リポジトリごとのメトリクスが取れるか確認する
もちろん、対象はDaily Median Time to Restore Services
SELECT
TIMESTAMP_TRUNC(time_created, DAY) as day,
#### Median time to resolve
PERCENTILE_CONT(
TIMESTAMP_DIFF(time_resolved, time_created, HOUR), 0.5)
OVER(PARTITION BY TIMESTAMP_TRUNC(time_created, DAY)
) as daily_med_time_to_restore,
FROM four_keys.incidents
where repository_id = 'xxxxxxxxxx' # 追加!
ORDER BY day
直接編集した感じ、いける。
3. GrafanaのSQLをDynamicなものにし、ユーザのプルダウン選択によってそのリポジトリのメトリクスを見れるように修正する
ここでGrafanaの変数を理解しなければならないので勉強する。
まず、今回はVariable Typeは動的に変わるので Query
を選ばないといけないはず。
SQL文はコレ。
SELECT JSON_EXTRACT_SCALAR(metadata, '$.repository.id') FROM `{google_cloud_project_name}.four_keys.events_raw`
これでリポジトリIDが取れる。
続いてWhere句にこの変数を追加する。
where repository_id = '${repository}'
できた。
4. リポジトリIDではなくリポジトリNameでプルダウン選択できるように修正する
ユーザ目線ではRepository IDだけ見せられてもどのリポジトリかわからないので、Repository Nameをプルダウンにしたい。
ただし、普通にやるとRenameされたときに過去と今後のメトリクスが別のリポジトリとして認識されてしまうので、ユーザはNameで選びつつ、クエリではIDを使いたい。
※念のためリポジトリ名を変えてみたが、IDは変わらないこと確認済み。以下のようなイベントが飛ぶ
「ID-Name」のテーブルをSELECTしてそれをクエリにすればいい……?
つまり、
SELECT * FROM viewAAA where repository_name = '${grafana_var_REPOSITORY_NAME}'
# ↑これだとRenameすると過去のデータが紐づかなくなるのでダメ。
SELECT * FROM viewAAA
WHERE repository_id = (
SELECT repository_id FROM name_id_table WHERE repository_name = '${grafana_var_REPOSITORY_NAME}'
)
# ↑これならいける。
完成したSQL
SELECT
TIMESTAMP_TRUNC(time_created, DAY) as day,
#### Median time to resolve
PERCENTILE_CONT(
TIMESTAMP_DIFF(time_resolved, time_created, HOUR), 0.5)
OVER(PARTITION BY TIMESTAMP_TRUNC(time_created, DAY)
) as daily_med_time_to_restore,
FROM four_keys.incidents
where repository_id = (
select repo_id FROM four_keys.repo_id_name where repo_name = '${VAR_NAME_IN_GRAFANA}'
)
ORDER BY day
この方法を実現するために、リポジトリが生成されるたび、そして更新されるたびに「ID-Name」紐づけテーブルを更新する必要がある。※削除時はいらんかな…?
その実装はたいして難しくないだろうから、PoCはいいや。
5. すべてのViewとクエリにApplyする
今回はIncidentsビューでPoCした。
これを別ビュー(計3つ)でやりながら、GrafanaDashboard側のSQLを修正して行く必要がある。
あとID - Name紐づけテーブルの作成と、それの挿入更新ロジックを dora-team/fourkeys
を魔改造して入れ込む必要がある。
疲れたのと、Grafanaのデータ永続化の設定が済んでいないのでまた今度で。。