Pathee engineering blog

世界をしなやかに変えるエンジニアたちのブログ

SentryのUserFeedbackをSlackに通知する仕組みを作る

SentryのUserFeedbackをSlackに通知する仕組みを作る

弊社ではメディアの記事を完成させるためのコンテンツマネジメントシステム(CMS)のエラー検知にSentryのUserFeedbackを利用しています。

docs.sentry.io

記事ライターさんがCMSでの作業中エラーに遭遇した場合、専用のダイアログを表示、エラーが起きた状況を入力してもらうことで、UserFeedbackの登録が完了します。

エンジニアはこのUserFeedbackの内容を受け、エラーを改善することで、ライターさんが快適に作業ができるようにしているのです。 しかしこのUserFeedbackは、現在メールへの通知しか行っていないのです! Sentryのエラー自体の通知はSlackに飛ばせるのになぜ…。

今回はUserFeedbackをSlackに通知するために、 GASを使って、UserFeedbackのメール内容を抽出、Slackに通知する仕組みを作りたいと思います。

1. UserFeedbackのメールにラベルを追加し、新規GASを作成する

まずはUserFeedbackのGmailをトリガーにしたGASを作成します。 大きくやることは下記の2点です。

  • Gmailに対し、UserFeedbackに特定のラベルを付与するフィルタを設定をする
  • GASを新規作成する

Gmailに対し、UserFeedbackに特定のラベルを付与するフィルタを設定をする

こちらはGmail側に設定するだけです。

専用のラベルをつける理由は、シンプルに対象を明確にする目的もあるのですが、 GASのメール取得件数には制限があるため、なるべく取得されるメールの数を減らす目的もあります。

developers.google.com

今回は、

条件: from:(noreply@md.getsentry.com) subject:(New Feedback from)
処理: ラベル「sentry_feedbuck」を付ける

といったフィルタを作成しました。

GASを新規作成する

今回作成するGASは大きく分けて以下の処理を行なっていきます。

  1. 新しく受信したUserFeedbackのメールを抽出する
  2. メールの本文(HTMLメール)を分解し、要点を抽出する
  3. 抽出された内容を整形し、Slackに通知する

以上の項目の1番目と2,3番目の大筋をここでは作成していきたいと思います。

まずはGmailの中から、「新しく受信したUserFeedbackのメール」を抽出します。 使うのはGmailApp.searchと呼ばれるメソッドです。

引数としてフィルターの条件を書くことで一致するメールのみを取得することができます。

ここでのポイントは、このメソッドで取得されるものがスレッドと呼ばれるメールの集まりだということです。

スレッドとは、同一メールアドレスとのやり取りを自動的にまとめてくれる機能のことです。

以下のコードを実行することで先ほど作成したラベル sentry_feedbuck のついてなおかつ、 新しく受信したメールを通知させたいため、未読のメールを含んだスレッドを取得しています。

function myFunction() {
  // メールボックスからスレッド(メールの送受信やりとりがまとまったもの)を取得する
  // 条件は ラベル:sentry_feedbuck がついていて、未読なもの
  var threads = GmailApp.search('label:sentry_feedbuck is:unread');
}

このスレッドをだけでは、「新しく受信したUserFeedbackのメール」の抽出ができないので上のコードを拡張していきます。

拡張の観点は以下の二点です。

  • スレッドの中に含まれるメールを抽出すること
  • 今受信したメールであること
  • 処理が終わったメールは既読扱いにし、再度抽出がされないようにする

特に実装上注意するべきなのは、2番目の「今受信したメールであること」です。

上で使ったようなスレッドのフィルター条件でも時間の指定位はできるのですが、分単位の指定ができません。

分単位の時間指定をする場合はUNIX時間とDateオブジェクトを使用することで実装が可能なのですが、 実装時点の2019年10月時点では、UNIX時間形式の時間指定とラベルを指定したスレッド抽出が行えませんでした………。

そのため、抽出の前に現在時刻から、(60+20)秒を引いた時間を求め、スレッド内の中でメールの受信時刻がその時間以降であることを条件に、 「新しく受信したメール」であることを判別しています。

この(60+20)秒というのは、このあとGASで1分置きにこの関数を実行すること、そしてその実行時間が若干ずれる可能性があることを考慮してマージンを付与することを目的とした時間です。(そのため、最悪同じ通知が届くこともあります…届かない可能性をなくすための20秒だとお考えいただけると幸いです)

こうして「新しく受信したメール」をmessageに取得することができました。

以降ではこの message に処理を施して、Slackに通知させたいと思います。

function myFunction() {
  const now_date = new Date() ;//現在時刻を取得
  var before1min_unix = now_date.getTime() - 1*(60+20)*1000; //ミリ秒なので(1分*60秒*1000)、GAS実効時間のマージンを20秒取っておく
  // メールボックスからスレッド(メールの送受信やりとりがまとまったもの)を取得する
  // 条件は ラベル:sentry_feedbuck がついていて、未読なもの
  var threads = GmailApp.search('label:sentry_feedbuck is:unread');
  for( var i in threads ) {
    // スレッドを1通1通のメールに分解する
    var messages = threads[i].getMessages();
    for (m in messages) {
      // メールの本文を取得する
      var message = messages[m].getBody();
      // メールの受信時刻を取得する
      var date = messages[m].getDate()
      // 受信時刻が1分以内のメールだけポストを行う
      // トリガーが1分周期なのと、こうしないとスレッド化している過去メールまで送信されてしまう。
      // また、UNIX時間を利用した分刻みのフィルタは、labelと平行できないため
      if (before1min_unix < date.getTime()){
        // messageが対象となるメールオブジェクト
      }
    }
    // 次回実行時に再抽出されないように既読にする
    threads[i].markRead();
  }
}

2. UserFeedbackのメールを分解し、必要な本文を抽出する

ここでは、メール本文から、UserFeedbackの内容を抽出する関数 ExtractPostText と、 内容をSlackに通知する関数 PostSlack を完成させます。

先ほどまでのコードを以下のように拡張した上で、2つの関数の実装をしていきます。

function myFunction() {
  const now_date = new Date() ;//現在時刻を取得
  var before1min_unix = now_date.getTime() - (1*60+20)*1000; //ミリ秒なので(1分*60秒*1000)、GAS実効時間のマージンを20秒取っておく
  // メールボックスからスレッド(メールの送受信やりとりがまとまったもの)を取得する
  // 条件は ラベル:sentry_feedbuck がついていて、未読なもの
  var threads = GmailApp.search('label:sentry_feedbuck is:unread');
  for( var i in threads ) {
    // スレッドを1通1通のメールに分解する
    var messages = threads[i].getMessages();
    for (m in messages) {
      // メールの本文を取得する
      var message = messages[m].getBody();
      // メールの受信時刻を取得する
      var date = messages[m].getDate()
      // 受信時刻が1分以内のメールだけポストを行う
      // トリガーが1分周期なのと、こうしないとスレッド化している過去メールまで送信されてしまう。
      // また、UNIX時間を利用した分刻みのフィルタは、labelと平行できないため
      if (before1min_unix < date.getTime()){
        // 本文から通知用に必要なテキスト(報告者、イシュー内容、フィードバック内容、リンク)を抽出する
        var post_text = ExtractPostText(message)
        // 抽出したテキストをSlackにポストする
        PostSlack(post_text)
      }
    }
    // 次回実行時に再抽出されないように既読にする
    threads[i].markRead();
  }
}

UserFeedbackの内容を抽出する

UserFeedbackの内容を抽出する関数 ExtractPostTextを完成させます。

メールはHTMLメールなので正規表現を利用して、報告者、イシュー内容、イシューへのリンク、報告内容を抽出します。 また抽出した本文の文字コードが#xx;形式になってしまっているので、同時にこれを通常のテキスト化もしています。

ここはただ正規表現を頑張るだけなので解説は省略します。 抽出がうまくいかなかった場合はその旨が代わりにポストされる仕組みです。 その時はこの抽出部を再調整する必要があります。

今回は上記の4項目を辞書形式でまとめています。

function ExtractPostText(message){
  // 本文から通知用に必要なテキスト(報告者、イシュー内容、フィードバック内容、リンク)を抽出する
  var post_text = {
    reporter: ExtractReporter(message), // 報告者抽出
    feedback : ExtractFeedback(message), // イシュー内容抽出
    issue : ExtractIssue(message), // フィードバック内容抽出
    link : ExtractLink(message) // リンク抽出
  }
  return post_text
}
function ExtractReporter(message){
  // メール本文から報告者を抽出する
  var regexp_reporter = /<h3.*>New Feedback from .*<\/h3>/;
  var tagged_reporter = message.match(regexp_reporter);
  if(tagged_reporter[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_reporter  = tagged_reporter[0].replace(regexp_tag, "")
    var clean_reporter  = clean_reporter.replace( /New Feedback from /, "")
    return TextCodeDecoder(clean_reporter) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からの報告者抽出に失敗しました"
  }
}
function ExtractFeedback(message){
  // メール本文からフィードバック内容を抽出する
  var regexp_feedback = /<p style=.*\/p>/;
  var tagged_feedback = message.match(regexp_feedback);
  if(tagged_feedback[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_feedback  = tagged_feedback[0].replace(regexp_tag, "")
    return TextCodeDecoder(clean_feedback) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのフィードバック抽出に失敗しました"
  }
}
function ExtractIssue(message){
    // メール本文からイシュー内容を抽出する
  var regexp_issue = /<a href="https:\/\/sentry\.io\/organizations\/pathee\/issues\/.*<\/a>/;
  var tagged_issue = message.match(regexp_issue);
  if(tagged_issue[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_issue  = tagged_issue[0].replace(regexp_tag, "")
    return TextCodeDecoder(clean_issue) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのイシュー抽出に失敗しました"
  }
}
function ExtractLink(message){
    // メール本文からリンクを抽出する
  var regexp_link = /https:\/\/sentry\.io\/pathee\/spotlist-web\/issues\/.*\/feedback\//;
  var tagged_link = message.match(regexp_link);
  if(tagged_link[0] !== undefined){
    var clean_issue  = tagged_link[0]
    return TextCodeDecoder(clean_issue) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのリンク抽出に失敗しました"
  }
}
function TextCodeDecoder(text){
  // メールの文字コードを変換し、日本語を正しく読めるようにする
  var regexp = /&#[0-9]*;/g;
  var decoded_text = text.replace( regexp, function(s){
    return String.fromCharCode(s.replace(/&#/, "").replace(/;/, ""));
  });
  return decoded_text;
}

内容をSlackに通知する

内容をSlackに通知する関数 PostSlack を完成させます。

ここではSlack側にポストをするチャンネルと、ポストするための設定を行う必要もあります。 SlackのSlackAppの追加を行い、通知対象のWebhook URLを取得しておいてください。

api.slack.com

Slackに通知する時のユーザ名と、アイコンはお好みで。 特定の誰かにメンションをしたい場合は <@ここにSlack member ID> を文字に含めると良いでしょう。

SLACK_WEBHOOK_URLの部分にwebhookのURLを記述してください。

自分の場合はパブリック変数として記述しています。 練習用と本番用を記述し、テスト時と本科同時に切り替えやすくするためです、ここは好みだと思います。

function PostSlack(post_text){
  // slack用に文章整形
  var slack_text = [
    "<@メンションをしたい場合、ここにSlack member ID>\n",
    "*SentryにUserFeedbackが追加されました*\n",
    "Reporter  : `"+post_text.reporter+"`\n",
    "Issue         : `"+post_text.issue+"`\n",
    "Feedback : `"+post_text.feedback+"`\n",
    "Link           : `"+post_text.link+"`",
  ].join("");
  // postのSlackwebhook実行
  callSlackWebhook(slack_text)
}
function callSlackWebhook(message) {  
  var params = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      username: 'Sentry User Feedback',
      icon_emoji: ':sentry_black:',
      text: message,
      link_names: 1,
    })
  };
  var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, params);
}

3.完成したコードをGASに書き込み、定期実行させる

ここまでで解説したコードをまとめたものが以下になります。

ここまでの読んでもわからん、という場合は下記コードで以下の部分だけ変更すればいいです

  • メールの抽出条件を変えたい
    • var threads = GmailApp.search('label:sentry_feedbuck is:unread'); の引数を「変更
  • Slackのポスト先を変えたい
    • var SLACK_WEBHOOK_URL = 'ここにwebhook URL';を変更
  • メールの抽出対象を変えたい
    • ExtractPostTextの中のExtractReporterExtractIssueを参考に正規表現をつかったhttpメールからの対象抽出メソッドを作る
  • GASの実行周期に合わせてクローニングのタイミングを変更したい
    • var before1min_unix = now_date.getTime() - (1*60+20)*1000; の部分を変更
    • GASの実行周期を伸ばす場合はここでのフィルター条件も同じように変更してあげてください
    • 5分であれば (5*60+20)*1000といった感じに。
    • ただし、GASの実行周期は最短でも1分でそれより短くすることはできません。
// slack通知用のwebhook
var SLACK_WEBHOOK_URL = 'ここにwebhook URL';

function myFunction() {
  const now_date = new Date() ;//現在時刻を取得
  var before1min_unix = now_date.getTime() - (1*60+20)*1000; //ミリ秒なので(1分*60秒*1000)、GAS実効時間のマージンを20秒取っておく
  // メールボックスからスレッド(メールの送受信やりとりがまとまったもの)を取得する
  // 条件は ラベル:sentry_feedbuck がついていて、未読なもの
  var threads = GmailApp.search('label:sentry_feedbuck is:unread');
  for( var i in threads ) {
    // スレッドを1通1通のメールに分解する
    var messages = threads[i].getMessages();
    for (m in messages) {
      // メールの本文を取得する
      var message = messages[m].getBody();
      // メールの受信時刻を取得する
      var date = messages[m].getDate()
      // 受信時刻が1分以内のメールだけポストを行う
      // トリガーが1分周期なのと、こうしないとスレッド化している過去メールまで送信されてしまう。
      // また、UNIX時間を利用した分刻みのフィルタは、labelと平行できないため
      if (before1min_unix < date.getTime()){
        // 本文から通知用に必要なテキスト(報告者、イシュー内容、フィードバック内容、リンク)を抽出する
        var post_text = ExtractPostText(message)
        // 抽出したテキストをSlackにポストする
        PostSlack(post_text) 
      }
    }
    // 次回実行時に再抽出されないように既読にする
    threads[i].markRead();
  }
}
function ExtractPostText(message){
  // 本文から通知用に必要なテキスト(報告者、イシュー内容、フィードバック内容、リンク)を抽出する
  var post_text = {
    reporter: ExtractReporter(message), // 報告者抽出
    feedback : ExtractFeedback(message), // イシュー内容抽出
    issue : ExtractIssue(message), // フィードバック内容抽出
    link : ExtractLink(message) // リンク抽出
  }
  return post_text
}
function ExtractReporter(message){
  // メール本文から報告者を抽出する
  var regexp_reporter = /<h3.*>New Feedback from .*<\/h3>/;
  var tagged_reporter = message.match(regexp_reporter);
  if(tagged_reporter[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_reporter  = tagged_reporter[0].replace(regexp_tag, "")
    var clean_reporter  = clean_reporter.replace( /New Feedback from /, "")
    return TextCodeDecoder(clean_reporter) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からの報告者抽出に失敗しました"
  }
}
function ExtractFeedback(message){
  // メール本文からフィードバック内容を抽出する
  var regexp_feedback = /<p style=.*\/p>/;
  var tagged_feedback = message.match(regexp_feedback);
  if(tagged_feedback[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_feedback  = tagged_feedback[0].replace(regexp_tag, "")
    return TextCodeDecoder(clean_feedback) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのフィードバック抽出に失敗しました"
  }
}
function ExtractIssue(message){
    // メール本文からイシュー内容を抽出する
  var regexp_issue = /<a href="https:\/\/sentry\.io\/organizations\/pathee\/issues\/.*<\/a>/;
  var tagged_issue = message.match(regexp_issue);
  if(tagged_issue[0] !== undefined){
    var regexp_tag  = /<.*?>/g;
    var clean_issue  = tagged_issue[0].replace(regexp_tag, "")
    return TextCodeDecoder(clean_issue) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのイシュー抽出に失敗しました"
  }
}
function ExtractLink(message){
    // メール本文からリンクを抽出する
  var regexp_link = /https:\/\/sentry\.io\/pathee\/spotlist-web\/issues\/.*\/feedback\//;
  var tagged_link = message.match(regexp_link);
  if(tagged_link[0] !== undefined){
    var clean_issue  = tagged_link[0]
    return TextCodeDecoder(clean_issue) // 文字コードを変換
  }
  else{
    return "⚠️BotError : メール文面からのリンク抽出に失敗しました"
  }
}
function TextCodeDecoder(text){
  // メールの文字コードを変換し、日本語を正しく読めるようにする
  var regexp = /&#[0-9]*;/g;
  var decoded_text = text.replace( regexp, function(s){
    return String.fromCharCode(s.replace(/&#/, "").replace(/;/, ""));
  });
  return decoded_text;
}
function PostSlack(post_text){
  // slack用に文章整形
  var slack_text = [
    "<@メンションをしたい場合、ここにSlack member ID>\n",
    "*SentryにUserFeedbackが追加されました*\n",
    "Reporter  : `"+post_text.reporter+"`\n",
    "Issue         : `"+post_text.issue+"`\n",
    "Feedback : `"+post_text.feedback+"`\n",
    "Link           : `"+post_text.link+"`",
  ].join("");
  // postのSlackwebhook実行
  callSlackWebhook(slack_text)
}
function callSlackWebhook(message) {
  var params = {
    method: 'post',
    contentType: 'application/json',
    payload: JSON.stringify({
      username: 'Sentry User Feedback',
      icon_emoji: ':sentry_black:',
      text: message,
      link_names: 1,
    })
  };
  var response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, params);
}

このコードを実際にGASに書き込んで、1分おきに定期実行させます。

ここでの1分おきというのはGASの定期実行の最小周期になります。 GASを追加し、プロジェクトのトリガーは以下の画像のように時間主導型で1分おきに実行をさせます。

f:id:pathee:20191207022453p:plain

できたもの

f:id:pathee:20191207022916p:plain

完成したGASを動かすと、UserFeedbackをこのようにSlackで受け取ることができました。 実際これ以降は、エンジニアが不具合を見逃すことも減り、第一報をスムーズに流せるようになったと思います!!

後日談

しかしこれを作った後、Slackが見れない人に対し、エンジニアの対応状況をリアルタイムに知れるような仕組みが欲しいという声が上がってきました。

確かにエンジニアが状況をスムーズにキャッチできるようにはなったけど、Slackを見れないライターさんにエンジニアが対応中ですよ、という状況を伝える手段ではないので、そのニーズもありそうですね…。

ということでこれを拡張したお話はまた次回。