GeoJam2021/Assets/UserReporting/Scripts/Client/UserReportingClient.cs
2021-07-23 09:45:12 +02:00

665 lines
26 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
namespace Unity.Cloud.UserReporting.Client
{
/// <summary>
/// Represents a user reporting client.
/// </summary>
public class UserReportingClient
{
#region Constructors
/// <summary>
/// Creates a new instance of the <see cref="UserReportingClient"/> class.
/// </summary>
/// <param name="endpoint">The endpoint.</param>
/// <param name="projectIdentifier">The project identifier.</param>
/// <param name="platform">The platform.</param>
/// <param name="configuration">The configuration.</param>
public UserReportingClient(string endpoint, string projectIdentifier, IUserReportingPlatform platform, UserReportingClientConfiguration configuration)
{
// Arguments
this.Endpoint = endpoint;
this.ProjectIdentifier = projectIdentifier;
this.Platform = platform;
this.Configuration = configuration;
// Configuration Clean Up
this.Configuration.FramesPerMeasure = this.Configuration.FramesPerMeasure > 0 ? this.Configuration.FramesPerMeasure : 1;
this.Configuration.MaximumEventCount = this.Configuration.MaximumEventCount > 0 ? this.Configuration.MaximumEventCount : 1;
this.Configuration.MaximumMeasureCount = this.Configuration.MaximumMeasureCount > 0 ? this.Configuration.MaximumMeasureCount : 1;
this.Configuration.MaximumScreenshotCount = this.Configuration.MaximumScreenshotCount > 0 ? this.Configuration.MaximumScreenshotCount : 1;
// Lists
this.clientMetrics = new Dictionary<string, UserReportMetric>();
this.currentMeasureMetadata = new Dictionary<string, string>();
this.currentMetrics = new Dictionary<string, UserReportMetric>();
this.events = new CyclicalList<UserReportEvent>(configuration.MaximumEventCount);
this.measures = new CyclicalList<UserReportMeasure>(configuration.MaximumMeasureCount);
this.screenshots = new CyclicalList<UserReportScreenshot>(configuration.MaximumScreenshotCount);
// Device Metadata
this.deviceMetadata = new List<UserReportNamedValue>();
foreach (KeyValuePair<string, string> kvp in this.Platform.GetDeviceMetadata())
{
this.AddDeviceMetadata(kvp.Key, kvp.Value);
}
// Client Version
this.AddDeviceMetadata("UserReportingClientVersion", "2.0");
// Synchronized Action
this.synchronizedActions = new List<Action>();
this.currentSynchronizedActions = new List<Action>();
// Update Stopwatch
this.updateStopwatch = new Stopwatch();
// Is Connected to Logger
this.IsConnectedToLogger = true;
}
#endregion
#region Fields
private Dictionary<string, UserReportMetric> clientMetrics;
private Dictionary<string, string> currentMeasureMetadata;
private Dictionary<string, UserReportMetric> currentMetrics;
private List<Action> currentSynchronizedActions;
private List<UserReportNamedValue> deviceMetadata;
private CyclicalList<UserReportEvent> events;
private int frameNumber;
private bool isMeasureBoundary;
private int measureFrames;
private CyclicalList<UserReportMeasure> measures;
private CyclicalList<UserReportScreenshot> screenshots;
private int screenshotsSaved;
private int screenshotsTaken;
private List<Action> synchronizedActions;
private Stopwatch updateStopwatch;
#endregion
#region Properties
/// <summary>
/// Gets the configuration.
/// </summary>
public UserReportingClientConfiguration Configuration { get; private set; }
/// <summary>
/// Gets the endpoint.
/// </summary>
public string Endpoint { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether the client is connected to the logger. If true, log messages will be included in user reports.
/// </summary>
public bool IsConnectedToLogger { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the client is self reporting. If true, event and metrics about the client will be included in user reports.
/// </summary>
public bool IsSelfReporting { get; set; }
/// <summary>
/// Gets the platform.
/// </summary>
public IUserReportingPlatform Platform { get; private set; }
/// <summary>
/// Gets the project identifier.
/// </summary>
public string ProjectIdentifier { get; private set; }
/// <summary>
/// Gets or sets a value indicating whether user reporting events should be sent to analytics.
/// </summary>
public bool SendEventsToAnalytics { get; set; }
#endregion
#region Methods
/// <summary>
/// Adds device metadata.
/// </summary>
/// <param name="name">The name.</param>
/// <param name="value">The value.</param>
public void AddDeviceMetadata(string name, string value)
{
lock (this.deviceMetadata)
{
UserReportNamedValue userReportNamedValue = new UserReportNamedValue();
userReportNamedValue.Name = name;
userReportNamedValue.Value = value;
this.deviceMetadata.Add(userReportNamedValue);
}
}
/// <summary>
/// Adds measure metadata. Measure metadata is associated with a period of time.
/// </summary>
/// <param name="name">The name.</param>
/// <param name="value">The value.</param>
public void AddMeasureMetadata(string name, string value)
{
if (this.currentMeasureMetadata.ContainsKey(name))
{
this.currentMeasureMetadata[name] = value;
}
else
{
this.currentMeasureMetadata.Add(name, value);
}
}
/// <summary>
/// Adds a synchronized action.
/// </summary>
/// <param name="action">The action.</param>
private void AddSynchronizedAction(Action action)
{
if (action == null)
{
throw new ArgumentNullException("action");
}
lock (this.synchronizedActions)
{
this.synchronizedActions.Add(action);
}
}
/// <summary>
/// Clears the screenshots.
/// </summary>
public void ClearScreenshots()
{
lock (this.screenshots)
{
this.screenshots.Clear();
}
}
/// <summary>
/// Creates a user report.
/// </summary>
/// <param name="callback">The callback. Provides the user report that was created.</param>
public void CreateUserReport(Action<UserReport> callback)
{
this.LogEvent(UserReportEventLevel.Info, "Creating user report.");
this.WaitForPerforation(this.screenshotsTaken, () =>
{
this.Platform.RunTask(() =>
{
// Start Stopwatch
Stopwatch stopwatch = Stopwatch.StartNew();
// Copy Data
UserReport userReport = new UserReport();
userReport.ProjectIdentifier = this.ProjectIdentifier;
// Device Metadata
lock (this.deviceMetadata)
{
userReport.DeviceMetadata = this.deviceMetadata.ToList();
}
// Events
lock (this.events)
{
userReport.Events = this.events.ToList();
}
// Measures
lock (this.measures)
{
userReport.Measures = this.measures.ToList();
}
// Screenshots
lock (this.screenshots)
{
userReport.Screenshots = this.screenshots.ToList();
}
// Complete
userReport.Complete();
// Modify
this.Platform.ModifyUserReport(userReport);
// Stop Stopwatch
stopwatch.Stop();
// Sample Client Metric
this.SampleClientMetric("UserReportingClient.CreateUserReport.Task", stopwatch.ElapsedMilliseconds);
// Copy Client Metrics
foreach (KeyValuePair<string, UserReportMetric> kvp in this.clientMetrics)
{
userReport.ClientMetrics.Add(kvp.Value);
}
// Return
return userReport;
}, (result) => { callback(result as UserReport); });
});
}
/// <summary>
/// Gets the endpoint.
/// </summary>
/// <returns>The endpoint.</returns>
public string GetEndpoint()
{
if (this.Endpoint == null)
{
return "https://localhost";
}
return this.Endpoint.Trim();
}
/// <summary>
/// Logs an event.
/// </summary>
/// <param name="level">The level.</param>
/// <param name="message">The message.</param>
public void LogEvent(UserReportEventLevel level, string message)
{
this.LogEvent(level, message, null, null);
}
/// <summary>
/// Logs an event.
/// </summary>
/// <param name="level">The level.</param>
/// <param name="message">The message.</param>
/// <param name="stackTrace">The stack trace.</param>
public void LogEvent(UserReportEventLevel level, string message, string stackTrace)
{
this.LogEvent(level, message, stackTrace, null);
}
/// <summary>
/// Logs an event with a stack trace and exception.
/// </summary>
/// <param name="level">The level.</param>
/// <param name="message">The message.</param>
/// <param name="stackTrace">The stack trace.</param>
/// <param name="exception">The exception.</param>
private void LogEvent(UserReportEventLevel level, string message, string stackTrace, Exception exception)
{
lock (this.events)
{
UserReportEvent userReportEvent = new UserReportEvent();
userReportEvent.Level = level;
userReportEvent.Message = message;
userReportEvent.FrameNumber = this.frameNumber;
userReportEvent.StackTrace = stackTrace;
userReportEvent.Timestamp = DateTime.UtcNow;
if (exception != null)
{
userReportEvent.Exception = new SerializableException(exception);
}
this.events.Add(userReportEvent);
}
}
/// <summary>
/// Logs an exception.
/// </summary>
/// <param name="exception">The exception.</param>
public void LogException(Exception exception)
{
this.LogEvent(UserReportEventLevel.Error, null, null, exception);
}
/// <summary>
/// Samples a client metric. These metrics are only sample when self reporting is enabled.
/// </summary>
/// <param name="name">The name.</param>
/// <param name="value">The value.</param>
public void SampleClientMetric(string name, double value)
{
if (double.IsInfinity(value) || double.IsNaN(value))
{
return;
}
if (!this.clientMetrics.ContainsKey(name))
{
UserReportMetric newUserReportMetric = new UserReportMetric();
newUserReportMetric.Name = name;
this.clientMetrics.Add(name, newUserReportMetric);
}
UserReportMetric userReportMetric = this.clientMetrics[name];
userReportMetric.Sample(value);
this.clientMetrics[name] = userReportMetric;
// Self Reporting
if (this.IsSelfReporting)
{
this.SampleMetric(name, value);
}
}
/// <summary>
/// Samples a metric. Metrics can be sampled frequently and have low overhead.
/// </summary>
/// <param name="name">The name.</param>
/// <param name="value">The value.</param>
public void SampleMetric(string name, double value)
{
if (this.Configuration.MetricsGatheringMode == MetricsGatheringMode.Disabled)
{
return;
}
if (double.IsInfinity(value) || double.IsNaN(value))
{
return;
}
if (!this.currentMetrics.ContainsKey(name))
{
UserReportMetric newUserReportMetric = new UserReportMetric();
newUserReportMetric.Name = name;
this.currentMetrics.Add(name, newUserReportMetric);
}
UserReportMetric userReportMetric = this.currentMetrics[name];
userReportMetric.Sample(value);
this.currentMetrics[name] = userReportMetric;
}
/// <summary>
/// Saves a user report to disk.
/// </summary>
/// <param name="userReport">The user report.</param>
public void SaveUserReportToDisk(UserReport userReport)
{
this.LogEvent(UserReportEventLevel.Info, "Saving user report to disk.");
string json = this.Platform.SerializeJson(userReport);
File.WriteAllText("UserReport.json", json);
}
/// <summary>
/// Sends a user report to the server.
/// </summary>
/// <param name="userReport">The user report.</param>
/// <param name="callback">The callback. Provides a value indicating whether sending the user report was successful and provides the user report after it is modified by the server.</param>
public void SendUserReport(UserReport userReport, Action<bool, UserReport> callback)
{
this.SendUserReport(userReport, null, callback);
}
/// <summary>
/// Sends a user report to the server.
/// </summary>
/// <param name="userReport">The user report.</param>
/// <param name="progressCallback">The progress callback. Provides the upload and download progress.</param>
/// <param name="callback">The callback. Provides a value indicating whether sending the user report was successful and provides the user report after it is modified by the server.</param>
public void SendUserReport(UserReport userReport, Action<float, float> progressCallback, Action<bool, UserReport> callback)
{
try
{
if (userReport == null)
{
return;
}
if (userReport.Identifier != null)
{
this.LogEvent(UserReportEventLevel.Warning, "Identifier cannot be set on the client side. The value provided was discarded.");
return;
}
if (userReport.ContentLength != 0)
{
this.LogEvent(UserReportEventLevel.Warning, "ContentLength cannot be set on the client side. The value provided was discarded.");
return;
}
if (userReport.ReceivedOn != default(DateTime))
{
this.LogEvent(UserReportEventLevel.Warning, "ReceivedOn cannot be set on the client side. The value provided was discarded.");
return;
}
if (userReport.ExpiresOn != default(DateTime))
{
this.LogEvent(UserReportEventLevel.Warning, "ExpiresOn cannot be set on the client side. The value provided was discarded.");
return;
}
this.LogEvent(UserReportEventLevel.Info, "Sending user report.");
string json = this.Platform.SerializeJson(userReport);
byte[] jsonData = Encoding.UTF8.GetBytes(json);
string endpoint = this.GetEndpoint();
string url = string.Format(string.Format("{0}/api/userreporting", endpoint));
this.Platform.Post(url, "application/json", jsonData, (uploadProgress, downloadProgress) =>
{
if (progressCallback != null)
{
progressCallback(uploadProgress, downloadProgress);
}
}, (success, result) =>
{
this.AddSynchronizedAction(() =>
{
if (success)
{
try
{
string jsonResult = Encoding.UTF8.GetString(result);
UserReport userReportResult = this.Platform.DeserializeJson<UserReport>(jsonResult);
if (userReportResult != null)
{
if (this.SendEventsToAnalytics)
{
Dictionary<string, object> eventData = new Dictionary<string, object>();
eventData.Add("UserReportIdentifier", userReport.Identifier);
this.Platform.SendAnalyticsEvent("UserReportingClient.SendUserReport", eventData);
}
callback(success, userReportResult);
}
else
{
callback(false, null);
}
}
catch (Exception ex)
{
this.LogEvent(UserReportEventLevel.Error, string.Format("Sending user report failed: {0}", ex.ToString()));
callback(false, null);
}
}
else
{
this.LogEvent(UserReportEventLevel.Error, "Sending user report failed.");
callback(false, null);
}
});
});
}
catch (Exception ex)
{
this.LogEvent(UserReportEventLevel.Error, string.Format("Sending user report failed: {0}", ex.ToString()));
callback(false, null);
}
}
/// <summary>
/// Takes a screenshot.
/// </summary>
/// <param name="maximumWidth">The maximum width.</param>
/// <param name="maximumHeight">The maximum height.</param>
/// <param name="callback">The callback. Provides the screenshot.</param>
public void TakeScreenshot(int maximumWidth, int maximumHeight, Action<UserReportScreenshot> callback)
{
this.TakeScreenshotFromSource(maximumWidth, maximumHeight, null, callback);
}
/// <summary>
/// Takes a screenshot.
/// </summary>
/// <param name="maximumWidth">The maximum width.</param>
/// <param name="maximumHeight">The maximum height.</param>
/// <param name="source">The source. Passing null will capture the screen. Passing a camera will capture the camera's view. Passing a render texture will capture the render texture.</param>
/// <param name="callback">The callback. Provides the screenshot.</param>
public void TakeScreenshotFromSource(int maximumWidth, int maximumHeight, object source, Action<UserReportScreenshot> callback)
{
this.LogEvent(UserReportEventLevel.Info, "Taking screenshot.");
this.screenshotsTaken++;
this.Platform.TakeScreenshot(this.frameNumber, maximumWidth, maximumHeight, source, (passedFrameNumber, data) =>
{
this.AddSynchronizedAction(() =>
{
lock (this.screenshots)
{
UserReportScreenshot userReportScreenshot = new UserReportScreenshot();
userReportScreenshot.FrameNumber = passedFrameNumber;
userReportScreenshot.DataBase64 = Convert.ToBase64String(data);
this.screenshots.Add(userReportScreenshot);
this.screenshotsSaved++;
callback(userReportScreenshot);
}
});
});
}
/// <summary>
/// Updates the user reporting client, which updates networking communication, screenshotting, and metrics gathering.
/// </summary>
public void Update()
{
// Stopwatch
this.updateStopwatch.Reset();
this.updateStopwatch.Start();
// Update Platform
this.Platform.Update(this);
// Measures
if (this.Configuration.MetricsGatheringMode != MetricsGatheringMode.Disabled)
{
this.isMeasureBoundary = false;
int framesPerMeasure = this.Configuration.FramesPerMeasure;
if (this.measureFrames >= framesPerMeasure)
{
lock (this.measures)
{
UserReportMeasure userReportMeasure = new UserReportMeasure();
userReportMeasure.StartFrameNumber = this.frameNumber - framesPerMeasure;
userReportMeasure.EndFrameNumber = this.frameNumber - 1;
UserReportMeasure evictedUserReportMeasure = this.measures.GetNextEviction();
if (evictedUserReportMeasure.Metrics != null)
{
userReportMeasure.Metadata = evictedUserReportMeasure.Metadata;
userReportMeasure.Metrics = evictedUserReportMeasure.Metrics;
}
else
{
userReportMeasure.Metadata = new List<UserReportNamedValue>();
userReportMeasure.Metrics = new List<UserReportMetric>();
}
userReportMeasure.Metadata.Clear();
userReportMeasure.Metrics.Clear();
foreach (KeyValuePair<string, string> kvp in this.currentMeasureMetadata)
{
UserReportNamedValue userReportNamedValue = new UserReportNamedValue();
userReportNamedValue.Name = kvp.Key;
userReportNamedValue.Value = kvp.Value;
userReportMeasure.Metadata.Add(userReportNamedValue);
}
foreach (KeyValuePair<string, UserReportMetric> kvp in this.currentMetrics)
{
userReportMeasure.Metrics.Add(kvp.Value);
}
this.currentMetrics.Clear();
this.measures.Add(userReportMeasure);
this.measureFrames = 0;
this.isMeasureBoundary = true;
}
}
this.measureFrames++;
}
else
{
this.isMeasureBoundary = true;
}
// Synchronization
lock (this.synchronizedActions)
{
foreach (Action synchronizedAction in this.synchronizedActions)
{
this.currentSynchronizedActions.Add(synchronizedAction);
}
this.synchronizedActions.Clear();
}
// Perform Synchronized Actions
foreach (Action synchronizedAction in this.currentSynchronizedActions)
{
synchronizedAction();
}
this.currentSynchronizedActions.Clear();
// Frame Number
this.frameNumber++;
// Stopwatch
this.updateStopwatch.Stop();
this.SampleClientMetric("UserReportingClient.Update", this.updateStopwatch.ElapsedMilliseconds);
}
/// <summary>
/// Updates the user reporting client at the end of the frame, which updates networking communication, screenshotting, and metrics gathering.
/// </summary>
public void UpdateOnEndOfFrame()
{
// Stopwatch
this.updateStopwatch.Reset();
this.updateStopwatch.Start();
// Update Platform
this.Platform.OnEndOfFrame(this);
// Stopwatch
this.updateStopwatch.Stop();
this.SampleClientMetric("UserReportingClient.UpdateOnEndOfFrame", this.updateStopwatch.ElapsedMilliseconds);
}
/// <summary>
/// Waits for perforation, a boundary between measures when no screenshots are in progress.
/// </summary>
/// <param name="currentScreenshotsTaken">The current screenshots taken.</param>
/// <param name="callback">The callback.</param>
private void WaitForPerforation(int currentScreenshotsTaken, Action callback)
{
if (this.screenshotsSaved >= currentScreenshotsTaken && this.isMeasureBoundary)
{
callback();
}
else
{
this.AddSynchronizedAction(() => { this.WaitForPerforation(currentScreenshotsTaken, callback); });
}
}
#endregion
}
}