Post

Crashlytics|Big Query 串接教学:自动查询并转发 iOS App 闪退报告到 Slack

学习如何利用 Crashlytics 与 Big Query 自动查询近 7 天 iOS App 闪退问题,并透过 Google Apps Script 定时转发到 Slack,减少手动追踪时间,提升团队即时掌握闪退状况与修复效率。

Crashlytics|Big Query 串接教学:自动查询并转发 iOS App 闪退报告到 Slack

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


Crashlytics + Big Query 打造更即时便利的 Crash 追踪工具

串接 Crashlytics 和 Big Query 自动转发闪退记录到 Slack Channel

成果

Pinkoi iOS Team 实拍图

Pinkoi iOS Team 实拍图

先上成果图,每周定时查询 Crashlytics 闪退纪录;筛选出闪退次数前 10 多的问题;将讯息发送到 Slack Channel,方便所有 iOS 队友快速了解目前稳定性。

问题

于 App 开发者来说 Crash-Free Rate 可以说是最重要的衡量指标;数据代表的意思是 App 的使用者 没遇到 闪退的比例,我想不管是什么 App 都应该希望自己的 Crash-Free Rate ~= 99.9%;但现实是不可能的,只要是程式就可能会有 Bug 更何况有的闪退问题是底层(Apple)或第三方 SDK 造成的,另外随著 DAU 体量不同,也会对 Crash-Free Rate 有一定影响,DAU 越高越容易踩到很多偶发的闪退问题。

既然 100% 不会闪退的 App 并不存在,那如何追踪、处理闪退就是一件很重要的事;除了最常见的 Google Firebase Crashlytics (前生 Fabric) 外其实还有其他选择 BugsnagBugfender …各工具我没有实际比较过,有兴趣的朋友可以自行研究;如果是用其他工具就用不到本篇文章要介绍的内容了。

Crashlytics

选择使用 Crashlytics 有以下好处:

  • 稳定,由 Google 撑腰

  • 免费、安装便利快速

  • 除闪退外,也可 Log Error Event (EX: Decode Error)

  • 一套 Firebase 即可打天下:其他服务还有 Google Analytics、Realtime Database、Remote Config、Authentication、Cloud Messaging、Cloud Storage…

题外话:不建议正式的服务完全使用 Firebase 搭建,因为后期流量起来后的收费会很贵…就是个养套杀的概念。

Crashlytics 缺点也很多:

  • Crashlytics 不提供 API 查询闪退资料

  • Crashlytics 仅会储存近 90 天闪退纪录

  • Crashlytics 的 Integrations 支援跟弹性极差

最痛的就是 Integrations 支援跟弹性极差再加上又没有 API 可以自己写脚本串闪退资料;只能三不五时靠人工手动上 Crashlytics 查看闪退纪录,追踪闪退问题。

Crashlytics 只支援的 Integrations:

  1. [Email 通知] — Trending stability issues (越来越多人遇到的闪退问题)

  2. [Slack, Email 通知] — New Fatal Issue (闪退问题)

  3. [Slack, Email 通知] — New Non-Fatal Issue (非闪退问题)

  4. [Slack, Email 通知] — Velocity Alert (数量突然一直上升的闪退问题)

  5. [Slack, Email 通知] — Regression Alert (已 Solved 但又出现的问题)

  6. Crashlytics to Jira issue

以上 Integrations 的内容、规则都无法客制化。

最一开始我们直接使用 2.New Fatal Issue to Slack or Email,to Email 的话再由 Google Apps Script 触发后续处理脚本 ;但是这个通知会疯狂轰炸通知频道,因为不管是大是小或只是使用者装置、iOS 本身很零星的问题造成的闪退都会通知;随著 DAU 增长每天都被这通知狂轰滥炸,而其中真的有价值,很多人踩到而且是跟我们程式错误有关的通知大概只占其中的 10%。

以至于根本没有解决 Crashlytics 难以自动追踪的问题,一样要花很多时间在审阅这个问题究竟重不重要之上。

Crashlytics + Big Query

转来转去只找到这个方法,官方也只提供这个方法;这就是免费糖衣下的陷阱,我猜不管是 Crashlytics 或 Analytics Event 都不会也没有计划推出 API 让使用者可以串 API 查资料;因为官方的唯一建议就是把资料汇入到 Big Query 使用,而 Big Query 超过免费储存与查询额度是要收费的。

储存:每个月前 10 GB 为免费。

查询:每个月前 1 TB 为免费。 (查询额度的意思是下 Select 时处理了多少容量的资料)

详细可参考 Big Query 定价说明

Crashlytics to Big Query 的设定细节可参考 官方文件 ,需启用 GCP 服务、绑定信用卡…等等。

开始使用 Big Query 查询 Crashlytics Log

设好 Crashlytics Log to Big Query 汇入周期&完成第一次汇入有资料后,我们就能开始查询资料啰。

首先到 Firebase 专案 -> Crashlytics -> 列表右上方的「•••」-> 点击前往「BigQuery dataset」。

前往 GCP -> Big Query 后可在左方「Exploer」中选择「firebase_crashlytics」->选择你的 Table 名称 ->「Detail」 -> 右边可查看 Table 资讯,包含最新修改时间、已使用容量、储存期限…等等。

确认已有汇入的资料可查询。

上方 Tab 可切换到「SCHEMA」查看 Table 的栏位资讯或参考 官方文件

点击右上方的「Query」可开启带有辅助 SQL Builder 的介面(如对 SQL 不熟建议使用这个):

或直接点「COMPOSE NEW QUERY」开一个空白的 Query Editor:

不管是哪种方法,都是同个文字编辑器;在输入完 SQL 之后可以预先在右上方自动完成 SQL 语法检查和预计会花费的查询额度( This query will process XXX when run. ):

确认要查询后点左上方「RUN」执行查询,结果会在下方 Query results 区块显示。

⚠️ 按下「RUN」执行查询后就会累积到查询额度,然后进行收费;所以请注意不要乱下 Query。

如对 SQL 较陌生可以先了解基本用法,然后参考 Crashlytics 官方的范例下去魔改

1.统计近 30 日每天的闪退次数:

1
2
3
4
5
6
7
8
9
10
SELECT
  COUNT(DISTINCT event_id) AS number_of_crashes,
  FORMAT_TIMESTAMP("%F", event_timestamp) AS date_of_crashes
FROM
 `你的ProjectID.firebase_crashlytics.你的TableName`
GROUP BY
  date_of_crashes
ORDER BY
  date_of_crashes DESC
LIMIT 30;

2.查询近 7 天最常出现的 TOP 10 闪退:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SELECT
  DISTINCT issue_id,
  COUNT(DISTINCT event_id) AS number_of_crashes,
  COUNT(DISTINCT installation_uuid) AS number_of_impacted_user,
  blame_frame.file,
  blame_frame.line
FROM
  `你的ProjectID.firebase_crashlytics.你的TableName`
WHERE
  event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),INTERVAL 168 HOUR)
  AND event_timestamp < CURRENT_TIMESTAMP()
GROUP BY
  issue_id,
  blame_frame.file,
  blame_frame.line
ORDER BY
  number_of_crashes DESC
LIMIT 10;

但官方范例这个下法查出来的资料跟 Crashlytics 看到的排序不一样,应该是它用 blame_frame.file (nullable), blame_frame.line (nullable) 去 Group 的原因导致。

3.查询近 7 天最常闪退的 10 种装置:

1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT
  device.model,
COUNT(DISTINCT event_id) AS number_of_crashes
FROM
  `你的ProjectID.firebase_crashlytics.你的TableName`
WHERE
  event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 168 HOUR)
  AND event_timestamp < CURRENT_TIMESTAMP()
GROUP BY
  device.model
ORDER BY
  number_of_crashes DESC
LIMIT 10;

更多范例请参考 官方文件

如果你下的 SQL 无任何资料,请先确定指定条件的 Crashlytics 资料已汇入 Big Query(例如预设的 SQL 范例会查当天 Crash 纪录,但其实资料还没同步汇入进来,所以会查不到);如果确定有资料,再来检查筛选条件是否正确。

Top 10 Crashlytics Issue Big Query SQL

这边参考第 2. 的官方范例修改,我们希望的结果是跟我们看 Crashlytics 第一页时一样的闪退问题及排序资料。

近 7 日闪退问题的 Top 10:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
SELECT 
  DISTINCT issue_id, 
  issue_title, 
  issue_subtitle, 
  COUNT(DISTINCT event_id) AS number_of_crashes, 
  COUNT(DISTINCT installation_uuid) AS number_of_impacted_user 
FROM 
  `你的ProjectID.firebase_crashlytics.你的TableName`
WHERE 
  is_fatal = true 
  AND event_timestamp >= TIMESTAMP_SUB(
    CURRENT_TIMESTAMP(), 
    INTERVAL 7 DAY
  ) 
GROUP BY 
  issue_id, 
  issue_title, 
  issue_subtitle 
ORDER BY 
  number_of_crashes DESC 
LIMIT 
  10;

比对 Crashlytics 的 Top 10 闪退问题结果,符合✅。

使用 Google Apps Script 定期查询&转发到 Slack

前往 Google Apps Script 首页 -> 登入与 Big Query 同个帐户 -> 点左上角「新专案」,开启新专案后可点左上方重新命名专案。

首先我们先完成串接 Big Query 取得查询资料:

参考 官方文件 范例,将上面的 Query SQL 带入。

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
function queryiOSTop10Crashes() {
  var request = {
    query: 'SELECT DISTINCT issue_id, issue_title, issue_subtitle, COUNT(DISTINCT event_id) AS number_of_crashes, COUNT(DISTINCT installation_uuid) AS number_of_impacted_user FROM `firebase_crashlytics.你的TableName` WHERE is_fatal = true AND event_timestamp >= TIMESTAMP_SUB( CURRENT_TIMESTAMP(), INTERVAL 7 DAY ) GROUP BY issue_id, issue_title, issue_subtitle ORDER BY number_of_crashes DESC LIMIT 10;',
    useLegacySql: false
  };
  var queryResults = BigQuery.Jobs.query(request, '你的ProjectID');
  var jobId = queryResults.jobReference.jobId;

  // Check on status of the Query Job.
  var sleepTimeMs = 500;
  while (!queryResults.jobComplete) {
    Utilities.sleep(sleepTimeMs);
    sleepTimeMs *= 2;
    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId);
  }

  // Get all the rows of results.
  var rows = queryResults.rows;
  while (queryResults.pageToken) {
    queryResults = BigQuery.Jobs.getQueryResults(projectId, jobId, {
      pageToken: queryResults.pageToken
    });
    Logger.log(queryResults.rows);
    rows = rows.concat(queryResults.rows);
  }

  var data = new Array(rows.length);
  for (var i = 0; i < rows.length; i++) {
    var cols = rows[i].f;
    data[i] = new Array(cols.length);
    for (var j = 0; j < cols.length; j++) {
      data[i][j] = cols[j].v;
    }
  }

  return data
}

query: 餐数可任意更换成写好的 Query SQL。

回传的物件结构如下:

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
[
  [
    "67583e77da3b9b9d3bd8feffeb13c8d0",
    "<compiler-generated> line 2147483647",
    "specialized @nonobjc NSAttributedString.init(data:options:documentAttributes:)",
    "417",
    "355"
  ],
  [
    "a590d76bc71fd2f88132845af5455c12",
    "libnetwork.dylib",
    "nw_endpoint_flow_copy_path",
    "259",
    "207"
  ],
  [
    "d7c3b750c3e5587c91119c72f9f6514d",
    "libnetwork.dylib",
    "nw_endpoint_flow_copy_path",
    "138",
    "118"
  ],
  [
    "5bab14b8f8b88c296354cd2e",
    "CoreFoundation",
    "-[NSCache init]",
    "131",
    "117"
  ],
  [
    "c6ce52f4771294f9abaefe5c596b3433",
    "XXX.m line 975",
    "-[XXXX scrollToMessageBottom]",
    "85",
    "57"
  ],
  [
    "712765cb58d97d253ec9cc3f4b579fe1",
    "<compiler-generated> line 2147483647",
    "XXXXX.heightForRow(at:tableViewWidth:)",
    "67",
    "66"
  ],
  [
    "3ccd93daaefe80f024cc8a7d0dc20f76",
    "<compiler-generated> line 2147483647",
    "XXXX.tableView(_:cellForRowAt:)",
    "59",
    "59"
  ],
  [
    "f31a6d464301980a41367b8d14f880a3",
    "XXXX.m line 46",
    "-[XXXX XXX:XXXX:]",
    "50",
    "41"
  ],
  [
    "c149e1dfccecff848d551b501caf41cc",
    "XXXX.m line 554",
    "-[XXXX tableView:didSelectRowAtIndexPath:]",
    "48",
    "47"
  ],
  [
    "609e79f399b1e6727222a8dc75474788",
    "Pinkoi",
    "specialized JSONDecoder.decode<A>(_:from:)",
    "47",
    "38"
  ]
]

可以看到是一个二维阵列。

加上转发 Slack 的 Function:

在上述程式码下方继续加入新 Function。

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
function sendTop10CrashToSlack() {

  var iOSTop10Crashes = queryiOSTop10Crashes();
  var top10Tasks = new Array();
  
  for (var i = 0; i < iOSTop10Crashes.length ; i++) {
    var issue_id = iOSTop10Crashes[i][0];
    var issue_title = iOSTop10Crashes[i][1];
    var issue_subtitle = iOSTop10Crashes[i][2];
    var number_of_crashes = iOSTop10Crashes[i][3];
    var number_of_impacted_user = iOSTop10Crashes[i][4];

    var strip_title = issue_title.replace(/[\<\\|\>]/g, '');
    var strip_subtitle = issue_subtitle.replace(/[\<\\|\>]/g, '');
    
    top10Tasks.push("<https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的专案ID/issues/"+issue_id+"\\|"+(i+1)+". Crash: "+number_of_crashes+" 次 ("+number_of_impacted_user+"人) - "+strip_title+" "+strip_subtitle+">");
  }

  var messages = top10Tasks.join("\n");
  var payload = {
    "blocks": [
      {
        "type": "header",
        "text": {
          "type": "plain_text",
          "text": ":bug::bug::bug: iOS 近 7 天闪退问题排行榜 :bug::bug::bug:",
          "emoji": true
        }
      },
      {
        "type": "divider"
      },
      {
        "type": "section",
        "text": {
          "type": "mrkdwn",
          "text": messages
        }
      },
      {
        "type": "divider"
      },
      {
        "type": "actions",
        "elements": [
          {
            "type": "button",
            "text": {
              "type": "plain_text",
              "text": "前往 Crashlytics 查看近 7 天纪录",
              "emoji": true
            },
            "url": "https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的专案ID/issues?time=last-seven-days&state=open&type=crash&tag=all"
          },
          {
            "type": "button",
            "text": {
              "type": "plain_text",
              "text": "前往 Crashlytics 查看近 30 天纪录",
              "emoji": true
            },
            "url": "https://console.firebase.google.com/u/1/project/你的ProjectID/crashlytics/app/你的专案ID/issues?time=last-thirty-days&state=open&type=crash&tag=all"
          }
        ]
      },
      {
        "type": "context",
        "elements": [
          {
            "type": "plain_text",
            "text": "Crash 次数及发生版本仅统计近 7 天之间数据,并非所有资料。",
            "emoji": true
          }
        ]
      }
    ]
  };

  var slackWebHookURL = "https://hooks.slack.com/services/XXXXX"; //更换成你的 in-coming webhook url
  UrlFetchApp.fetch(slackWebHookURL,{
    method             : 'post',
    contentType        : 'application/json',
    payload            : JSON.stringify(payload)
  })
}

如果不知道怎么取得 in-cming WebHook URL 可以参考 此篇文章 的「取得 Incoming WebHooks App URL」章节。

测试&设定排程

此时你的 Google Apps Script 专案应该会有上述两个 Function。

接下来请在上方的选择「sendTop10CrashToSlack」Function,然后点击 Debug 或 Run 执行测试一次;因第一次执行需要完成身份验证,所以请至少执行过一次再进行下一步。

执行测试一次没问题后,可以开始设定排程自动执行:

于左方选择闹钟图案,再选择右下方「+ Add Trigger」。

第一个「Choose which function to run」(需要执行的 function 入口) 请改为 sendTop10CrashToSlack ,时间周期可依个人喜好设定。

⚠️⚠️⚠️ 请特别注意每次查询都会累积然后收费的,所以千万不要乱设定;否则可能被排程自动执行搞到破产。

完成

范例成果图

范例成果图

现在起,你只要在 Slack 上就能快速追踪当前 App 闪退问题;甚至直接在上面进行讨论。

App Crash-Free Users Rate?

如果你想追的是 App Crash-Free Users Rate,可参考下篇「 Crashlytics + Google Analytics 自动查询 App Crash-Free Users Rate

延伸阅读

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.