【Unity】完全無料でリリース後のエラーを取得し、外部サーバーに送信してみる

# 皆さん、こんな経験ありませんか？
・リリースしたゲームのエラー情報を見たり、統計を取ったりしたい
・特定のバグが何故かエディタ上では発生しないので、原因の特定が難しい

# 今回実装する事
・リリースした後のゲーム内で発生したエラーを取得し、外部のサーバーに保存します
　今回はPOSTするサーバーにAWSの[Lambda](https://aws.amazon.com/jp/lambda/ "AWS公式サイト")を採用し、[無料利用枠内](https://aws.amazon.com/jp/free/?all-free-tier.sort-by=item.additionalFields.SortRank&all-free-tier.sort-order=asc&awsf.Free%20Tier%20Types=*all&awsf.Free%20Tier%20Categories=*all&all-free-tier.q=Lambda&all-free-tier.q_operator=AND "AWS公式サイト")であれば完全無料で実現します
　保存する外部サーバーはAPIアクセスが可能なものなら何でもOKですが、今回はDropboxを採用します

:::note info
なぜ直接Dropboxに保存せずに、
Lambdaを経由するのか？といった疑問を持つ方もいると思いますが
Unityのスクリプトは、基本的にビルド後に解析可能となっています
これはIL2CPPビルドでも同じで、多少の難読化は出来ますが防ぐことはできません
今回のケースではDropboxに保存するため、
どこかしらにDropboxのアクセストークンを書く事になります
もしUnityのスクリプト内に直接書いたら...？
結果として攻撃者はアクセストークンを入手し
Dropbox内のファイルを好き勝手できるようになってしまいます
そこでLambdaを使用し、外部から見えない場所にアクセストークンを置こうというものです
:::

# 実際に試した環境
・Unity 2021.3.0 LTS
・Visual Studio2017
・ビルドターゲットはWindows
・AWS Lambda Python3.9

:::note alert
この方法では、プレイヤーがインターネット接続されたPCで遊んでいる必要があります
速度面では、1Mbpsもあればゲーム動作に支障はありません
予めログの出力でErrorを有効化する必要があります
(PlayerSettings→other→Loggingの中のErrorなどをONにしておく)
:::

# 実装方法
①新規にスクリプトを作成します(名前は何でもいいです)
②スクリプト作成時に書いてあるStart関数とUpdate関数を消して、usingに以下の項目を追加します
```C#:Error_Report
using System.IO;
using System;
using UnityEngine.Networking;
```
③変数を追加します
```C#:Error_Report
    string FilePath; //エラー情報を一時的に保存するファイルパスです
    string URL; //サーバーにPOST通信するURLです
    string Send_Text; //実際のエラー情報を格納する変数です
```
④初期化処理を記載します
この初期化でログの検出ができるようになります
```C#:Error_Report
    public void Start()
    {
        Application.logMessageReceived += OnReceiveLog;
    }
    void OnDestroy()
    {
        Application.logMessageReceived -= OnReceiveLog; //これ忘れると地獄になります
    }
```
⑤実際にエラーが発生した際の処理を記載します
```C#:Error_Report
    private void OnReceiveLog(string logText, string stackTrace, LogType logType)
    {
        try
        {
            if(Application.isEditor == false)
            {
                if (logType == LogType.Error | logType == LogType.Exception)
                {
                    Send_Text = "Error Log:" + "\n";
                    Send_Text = Send_Text + "Game Name:" + Application.productName + "\n";
                    Send_Text = Send_Text + "Game Ver:" + Application.version + "\n";
                    Send_Text = Send_Text + "Error Type:" + logType + "\n";
                    Send_Text = Send_Text + "Error Text:" + logText + "\n";
                    Send_Text = Send_Text + "Error Stack:" + stackTrace;
                    //他にもエラーが起きたScene名など含めたい情報はじゃんじゃん入れましょう、情報は多い方が得です
                    Send_Text = Send_Text + ":Log End" + "\n" + "\n";
                    Error_Send();
                }
            }
        }
        catch(Exception e)
        {
            Debug.Log(Send_Text);
            Debug.Log("エラーログの送信に失敗" + e.Message);
        }

    }
```
:::note info
ここの処理のポイントとしては、ログの種類をエラー級のものに限定しています
さすがに頻繁に呼ばれると、ゲーム体験に影響を与えるほどのパフォーマンス悪化が発生します
また、Editor上で発生したログは無視しています
:::
⑥エラーを送る準備をします
```C#:Error_Report
    public void Error_Send()
    {
        FilePath = Application.dataPath + ""; //見つからないサブフォルダ等に置くのが無難です
        if (!File.Exists(FilePath))
        {
            using (File.Create(FilePath)) { }
        }
        File.AppendAllText(FilePath, Send_Text);
        StartCoroutine(Wait_File());
    }
```
:::note alert
今回は必要最低限の記述なので省略していますが
今のままだとエラー情報のファイル名が同一であるため、ファイルが重複します
そのため基本的にファイル名には、プレイヤー名+日時の形式を取る事をお勧めします
Steam APIでは`SteamFriends.GetPersonaName()`でプレイヤー名の取得が可能です
なお、プレイヤー名を使用する際は
特殊文字をエスケープする処理を忘れないようにする必要があります
:::
⑦実際にPOST通信を実施します
```C#:Error_Report
    public IEnumerator Wait_File()
    {
        yield return new WaitForSecondsRealtime(1.0f);
        if (File.Exists(FilePath))
        {
            byte[] bytes = File.ReadAllBytes(FilePath); //バイトに変換します
            StartCoroutine(Send(bytes));
        }
    }

    public IEnumerator Send(byte[] bytes)
    {
        string FileName = Path.GetFileName(FilePath);
        var form = new WWWForm();
        form.AddBinaryData("ErrorData", bytes, FileName, "application/octet-stream");
        using (var req = UnityWebRequest.Post("POST通信先のURL", form))
        {
            yield return req.SendWebRequest();
        }
        if (File.Exists(FilePath))
        {
            File.Delete(FilePath); //証拠隠滅しましょう
        }
    }
```

:::note info
POST通信先のURLには、
パラメータとしてファイル名、プレイヤー名、プラットフォーム名を
追加した想定で以降の操作が進みます
:::

⑧AWSのアカウントを作成しましょう
本来はこの記事でもスクリーンショット付きで解説したかったのですが
私は既にAWSアカウントを持っているので、公式の記事を引用させて頂きます

https://aws.amazon.com/jp/register-flow/

⑨アカウントを作成すると、ダッシュボードが表示されすぐに開発できるようになりますが
まずは一旦落ち着いて必ずやっておくべき事を実施しましょう
以下のサイトにわかりやすくまとめられていますが、
中には有料のものもあるため慎重に行いましょう
有料の設定はアカウントのセキュリティを高めてくれますが
果たしてエラーデータごときにそこまでやる必要があるのか？については判断が必要です

:::note info
私は以下の設定を実施しました(全て無料です)
・デフォルトVPCの削除
・無料利用枠アラートの設定
・MFA認証の有効化
・アカウントの秘密の質問の設定
・AWS Budgetsの予算の作成
・リージョンの設定→基本的に東京リージョンで問題ありません
:::

https://dev.classmethod.jp/articles/aws-baseline-setting-202206/

⑩AWSマネジメントコンソールからIAMを選択し、Lambdaに使用するロールを作成します
与える権限としては、`logs:CreateLogGroup`と`logs:CreateLogStream`と`logs:PutLogEvents`です
以下のサイトに詳しくロールの作成方法が記載されています

https://catalog.us-east-1.prod.workshops.aws/workshops/b97f7cb6-0ec4-41c7-97ea-c4156f4f1e0d/ja-JP/phase3/01

⑪AWSマネジメントコンソールからLambdaを選択し、関数を作成します
下のサイトに詳しくLambdaの作成方法が記載されています

https://kacfg.com/aws-lambda-function-urls-basic/

:::note info
この際、以下の設定を実施します
・関数URL : あり(IAM認証無し)
・⑩で作成したロールの割り当て
・メモリ使用量を512MBに変更
・タイムアウトを30秒に変更
:::

⑫LambdaからDropboxにアクセスするために、Lambdalayerを追加します
以下のサイトを参考にLambdalayerの設定画面まで進み
`arn:aws:lambda:ap-northeast-1:770693421928:layer:Klayers-p39-dropbox:9`
と入力する事でLambda内でDropbox APIが使用可能になります

https://aduce.jp/aws/volunteers-lambda-python-layers

⑬⑪で作成したLambdaの内容を変更し、layerには先ほど追加したDropboxのものを追加します

⑭Dropbox側の設定を実施し、アクセストークンを持ってきます
以下のサイトの内容を実施します
`APP_KEY` `APP_SECRET` `REFRESH_TOKEN`はLambda内で使用するのでメモしておいてください

https://zenn.dev/yakumo/articles/75d3df651d0609

⑮Lambdaの内容を修正します
一旦Lambdaの内容は全部消して、最初に必要なモジュールをインポートします
```Python:Error_Report_Receiver
import json
import io
import requests
import base64
import os
import cgi
import dropbox
```
次にアクセストークンを取得する関数を作ります
```Python:Error_Report_Receiver
def Get_Dropboxtoken():
    APP_KEY = 'ご自身のAPP_KEY'
    APP_SECRET = 'ご自身のAPP_SECRET'
    REFRESH_TOKEN = 'ご自身のREFRESH_TOKEN'
    data = {
      'grant_type': 'refresh_token',
      'refresh_token': REFRESH_TOKEN
    }
    response = requests.post('https://api.dropbox.com/oauth2/token', data=data, auth=(APP_KEY, APP_SECRET))
    access_token = response.json()['access_token']
    return access_token
```
次にメイン関数を記載します
```Python:Error_Report_Receiver
def lambda_handler(event, context):
    
    #ファイル名を取得、POST時にfilenameで渡す必要がある
    FileName = event['queryStringParameters']['filename']
    #プレイヤー名を取得、POST時にPlayerNameで渡す必要がある
    PlayerName = event['queryStringParameters']['PlayerName']
    #プラットフォーム名を取得、POST時にPlatformで渡す必要がある
    PlatformName = event['queryStringParameters']['Platform']
    
    body = event['body']
    body_byte = base64.b64decode(body).decode()
    
    #ファイルの書き込み
    path = '/tmp/' + FileName
    Write_text = body_byte
    with open(path, mode='w') as f:
        f.write(Write_text)
    
    #ファイルの読み込み
    file = open(path , 'rb')
    result = file.read()
    file.close()
    
    Dropbox_BasicPath = '/Error' #ここにはファイルをぶち込みたいDropBOXのフォルダ名を入れましょう
    Dropbox_token = Get_Dropboxtoken()
    dbx = dropbox.Dropbox(Dropbox_token)
    
    Dropbox_BasicPath = Dropbox_BasicPath + '/' + PlatformName 
    try:
        dbx.files_create_folder(Dropbox_BasicPath)
    except Exception as e:
        print('プラットフォームフォルダは既にある')
        print(e)

    Dropbox_FolderPath = Dropbox_BasicPath + '/' + PlayerName
    try:
        dbx.files_create_folder(Dropbox_FolderPath)
    except Exception as e:
        print('プレイヤーフォルダは既にある')
        print(e)
     
    dbx.files_upload(result, Dropbox_FolderPath + '/' + FileName + '.txt')
    
    return {
        'statusCode': 200,
        'body': event
    }
```
⑯Lambdaをデプロイし、関数URLをUnityのスクリプト内に貼り付けパラメータを付与します
⑰あとは実機でエラーを起こせばレポートがDropbox内に保存されます

:::note info
エディタ上で確認したい際はUnityのスクリプト内で
`Application.isEditor == false`をtrueに変えましょう
:::




以上です！
エラーを分析して、ゲームをもっとレベルアップしていきましょう！！

開発者の[Twitter](https://twitter.com/KENGOHAZARD "Twitter")では、開発時の知見をアップしています！