//-----------------------------------------------------------------------
//
// Copyright (c) Outercurve Foundation. All rights reserved.
//
//-----------------------------------------------------------------------
namespace DotNetOpenAuth {
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.IO.IsolatedStorage;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Security;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using DotNetOpenAuth.Configuration;
using DotNetOpenAuth.Logging;
using DotNetOpenAuth.Messaging;
using DotNetOpenAuth.Messaging.Bindings;
using Validation;
///
/// The statistical reporting mechanism used so this library's project authors
/// know what versions and features are in use.
///
[SuppressMessage("Microsoft.Design", "CA1053:StaticHolderTypesShouldNotHaveConstructors", Justification = "This class is derived from, so it can't be static.")]
public class Reporting {
///
/// A UTF8 encoder that doesn't emit the preamble. Used for mid-stream writers.
///
private static readonly Encoding Utf8NoPreamble = new UTF8Encoding(false);
///
/// A value indicating whether reporting is desirable or not. Must be logical-AND'd with !.
///
private static bool enabled;
///
/// A value indicating whether reporting experienced an error and cannot be enabled.
///
private static bool broken;
///
/// A value indicating whether the reporting class has been initialized or not.
///
private static bool initialized;
///
/// The object to lock during initialization.
///
private static object initializationSync = new object();
///
/// The isolated storage to use for collecting data in between published reports.
///
private static IsolatedStorageFile file;
///
/// The GUID that shows up at the top of all reports from this user/machine/domain.
///
private static Guid reportOriginIdentity;
///
/// The recipient of collected reports.
///
private static Uri wellKnownPostLocation = new Uri("https://reports.dotnetopenauth.net/ReportingPost.ashx");
///
/// A few HTTP request hosts and paths we've seen.
///
private static PersistentHashSet observedRequests;
///
/// Cultures that have come in via HTTP requests.
///
private static PersistentHashSet observedCultures;
///
/// Features that have been used.
///
private static PersistentHashSet observedFeatures;
///
/// A collection of all the observations to include in the report.
///
private static List observations = new List();
///
/// The named events that we have counters for.
///
private static Dictionary events = new Dictionary(StringComparer.OrdinalIgnoreCase);
///
/// The lock acquired while considering whether to publish a report.
///
private static object publishingConsiderationLock = new object();
///
/// The time that we last published reports.
///
private static DateTime lastPublished = DateTime.Now;
///
/// Initializes static members of the class.
///
[SuppressMessage("Microsoft.Performance", "CA1810:InitializeReferenceTypeStaticFieldsInline", Justification = "We do more than field initialization here.")]
[SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "Reporting MUST NOT cause unhandled exceptions.")]
static Reporting() {
Enabled = DotNetOpenAuthSection.Reporting.Enabled;
}
///
/// Initializes a new instance of the class.
///
protected Reporting() {
}
///
/// Gets or sets a value indicating whether this reporting is enabled.
///
/// true if enabled; otherwise, false.
///
/// Setting this property to true may have no effect
/// if reporting has already experienced a failure of some kind.
///
public static bool Enabled {
get {
return enabled && !broken;
}
set {
if (value) {
Initialize();
}
// Only set the static field here, so that other threads
// don't try to use reporting while we're initializing it.
enabled = value;
}
}
///
/// Gets the observed features.
///
internal static PersistentHashSet ObservedFeatures {
get { return observedFeatures; }
}
///
/// Gets the configuration to use for reporting.
///
internal static ReportingElement Configuration {
get { return DotNetOpenAuthSection.Reporting; }
}
///
/// Records an event occurrence.
///
/// Name of the event.
/// The category within the event. Null and empty strings are allowed, but considered the same.
[SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "PersistentCounter instances are stored in a table for later use.")]
internal static void RecordEventOccurrence(string eventName, string category) {
Requires.NotNullOrEmpty(eventName, "eventName");
// In release builds, just quietly return.
if (string.IsNullOrEmpty(eventName)) {
return;
}
if (Enabled && Configuration.IncludeEventStatistics) {
PersistentCounter counter;
lock (events) {
if (!events.TryGetValue(eventName, out counter)) {
events[eventName] = counter = new PersistentCounter(file, "event-" + SanitizeFileName(eventName) + ".txt");
}
}
counter.Increment(category);
Touch();
}
}
///
/// Records an event occurence.
///
/// The object whose type name is the event name to record.
/// The category within the event. Null and empty strings are allowed, but considered the same.
internal static void RecordEventOccurrence(object eventNameByObjectType, string category) {
Requires.NotNull(eventNameByObjectType, "eventNameByObjectType");
// In release builds, just quietly return.
if (eventNameByObjectType == null) {
return;
}
if (Enabled && Configuration.IncludeEventStatistics) {
RecordEventOccurrence(eventNameByObjectType.GetType().Name, category);
}
}
///
/// Records the use of a feature by name.
///
/// The feature.
internal static void RecordFeatureUse(string feature) {
Requires.NotNullOrEmpty(feature, "feature");
// In release builds, just quietly return.
if (string.IsNullOrEmpty(feature)) {
return;
}
if (Enabled && Configuration.IncludeFeatureUsage) {
observedFeatures.Add(feature);
Touch();
}
}
///
/// Records the use of a feature by object type.
///
/// The object whose type is the feature to set as used.
internal static void RecordFeatureUse(object value) {
Requires.NotNull(value, "value");
// In release builds, just quietly return.
if (value == null) {
return;
}
if (Enabled && Configuration.IncludeFeatureUsage) {
observedFeatures.Add(value.GetType().Name);
Touch();
}
}
///
/// Records the use of a feature by object type.
///
/// The object whose type is the feature to set as used.
/// Some dependency used by .
internal static void RecordFeatureAndDependencyUse(object value, object dependency1) {
Requires.NotNull(value, "value");
// In release builds, just quietly return.
if (value == null) {
return;
}
if (Enabled && Configuration.IncludeFeatureUsage) {
StringBuilder builder = new StringBuilder();
builder.Append(value.GetType().Name);
builder.Append(" ");
builder.Append(dependency1 != null ? dependency1.GetType().Name : "(null)");
observedFeatures.Add(builder.ToString());
Touch();
}
}
///
/// Records the use of a feature by object type.
///
/// The object whose type is the feature to set as used.
/// Some dependency used by .
/// Some dependency used by .
internal static void RecordFeatureAndDependencyUse(object value, object dependency1, object dependency2) {
Requires.NotNull(value, "value");
// In release builds, just quietly return.
if (value == null) {
return;
}
if (Enabled && Configuration.IncludeFeatureUsage) {
StringBuilder builder = new StringBuilder();
builder.Append(value.GetType().Name);
builder.Append(" ");
builder.Append(dependency1 != null ? dependency1.GetType().Name : "(null)");
builder.Append(" ");
builder.Append(dependency2 != null ? dependency2.GetType().Name : "(null)");
observedFeatures.Add(builder.ToString());
Touch();
}
}
///
/// Records statistics collected from incoming requests.
///
/// The request.
internal static void RecordRequestStatistics(HttpRequestBase request) {
Requires.NotNull(request, "request");
// In release builds, just quietly return.
if (request == null) {
return;
}
if (Enabled) {
if (Configuration.IncludeCultures) {
observedCultures.Add(Thread.CurrentThread.CurrentCulture.Name);
}
if (Configuration.IncludeLocalRequestUris && !observedRequests.IsFull) {
var requestBuilder = new UriBuilder(request.GetPublicFacingUrl());
requestBuilder.Query = null;
requestBuilder.Fragment = null;
observedRequests.Add(requestBuilder.Uri.AbsoluteUri);
}
Touch();
}
}
///
/// Called by every internal/public method on this class to give
/// periodic operations a chance to run.
///
protected static void Touch() {
// Publish stats if it's time to do so.
lock (publishingConsiderationLock) {
if (DateTime.Now - lastPublished > Configuration.MinimumReportingInterval) {
lastPublished = DateTime.Now;
var fireAndForget = SendStatsAsync();
}
}
}
///
/// Initializes Reporting if it has not been initialized yet.
///
[SuppressMessage("Microsoft.Design", "CA1031:DoNotCatchGeneralExceptionTypes", Justification = "This method must never throw.")]
private static void Initialize() {
lock (initializationSync) {
if (!broken && !initialized) {
try {
file = GetIsolatedStorage();
reportOriginIdentity = GetOrCreateOriginIdentity();
observations.Add(observedRequests = new PersistentHashSet(file, "requests.txt", 3));
observations.Add(observedCultures = new PersistentHashSet(file, "cultures.txt", 20));
observations.Add(observedFeatures = new PersistentHashSet(file, "features.txt", int.MaxValue));
// Record site-wide features in use.
if (HttpContext.Current != null && HttpContext.Current.ApplicationInstance != null) {
// MVC or web forms?
// front-end or back end web farm?
// url rewriting?
////RecordFeatureUse(IsMVC ? "ASP.NET MVC" : "ASP.NET Web Forms");
}
initialized = true;
} catch (Exception e) {
// This is supposed to be as low-risk as possible, so if it fails, just disable reporting
// and avoid rethrowing.
broken = true;
Logger.Library.ErrorException("Error while trying to initialize reporting.", e);
}
}
}
}
///
/// Creates an HTTP client that can be used for outbound HTTP requests.
///
/// The HTTP client to use.
private static HttpClient CreateHttpClient() {
var channel = new HttpClientHandler();
channel.AllowAutoRedirect = false;
var webRequestHandler = new HttpClient(channel);
webRequestHandler.DefaultRequestHeaders.UserAgent.Add(Util.LibraryVersionHeader);
return webRequestHandler;
}
///
/// Assembles a report for submission.
///
/// A stream that contains the report.
[SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "If we dispose of the StreamWriter, it disposes of the underlying stream.")]
private static Stream GetReport() {
var stream = new MemoryStream();
try {
var writer = new StreamWriter(stream, Encoding.UTF8);
writer.WriteLine(reportOriginIdentity.ToString("B"));
writer.WriteLine(Util.LibraryVersion);
writer.WriteLine(".NET Framework {0}", Environment.Version);
foreach (var observation in observations) {
observation.Flush();
writer.WriteLine("====================================");
writer.WriteLine(observation.FileName);
try {
using (var fileStream = new IsolatedStorageFileStream(observation.FileName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, file)) {
writer.Flush();
fileStream.CopyTo(writer.BaseStream);
}
} catch (FileNotFoundException) {
writer.WriteLine("(missing)");
}
}
// Not all event counters may have even loaded in this app instance.
// We flush the ones in memory, and then read all of them off disk.
foreach (var counter in events.Values) {
counter.Flush();
}
foreach (string eventFile in file.GetFileNames("event-*.txt")) {
writer.WriteLine("====================================");
writer.WriteLine(eventFile);
using (var fileStream = new IsolatedStorageFileStream(eventFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, file)) {
writer.Flush();
fileStream.CopyTo(writer.BaseStream);
}
}
// Make sure the stream is positioned at the beginning.
writer.Flush();
stream.Position = 0;
return stream;
} catch {
stream.Dispose();
throw;
}
}
///
/// Sends the usage reports to the library authors.
///
/// A value indicating whether submitting the report was successful.
private static async Task SendStatsAsync() {
try {
Stream report = GetReport();
var content = new StreamContent(report);
content.Headers.ContentType = new MediaTypeHeaderValue("text/dnoa-report1");
using (var webRequestHandler = CreateHttpClient()) {
using (var response = await webRequestHandler.PostAsync(wellKnownPostLocation, content)) {
Logger.Library.Info("Statistical report submitted successfully.");
// The response stream may contain a message for the webmaster.
// Since as part of the report we submit the library version number,
// the report receiving service may have alerts such as:
// "You're using an obsolete version with exploitable security vulnerabilities."
using (var responseReader = new StreamReader(await response.Content.ReadAsStreamAsync())) {
string line = await responseReader.ReadLineAsync();
if (line != null) {
DemuxLogMessage(line);
}
}
}
}
// Report submission was successful. Reset all counters.
lock (events) {
foreach (PersistentCounter counter in events.Values) {
counter.Reset();
counter.Flush();
}
// We can just delete the files for counters that are not currently loaded.
foreach (string eventFile in file.GetFileNames("event-*.txt")) {
if (!events.Values.Any(e => string.Equals(e.FileName, eventFile, StringComparison.OrdinalIgnoreCase))) {
file.DeleteFile(eventFile);
}
}
}
return true;
} catch (ProtocolException ex) {
Logger.Library.ErrorException("Unable to submit statistical report due to an HTTP error.", ex);
} catch (FileNotFoundException ex) {
Logger.Library.ErrorException("Unable to submit statistical report because the report file is missing.", ex);
}
return false;
}
///
/// Interprets the reporting response as a log message if possible.
///
/// The line from the HTTP response to interpret as a log message.
private static void DemuxLogMessage(string line) {
if (line != null) {
string[] parts = line.Split(new char[] { ' ' }, 2);
if (parts.Length == 2) {
string level = parts[0];
string message = parts[1];
switch (level) {
case "INFO":
Logger.Library.Info(message);
break;
case "WARN":
Logger.Library.Warn(message);
break;
case "ERROR":
Logger.Library.Error(message);
break;
case "FATAL":
Logger.Library.Fatal(message);
break;
}
}
}
}
///
/// Gets the isolated storage to use for reporting.
///
/// An isolated storage location appropriate for our host.
private static IsolatedStorageFile GetIsolatedStorage() {
IsolatedStorageFile result = null;
// We'll try for whatever storage location we can get,
// and not catch exceptions from the last attempt so that
// the overall failure is caught by our caller.
try {
// This works on Personal Web Server
result = IsolatedStorageFile.GetUserStoreForDomain();
} catch (SecurityException) {
} catch (IsolatedStorageException) {
}
// This works on IIS when full trust is granted.
if (result == null) {
result = IsolatedStorageFile.GetMachineStoreForDomain();
}
Logger.Library.InfoFormat("Reporting will use isolated storage with scope: {0}", result.Scope);
return result;
}
///
/// Gets a unique, pseudonymous identifier for this particular web site or application.
///
/// A GUID that will serve as the identifier.
///
/// The identifier is made persistent by storing the identifier in isolated storage.
/// If an existing identifier is not found, a new one is created, persisted, and returned.
///
[SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "No apparent problem. False positive?")]
private static Guid GetOrCreateOriginIdentity() {
RequiresEx.ValidState(file != null, "file not set.");
Guid identityGuid = Guid.Empty;
const int GuidLength = 16;
using (var identityFileStream = new IsolatedStorageFileStream("identity.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, file)) {
if (identityFileStream.Length == GuidLength) {
byte[] guidBytes = new byte[GuidLength];
if (identityFileStream.Read(guidBytes, 0, GuidLength) == GuidLength) {
identityGuid = new Guid(guidBytes);
}
}
if (identityGuid == Guid.Empty) {
identityGuid = Guid.NewGuid();
byte[] guidBytes = identityGuid.ToByteArray();
identityFileStream.SetLength(0);
identityFileStream.Write(guidBytes, 0, guidBytes.Length);
}
return identityGuid;
}
}
///
/// Sanitizes the name of the file so it only includes valid filename characters.
///
/// The filename to sanitize.
/// The filename, with any and all invalid filename characters replaced with the hyphen (-) character.
private static string SanitizeFileName(string fileName) {
Requires.NotNullOrEmpty(fileName, "fileName");
char[] invalidCharacters = Path.GetInvalidFileNameChars();
if (fileName.IndexOfAny(invalidCharacters) < 0) {
return fileName; // nothing invalid about this filename.
}
// Use a stringbuilder since we may be replacing several characters
// and we don't want to instantiate a new string buffer for each new version.
StringBuilder sanitized = new StringBuilder(fileName);
foreach (char invalidChar in invalidCharacters) {
sanitized.Replace(invalidChar, '-');
}
return sanitized.ToString();
}
///
/// A set of values that persist the set to disk.
///
internal class PersistentHashSet : IDisposable {
///
/// The isolated persistent storage.
///
private readonly FileStream fileStream;
///
/// The persistent reader.
///
private readonly StreamReader reader;
///
/// The persistent writer.
///
private readonly StreamWriter writer;
///
/// The total set of elements.
///
private readonly HashSet memorySet = new HashSet(StringComparer.OrdinalIgnoreCase);
///
/// The maximum number of elements to track before not storing new elements.
///
private readonly int maximumElements;
///
/// The set of new elements added to the since the last flush.
///
private List newElements = new List();
///
/// The time the last flush occurred.
///
private DateTime lastFlushed;
///
/// A flag indicating whether the set has changed since it was last flushed.
///
private bool dirty;
///
/// Initializes a new instance of the class.
///
/// The storage location.
/// Name of the file.
/// The maximum number of elements to track.
internal PersistentHashSet(IsolatedStorageFile storage, string fileName, int maximumElements) {
Requires.NotNull(storage, "storage");
Requires.NotNullOrEmpty(fileName, "fileName");
this.FileName = fileName;
this.maximumElements = maximumElements;
// Load the file into memory.
this.fileStream = new IsolatedStorageFileStream(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, storage);
this.reader = new StreamReader(this.fileStream, Encoding.UTF8);
while (!this.reader.EndOfStream) {
this.memorySet.Add(this.reader.ReadLine());
}
this.writer = new StreamWriter(this.fileStream, Utf8NoPreamble);
this.lastFlushed = DateTime.Now;
}
///
/// Gets a value indicating whether the hashset has reached capacity and is not storing more elements.
///
/// true if this instance is full; otherwise, false.
internal bool IsFull {
get {
lock (this.memorySet) {
return this.memorySet.Count >= this.maximumElements;
}
}
}
///
/// Gets the name of the file.
///
/// The name of the file.
internal string FileName { get; private set; }
#region IDisposable Members
///
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
///
public void Dispose() {
this.Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
///
/// Adds a value to the set.
///
/// The value.
internal void Add(string value) {
lock (this.memorySet) {
if (!this.IsFull) {
if (this.memorySet.Add(value)) {
this.newElements.Add(value);
this.dirty = true;
if (this.IsFull) {
this.Flush();
}
}
if (this.dirty && DateTime.Now - this.lastFlushed > Configuration.MinimumFlushInterval) {
this.Flush();
}
}
}
}
///
/// Flushes any newly added values to disk.
///
internal void Flush() {
lock (this.memorySet) {
foreach (string element in this.newElements) {
this.writer.WriteLine(element);
}
this.writer.Flush();
// Assign a whole new list since future lists might be smaller in order to
// decrease demand on memory.
this.newElements = new List();
this.dirty = false;
this.lastFlushed = DateTime.Now;
}
}
///
/// Releases unmanaged and - optionally - managed resources
///
/// true to release both managed and unmanaged resources; false to release only unmanaged resources.
protected virtual void Dispose(bool disposing) {
if (disposing) {
this.writer.Dispose();
this.reader.Dispose();
this.fileStream.Dispose();
}
}
}
///
/// A feature usage counter.
///
private class PersistentCounter : IDisposable {
///
/// The separator to use between category names and their individual counters.
///
private static readonly char[] separator = new char[] { '\t' };
///
/// The isolated persistent storage.
///
private readonly FileStream fileStream;
///
/// The persistent reader.
///
private readonly StreamReader reader;
///
/// The persistent writer.
///
private readonly StreamWriter writer;
///
/// The time the last flush occurred.
///
private DateTime lastFlushed;
///
/// The in-memory copy of the counter.
///
private Dictionary counters = new Dictionary(StringComparer.OrdinalIgnoreCase);
///
/// A flag indicating whether the set has changed since it was last flushed.
///
private bool dirty;
///
/// Initializes a new instance of the class.
///
/// The storage location.
/// Name of the file.
internal PersistentCounter(IsolatedStorageFile storage, string fileName) {
Requires.NotNull(storage, "storage");
Requires.NotNullOrEmpty(fileName, "fileName");
this.FileName = fileName;
// Load the file into memory.
this.fileStream = new IsolatedStorageFileStream(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.Read, storage);
this.reader = new StreamReader(this.fileStream, Encoding.UTF8);
while (!this.reader.EndOfStream) {
string line = this.reader.ReadLine();
string[] parts = line.Split(separator, 2);
int counter;
if (int.TryParse(parts[0], out counter)) {
string category = string.Empty;
if (parts.Length > 1) {
category = parts[1];
}
this.counters[category] = counter;
}
}
this.writer = new StreamWriter(this.fileStream, Utf8NoPreamble);
this.lastFlushed = DateTime.Now;
}
///
/// Gets the name of the file.
///
/// The name of the file.
internal string FileName { get; private set; }
#region IDisposable Members
///
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
///
public void Dispose() {
this.Dispose(true);
GC.SuppressFinalize(this);
}
#endregion
///
/// Increments the counter.
///
/// The category within the event. Null and empty strings are allowed, but considered the same.
internal void Increment(string category) {
if (category == null) {
category = string.Empty;
}
lock (this) {
int counter;
this.counters.TryGetValue(category, out counter);
this.counters[category] = counter + 1;
this.dirty = true;
if (this.dirty && DateTime.Now - this.lastFlushed > Configuration.MinimumFlushInterval) {
this.Flush();
}
}
}
///
/// Flushes any newly added values to disk.
///
internal void Flush() {
lock (this) {
this.writer.BaseStream.Position = 0;
this.writer.BaseStream.SetLength(0); // truncate file
foreach (var pair in this.counters) {
this.writer.Write(pair.Value);
this.writer.Write(separator[0]);
this.writer.WriteLine(pair.Key);
}
this.writer.Flush();
this.dirty = false;
this.lastFlushed = DateTime.Now;
}
}
///
/// Resets all counters.
///
internal void Reset() {
lock (this) {
this.counters.Clear();
}
}
///
/// Releases unmanaged and - optionally - managed resources
///
/// true to release both managed and unmanaged resources; false to release only unmanaged resources.
protected virtual void Dispose(bool disposing) {
if (disposing) {
this.writer.Dispose();
this.reader.Dispose();
this.fileStream.Dispose();
}
}
}
}
}