3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-05-26

皆さん、こんな経験ありませんか?

・リリースしたゲームのエラー情報を見たり、統計を取ったりしたい
・特定のバグが何故かエディタ上では発生しないので、原因の特定が難しい

今回実装する事

・リリースした後のゲーム内で発生したエラーを取得し、外部のサーバーに保存します
 今回はPOSTするサーバーにAWSのLambdaを採用し、無料利用枠内であれば完全無料で実現します
 保存する外部サーバーはAPIアクセスが可能なものなら何でもOKですが、今回はDropboxを採用します

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

実際に試した環境

・Unity 2021.3.0 LTS
・Visual Studio2017
・ビルドターゲットはWindows
・AWS Lambda Python3.9

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

実装方法

①新規にスクリプトを作成します(名前は何でもいいです)
②スクリプト作成時に書いてあるStart関数とUpdate関数を消して、usingに以下の項目を追加します

Error_Report
using System.IO;
using System;
using UnityEngine.Networking;

③変数を追加します

Error_Report
    string FilePath; //エラー情報を一時的に保存するファイルパスです
    string URL; //サーバーにPOST通信するURLです
    string Send_Text; //実際のエラー情報を格納する変数です

④初期化処理を記載します
この初期化でログの検出ができるようになります

Error_Report
    public void Start()
    {
        Application.logMessageReceived += OnReceiveLog;
    }
    void OnDestroy()
    {
        Application.logMessageReceived -= OnReceiveLog; //これ忘れると地獄になります
    }

⑤実際にエラーが発生した際の処理を記載します

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);
        }

    }

ここの処理のポイントとしては、ログの種類をエラー級のものに限定しています
さすがに頻繁に呼ばれると、ゲーム体験に影響を与えるほどのパフォーマンス悪化が発生します
また、Editor上で発生したログは無視しています

⑥エラーを送る準備をします

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());
    }

今回は必要最低限の記述なので省略していますが
今のままだとエラー情報のファイル名が同一であるため、ファイルが重複します
そのため基本的にファイル名には、プレイヤー名+日時の形式を取る事をお勧めします
Steam APIではSteamFriends.GetPersonaName()でプレイヤー名の取得が可能です
なお、プレイヤー名を使用する際は
特殊文字をエスケープする処理を忘れないようにする必要があります

⑦実際にPOST通信を実施します

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); //証拠隠滅しましょう
        }
    }

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

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

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

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

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

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

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

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

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

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

⑮Lambdaの内容を修正します
一旦Lambdaの内容は全部消して、最初に必要なモジュールをインポートします

Error_Report_Receiver
import json
import io
import requests
import base64
import os
import cgi
import dropbox

次にアクセストークンを取得する関数を作ります

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

次にメイン関数を記載します

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内に保存されます

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

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

開発者のTwitterでは、開発時の知見をアップしています!

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?