將 Jenkins Job 的歷史結果整理出視覺化的 Daily Report mail (二)

前一篇文章說明了 Daily Report 的 Jenkins Job 設定,接下來本篇會說明如何實作 daily_report.py

這個 script 會生成像這樣的 PNG 圖表:
首先要安裝 Pandasmatplotlib,我們會使用 Pandas 來讀取 csv 檔案並使用相關便捷的功能來簡化/加速資料分析的步驟,並使用 matplotlib 來產生視覺化的 PNG 圖檔。

python -m pip install Pandas
python -m pip install matplotlib

script 相關的 module import 如下:

1
2
3
4
5
6
import os, time, sys, re
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import datetime, date, timedelta

我們在 Jenkins Job 內設定預設讀取 config\daily_report_demo.prop,當作 Job 參數。
config\daily_report_demo.prop 的內容如下:

MY_JOB_NAME=daily_report_demo
DAILY_REPORT_MAIL_TO=my_account@gmail.com

MY_JOB_NAME 是指定上游 Jenkins Job 的 Job name,意思就是這個 Daily Report 是統整 daily_report_demo 這個 Job 的建置結果。所以接下來:
  1. 讀取 WORKSPACE 環境變數,這個環境變數是 Jenkins 設定的,用來取得 Jenkins job 的自訂工作區位置,以我們之前的設定為例,工作區是在 D:\workshop
  2. 讀取 MY_JOB_NAME 環境變數
1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if __name__ == '__main__':
    
    try:
     env_workspace = os.environ["WORKSPACE"]
     env_job_name = os.environ["MY_JOB_NAME"]
    except Exception as e:
     print ("Error: can not find environment variables")
     raise e

    folder = "%s\\workspace\\%s"%(env_workspace, env_job_name)
    if not os.path.exists(folder):
     print ("%s not exist!"%folder)
     sys.exit(1)

接下來讀取之前上游 Job 建置時留下的紀錄 (.csv),csv 檔案的內容如下:

BUILD_NUMBER,BUILD_TIMESTAMP,RESULT,BUILD,DOWNLOAD,BOOTUP,REBOOT
1,06:00:00,SUCCESS,SUCCESS,SUCCESS,SUCCESS,SUCCESS
2,07:00:00,FAILURE,SUCCESS,SUCCESS,FAILURE,FAILURE
3,08:00:00,FAILURE,SUCCESS,SUCCESS,FAILURE,FAILURE
4,09:00:00,FAILURE,SUCCESS,SUCCESS,FAILURE,FAILURE
...
15,20:00:00,SUCCESS,SUCCESS,SUCCESS,SUCCESS,SUCCESS
16,21:00:00,SUCCESS,SUCCESS,SUCCESS,SUCCESS,SUCCESS
17,22:00:00,UNSTABLE,SUCCESS,SUCCESS,SUCCESS,SUCCESS
18,23:00:00,SUCCESS,SUCCESS,SUCCESS,SUCCESS,SUCCESS

daily_report_demo Job 共有四個下游 Job,分別是 BUILD,DOWNLOAD,BOOTUP,REBOOT,csv 檔名會以建置日期作命名,ex: 2018-03-20.csv,內容就是每一次建置的結果。

然後,為了在 mail 裡面表列前一天所有建置的結果,會讀取最新的一筆csv紀錄,並轉成環境變數的格式紀錄在 workspace\daily_report_demo\hourly\2018-03-20.csv.prop,方便從 jelly script 中讀取,會在第三篇說明 jelly script 的文章再詳細說明。

HOURLY_DATA_0=BUILD_NUMBER,BUILD_TIMESTAMP,RESULT,BUILD,DOWNLOAD,BOOTUP,REBOOT
HOURLY_DATA_1=1,06:00:00,SUCCESS,SUCCESS,SUCCESS,SUCCESS,SUCCESS
HOURLY_DATA_2=2,07:00:00,FAILURE,SUCCESS,SUCCESS,FAILURE,FAILURE
HOURLY_DATA_3=3,08:00:00,FAILURE,SUCCESS,SUCCESS,FAILURE,FAILURE
...


1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
    today = datetime.today()
    report_tag = ""

    hourly_folder = "%s\\hourly"%(folder)
    if not os.path.exists(hourly_folder):
        os.makedirs(hourly_folder)

    try:
        dirs = os.listdir(folder)
        dirs.sort(key=natural_keys)
        dirs = reversed(dirs)
        for file in dirs:
            if not file.endswith("csv"):
                continue
            csv_path = "%s\\%s"%(folder, file)

            # Read a line then write to .prop file for later environment variable injection
            prop_path = "%s\\%s.prop"%(hourly_folder, file)
            with open(prop_path, "w") as prop_file:
                if prop_file != "":
                    with open(csv_path, "r") as csv_file:
                        report_tag = file.strip(".csv")
                        i = 0
                        for line in csv_file:
                            prop_file.write("HOURLY_DATA_%s=%s"%(str(i), line))
                            i += 1
            # only processing the latest record
            break
    except Exception as e:
        raise e

接下來,用 Pandas 讀取 csv 檔案,read_csv() 會回傳 dataframe
1
df = pd.read_csv(csv_path)

定義一個 getStatistic(df, column, result),用來回傳 dataframe 中指定欄位及內容的次數及百分比。以下面這行為例,就是計算 RESULT 欄位裡,SUCCESS 的次數及百分比。

1
rcount, rate = getStatistic(df, 'RESULT', 'SUCCESS')

說明一下 getStatistic() 的實作,df[colume] 是指向一個 Seriesvalue_counts() 用來回傳這一欄裡 unique value 的次數,然後用 to_numeric() 轉成浮點數並進行百分比計算。

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def getStatistic(df, column, result):
    total = len(df[column])

    # special case, not return 0 but return None
    if total == 0:
        return None, None

    stat = df[column].value_counts()
    stat = pd.to_numeric(stat, downcast='float')

    rcount = 0
    rate = 0.0

    if result in stat:
        rcount = stat[result]

    if rcount != 0:
        rate = rcount/total*100

    return rcount, rate

完整的代碼如下:
為了方便理解,所以把不同欄位的資料給定不同的變數名稱。
date_list 是 x軸資料,代表每一天的建置狀態。
s_rate_list 這些是第一個 y軸的資料,代表 SUCCESS/FAILURE/UNSTABLE 的百分比。
build_f_count_list 這些是第二個 y軸的資料,代表下游 job failure 的次數。
1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    report_days = 14
    pattern = "(.*).csv"
    date = ""
    date_list = []
    s_rate_list = []
    f_rate_list = []
    u_rate_list = []
    build_f_count_list = []
    download_f_count_list = []
    bootup_f_count_list = []
    reboot_f_count_list = []

    try:
        dirs = os.listdir(folder)
        dirs.sort(key=natural_keys)
        dirs = reversed(dirs)
        for file in dirs:
            if not file.endswith("csv"):
                continue

            if report_days != 0:
                if len(date_list) >= report_days:
                    break

            csv_path = "%s\\%s"%(folder, file)
            df = pd.read_csv(csv_path)

            ## data for first y scale
            rcount, rate = getStatistic(df, 'RESULT', 'SUCCESS')
            # no data in this csv file
            if rcount == None:
                continue
            s_rate_list.append(rate)
            rcount, rate = getStatistic(df, 'RESULT', 'FAILURE')
            f_rate_list.append(rate)
            rcount, rate = getStatistic(df, 'RESULT', 'UNSTABLE')
            u_rate_list.append(rate)

            ## data for second y scale
            rcount, tmp = getStatistic(df, 'BUILD', 'FAILURE')
            build_f_count_list.append(rcount)
            rcount, tmp = getStatistic(df, 'DOWNLOAD', 'FAILURE')
            download_f_count_list.append(rcount)
            rcount, tmp = getStatistic(df, 'BOOTUP', 'FAILURE')
            bootup_f_count_list.append(rcount)
            rcount, tmp = getStatistic(df, 'REBOOT', 'FAILURE')
            reboot_f_count_list.append(rcount)

            ## date is x scale
            date_p = re.search(pattern, file)
            if date_p != None:
                rdate = date_p.group(1)

            date_list.append(rdate)

            print("\n")
    except Exception as e:
        raise e

這樣我們需要的資料都已經統計完畢,接下來就是使用 matplotlib 生成 PNG 檔案。

重點說明幾個地方,datestr2num() 會將日期字串轉成數字,顯示的時候再用 DateFormatter() 指定我們想要呈現的字串格式。
plot_date() 可以指定 x 軸或 y 軸為日期,預設是 x 軸為日期。
若是統計資料超過14天,內容會變得雜亂,因此超過14天會使用 WeekdayLocator(),並且不顯示 annotation。百分比若是 0% 或 100%,也一律不在圖表上顯示。

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
    # use matplotlib to draw and export PNG
    fig, ax = plt.subplots()

    # first y scale is success/failure/unstable rate
    ax.set_xlabel("Date")
    ax.set_ylabel("Percentage")
    ax.set_title("Daily Status", y=1.2)
    ax.set_ylim(-10, 110)
    plt.subplots_adjust(top=0.8)

    dates = mdates.datestr2num(date_list)

    s_rect = ax.plot_date(dates, s_rate_list, color='tab:green', ls='solid', marker='.', lw=2, label='SUCCESS')
    f_rect = ax.plot_date(dates, f_rate_list, color='tab:red', ls='solid', marker='.', lw=2, label='FAILURE')
    u_rect = ax.plot_date(dates, u_rate_list, color='tab:orange', ls='solid', marker='.', lw=2, label='UNSTABLE')

    box = ax.get_position()
    ax.set_position([box.x0, box.y0 + box.height * 0.2, box.width, box.height * 0.8])

    ax.legend((s_rect[0], f_rect[0], u_rect[0]),
              ('SUCCESS', 'FAILURE', 'UNSTABLE'),
              bbox_to_anchor=(0., 1.10, 1., .102),
              ncol=3, loc='upper center', fontsize=7)
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%m-%d'))

    isSkipAnno = False
    if (today - datetime.strptime(date_list[0], '%Y-%m-%d')) > timedelta(days=14):
        ax.xaxis.set_major_locator(mdates.WeekdayLocator())
        isSkipAnno = True
    else:
        ax.xaxis.set_major_locator(mdates.DayLocator())

    if not isSkipAnno:
        for i,j in zip(dates, s_rate_list):
            if j != 0.0 and j != 100.0:
                ax.annotate("{:.1f}%".format(j), xy=(i, j), fontsize=7)

        for i,j in zip(dates, f_rate_list):
            if j != 0.0 and j != 100.0:
                ax.annotate("{:.1f}%".format(j), xy=(i, j), fontsize=7)

        for i,j in zip(dates, u_rate_list):
            if j != 0.0 and j != 100.0:
                ax.annotate("{:.1f}%".format(j), xy=(i, j), fontsize=7)

接下來處理第二個 y 軸,因為共用 x 時間軸,所以呼叫 twinx()
第二個 y 軸用來顯示 Failure 次數,因此用柱狀圖表示。

1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
    # second y scale is the failure counts of upstream jobs
    rects = []
    width = 0.2
    ax2 = ax.twinx()
    ax2.set_ylabel("Failure Counts")
    ax2.set_ylim(-2, 24)

    rects.append(ax2.bar(dates-width, build_f_count_list, width))
    rects.append(ax2.bar(dates, download_f_count_list, width))
    rects.append(ax2.bar(dates+width, bootup_f_count_list, width))
    rects.append(ax2.bar(dates+(width*2), reboot_f_count_list, width))

    ax2.legend((rects[0][0], rects[1][0], rects[2][0], rects[3][0]),
               ('Download', 'Build', 'Bootup', 'Reboot'),
               bbox_to_anchor=(0, 1.02, 1, .102),
               ncol=4, loc='upper center', fontsize=7)

    autolabel(ax2, rects[0])
    autolabel(ax2, rects[1])
    autolabel(ax2, rects[2])
    autolabel(ax2, rects[3])
    fig.autofmt_xdate()

autolabel() 的實作如下,用來計算顯示 annotation 的位置。

1
 2
 3
 4
 5
 6
 7
 8
 9
10
def autolabel(ax, rects):
    '''
    Attach a text label above each bar displaying its height
    '''
    for rect in rects:
        height = rect.get_height()
        if height != 0:
            ax.text(rect.get_x() + rect.get_width()/2., 1.05*height,
                '%d' % int(height), 
                ha='center', va='bottom')

接下來儲存成 PNG 檔案

1
2
3
4
5
6
7
    # save to PNG
    png_path = "%s\\png\\"%folder
    if not os.path.exists(png_path):
        os.makedirs(png_path)
    png_path = "%s\\status_%s.png"%(png_path, report_tag)

    plt.savefig("%s"%png_path, dpi=100, format="png")

可以看到我們用 report_tag 當作 PNG 檔名的一部份,因此我們把 report_tag 存在 prop 檔案裏面,讓 Jenkins Job inject 成環境變數。

1
2
3
4
5
6
7
8
    try:
        prop_path = "%s\\daily-report.prop"%folder
        with open(prop_path, "w") as prop_file:
            if report_tag != "":
                prop_file.write("REPORT_TAG=%s\n"%report_tag)
    except Exception as e:
        raise e
        sys.exit(1)

這樣 daily_report.py 就完成了。

留言

這個網誌中的熱門文章

以樂透為例,用Python統計馬可夫矩陣

將 Jenkins Job 的歷史結果整理出視覺化的 Daily Report mail (一)

如何用 Jenkins API 取得 Job Build Result