こんにちは、私はVilian Iaumbaevです! 最近、iOSとAndroidの両方で自動的に新しい事故レポートを処理するシステムを構築しました - トラッキングと問題の修正を非常に容易にします。 なぜこんなことをしたのか。 適切な開発者に事故を手動で割り当てることは、すぐに退屈で信頼できない作業となりました。何かを忘れ、エッジケースを無視したり、複雑な問題を省略したりするのは簡単でした。 概要 始めるには、Googleのツールが必要です。 で、 しかもJIRA。 Crashlytics Google Cloud プラットフォーム Crashlytics Google Cloud プラットフォーム Google サービスでプロジェクトを設定すると、Crashlytics から GCP へのデータ転送を構成します。 その後、すべてのクラッシュレポートがBigQueryテーブルに表示されます。 integration page integration page iOSとAndroidのクラッシュデータ構造はほぼ同じで、わずかな違いがいくつかありますが、これは単一のスクリプトを使用して両方を処理できることを意味します。 だから今、あなたはこのデータでいくつかの作業を実行できることを意味するBigQueryであなたのクラッシュデータを持っています. You can request all crash data and analyze it as you want on your side. 私はPython言語を選択し、この例で説明します。まず、我々はすべてのクラッシュデータを分析する必要がありますが、あなたが100万人以上のユーザーの大きな量のデータを持っている場合は、Google側のすべてのデータをプレプロセスする方が良い、いくつかの合計を作成します。 プラン いくつかの基本的なSQLを学び、BigQueryからクラッシュデータを取得します。 Python を使用した Query Crash Data Get all committers from the repository and merge duplicates. すべての committers をリポジトリから取得し、duplicates を統合します。 各問題をレポファイルとその所有者にマップする タスクが既に存在しない場合、ファイル所有者のために Jira タスクを作成する いくつかのSQLの基本を学び、BigQueryからデータを取得します。 BigQuery は独自の SQL 言語を使用しており、標準の SQL に似ていますが、データ分析のための追加の便利性を提供します。私たちの統合のために、完全なクラッシュ データセットを使用する必要がありましたが、合計形式で使用しました。具体的には、個々のクラッシュ レポートをクラッシュ サインにグループ化し、それぞれのグループ内の関連データを合計しました - 発生件数、影響を受けたユーザー数、バージョン分解など。 https://console.cloud.google.com/bigquery https://console.cloud.google.com/bigquery WITH pre as( SELECT issue_id, ARRAY_AGG(DISTINCT issue_title IGNORE NULLS) as issue_titles, ARRAY_AGG(DISTINCT blame_frame.file IGNORE NULLS) as blame_files, ARRAY_AGG(DISTINCT blame_frame.library IGNORE NULLS) as blame_libraries, ARRAY_AGG(DISTINCT blame_frame.symbol IGNORE NULLS) as blame_symbols, COUNT(DISTINCT event_id) as total_events, COUNT(DISTINCT installation_uuid) as total_users, '{"version":"' || application.display_version || '","events":' || COUNT(DISTINCT event_id) || ',"users":' || COUNT(DISTINCT installation_uuid) || '}' AS events_info FROM `YOUR TABLE NAME` WHERE 1=1 AND event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 24 HOUR) AND event_timestamp < CURRENT_TIMESTAMP() AND error_type = "FATAL" GROUP BY issue_id, application.display_version ) SELECT issue_id, ARRAY_CONCAT_AGG(issue_titles) as issue_titles, ARRAY_CONCAT_AGG(blame_files) as blame_files, ARRAY_CONCAT_AGG(blame_libraries) as blame_libraries, ARRAY_CONCAT_AGG(blame_symbols) as blame_symbols, SUM(total_events) as total_events, SUM(total_users) as total_users, '[' || STRING_AGG(events_info, ",") || ']' as events_info FROM pre WHERE 1=1 AND issue_id IS NOT NULL AND events_info IS NOT NULL GROUP BY issue_id ORDER BY total_users DESC その結果、1 行あたりのユニークな issue_id が、次の集計フィールドと共に表示されます。 issue_titles — a list of all crash titles. This is an array to account for cases where multiple unique titles exist for the same issue. In the scripting part, we will select the most frequent one. これは、同じ問題のための複数のユニークなタイトルが存在する場合の数値です。 blame_files — クラッシュの責任を負ったトップの stacktrace ファイルのリスト. This will be non-empty if the crash occurred in your codebase (rather than in system libraries). blame_libraries ── crash に関連するライブラリの一覧です. This is also an array, constructed for reasons similar to issue_titles. blame_symbols — crash が発生したコード シンボル(関数/方法)のリスト. Like the other fields above, it is an array. total_events - 選択された期間中に発生した事故の合計数。 total_users — 影響を受けるユニークなユーザーの数. 時には、特定のユーザーのグループにのみクーデターが発生する場合があります。 events_info — a JSON array (as a string) containing total_events and total_users broken down by app version. 以下の例を参照してください。 [ { "version": "1.0.1", "events": 131, "users": 110 }, { "version": "1.2.1", "events": 489, "users": 426 } ] Python を使用して BigQuery からデータをリクエスト 始めるには、BigQuery Python クライアントライブラリをインストールします。 . After the installation, create a BigQueryExecutor.py file — this module will handle all communication with Google Cloud BigQuery. ピピ ピピ import os import json import tempfile from google.oauth2 import service_account from google.cloud import bigquery from collections import Counter class BigQueryExecutor: def __init__(self, credentialsJson: str, bqProjectId: str = ''): temp_file_path='' with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp_file: json.dump(json.loads(credentialsJson), temp_file, indent=4) temp_file_path = temp_file.name credentials = service_account.Credentials.from_service_account_file(temp_file_path) os.remove(temp_file_path) self.client = bigquery.Client(project=bqProjectId, credentials=credentials) 脚本の使用を開始するには、あなたが必要なのは2つだけです。 Google サービス アカウント JSON 認証ファイル あなたのBigQueryプロジェクトの名前(またはID) これらがあれば、認証し、スクリプトを通じてクエリを実行し始めることができます。 Google アカウント JSON Credential サービスアカウントを作成するには、 を割り当て、 役割 Google Cloud コンソール BigQuery Data Editor Google Cloud コンソール アカウントが作成されると、それを開いて、Navigate to the タブ、クリック , and choose これは、サービスアカウントのためのJSON認証ファイルを生成し、ダウンロードします。 “Keys” “Add key” “JSON” サービスアカウントの JSON は通常、以下のようになります。 { "type": "service_account", "project_id": YOUR_PROJECT, "private_key_id": private_key_id, "private_key": GCP_PRIVATE_KEY, "client_email": "email", "client_id": "id", "auth_uri": "auth_uri", "token_uri": "token_uri", "auth_provider_x509_cert_url": "auth_provider_x509_cert_url", "client_x509_cert_url": "url", "universe_domain": "universe_domain" } テスト目的では、JSON 認証をシングルライン文字列に変換し、スクリプトに直接埋め込むことができます。 シークレットマネージャーを使用して、代わりにあなたの認証情報を安全に保存し、管理します。 not recommended for production bqProjectId をプロジェクト_id フィールドから JSON 認証ファイル内から抽出することもできます。 モデル BigQuery データを使用するには、クエリ 結果の構造を反映するデータ モデルを定義するのに役立ちます. This allows you to write cleaner, safer, and more maintainable code. 以下は、このようなモデルクラスの例です。 class BQCrashlyticsVersionsModel: def __init__(self, version: str, events: int, users: int ): self.version = version self.events = events self.users = users class BQCrashlyticsIssueModel: def __init__(self, issue_id: str, issue_title: str, blame_file: str, blame_library: str, blame_symbol: str, total_events: int, total_users: int, versions: list[BQCrashlyticsVersionsModel] ): self.issue_id = issue_id self.issue_title = issue_title self.blame_file = blame_file self.blame_library = blame_library self.blame_symbol = blame_symbol self.total_events = total_events self.total_users = total_users self.versions = versions タグ 機能 機能 そして最後に、BigQueryからデータを取得することができます。 既存の BigQueryExecutor クラスに次のメソッドを追加します — it will execute the SQL query described earlier in the モデルインスタンスにパスされた結果を返します。 BigQuery SQL def getCrashlyticsIssues(self, lastHoursCount: int, tableName: str) -> list[BQCrashlyticsIssueModel]: firstEventsInfo = """'[' || STRING_AGG(events_info, ",") || ']' as events_info""" asVersions = """ '{"version":"' || application.display_version || '","events":' || COUNT(DISTINCT event_id) || ',"users":' || COUNT(DISTINCT installation_uuid) || '}' AS events_info """ query = f""" WITH pre as( SELECT issue_id, ARRAY_AGG(DISTINCT issue_title IGNORE NULLS) as issue_titles, ARRAY_AGG(DISTINCT blame_frame.file IGNORE NULLS) as blame_files, ARRAY_AGG(DISTINCT blame_frame.library IGNORE NULLS) as blame_libraries, ARRAY_AGG(DISTINCT blame_frame.symbol IGNORE NULLS) as blame_symbols, COUNT(DISTINCT event_id) as total_events, COUNT(DISTINCT installation_uuid) as total_users, {asVersions} FROM `{tableName}` WHERE 1=1 AND event_timestamp >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {lastHoursCount} HOUR) AND event_timestamp < CURRENT_TIMESTAMP() AND error_type = "FATAL" GROUP BY issue_id, application.display_version ) SELECT issue_id, ARRAY_CONCAT_AGG(issue_titles) as issue_titles, ARRAY_CONCAT_AGG(blame_files) as blame_files, ARRAY_CONCAT_AGG(blame_libraries) as blame_libraries, ARRAY_CONCAT_AGG(blame_symbols) as blame_symbols, SUM(total_events) as total_events, SUM(total_users) as total_users, {firstEventsInfo} FROM pre WHERE 1=1 AND issue_id IS NOT NULL AND events_info IS NOT NULL GROUP BY issue_id ORDER BY total_users DESC """ bqRows = self.client.query(query).result() rows: list[BQCrashlyticsIssueModel] = [] def mergeArray(array: list[str]) -> str: if not array: return '' counter = Counter(array) most_common = counter.most_common(1) return most_common[0][0] if most_common else '' for row in bqRows: issueModel = BQCrashlyticsIssueModel( issue_id=row.issue_id, issue_title=mergeArray(row.issue_titles), blame_file=mergeArray(row.blame_files), blame_library=mergeArray(row.blame_libraries), blame_symbol=mergeArray(row.blame_symbols), total_events=row.total_events, total_users=row.total_users, versions=[BQCrashlyticsVersionsModel(version=jj['version'], events=jj['events'], users=jj['users']) for jj in json.loads(row.events_info)] ) rows.append(issueModel) return rows 今では、Python から直接 BigQuery に SQL リクエストを実行できます。 以下は、クエリを実行し、結果で作業する方法の完全な例です。 executor = BigQueryExecutor(credentialsJson=_jsonToken, bqProjectId=_bqProjectId) allCrashes = executor.getCrashlyticsIssues(lastHoursCount=24, tableName="tableNAME_IOS_REALTIME") for i in allCrashes: print(i.issue_title) [libswift_Concurrency.dylib] swift::swift_Concurrency_fatalError(unsigned int, char const*, ...) [SwiftUI] static DisplayList.ViewUpdater.Platform.updateClipShapesAsync(layer:oldState:newState:) Foundation [SwiftUI] __swift_memcpy112_8 [libswift_Concurrency.dylib] swift::AsyncTask::waitFuture(swift::AsyncTask*, swift::AsyncContext*, void (swift::AsyncContext* swift_async_context) swiftasynccall*, swift::AsyncContext*, swift::OpaqueValue*) [libobjc.A.dylib] objc_opt_respondsToSelector [VectorKit] void md::COverlayRenderLayer::layoutRibbon<md::Ribbons::PolylineOverlayRibbonDescriptor>(std::__1::unique_ptr<md::PolylineOverlayLayer<md::Ribbons::PolylineOverlayRibbonDescriptor>, std::__1::default_delete<md::PolylineOverlayLayer<md::Ribbons::PolylineOverlayRibbonDescriptor> > > const&, ggl::CommandBuffer*, md::PolylineOverlayLayoutContext&, unsigned int, unsigned long long, bool, bool, float) [libswiftCore.dylib] _swift_release_dealloc [libobjc.A.dylib] objc_msgSend 今、BigQueryからクラッシュデータを取得できるようになったので、次のステップに進み、トップ5の最も頻繁なクラッシュを取り出し、Jiraタスクを自動的に作成することができます。 リポジトリのすべての委員を取得し、それらを統合する crash 問題を開発者に割り当てる前に、最初に各 crash の潜在所有者を特定する必要があります。 私たちはGitHubを使用しているので、いくつかの具体的な詳細に気付くべきです。 一部の開発者は複数の電子メールアドレスを使用する場合がありますので、適切な場合にアイデンティティを統合する必要があります。 GitHub はしばしば電子メール (e.g. username@users.noreply.github.com) を使用しますので、それに応じてこれらのケースを処理します。 このステップの主な目的は、以下のコマンドを使用して、Gitの著者リストとその名前と電子メールを抽出し、正常化することです。 git log | grep ‘^Author’ | sort | uniq -c import re class GitUserModel: def __init__(self, nicknames: set[str], emails: set[str], gitLogins: set[str] ): self.nicknames = nicknames self.emails = emails self.gitLogins = gitLogins def returnPossibleNicknames(text: str) -> set[str]: res = [findEmail(text), loginFromEmail(text), findGitNoreplyLogin(text)] return set(list(filter(None, res))) def findEmail(text: str) -> str: e = re.match(r"(([A-Za-z0-9+\.\_\-]*@[A-Za-z0-9+]*\.[A-Za-z0-9+]*))", text) if e: return e.group(1) def loginFromEmail(text: str) -> str: e = re.match(r"(([A-Za-z0-9+\.\_\-]*))@[A-Za-z0-9+]*\.[A-Za-z0-9+]*", text) if e: return e.group(1) def findGitNoreplyLogin(text: str) -> str: gu = re.match(r"\d+\+(([A-Za-z0-9+\.\_\-]*))@users\.noreply\.github\.com", text) if gu: return gu.group(1) else: gu = re.match(r"(([A-Za-z0-9+\.\_\-]*))@users\.noreply\.github\.com", text) if gu: return gu.group(1) class GitBlamer: def getAllRepoUsersMap(self, projectRootPath: str) -> list[GitUserModel]: users: list[GitUserModel] = [] allGitLog = os.popen("cd {}; git log | grep '^Author' | sort | uniq -c".format(projectRootPath)).read() for line in allGitLog.split('\n'): user = self._createUserFromBlameLine(line) if user: users.append(user) self._enrichUsersNicknames(users=users) users = self._mergeSameUsers(users) users = sorted(users, key=lambda x: list(x.emails)[0] if x.emails else list(x.gitLogins)[0] if x.gitLogins else "") return users def _createUserFromBlameLine(self, line): m = re.match(r".* Author: (.*) <(.*)>", line) user = GitUserModel(nicknames=set(), emails=set(), gitLogins=set()) if m: val=set() if m.group(1): val.add(m.group(1)) if m.group(2): val.add(m.group(2)) user.nicknames = val else: return return user def _enrichUsersNicknames(self, users: list[GitUserModel]): for user in users: possibleNicknames = set() for nick in user.nicknames: possibleNicknames = possibleNicknames.union(returnPossibleNicknames(text=nick)) e = findEmail(text=nick) if e: user.emails.add(e) gu = findGitNoreplyLogin(text=nick) if gu: user.gitLogins.add(gu) user.nicknames = user.nicknames.union(possibleNicknames) def _mergeSameUsers(self, users: list[GitUserModel]): for i in range(0, len(users)): if i >= len(users): break for j in range(i+1, len(users)): if j >= len(users): break setLoweredJNicknames=set([u.lower() for u in users[j].nicknames]) for k in range(0, j): if k >= j: break setLoweredKNicknames=set([u.lower() for u in users[k].nicknames]) isSameNickname=len(setLoweredKNicknames.intersection(setLoweredJNicknames)) > 0 if isSameNickname: users[j].gitLogins = users[j].gitLogins.union(users[k].gitLogins) users[j].emails = users[j].emails.union(users[k].emails) users.pop(k) break return users 以下のコードでは、同じ人に属する可能性のある異なるコミットアイデンティティを一致させようとします - たとえば、 user@gmail.com および user@users.noreply.github.com. 私たちはまた、彼らの名前とGitHubユーザ名(利用可能な場合)を抽出し、グループ化します。 以下のスクリプトを使用すると、このプロセスを起動し、リポジトリ内のすべてのコマッターの清潔でデダプライクされたリストを得ることができます。 projectRootPath="/IOS_project_path" blamer = GitBlamer() allUsers = blamer.getAllRepoUsersMap(projectRootPath=projectRootPath) for user in allUsers: print(", ".join(user.nicknames)) Map each issue to file of repository and file owner. 各問題をリポジトリおよびファイル所有者のファイルにマップします。 この時点で、我々は、我々の事故とそれに影響を受けたユーザーに関する詳細な情報を持っているので、特定の事故を特定のユーザと関連付けることができ、自動的に相応のJiraタスクを作成することができます。 crash-to-user マッピングの論理を実装する前に、iOS と Android のワークフローを分離しました。これらのプラットフォームは異なるシンボル形式を使用し、トラブルファイルを問題にリンクする基準も異なります。 class AbstractFileToIssueMapper: def isPathBelongsToIssue(self, file: str, filePath: str, issue: BQCrashlyticsIssueModel) -> bool: raise Exception('Not implemented method AbstractFileToIssueMapper') class AndroidFileToIssueMapper(AbstractFileToIssueMapper): def __init__(self): self.libraryName = 'inDrive' def isPathBelongsToIssue(self, file: str, filePath: str, issue: BQCrashlyticsIssueModel) -> bool: if file != issue.blame_file or not issue.blame_symbol.startswith(self.libraryName): return False fileNameNoExtension = file.split('.')[0] fileNameIndexInSymbol = issue.blame_symbol.find(fileNameNoExtension) if fileNameIndexInSymbol < 0: return False relativeFilePathFromSymbol = issue.blame_symbol[0:fileNameIndexInSymbol].replace('.', '/') relativeFilePathFromSymbol = relativeFilePathFromSymbol + file return filePath.endswith(relativeFilePathFromSymbol) class IosFileToIssueMapper(AbstractFileToIssueMapper): def __init__(self): self.indriveLibraryName = 'inDrive' self.indriveFolderName = 'inDrive' self.modulesFolderName = 'Modules' def isPathBelongsToIssue(self, file: str, filePath: str, issue: BQCrashlyticsIssueModel) -> bool: if file != issue.blame_file: return False isMatchFolder = False if issue.blame_library == self.indriveLibraryName: isMatchFolder = filePath.startswith('{}/'.format(self.indriveFolderName)) else: isMatchFolder = filePath.startswith('{}/{}/'.format(self.modulesFolderName, issue.blame_library)) return isMatchFolder 特定の実装はプロジェクトによって異なりますが、このクラスの主な責任は、特定のファイルで特定のクラッシュが発生したかどうかを決定することです。 この論理が適用されると、問題にファイルをマッピングし、それらを関連するファイル所有者に割り当てることができます。 import subprocess class MappedIssueFileModel: def __init__(self, fileGitLink: str, filePath: str, issue: BQCrashlyticsIssueModel, fileOwner: GitUserModel ): self.fileGitLink = fileGitLink self.filePath = filePath self.issue = issue self.fileOwner = fileOwner class BigQueryCrashesFilesMapper: def getBlameOut(self, filePath: str, projectRootPath: str) -> list[str]: dangerousChars = re.compile(r'[;|&\r\n]|\.\.') if dangerousChars.search(filePath) or dangerousChars.search(projectRootPath): return None if not subprocess.check_output(['git', 'ls-files', filePath], cwd=projectRootPath, text=True): return None blameProc = subprocess.Popen(['git', 'blame', filePath, '-cwe'], cwd=projectRootPath, stdout=subprocess.PIPE, text=True) blameRegex=r'<[a-zA-Z0-9\+\.\_\-]*@[a-zA-Z0-9\+\.\_\-]*>' grepProc = subprocess.Popen(['grep', '-o', blameRegex], stdin=blameProc.stdout, stdout=subprocess.PIPE, text=True) blameProc.stdout.close() sortProc = subprocess.Popen(['sort'], stdin=grepProc.stdout, stdout=subprocess.PIPE, text=True) grepProc.stdout.close() uniqProc = subprocess.Popen(['uniq', '-c'], stdin=sortProc.stdout, stdout=subprocess.PIPE, text=True) sortProc.stdout.close() finalProc = subprocess.Popen(['sort', '-bgr'], stdin=uniqProc.stdout, stdout=subprocess.PIPE, text=True) uniqProc.stdout.close() blameOut, _ = finalProc.communicate() blameArray=list(filter(len, blameOut.split('\n'))) return blameArray def findFileOwner(self, filePath: str, gitFileOwners: list[GitUserModel], projectRootPath: str) -> GitUserModel: blameArray = self.getBlameOut(filePath=filePath, projectRootPath=projectRootPath) if not blameArray: return foundAuthor = None for blameI in range(0, len(blameArray)): author = re.match(r".*\d+ <(.*)>", blameArray[blameI]) if author: possibleNicknames = returnPossibleNicknames(text=author.group(1)) for gitFileOwner in gitFileOwners: if len(gitFileOwner.nicknames.intersection(possibleNicknames)) > 0: foundAuthor = gitFileOwner break if foundAuthor: break return foundAuthor def mapBQResultsWithFiles(self, fileToIssueMapper: AbstractFileToIssueMapper, issues: list[BQCrashlyticsIssueModel], gitFileOwners: list[GitUserModel], projectRootPath: str ) -> list[MappedIssueFileModel]: mappedArray: list[MappedIssueFileModel] = [] githubMainBranch = "https://github.com/inDriver/UDF/blob/master" for root, dirs, files in os.walk(projectRootPath): for file in files: filePath = os.path.join(root, file).removeprefix(projectRootPath).strip('/') gitFileOwner = None for issue in issues: if fileToIssueMapper.isPathBelongsToIssue(file, filePath, issue): if not gitFileOwner: gitFileOwner = self.findFileOwner(filePath=filePath, gitFileOwners=gitFileOwners, projectRootPath=projectRootPath) mappedIssue = MappedIssueFileModel( fileGitLink='{}/{}'.format(githubMainBranch, filePath.strip('/')), filePath=filePath, issue=issue, fileOwner=gitFileOwner ) mappedArray.append(mappedIssue) mappedArray.sort(key=lambda x: x.issue.total_users, reverse=True) return mappedArray この時点でやるべきことは、githubMainBranchのプロパティを自分のリポジトリへのリンクで更新することです。 次に、問題とファイル所有者を収集し、下のコードを使用してファイルをマップし、最終的な結果 - total_users に分類された問題のリストを下方順に取得します。 mapper = BigQueryCrashesFilesMapper() mappedIssues = mapper.mapBQResultsWithFiles( fileToIssueMapper=IosFileToIssueMapper(), issues=allCrashes, gitFileOwners=allUsers, projectRootPath=projectRootPath ) for issue in mappedIssues: if issue.fileOwner: print(list(issue.fileOwner.nicknames)[0], issue.issue.total_users, issue.issue.issue_title) else: print('no owner', issue.issue.total_users, issue.issue.issue_title) crash ファイル所有者のための Jira タスクを作成する しかし、Jira コンフィギュレーションは企業ごとに頻繁に異なるため、カスタマイズされたフィールド、ワークフロー、および許可は異なる場合があります。 以下は、私たちの経験に基づく実践的なヒントです。 すべての問題に対してタスクを作成しないでください. 影響を受けるユーザーの数または一定の影響の限界に基づいて、トップ5〜10の問題に焦点を当ててください。 タスクメタデータは持続します 作成されたタスクに関する情報を永続的なストレージに保存します. I use BigQuery, saving data in a separate table and updating it on each script run. タスクメタデータは別々のテーブルに保存し、各スクリプトを実行するときに更新します。 アプリの新しいバージョンで問題が再現された場合は、閉じたタスクを再生する - これにより、回帰が無視されないようにします。 同じ問題のタスクをリンクして、将来の調査を簡素化し、複製を避ける。 タスクの説明にできるだけ多くの詳細を含めます. crash aggregations, affected user counts, versions, etc. を追加します。 リンク関連のクラッシュは、同じファイルから発生した場合 - これは追加の文脈を提供します。 in Slack (or another messaging system) when new tasks are created or existing ones need attention. Include helpful links to the crash report, task, relevant GitHub files, etc. Notify your team エラー処理をスクリプトに追加します. Use try/except blocks and send Slack alerts when something fails. ブロックを使用して、何か失敗したときにSlackの警告を送信します。 例えば、Cache BigQuery crash retrievals をローカルにキャッシュしてイテレーションを加速します。 これらの場合、タスクを手動で割り当てる必要がありますが、完全なクラッシュコンテキストでJiraの問題を自動的に作成することはまだ便利です。 結論 このシステムにより、毎日数千件の事故レポートを処理し、数分で適切な開発者にリダイレクトできます。 あなたのチームが未分類の事故に溺れている場合は、それを自動化してください。