diff --git a/.github/workflows/run-tests-and-package.yml b/.github/workflows/run-tests-and-package.yml index a755c6e0..c2513831 100644 --- a/.github/workflows/run-tests-and-package.yml +++ b/.github/workflows/run-tests-and-package.yml @@ -772,7 +772,7 @@ jobs: runs-on: [ubuntu-latest] needs: [test-samples, run-integration-tests] timeout-minutes: 20 - if: startsWith(github.ref, 'refs/tags/v') + if: (startsWith(github.ref, 'refs/pull') && endsWith(github.base_ref, 'main')) || (startsWith(github.ref, 'refs/heads') && endsWith(github.ref, 'main')) env: LL_USE_STAGE: false strategy: diff --git a/Runtime/Client/LootLockerHTTPClient.cs b/Runtime/Client/LootLockerHTTPClient.cs index 32748594..bd7aca50 100644 --- a/Runtime/Client/LootLockerHTTPClient.cs +++ b/Runtime/Client/LootLockerHTTPClient.cs @@ -157,6 +157,11 @@ void ILootLockerService.Initialize() // Initialize request tracking CurrentlyOngoingRequests = new Dictionary(); HTTPExecutionQueue = new Dictionary(); + FailedRequestHistory = new List(MAX_FAILED_REQUEST_HISTORY); + for (int i = 0; i < MAX_FAILED_REQUEST_HISTORY; i++) + { + FailedRequestHistory.Add(null); + } CompletedRequestIDs = new List(); ExecutionItemsNeedingRefresh = new UniqueList(); OngoingIdsToCleanUp = new List(); @@ -165,6 +170,14 @@ void ILootLockerService.Initialize() IsInitialized = true; _instance = this; + + if (!string.IsNullOrEmpty(LootLockerStateData.GetDefaultPlayerULID())) + { + if(LootLockerFailureFeedbackCategory.Equals("Not Initialized")) + { + RefreshFailureFeedbackCategoryId((_ignored) => {}); + } + } } LootLockerLogger.Log("HTTPClient initialized", LootLockerLogger.LogLevel.Verbose); } @@ -260,7 +273,8 @@ private void AbortAllOngoingRequestsWithCallback(string abortReason) var abortedResponse = LootLockerResponseFactory.ClientError( abortReason, executionItem.RequestData.ForPlayerWithUlid, - executionItem.RequestData.RequestStartTime + executionItem.RequestData.RequestStartTime, + executionItem.RequestData.RequestId ); executionItem.RequestData.CallListenersWithResult(abortedResponse); @@ -281,6 +295,13 @@ private void ClearAllCollections() { CurrentlyOngoingRequests?.Clear(); HTTPExecutionQueue?.Clear(); + if (FailedRequestHistory != null) + { + for (int i = 0; i < FailedRequestHistory.Count; i++) + { + FailedRequestHistory[i] = null; + } + } CompletedRequestIDs?.Clear(); ExecutionItemsNeedingRefresh?.Clear(); OngoingIdsToCleanUp?.Clear(); @@ -354,6 +375,7 @@ public void OverrideCertificateHandler(CertificateHandler certificateHandler) #region Private Fields private Dictionary HTTPExecutionQueue = new Dictionary(); + private List FailedRequestHistory = new List(MAX_FAILED_REQUEST_HISTORY); private List CompletedRequestIDs = new List(); private UniqueList ExecutionItemsNeedingRefresh = new UniqueList(); private List OngoingIdsToCleanUp = new List(); @@ -364,6 +386,7 @@ public void OverrideCertificateHandler(CertificateHandler certificateHandler) private const int CLEANUP_THRESHOLD = 500; private DateTime _lastCleanupTime = DateTime.MinValue; private const int CLEANUP_INTERVAL_SECONDS = 30; + private const int MAX_FAILED_REQUEST_HISTORY = 30; #endregion #region Class Logic @@ -490,7 +513,7 @@ private void LateUpdate() { if (WebRequestSucceeded(completedRequest.WebRequest)) { - CallListenersAndMarkDone(completedRequest, LootLockerResponseFactory.Success((int)completedRequest.WebRequest.responseCode, completedRequest.WebRequest.downloadHandler.text, completedRequest.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(completedRequest, LootLockerResponseFactory.Success((int)completedRequest.WebRequest.responseCode, completedRequest.WebRequest.downloadHandler.text, completedRequest.RequestData.ForPlayerWithUlid, completedRequest.RequestData.RequestStartTime, completedRequest.RequestData.RequestId)); } else { @@ -499,7 +522,7 @@ private void LateUpdate() } else { - CallListenersAndMarkDone(completedRequest, LootLockerResponseFactory.ClientError("Request completed but no response was present", completedRequest.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(completedRequest, LootLockerResponseFactory.ClientError("Request completed but no response was present", completedRequest.RequestData.ForPlayerWithUlid, completedRequest.RequestData.RequestStartTime, completedRequest.RequestData.RequestId)); } } @@ -550,7 +573,7 @@ private IEnumerator _ScheduleRequest(LootLockerHTTPRequestData request) { LootLockerLogger.Log($"HTTP queue full: {HTTPExecutionQueue.Count}/{configuration.MaxQueueSize} requests queued", LootLockerLogger.LogLevel.Warning); } - request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime)); + request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime, request.RequestId)); yield break; } @@ -563,7 +586,7 @@ private IEnumerator _ScheduleRequest(LootLockerHTTPRequestData request) { LootLockerLogger.Log($"HTTP queue backed up: {HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count} requests queued", LootLockerLogger.LogLevel.Warning); } - request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime)); + request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime, request.RequestId)); yield break; } @@ -580,7 +603,7 @@ private bool CreateAndSendRequest(LootLockerHTTPExecutionQueueItem executionItem // Rate limiting is optional - if no RateLimiter is set, requests proceed without rate limiting if (_cachedRateLimiter?.AddRequestAndCheckIfRateLimitHit() == true) { - CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RateLimitExceeded(executionItem.RequestData.Endpoint, _cachedRateLimiter.GetSecondsLeftOfRateLimit(), executionItem.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RateLimitExceeded(executionItem.RequestData.Endpoint, _cachedRateLimiter.GetSecondsLeftOfRateLimit(), executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId)); return false; } @@ -589,7 +612,7 @@ private bool CreateAndSendRequest(LootLockerHTTPExecutionQueueItem executionItem executionItem.WebRequest = CreateWebRequest(executionItem.RequestData); if (executionItem.WebRequest == null) { - CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.ClientError($"Call to {executionItem.RequestData.Endpoint} failed because Unity Web Request could not be created", executionItem.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.ClientError($"Call to {executionItem.RequestData.Endpoint} failed because Unity Web Request could not be created", executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId)); return false; } @@ -653,7 +676,7 @@ private void HandleRequestResult(LootLockerHTTPExecutionQueueItem executionItem, } case HTTPExecutionQueueProcessingResult.Completed_Success: { - CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.Success((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.Success((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId)); } break; case HTTPExecutionQueueProcessingResult.ShouldBeRetried: @@ -664,7 +687,7 @@ private void HandleRequestResult(LootLockerHTTPExecutionQueueItem executionItem, // If the retry after header suggests to retry after we'd have timed out the request then handle it as a failure if (executionItem.RequestStartTime + RetryAfterHeader > LootLockerConfig.current.clientSideRequestTimeOut) { - LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid); + LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId); response.errorData = ExtractErrorData(response); if (response.errorData != null) { @@ -691,7 +714,7 @@ private void HandleRequestResult(LootLockerHTTPExecutionQueueItem executionItem, } case HTTPExecutionQueueProcessingResult.Completed_TimedOut: { - CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RequestTimeOut(executionItem.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RequestTimeOut(executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId)); } break; case HTTPExecutionQueueProcessingResult.Completed_Failed: @@ -728,10 +751,17 @@ private void CallListenersAndMarkDone(LootLockerHTTPExecutionQueueItem execution { LootLockerLogger.Log($"Failed to log HTTP request: {ex}", LootLockerLogger.LogLevel.Warning); } + + response.requestContext = new LootLockerRequestContext(executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId); + + if (response != null && !response.success) + { + StoreFailedRequestReport(response, executionItem); + } + CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); executionItem.IsWaitingForSessionRefresh = false; executionItem.Done = true; - response.requestContext = new LootLockerRequestContext(executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime); executionItem.Response = response; if (!CompletedRequestIDs.Contains(executionItem.RequestData.RequestId)) { @@ -906,6 +936,263 @@ private void HandleSessionRefreshResult(LootLockerResponse newSessionResponse, s #endregion + #region Failure Reporting + + /// + /// Data structure for storing information about failed requests for the purpose of error reporting. This is not used for successful requests to avoid unnecessary memory usage, as successful requests are expected to be much more common than failed requests. + /// + public class LootLockerFailedRequestReport + { + /// + /// Optional field for user provided description of the events leading up to the failed request, to be included in the error report. This can be set by the user when they choose to send an error report for a failed request, but is not required and is not expected to be set in most cases. + /// + public string UserDescription { get; set; } + /// + /// The unique client identifier for the request. + /// + public string ClientRequestId { get; set; } + /// + /// The unique server identifier for the request. + /// + public string ServerRequestId { get; set; } + /// + /// The unique identifier for the trace of the request. + /// + public string TraceId { get; set; } + /// + /// The HTTP status code returned by the server in response to the request, or 0 if the request did not receive a response. + /// + public int StatusCode { get; set; } + /// + /// The error message returned by the server in response to the request, or a client-side error message if the request did not receive a response or if an error occurred on the client side before the request was sent. + /// + public string Message { get; set; } + /// + /// The endpoint that the request was sent to, including path parameters but excluding query parameters. + /// + public string Endpoint { get; set; } + /// + /// The HTTP method used for the request (e.g. GET, POST, etc.). + /// + public string http_method { get; set; } + /// + /// The raw JSON body of the response returned by the server, or null if the request did not receive a response or if the response did not have a JSON body. This is stored as a string rather than a deserialized object to avoid unnecessary memory usage, as the response body is not expected to be used in most error reports and can be easily inspected in its raw form in the log viewer. + /// + public string ResponseJsonBody { get; set; } + /// + /// The headers returned by the server in response to the request, or null if the request did not receive a response. Each header is stored as a string in the format "Header-Name: Header-Value" for easy readability in the log viewer. This is stored as an array of strings rather than a dictionary to avoid unnecessary memory usage, as the headers are not expected to be used in most error reports and can be easily inspected in their raw form in the log viewer. + /// + public string[] ResponseHeaders { get; set; } + /// + /// The raw JSON body of the request sent to the server, or null if the request did not have a JSON body. This is stored as a string rather than a deserialized object to avoid unnecessary memory usage, as the request body is not expected to be used in most error reports and can be easily inspected in its raw form in the log viewer. + /// + public string RequestBody { get; set; } + /// + /// The headers sent to the server in the request, or null if no headers were sent. Each header is stored as a string in the format "Header-Name: Header-Value" for easy readability in the log viewer. This is stored as an array of strings rather than a dictionary to avoid unnecessary memory usage, as the headers are not expected to be used in most error reports and can be easily inspected in their raw form in the log viewer. + /// + public string[] RequestHeaders { get; set; } + /// + /// The number of times the request was retried due to failure before it ultimately failed. This can be useful information for debugging and error analysis, as a high number of retries may indicate an issue with the server or with the client's network connection. + /// + public int RetryAttempts { get; set; } + /// + /// The duration in seconds from when the request was first sent to when it ultimately failed. This can be useful information for debugging and error analysis, as a long duration may indicate an issue with the server or with the client's network connection. + /// + public float RequestDurationSeconds { get; set; } + /// + /// The timestamp of when the server responded to the request, or null if the request did not receive a response. This can be useful information for debugging and error analysis, as it can help determine if the failure was due to a server issue (e.g. if the server responded with an error) or a client issue (e.g. if the request did not receive a response). + /// + public string ServerTimestamp { get; set; } + /// + /// The timestamp of when the request ultimately failed, as recorded by the client. This can be useful information for debugging and error analysis, as it can help determine if the failure was due to a server issue (e.g. if the server responded with an error) or a client issue (e.g. if the request did not receive a response). It can also be useful for analyzing patterns of failures over time. + /// + public string ClientTimestamp { get; set; } + /// + /// The ULID of the player for whom the request was made. This can be useful information for debugging and error analysis, as it can help determine if the failure was specific to a particular player or if it was a more widespread issue. It can also be useful for analyzing patterns of failures across different players. + /// + public string PlayerUlid { get; set; } + } + + private static string LootLockerFailureFeedbackCategory = "Not Initialized"; + + public bool IsFailureReportingEnabled() + { + return !string.IsNullOrEmpty(LootLockerFailureFeedbackCategory); + } + + public string GetFailureFeedbackCategoryId() + { + return LootLockerFailureFeedbackCategory; + } + + public void RefreshFailureFeedbackCategoryId(Action value) + { + if(string.IsNullOrEmpty(LootLockerFailureFeedbackCategory)) + { + value?.Invoke(false); + return; + } + if(LootLockerFailureFeedbackCategory.Equals("Not Initialized")) + { + LootLockerSDKManager.ListGameFeedbackCategories((feedbackCategoriesResponse) => + { + if(!feedbackCategoriesResponse.success) + { + LootLockerLogger.Log($"Failed to retrieve feedback categories for error report. Status code: {feedbackCategoriesResponse.statusCode} and message: {feedbackCategoriesResponse.errorData.message}", LootLockerLogger.LogLevel.Debug); + LootLockerFailureFeedbackCategory = null; + value?.Invoke(false); + return; + } + foreach(var cat in feedbackCategoriesResponse.categories) + { + if(cat?.name == "lootlocker_request_failure") + { + LootLockerFailureFeedbackCategory = cat.id; + value?.Invoke(true); + return; + } + } + LootLockerFailureFeedbackCategory = null; + LootLockerLogger.Log($"Failed to find appropriate category to send error report under. Feedback categories retrieved successfully but no category with name 'lootlocker_request_failure' was found. LootLocker Error reporting turned off", LootLockerLogger.LogLevel.Debug); + value?.Invoke(false); + }, LootLockerStateData.GetDefaultPlayerULID()); + } + else { + // Category is already initialized, just return true + value?.Invoke(true); + } + } + + public bool TryGetFailedRequestReportForRequestId(string requestId, out LootLockerFailedRequestReport report) + { + report = null; + if (FailedRequestHistory == null || FailedRequestHistory.Count == 0) + { + return false; + } + foreach(var failedReport in FailedRequestHistory) + { + if(failedReport != null && failedReport.ClientRequestId.Equals(requestId)) + { + report = failedReport; + return true; + } + } + return false; + } + + private void StoreFailedRequestReport(LootLockerResponse failedResponse, LootLockerHTTPExecutionQueueItem executionItem) + { + if(string.IsNullOrEmpty(LootLockerFailureFeedbackCategory)) + { + // Auto reporting is disabled, do not store error report + return; + } + if(failedResponse == null || failedResponse.errorData == null) + { + LootLockerLogger.Log($"Failed response or error data was null, cannot construct valid error report. Player ULID: {executionItem.RequestData.ForPlayerWithUlid}", LootLockerLogger.LogLevel.Debug); + return; + } + if(executionItem == null || executionItem.RequestData == null) + { + LootLockerLogger.Log($"Execution item or request data was null, cannot construct valid error report. Player ULID: {executionItem?.RequestData?.ForPlayerWithUlid}", LootLockerLogger.LogLevel.Debug); + return; + } + + if(failedResponse.statusCode == 401) + { + LootLockerLogger.Log($"Request unauthorized - cannot construct valid error report for a request that was unauthorized. Player ULID: {executionItem.RequestData.ForPlayerWithUlid}", LootLockerLogger.LogLevel.Debug); + return; + } + if(failedResponse.errorData.retry_after_seconds > 0) + { + LootLockerLogger.Log($"Request throttled - cannot construct valid error report for a request that was throttled. Retry after {failedResponse.errorData.retry_after_seconds} seconds. Player ULID: {executionItem.RequestData.ForPlayerWithUlid}", LootLockerLogger.LogLevel.Debug); + return; + } + + string failedResponsePlayerUlid = executionItem.RequestData.ForPlayerWithUlid; + string requestBody = null; + if(executionItem.RequestData.Content != null && executionItem.RequestData.Content.dataType == LootLocker.LootLockerEnums.LootLockerHTTPRequestDataType.JSON) + { + requestBody = ((LootLockerJsonBodyRequestContent)executionItem.RequestData.Content).jsonBody; + } + + var FailedRequestReport = new LootLockerFailedRequestReport + { + ClientRequestId = executionItem.RequestData.RequestId, + ServerRequestId = failedResponse.errorData.request_id, + TraceId = failedResponse.errorData.trace_id, + StatusCode = failedResponse.statusCode, + Message = failedResponse.errorData.message, + Endpoint = executionItem.RequestData.FormattedURL, + http_method = executionItem.RequestData.HTTPMethod.ToString(), + ResponseJsonBody = failedResponse.text, + ResponseHeaders = null, + RequestBody = requestBody, + RequestHeaders = null, + RetryAttempts = executionItem.RequestData.TimesRetried, + RequestDurationSeconds = Time.time - executionItem.RequestStartTime, + ServerTimestamp = "", // We resolve this below + ClientTimestamp = (DateTime.Now - TimeSpan.FromSeconds(Time.time - executionItem.RequestStartTime)).ToString("o"), // ISO 8601 format + PlayerUlid = failedResponsePlayerUlid + }; + + var requestHeaders = executionItem.RequestData.ExtraHeaders; + List requestHeadersForReport = new List(); + if(requestHeaders != null && requestHeaders.Count > 0) { + foreach (var requestHeader in requestHeaders) + { + requestHeadersForReport.Add($"{requestHeader.Key} : {requestHeader.Value}"); + } + } + FailedRequestReport.RequestHeaders = requestHeadersForReport.ToArray(); + + var responseHeaders = executionItem.WebRequest?.GetResponseHeaders(); + List responseHeadersForReport = new List(); + if(responseHeaders != null && responseHeaders.Count > 0) + { + foreach (var responseHeader in responseHeaders) + { + responseHeadersForReport.Add($"{responseHeader.Key} : {responseHeader.Value}"); + } + if(responseHeaders.TryGetValue("Date", out var serverDate)) + { + if(DateTime.TryParse(serverDate, out var parsedServerDate)) + { + FailedRequestReport.ServerTimestamp = parsedServerDate.ToString("o"); // ISO 8601 format + } + } + } + FailedRequestReport.ResponseHeaders = responseHeadersForReport.ToArray(); + + + DateTime lowestTimeStamp = DateTime.MaxValue; + int lowestTimeStampIndex = -1; + if (FailedRequestHistory == null) + { + return; + } + for (int i = 0; i < FailedRequestHistory.Count; i++) + { + if (FailedRequestHistory[i] == null) + { + FailedRequestHistory[i] = FailedRequestReport; + return; + } + var dt = DateTime.Parse(FailedRequestHistory[i].ClientTimestamp); + if (dt < lowestTimeStamp) + { + lowestTimeStamp = dt; + lowestTimeStampIndex = i; + } + } + if(lowestTimeStampIndex >= 0 && lowestTimeStampIndex < FailedRequestHistory.Count) + { + FailedRequestHistory[lowestTimeStampIndex] = FailedRequestReport; + } + } + #endregion + #region Session Refresh Helper Methods private static bool ShouldRetryRequest(long statusCode, int timesRetried) @@ -986,7 +1273,7 @@ private bool WebRequestSucceeded(UnityWebRequest webRequest) private LootLockerResponse ExtractFailureResponseFromExecutionItem(LootLockerHTTPExecutionQueueItem executionItem) { - LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid); + LootLockerResponse response = LootLockerResponseFactory.Failure((int)executionItem.WebRequest.responseCode, executionItem.WebRequest.downloadHandler.text, executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId); response.errorData = ExtractErrorData(response); if (response.errorData != null) { @@ -1014,7 +1301,7 @@ private UnityWebRequest CreateWebRequest(LootLockerHTTPRequestData request) case LootLockerHTTPMethod.UPDATE_FILE: if (request.Content.dataType != LootLockerHTTPRequestDataType.FILE) { - request.CallListenersWithResult(LootLockerResponseFactory.ClientError("File request without file content", request.ForPlayerWithUlid, request.RequestStartTime)); + request.CallListenersWithResult(LootLockerResponseFactory.ClientError("File request without file content", request.ForPlayerWithUlid, request.RequestStartTime, request.RequestId)); return null; } webRequest = UnityWebRequest.Post(request.FormattedURL, ((LootLockerFileRequestContent)request.Content).fileForm); @@ -1039,7 +1326,7 @@ private UnityWebRequest CreateWebRequest(LootLockerHTTPRequestData request) } break; default: - request.CallListenersWithResult(LootLockerResponseFactory.ClientError("Unsupported HTTP Method", request.ForPlayerWithUlid, request.RequestStartTime)); + request.CallListenersWithResult(LootLockerResponseFactory.ClientError("Unsupported HTTP Method", request.ForPlayerWithUlid, request.RequestStartTime, request.RequestId)); return webRequest; } diff --git a/Runtime/Client/LootLockerRequestContext.cs b/Runtime/Client/LootLockerRequestContext.cs index 03fdafdc..53aefb47 100644 --- a/Runtime/Client/LootLockerRequestContext.cs +++ b/Runtime/Client/LootLockerRequestContext.cs @@ -11,18 +11,22 @@ public class LootLockerRequestContext public LootLockerRequestContext() { request_time = null; + player_ulid = null; + request_id = null; } public LootLockerRequestContext(string playerUlid) { player_ulid = playerUlid; request_time = null; + request_id = null; } - public LootLockerRequestContext(string playerUlid, DateTime? requestTime) + public LootLockerRequestContext(string playerUlid, DateTime? requestTime, string requestId = null) { player_ulid = playerUlid; request_time = requestTime; + request_id = requestId; } /// @@ -34,5 +38,10 @@ public LootLockerRequestContext(string playerUlid, DateTime? requestTime) /// The time that this request was made /// public DateTime? request_time { get; set; } + + /// + /// The unique identifier for this request, generated by the client + /// + public string request_id { get; set; } } } diff --git a/Runtime/Client/LootLockerResponse.cs b/Runtime/Client/LootLockerResponse.cs index b954d77d..17fe4387 100644 --- a/Runtime/Client/LootLockerResponse.cs +++ b/Runtime/Client/LootLockerResponse.cs @@ -46,6 +46,20 @@ public class LootLockerResponse /// public string EventId { get; set; } = Guid.NewGuid().ToString(); + /// + /// Sends a report about a failed request to be viewable in the LootLocker dashboard. + /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. + /// It will not work for successful requests, unauthorized requests, or requests that were throttled due to too many requests. + /// The request must have failed with a response from the server containing details about the failure in order for this method to work. + /// If the request failed without a response from the server, or if the response from the server could not be deserialized properly, then this method will not be able to send a report. + /// + /// A description provided by the user about the failure. + /// A callback to be invoked when the report has been sent. + public void ReportFailure(string userDescription, Action onComplete) + { + LootLocker.Requests.LootLockerSDKManager.SendLootLockerErrorReport(userDescription, this, onComplete); + } + public static void Deserialize(Action onComplete, LootLockerResponse serverResponse, #if LOOTLOCKER_USE_NEWTONSOFTJSON JsonSerializerSettings options = null @@ -97,7 +111,7 @@ public class LootLockerResponseFactory /// /// Construct a success response /// - public static T Success(int statusCode, string responseBody, string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T Success(int statusCode, string responseBody, string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { return new T() { @@ -105,14 +119,14 @@ public class LootLockerResponseFactory text = responseBody, statusCode = statusCode, errorData = null, - requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime) + requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime, requestId) }; } /// /// Construct a failure response /// - public static T Failure(int statusCode, string responseBody, string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T Failure(int statusCode, string responseBody, string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { return new T() { @@ -120,7 +134,7 @@ public class LootLockerResponseFactory text = responseBody, statusCode = statusCode, errorData = null, - requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime) + requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime, requestId) }; } @@ -142,7 +156,7 @@ public class LootLockerResponseFactory /// /// Construct an error response from a network request to send to the client. /// - public static T NetworkError(string errorMessage, int httpStatusCode, string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T NetworkError(string errorMessage, int httpStatusCode, string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { return new T() { @@ -150,14 +164,14 @@ public class LootLockerResponseFactory text = "{ \"message\": \"" + errorMessage + "\"}", statusCode = httpStatusCode, errorData = new LootLockerErrorData(httpStatusCode, errorMessage), - requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime) + requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime, requestId) }; } /// /// Construct an error response from a client side error to send to the client. /// - public static T ClientError(string errorMessage, string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T ClientError(string errorMessage, string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { return new T() { @@ -168,48 +182,48 @@ public class LootLockerResponseFactory { message = errorMessage, }, - requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime) + requestContext = new LootLockerRequestContext(forPlayerWithUlid, requestTime, requestId) }; } /// /// Construct an error response for token expiration. /// - public static T TokenExpiredError(string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T TokenExpiredError(string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { - return NetworkError("Token Expired", 401, forPlayerWithUlid, requestTime); + return NetworkError("Token Expired", 401, forPlayerWithUlid, requestTime, requestId); } /// /// Construct an error response for the request being timed out client side /// - public static T RequestTimeOut(string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T RequestTimeOut(string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { - return NetworkError("The request has timed out", 408, forPlayerWithUlid, requestTime); + return NetworkError("The request has timed out", 408, forPlayerWithUlid, requestTime, requestId); } /// /// Construct an error response specifically when the SDK has not been initialized. /// - public static T SDKNotInitializedError(string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T SDKNotInitializedError(string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { - return ClientError("The LootLocker SDK has not been initialized, please start a session to call this method", forPlayerWithUlid, requestTime); + return ClientError("The LootLocker SDK has not been initialized, please start a session to call this method", forPlayerWithUlid, requestTime, requestId); } /// /// Construct an error response because an unserializable input has been given /// - public static T InputUnserializableError(string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T InputUnserializableError(string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { - return ClientError("Method parameter could not be serialized", forPlayerWithUlid, requestTime); + return ClientError("Method parameter could not be serialized", forPlayerWithUlid, requestTime, requestId); } /// /// Construct an error response because the rate limit has been hit /// - public static T RateLimitExceeded(string method, int secondsLeftOfRateLimit, string forPlayerWithUlid, DateTime? requestTime = null) where T : LootLockerResponse, new() + public static T RateLimitExceeded(string method, int secondsLeftOfRateLimit, string forPlayerWithUlid, DateTime? requestTime = null, string requestId = null) where T : LootLockerResponse, new() { - var error = ClientError($"Your request to {method} was not sent. You are sending too many requests and are being rate limited for {secondsLeftOfRateLimit} seconds", forPlayerWithUlid, requestTime); + var error = ClientError($"Your request to {method} was not sent. You are sending too many requests and are being rate limited for {secondsLeftOfRateLimit} seconds", forPlayerWithUlid, requestTime, requestId); error.errorData.retry_after_seconds = secondsLeftOfRateLimit; return error; } diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index e4a6e3ec..be03fd57 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -8250,6 +8250,98 @@ public static void SendUGCFeedback(string ulid, string description, string categ SendFeedback(LootLockerFeedbackTypes.ugc, ulid, description, category_id, onComplete, forPlayerWithUlid); } + /// + /// Sends a report about a failed request to be viewable in the LootLocker dashboard. + /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. + /// It will not work for successful requests, unauthorized requests, or requests that were throttled due to too many requests. + /// The request must have failed with a response from the server containing details about the failure in order for this method to work. + /// If the request failed without a response from the server, or if the response from the server could not be deserialized properly, then this method will not be able to send a report. + /// + /// A description from the developer or player about the circumstances of the failure. + /// The response from the failed request that you want to send a report about + /// onComplete Action for handling the response + public static void SendLootLockerErrorReport(string userDescription, LootLockerResponse failedResponse, Action onComplete) + { + if(failedResponse == null) + { + onComplete?.Invoke(LootLockerResponseFactory.InputUnserializableError(null)); + return; + } + if(failedResponse.success) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Cannot send error report for a successful response", failedResponse?.requestContext?.player_ulid)); + return; + } + if (failedResponse.errorData == null) + { + onComplete?.Invoke(LootLockerResponseFactory.InputUnserializableError(failedResponse?.requestContext?.player_ulid)); + return; + } + if (failedResponse.requestContext == null) + { + onComplete?.Invoke(LootLockerResponseFactory.InputUnserializableError(null)); + return; + } + if (failedResponse.requestContext.request_id == null) + { + onComplete?.Invoke(LootLockerResponseFactory.InputUnserializableError(failedResponse?.requestContext?.player_ulid)); + return; + } + if (!CheckInitialized(false, failedResponse.requestContext.player_ulid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(failedResponse?.requestContext?.player_ulid)); + return; + } + if(failedResponse.statusCode == 401) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Unauthorized request - cannot send error report for an unauthorized request. Session token is invalid", failedResponse?.requestContext?.player_ulid)); + return; + } + if(failedResponse.errorData.retry_after_seconds > 0) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError($"Too many requests - cannot send error report for a request that was throttled. Retry after {failedResponse.errorData.retry_after_seconds} seconds", failedResponse?.requestContext?.player_ulid)); + return; + } + var httpClient =LootLockerHTTPClient.Get(); + if(httpClient == null) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Failed to send error report because HTTP client was not available", failedResponse?.requestContext?.player_ulid)); + return; + } + if(!httpClient.IsFailureReportingEnabled()) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Failed to send error report because failure reporting is not enabled", failedResponse?.requestContext?.player_ulid)); + return; + } + + if(!httpClient.TryGetFailedRequestReportForRequestId(failedResponse.requestContext.request_id, out var report)) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError($"Failed to send error report because the details of the failed request could not be retrieved. Attempted to retrieve report for request id {failedResponse.requestContext.request_id} but was unsuccessful", failedResponse?.requestContext?.player_ulid)); + return; + } + + report.UserDescription = userDescription; + string reportAsJson = LootLockerJson.SerializeObject(report); + reportAsJson = LootLockerObfuscator.ObfuscateJsonStringForLogging(reportAsJson); + reportAsJson = LootLockerJson.PrettifyJsonString(reportAsJson); + + string failureFeedbackCategoryId = httpClient.GetFailureFeedbackCategoryId(); + if(failureFeedbackCategoryId.Equals("Not Initialized")) + { + httpClient.RefreshFailureFeedbackCategoryId((bool success) => + { + if(!success) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Failed to send error report because failure feedback category id could not be retrieved from the server", failedResponse?.requestContext?.player_ulid)); + return; + } + SendGameFeedback(reportAsJson, httpClient.GetFailureFeedbackCategoryId(), onComplete, failedResponse.requestContext.player_ulid); + }); + return; + } + SendGameFeedback(reportAsJson, httpClient.GetFailureFeedbackCategoryId(), onComplete, failedResponse.requestContext.player_ulid); + } + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. private static void SendFeedback(LootLockerFeedbackTypes type, string ulid, string description, string category_id, Action onComplete, string forPlayerWithUlid = null) {