GTMのエラーをASTとかいろいろ使ってデバッグする

## 最初に

この記事は[LIFULL advent calendar](https://qiita.com/advent-calendar/2018/lifull)の10日目の記事です。

突然ですが、GTM(Google Tag Manager)を利用して計測タグを差し込んだりしてると、たまにどのタグが原因かよくわからないエラーと出会いませんかね？
GTM経由でいれる計測タグは広告業界の慣習的にも___挿入したタグが別のscriptタグを作ってまたそれ読んでみたいなのを繰り返す___傾向にあります。
その先で出たエラーなどはそれだけを見てもどのタグが起点になって出たエラーなのか追い辛いものです。
今回はそんな読み込みの連鎖の中で発生したエラーみたいな追いづらいやつを可能な限りデバッグする方法を考えます。

## アイデア

読み込まれたscriptにはどのscriptから読み込まれたかの情報はないのでたどるのが難しいです。
読み込まれたタグごとに独立した実行空間を作ってあげることによって原因のエラーがどの空間で起きたものかわかるようにしてタグを辿ろうと思います。
実行空間というとNodeだとVMモジュール使えば・・・みたいな感じになるでしょうが、フロントエンドだとどういう手法になるでしょう...？

実はフロントにも古来から実行空間を分ける簡単な方法があります。
そう、___iframe___ですね。

GTMから読み込まれるタグごとにiframeを作ってそこでタグを実行してあげれば、その中でどんなにscriptをはしごしてエラーをだそうとも、元となったのがどこかすぐ分かりますね。

```js
{
  "script1.js": ["Reference Error, aaa is not defined", Error...],
  "script2.js": [Error...],
}
```

![スクリーンショット 2018-12-10 19.23.09.png](https://qiita-image-store.s3.amazonaws.com/0/12806/da6626bf-5003-e906-c067-95b5ad93a462.png)


今回はこのアプローチでトライしていこうと思います。

## デバッグ手順

### GTMがタグを埋め込む処理をフックする

一言に向け先をiframeにするといってもGTM本体のタグは我々の手元にはありません。
これを差し替えるのにはコツとカンがいります。

まずはコードを自由に書き換えるためにlocal proxyをして`https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXX`をローカルのサーバーに差し替えましょう。
local proxyはなんでもいいですが僕は某氏の作ってるchrome extensionの[Trumpet](https://chrome.google.com/webstore/detail/trumpet/cflekmkldaldnelemkkldoaedapbkmog?hl=ja)を使っています。

![スクリーンショット 2018-12-10 15.04.34.png](https://qiita-image-store.s3.amazonaws.com/0/12806/08e596b3-ed6d-cf89-2d7d-68eceee06ae9.png)

今回はこんな感じで`localhost:3005/proxy-gtm.js`に向けました。

そして3005番でサーバーを立ち上げれば手元にリクエストがくるので直接GTMのソースを取得してそれを改変して配信してもいいんだけど、gtm.jsは大部分が圧縮されてたりして変更し辛いのと、なるべくgtm.js側の変更に強くしたいのでGTMのコードを静的解析してタグを挿入する部分にフックするコードに変換しましょう。

タグを挿入して、かつ実行させるとなるので考えられる挿入方法は大きく以下の3つ

* document.write
* Node.appendChild
* Node.insertBefore

他にもいくつかあるけどわざわざこれ以外の方法をとるケースも思いつかばないのでこれらを検証していきます。

#### document.write

document.writeは残りの２つと違ってDocumentストリームが開いてるパース中にHTMLStringを与えることで文章中にもともとそのHTMLがあったように振る舞わせるもので、Documentストリームが開いてる間でしか使えません。(それ以外のタイミングで使うとDocumentストリームを新規に開き直してまっさらの状態にしてしまう)

![image017.png](https://qiita-image-store.s3.amazonaws.com/0/12806/5a2001d2-371d-f17f-eb30-097ee28770b7.png)
(有名なやつ)

それを踏まえてGTMそのものを導入する手順を見ていくとこんなタグを挿入しろっていうシーンがあります。

![スクリーンショット 2018-12-10 15.14.07.png](https://qiita-image-store.s3.amazonaws.com/0/12806/87b610cf-5867-cabd-8ce1-729b1801f658.png)


```html
<script type="text/javascript">
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start': new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0], j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= '//www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f); })(window,document,'script','dataLayer','GTM-XXXXXX
</script> 
```

内容をよくみてみるとgtm.jsを読み込むscriptを作ってるようですがここに`asyc=true`という属性が付与されていることがわかります。
ここからgtm.jsは非同期と読み込まれることがわかります。
つまりこれはGTMによってタグを挿入する際にはDocumentストリームが開いている保証がないことになるので`document.write`を使ってることは考え辛くなります。

#### Node.appendChild/Node.insertBefore

appendChild/insertBeforeは上記のDocumentストリームの制約をうけないのでどちらの可能性も残ります。
静的解析してフックしてみないとわからないんだけれどもやってみた結論からいうとinsertBeforeを利用してタグを挿入してるみたいです。

というわけでgtm.jsを静的解析してinsertBeforeのタイミングでフックしていきたいと思います。

server側はただgtmのコードひっぱって変換かけて返すだけなのでconnect使ってベーシックなサーバを作ります
静的解析と変換、生成はesprima, estraverse, escodegenの御三家ライブラリを使おうと思います。

```js
let connect = require('connect')
  , http = require('http')
  , query = require('connect-query')
  , request = require('request')
  , util = require('util')
  ;

let esprima = require('esprima')
  , escodegen = require('escodegen')
  , estraverse = require('estraverse')
  , get = util.promisify(request.get)
  , app = connect()
  ;

app.use(query());
app.use('/proxy-gtm.js', async (req, res, next) => {
  let gtmId = req.query.id
    , url = `https://www.googletagmanager.com/gtm.js?id=${gtmId}`
    , code = await get({uri: url})
    , ast = esprima.parse(code.body) // gtm.jsをastに変換
    ;

  // コードをトラバースし、変換対象のnodeを差し替える
  estraverse.traverse(ast, {
    enter: (node, parent) => {
      /* xxx.insertBeforeをフック */

      // callexpression ( hogehoge() ) みたいなの以外は無視
      if (!node || !node.type || node.type !== 'CallExpression') {
        return;
      }
      // 親がない呼び出しは無視(= xxx.insertBeforeの呼び出しの形じゃないのは無視)
      if (node.callee.type !== 'MemberExpression') {
        return;
      }
      // insertBefore以外のメソッド呼び出しは無視
      if (!/^(insertBefore)$/.test(node.callee.property.name)) {
        return;
      }
      // ここで変換処理 [1]
    }
  });

  // 変換後のASTからコードを生成
  res.end(`(function () {
    ${escodegen.generate(ast, {format: {style: {indent: '  '}}})}
  }());`)
});

// サーバー起動
http.createServer(app).listen(3005);
```

### insertBeforeで差し込まれるタグをiframeに流し込む

続いて変換処理[1]のところを書いていきましょう。

ここまでのフィルタリング処理のおかげで、[1]のところでは、`parent.insertBefore(newNode, referenceNode)`において

* node = parent.insertBefore()
* newNode = node.arguments[0]
* referenceNode = node.arguments[1]

という対応付けがされてる状態になっています。

差し込まれるタグはinsertBeforeの第一引数のnewNodeになるので`node.arguments[0]`がそれをさします。
なので`node.arguments[0]`を差し替えていきたいと思います。

強引ですが即時実行関数をかませてこんな形を目指そうとおもいます。

元のコード

```js
parent.inserBefore(newNode, referenceNode)
```

変換後のコード

```js
parent.insertBefore((function () {
  let node = newNode
    , iframe = document.createElement('iframe')
    , errors = window.errors = window.errors || {}
    ;

  document.body.appendChild(iframe);
  let idoc = iframe.contentWindow.document;

  // iframe側のwindowの状態をなるべくメインフレームの環境に寄せてあげる
  Object.keys(window).forEach(key => {
    try {
      if (!iframe.contentWindow.hasOwnProperty(key)) {
        iframe.contentWindow[key] = window[key];
      }
    } catch(err) {}
  })

  // タグを文字列に戻す(一回DOMにつっこんでinnerHTMLで抽出する)
  let wrap = document.createElement('div')
  wrap.appendChild(node);
  let tagSnippet = wrap.innerHTML;

  // iframeに流し込むためのdocumentストリームを作成
  idoc.open();
  // iframeのエラーをコレクションする
  idoc.write(`<script>window.onerror = function (msg) {  window._err = window._err || []; window._err.push(msg) }</script>`)
  // iframeにタグを流し込む
  idoc.write(tagSnippet);
  // 読み込み終わった後にiframe側で起きたエラーをメインフレーム側に持ってくる
  iframe.onload = function () {
    let errors = iframe.contentWindow._err || [];
    if (errors.length) {
      errors[tagSnippet] = iframe.contentWindow._err || [];
    }
  }
  idoc.close();

  // なんでもいいのでメインフレームのinsertBeforeの第一引数に流し込まれるdomを返す
  return document.createTextNode('foo');
}()), referenceNode);
```

要約すると以下3ステップをする即実行関数を突っ込んでます

1. iframeを容易してタグをiframeにdocument.writeで書き込む
1. iframeに潜めておいたwindow.onerrorでエラーを収集
1. iframe.onloadのタイミングでたまったエラーをメインフレームに持ってくる

この即時実行関数の部分をesprimaでparseしてASTに変換してnode.arguments[0]に突っ込んであげればいいわけです。

### 集めたエラーを表示する

あとはメインフレーム側の`window.onload`のタイミングで`window.iframeErrors`の中身を見てあげればエラーを履いてるscriptとエラーの内容を見ることができます。

res.sendでわたしてるescodegenのところにその辺のコードを付け足してみましょう

前

```js
  // 変換後のASTからコードを生成
  res.end(`(function () {
    ${escodegen.generate(ast, {format: {style: {indent: '  '}}})}
  }());`)
```

後

```js
  // 変換後のASTからコードを生成
  res.end(`(function () {
    ${escodegen.generate(ast, {format: {style: {indent: '  '}}})};
    // iframeを全部読み終わったら集めたエラーをconsoleにはく
    window.onload = function () {
      console.table(window.iframeErrors);
    }
   }())`)
```

これで実行してみると

![スクリーンショット 2018-12-10 18.54.15.png](https://qiita-image-store.s3.amazonaws.com/0/12806/e0e0f175-ee5b-ec79-74b5-c3bf2f97767c.png)


こんな感じで起点のscriptとエラー内容のセットが表示されます。
これでscript1.js経由でエラーがでてることがわかります。

ちなみにscript1.jsの中身はこうです

```js
var script = document.createElement('script');
script.src="http://localhost:3444/qiita_test/tag/script1_1.js";
document.addEventListener('DOMContentLoaded', function () {
  document.body.appendChild(script);
}, false)
```

script1_1を読み込んでいます。 script1_1.jsは`console.log(undefinedVariable.undefinedProp)`みたいなわけのわからんコードを実行してundefinedVariableにundefinedPropなんてプロパティないよっていう参照エラーを出しています。

あとは[google tag managerのtag一覧を取得するAPI](https://developers.google.com/apis-explorer/#p/tagmanager/v2/tagmanager.accounts.containers.workspaces.tags.list)にfields: `tag(name,parameter/value)`
を投げて得られたタグ一覧の中から、先ほどconsoleに出力されたエラーの出たタグを突き合わせてnameを見れば、エラーの出てたタグのタグ名がわかるという次第です。
![スクリーンショット 2018-12-10 18.43.37.png](https://qiita-image-store.s3.amazonaws.com/0/12806/a7e5658a-3686-930b-a2b7-dd158c25c798.png)

## 終わりに

今まで平穏に動いてたタグが急にエラーはいてサイト側の障害になることもありうる世界線なので問題発覚時にすみやかに原因特定できるように心がけたいものですね。