Open14

dora-team/fourkeysの中身を読んで理解する

takamin55takamin55

まずはREADMEを読むか。

Background

DORAがソフトウェアデリバリーのパフォーマンスを示す4つのKeyを特定した

  • Deployment Frequency
  • Lead Time For Changes
  • Time to Restore Services
  • Change Failure Rate

例えば新ツールの導入効果を測定したかったり、そもそも開発チームのパフォーマンスを測定したかったりする場合にFour Keysを使うことができる

How it works

  1. Google Cloud Run上のWebhookターゲットに向かってイベント(例えばGitHubのPRやissueに関する)が送信される
  2. CloudRunは受けたすべてのイベントを Pub/Subにパブリッシュする
  3. CloudRunインスタンスは Pub/Subをサブスクライブ
takamin55takamin55

処理を読む

アーキテクチャ的にはこんな感じ。

  1. GitHub(など)のイベントをwebhookで取得
  2. パース
  3. BigQueryに保存
  4. Viewに対してGrafanaがSQLを実行し、UIとして表示

takamin55takamin55

1. EventHandler

event-handler/event_handler.py

一番最初の処理。webhookイベントから発信元を特定し、Pub/Subの適切なトピックに送信する。

  1. source (webhookの発信元)をheaderから判定 = 今回はgithub
  2. 署名(webhook secret)の検証
  3. Pub / Sub のトピックにwebhookのボディとヘッダーを送信

※ちなみに Pub/Sub の topic_path はgithubの場合こんな感じになる:

projects/__GOOGLE_CLOUD_PROJECT_NAME__/topics/github
takamin55takamin55

2. Pub / Subのサブスクライバー(Data Parsers)

bq-workers/github-parser/main.py

Pub / SubのサブスクライバーはCloudRunサービスである。
余談だが terraform applyしたときに選択した sourceの分だけCloudRunサービスが生成される。
今回は github を選択している。

  1. サポートされたgithub webhook eventかどうかを確認(push, pull_request...etc)
  2. 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
  1. それをBigQueryのevents_rowテーブルに挿入する
takamin55takamin55

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が発生しているのでマージコミットも格納される
takamin55takamin55

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を集められるようになる)、

スキーマ

フィールド名 種類 意味
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はつねにマージコミットである必要がある。
takamin55takamin55

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_typeissue%か、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の時刻のうち、早い方をそれとする。

takamin55takamin55

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じゃだめだろうか、カウントだとデプロイしなかった日のスコアが良くなって、たくさんデプロイした日にたくさんのインシデントが発生したらスコアが悪くなるから、レートのほうが良いんかな。

takamin55takamin55

デフォルトのSQLでは怪しいが、やりたいこと

全体的に

対象リポジトリの切り替え
メトリクスの対象リポジトリを単一にしたり、複数にしたり、全てにしたり。

デプロイ頻度

特定期間のデプロイ数の取得
GROUP BY dayがガッツリ書かれているので、日ごとにしか見れない。
おそらく変数機能を使えばいけるはず。

何をデプロイしたか
デプロイ数が多い・少ない月には一体どのようなものをデプロイしたのかを見たくなるはず。

変更のリードタイム

どこに時間がかかったか

  • コミットからPR
  • PRからレビュー
  • レビューからマージ
  • マージからデプロイ

あたりを改善のためには知りたいはず。

リードタイムが長かったコミットは何か
リードタイムが長かったコミットは何か

変更障害率

変更障害が起きたコミット
変更障害が起きたコミットの特徴は何だったか。例えばテストケースが不十分だとか。

障害復旧時間

どこに時間がかかったか

  • 検知から起票
  • コミットからPR
  • PRからレビュー
  • レビューからマージ
  • マージからデプロイ

あたりを改善のためには知りたいはず。
このあたりを実現する方法を見ていくか。(Findy Team +さんでできることが気になるぞ。)

takamin55takamin55

デプロイ頻度改善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

takamin55takamin55

デプロイ頻度改善2: 何をデプロイしたか。

デプロイ数が多い・少ない月には、それはなぜか?一体どのようなものをデプロイしたのか?
を見たくなるはず。

理想:例えばDAYのグラフをクリックしたら、その日に行われたデプロイ・コミットIDが表示できる。そしてGitHubにリンクで飛べるようにしたい。

Grafanaにクリックしたらなにかアクションを起こす機能はあるのかしら。
→ リンクを使えばいけそう?

リンクの場合、パネルをクリックすると別ダッシュボードへ遷移することができるぽい。
しかしVariablesくらいしか引き継げず、クリックしたポイントの情報を渡す、などと言った細かいことはできないらしい。

うーん。それだともはや「コミットID / コミットメッセージ / 日付」をリストにしたVisualがあったほうがマシかも知れないな。。いったんその方向性で行く。

PoC方針

何を取りたいかは別に決めていないので、いったん適当に「コミットID / コミットメッセージ / 日付」の3つを表示する。

  1. metadataに入っているか確認
  2. changes viewを修正し、コミットメッセージを入れる
  3. Grafanaでパネルを作る。SQLを用意する

手順を書いてみたら簡単だった。。

takamin55takamin55

そもそも複数リポジトリを連携した場合、現在はどうなるんだ…
やってみる。

→ 当たり前のようにすべてのリポジトリからのWebhookが単一の指標として記録された。
つまり、リポジトリごとのメトリクスを取れないということ。

プロダクトのコードはリポジトリごとに別れているのが基本なので、これはなんとしてでも解決したい。

  • 方法1: リポジトリごとにインフラ基盤を作成する
    • コストがN倍になる。さすがにひどい。
  • 方法2: データは単一の場所に記録し、クエリをうまく使ってリポジトリを分ける
    • これだろう。データが膨張する問題に関しては今後の対応ということで。

すべてのWebhookEventは events_rawテーブルに挿入されるが、この中身を見てみるとmetadataカラムの中にリポジトリIDやリポジトリNameが含まれている。Nameは変わりがちだと思うので、IDをまさしくUniqueなキーとして、Nameをエイリアスとして紐づけてあげる。そしてNameをGrafanaダッシュボードの変数としてあげて、ユーザは変数を変更してあげればリポジトリごとのメトリクスを表示できる。

こんな感じのものを作ってみたいところだ。
※今回はリポジトリの情報がmetadataに入っていたけど、入っていない場合は追加データとして保存できるようにする必要もあるかもなー。「独自ラベルを追加したい」みたいな思いを叶えるために。まぁそれは優先度低で。

リポジトリごとにメトリクスを見るぞ

いったん、1View単体で完結するViewでPoCしてみよう。それはつまりIncidentだな。

  1. Incident ViewのSQLを修正し、リポジトリIDカラムを追加する
  2. GrafanaのSQLを直接修正し、リポジトリごとのメトリクスが取れるか確認する
  3. GrafanaのSQLをDynamicなものにし、ユーザのプルダウン選択によってそのリポジトリのメトリクスを見れるように修正する
  4. リポジトリIDではなくリポジトリNameでプルダウン選択できるように修正する
  5. すべての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のデータ永続化の設定が済んでいないのでまた今度で。。