using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Text; namespace Unity.Cloud.UserReporting.Client { /// /// Represents a user reporting client. /// public class UserReportingClient { #region Constructors /// /// Creates a new instance of the class. /// /// The endpoint. /// The project identifier. /// The platform. /// The configuration. 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(); this.currentMeasureMetadata = new Dictionary(); this.currentMetrics = new Dictionary(); this.events = new CyclicalList(configuration.MaximumEventCount); this.measures = new CyclicalList(configuration.MaximumMeasureCount); this.screenshots = new CyclicalList(configuration.MaximumScreenshotCount); // Device Metadata this.deviceMetadata = new List(); foreach (KeyValuePair kvp in this.Platform.GetDeviceMetadata()) { this.AddDeviceMetadata(kvp.Key, kvp.Value); } // Client Version this.AddDeviceMetadata("UserReportingClientVersion", "2.0"); // Synchronized Action this.synchronizedActions = new List(); this.currentSynchronizedActions = new List(); // Update Stopwatch this.updateStopwatch = new Stopwatch(); // Is Connected to Logger this.IsConnectedToLogger = true; } #endregion #region Fields private Dictionary clientMetrics; private Dictionary currentMeasureMetadata; private Dictionary currentMetrics; private List currentSynchronizedActions; private List deviceMetadata; private CyclicalList events; private int frameNumber; private bool isMeasureBoundary; private int measureFrames; private CyclicalList measures; private CyclicalList screenshots; private int screenshotsSaved; private int screenshotsTaken; private List synchronizedActions; private Stopwatch updateStopwatch; #endregion #region Properties /// /// Gets the configuration. /// public UserReportingClientConfiguration Configuration { get; private set; } /// /// Gets the endpoint. /// public string Endpoint { get; private set; } /// /// Gets or sets a value indicating whether the client is connected to the logger. If true, log messages will be included in user reports. /// public bool IsConnectedToLogger { get; set; } /// /// 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. /// public bool IsSelfReporting { get; set; } /// /// Gets the platform. /// public IUserReportingPlatform Platform { get; private set; } /// /// Gets the project identifier. /// public string ProjectIdentifier { get; private set; } /// /// Gets or sets a value indicating whether user reporting events should be sent to analytics. /// public bool SendEventsToAnalytics { get; set; } #endregion #region Methods /// /// Adds device metadata. /// /// The name. /// The value. public void AddDeviceMetadata(string name, string value) { lock (this.deviceMetadata) { UserReportNamedValue userReportNamedValue = new UserReportNamedValue(); userReportNamedValue.Name = name; userReportNamedValue.Value = value; this.deviceMetadata.Add(userReportNamedValue); } } /// /// Adds measure metadata. Measure metadata is associated with a period of time. /// /// The name. /// The value. public void AddMeasureMetadata(string name, string value) { if (this.currentMeasureMetadata.ContainsKey(name)) { this.currentMeasureMetadata[name] = value; } else { this.currentMeasureMetadata.Add(name, value); } } /// /// Adds a synchronized action. /// /// The action. private void AddSynchronizedAction(Action action) { if (action == null) { throw new ArgumentNullException("action"); } lock (this.synchronizedActions) { this.synchronizedActions.Add(action); } } /// /// Clears the screenshots. /// public void ClearScreenshots() { lock (this.screenshots) { this.screenshots.Clear(); } } /// /// Creates a user report. /// /// The callback. Provides the user report that was created. public void CreateUserReport(Action 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 kvp in this.clientMetrics) { userReport.ClientMetrics.Add(kvp.Value); } // Return return userReport; }, (result) => { callback(result as UserReport); }); }); } /// /// Gets the endpoint. /// /// The endpoint. public string GetEndpoint() { if (this.Endpoint == null) { return "https://localhost"; } return this.Endpoint.Trim(); } /// /// Logs an event. /// /// The level. /// The message. public void LogEvent(UserReportEventLevel level, string message) { this.LogEvent(level, message, null, null); } /// /// Logs an event. /// /// The level. /// The message. /// The stack trace. public void LogEvent(UserReportEventLevel level, string message, string stackTrace) { this.LogEvent(level, message, stackTrace, null); } /// /// Logs an event with a stack trace and exception. /// /// The level. /// The message. /// The stack trace. /// The exception. 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); } } /// /// Logs an exception. /// /// The exception. public void LogException(Exception exception) { this.LogEvent(UserReportEventLevel.Error, null, null, exception); } /// /// Samples a client metric. These metrics are only sample when self reporting is enabled. /// /// The name. /// The value. 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); } } /// /// Samples a metric. Metrics can be sampled frequently and have low overhead. /// /// The name. /// The value. 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; } /// /// Saves a user report to disk. /// /// The user report. 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); } /// /// Sends a user report to the server. /// /// The user report. /// 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. public void SendUserReport(UserReport userReport, Action callback) { this.SendUserReport(userReport, null, callback); } /// /// Sends a user report to the server. /// /// The user report. /// The progress callback. Provides the upload and download progress. /// 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. public void SendUserReport(UserReport userReport, Action progressCallback, Action 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(jsonResult); if (userReportResult != null) { if (this.SendEventsToAnalytics) { Dictionary eventData = new Dictionary(); 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); } } /// /// Takes a screenshot. /// /// The maximum width. /// The maximum height. /// The callback. Provides the screenshot. public void TakeScreenshot(int maximumWidth, int maximumHeight, Action callback) { this.TakeScreenshotFromSource(maximumWidth, maximumHeight, null, callback); } /// /// Takes a screenshot. /// /// The maximum width. /// The maximum height. /// 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. /// The callback. Provides the screenshot. public void TakeScreenshotFromSource(int maximumWidth, int maximumHeight, object source, Action 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); } }); }); } /// /// Updates the user reporting client, which updates networking communication, screenshotting, and metrics gathering. /// 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(); userReportMeasure.Metrics = new List(); } userReportMeasure.Metadata.Clear(); userReportMeasure.Metrics.Clear(); foreach (KeyValuePair kvp in this.currentMeasureMetadata) { UserReportNamedValue userReportNamedValue = new UserReportNamedValue(); userReportNamedValue.Name = kvp.Key; userReportNamedValue.Value = kvp.Value; userReportMeasure.Metadata.Add(userReportNamedValue); } foreach (KeyValuePair 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); } /// /// Updates the user reporting client at the end of the frame, which updates networking communication, screenshotting, and metrics gathering. /// 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); } /// /// Waits for perforation, a boundary between measures when no screenshots are in progress. /// /// The current screenshots taken. /// The callback. private void WaitForPerforation(int currentScreenshotsTaken, Action callback) { if (this.screenshotsSaved >= currentScreenshotsTaken && this.isMeasureBoundary) { callback(); } else { this.AddSynchronizedAction(() => { this.WaitForPerforation(currentScreenshotsTaken, callback); }); } } #endregion } }