diff --git a/Sentry.sln b/Sentry.sln index af85be2b7b..885e98afb6 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -28,6 +28,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 - Solution Items", "1 - S README.md = README.md EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{77454495-55EE-4B40-A089-71B9E8F82E89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.Console.Basic", "samples\Sentry.Samples.Console.Basic\Sentry.Samples.Console.Basic.csproj", "{65F5A969-B386-48F5-8B7E-6C90D6C720BC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,6 +46,10 @@ Global {1E0F969B-67F9-4FCC-BCBF-596DB6460C7C}.Debug|Any CPU.Build.0 = Debug|Any CPU {1E0F969B-67F9-4FCC-BCBF-596DB6460C7C}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E0F969B-67F9-4FCC-BCBF-596DB6460C7C}.Release|Any CPU.Build.0 = Release|Any CPU + {65F5A969-B386-48F5-8B7E-6C90D6C720BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65F5A969-B386-48F5-8B7E-6C90D6C720BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65F5A969-B386-48F5-8B7E-6C90D6C720BC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65F5A969-B386-48F5-8B7E-6C90D6C720BC}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -49,6 +57,7 @@ Global GlobalSection(NestedProjects) = preSolution {F2486CC8-FAB7-4775-976F-C5A4CF97867F} = {AF6AF4C7-8AA2-4D59-8064-2D79560904EB} {1E0F969B-67F9-4FCC-BCBF-596DB6460C7C} = {83263231-1A2A-4733-B759-EEFF14E8C5D5} + {65F5A969-B386-48F5-8B7E-6C90D6C720BC} = {77454495-55EE-4B40-A089-71B9E8F82E89} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0C652B1A-DF72-4EE5-A98B-194FE2C054F6} diff --git a/samples/Sentry.Samples.Console.Basic/Program.cs b/samples/Sentry.Samples.Console.Basic/Program.cs new file mode 100644 index 0000000000..e66da1a9a2 --- /dev/null +++ b/samples/Sentry.Samples.Console.Basic/Program.cs @@ -0,0 +1,12 @@ +namespace Sentry.Samples.Console.Basic +{ + static class Program + { + static void Main() + { + var sentry = new HttpSentryClient(); + // This exception is captured and sent to Sentry + throw null; + } + } +} diff --git a/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj b/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj new file mode 100644 index 0000000000..714e077e85 --- /dev/null +++ b/samples/Sentry.Samples.Console.Basic/Sentry.Samples.Console.Basic.csproj @@ -0,0 +1,13 @@ + + + + Exe + netcoreapp2.0 + false + + + + + + + diff --git a/src/Sentry/Class1.cs b/src/Sentry/Class1.cs deleted file mode 100644 index 0d2d646cb3..0000000000 --- a/src/Sentry/Class1.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System; - -namespace Sentry -{ - /// - public class Class1 - { - } -} diff --git a/src/Sentry/Constants.cs b/src/Sentry/Constants.cs new file mode 100644 index 0000000000..677986126d --- /dev/null +++ b/src/Sentry/Constants.cs @@ -0,0 +1,8 @@ +namespace Sentry +{ + internal static class Constants + { + public const string DsnEnvironmentVariable = "SENTRY_DSN"; + public const string DisableSdkDsnValue = "disabled"; + } +} diff --git a/src/Sentry/Dsn.cs b/src/Sentry/Dsn.cs new file mode 100644 index 0000000000..7b9d00c0c5 --- /dev/null +++ b/src/Sentry/Dsn.cs @@ -0,0 +1,178 @@ +using System; +using System.Diagnostics; + +namespace Sentry +{ + /// + /// The Data Source Name of a given project in Sentry. + /// + /// + /// + /// + public class Dsn + { + private readonly string _dsn; + + /// + /// The project ID which the authenticated user is bound to. + /// + public string ProjectId { get; } + /// + /// An optional path of which Sentry is hosted + /// + public string Path { get; } + /// + /// The optional secret key to authenticate the SDK. + /// + public string SecretKey { get; } + /// + /// The required public key to authenticate the SDK. + /// + public string PublicKey { get; } + /// + /// The URI used to communicate with Sentry + /// + public Uri SentryUri { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The DSN in the format: {PROTOCOL}://{PUBLIC_KEY}@{HOST}/{PATH}{PROJECT_ID} + /// + /// A legacy DSN containing a secret will also be accepted: {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID} + /// + public Dsn(string dsn) + { + var parsed = Parse(dsn, throwOnError: true); + Debug.Assert(parsed != null, "Parse should throw instead of returning null!"); + + var (projectId, path, secretKey, publicKey, sentryUri) = parsed.Value; + + _dsn = dsn; + ProjectId = projectId; + Path = path; + SecretKey = secretKey; + PublicKey = publicKey; + SentryUri = sentryUri; + } + + private Dsn(string dsn, string projectId, string path, string secretKey, string publicKey, Uri sentryUri) + { + _dsn = dsn; + ProjectId = projectId; + Path = path; + SecretKey = secretKey; + PublicKey = publicKey; + SentryUri = sentryUri; + } + + /// + /// Tries to parse the string into a + /// + /// The string to attempt parsing. + /// The when successfully parsed. + /// true if the string is a valid as was successfully parsed. Otherwise, false. + public static bool TryParse(string dsn, out Dsn finalDsn) + { + try + { + var parsed = Parse(dsn, throwOnError: false); + if (!parsed.HasValue) + { + finalDsn = null; + return false; + } + + var (projectId, path, secretKey, publicKey, sentryUri) = parsed.Value; + + finalDsn = new Dsn( + dsn, + projectId, + path, + secretKey, + publicKey, + sentryUri); + + return true; + } + catch + { + // Parse should not throw though! + finalDsn = null; + return false; + } + } + + private static (string projectId, string path, string secretKey, string publicKey, Uri sentryUri)? + Parse(string dsn, bool throwOnError) + { + Uri uri; + if (throwOnError) + { + uri = new Uri(dsn); // Throws the UriFormatException one would expect + } + else if (Uri.TryCreate(dsn, UriKind.Absolute, out var parsedUri)) + { + uri = parsedUri; + } + else + { + return null; + } + + // uri.UserInfo returns empty string instead of null when no user info data is provided + if (string.IsNullOrWhiteSpace(uri.UserInfo)) + { + if (throwOnError) + { + throw new ArgumentException("Invalid DSN: No public key provided."); + } + return null; + } + + var keys = uri.UserInfo.Split(':'); + var publicKey = keys[0]; + if (string.IsNullOrWhiteSpace(publicKey)) + { + if (throwOnError) + { + throw new ArgumentException("Invalid DSN: No public key provided."); + } + return null; + } + + string secretKey = null; + if (keys.Length > 1) + { + secretKey = keys[1]; + } + + var path = uri.AbsolutePath.Substring(0, uri.AbsolutePath.LastIndexOf('/')); + var projectId = uri.AbsoluteUri.Substring(uri.AbsoluteUri.LastIndexOf('/') + 1); + + if (string.IsNullOrWhiteSpace(projectId)) + { + if (throwOnError) + { + throw new ArgumentException("Invalid DSN: A Project Id is required."); + } + return null; + } + + var builder = new UriBuilder + { + Scheme = uri.Scheme, + Host = uri.DnsSafeHost, + Port = uri.Port, + Path = $"{path}/api/{projectId}/store/" + }; + + return (projectId, path, secretKey, publicKey, builder.Uri); + } + + /// + /// The original DSN string used to create this instance + /// + public override string ToString() => _dsn; + } +} diff --git a/src/Sentry/HttpSentryClient.cs b/src/Sentry/HttpSentryClient.cs new file mode 100644 index 0000000000..2bbea012df --- /dev/null +++ b/src/Sentry/HttpSentryClient.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Sentry +{ + /// + public class HttpSentryClient : ISentryClient, IDisposable + { + /// + public HttpSentryClient() + { + AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; + } + + private void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + // TODO: A proper implementation + CaptureEventAsync(new SentryEvent(e.ExceptionObject as Exception)); + } + + /// + public Task CaptureEventAsync(SentryEvent @event, CancellationToken cancellationToken = default) + => Task.FromResult(new SentryResponse(false)); + + /// + public SentryResponse CaptureEvent(SentryEvent @event) => new SentryResponse(false); + + /// + public void Dispose() + { + AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException; + } + } +} diff --git a/src/Sentry/ISentryClient.cs b/src/Sentry/ISentryClient.cs new file mode 100644 index 0000000000..557bcf4747 --- /dev/null +++ b/src/Sentry/ISentryClient.cs @@ -0,0 +1,26 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Sentry +{ + /// + /// Sentry client + /// + public interface ISentryClient + { + /// + /// Sends the to Sentry asynchronously + /// + /// The event to send to Sentry. + /// The cancellation token. + /// + Task CaptureEventAsync(SentryEvent @event, CancellationToken cancellationToken = default); + + /// + /// Sends the to Sentry + /// + /// The event to send to Sentry. + /// + SentryResponse CaptureEvent(SentryEvent @event); + } +} diff --git a/src/Sentry/JsonSerializer.cs b/src/Sentry/JsonSerializer.cs new file mode 100644 index 0000000000..1a37d264d0 --- /dev/null +++ b/src/Sentry/JsonSerializer.cs @@ -0,0 +1,12 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; + +namespace Sentry +{ + internal static class JsonSerializer + { + private static readonly StringEnumConverter StringEnumConverter = new StringEnumConverter(); + + public static string SerializeObject(T @object) => JsonConvert.SerializeObject(@object, StringEnumConverter); + } +} diff --git a/src/Sentry/Properties/AssemblyInfo.cs b/src/Sentry/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..a8b4070590 --- /dev/null +++ b/src/Sentry/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Sentry.Tests")] + +[assembly: CLSCompliant(true)] diff --git a/src/Sentry/Protocol/Context/App.cs b/src/Sentry/Protocol/Context/App.cs new file mode 100644 index 0000000000..4f40dffa28 --- /dev/null +++ b/src/Sentry/Protocol/Context/App.cs @@ -0,0 +1,70 @@ +using System; +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Describes the application. + /// + /// + /// As opposed to the runtime, this is the actual application that + /// was running and carries meta data about the current session. + /// + /// + [DataContract] + public class App + { + /// + /// Version-independent application identifier, often a dotted bundle ID. + /// + [DataMember(Name = "app_identifier", EmitDefaultValue = false)] + public string Identifier { get; set; } + /// + /// Formatted UTC timestamp when the application was started by the user. + /// + [DataMember(Name = "app_start_time", EmitDefaultValue = false)] + public DateTimeOffset? StartTime { get; set; } + /// + /// Application specific device identifier. + /// + [DataMember(Name = "device_app_hash", EmitDefaultValue = false)] + public string Hash { get; set; } + /// + /// String identifying the kind of build, e.g. testflight. + /// + [DataMember(Name = "build_type", EmitDefaultValue = false)] + public string BuildType { get; set; } + /// + /// Human readable application name, as it appears on the platform. + /// + [DataMember(Name = "app_name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// Human readable application version, as it appears on the platform. + /// + [DataMember(Name = "app_version", EmitDefaultValue = false)] + public string Version { get; set; } + /// + /// Internal build identifier, as it appears on the platform. + /// + [DataMember(Name = "app_build", EmitDefaultValue = false)] + public string Build { get; set; } + + /// + /// Clones this instance + /// + /// + internal App Clone() + => new App + { + Identifier = Identifier, + StartTime = StartTime, + Hash = Hash, + BuildType = BuildType, + Name = Name, + Version = Version, + Build = Build + }; + } +} diff --git a/src/Sentry/Protocol/Context/Browser.cs b/src/Sentry/Protocol/Context/Browser.cs new file mode 100644 index 0000000000..e1e4e9816a --- /dev/null +++ b/src/Sentry/Protocol/Context/Browser.cs @@ -0,0 +1,37 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Carries information about the browser or user agent for web-related errors. + /// This can either be the browser this event ocurred in, or the user agent of a + /// web request that triggered the event. + /// + /// + [DataContract] + public class Browser + { + /// + /// Display name of the browser application. + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// Version string of the browser. + /// + [DataMember(Name = "version", EmitDefaultValue = false)] + public string Version { get; set; } + + /// + /// Clones this instance + /// + /// + internal Browser Clone() + => new Browser + { + Name = Name, + Version = Version + }; + } +} diff --git a/src/Sentry/Protocol/Context/Contexts.cs b/src/Sentry/Protocol/Context/Contexts.cs new file mode 100644 index 0000000000..5b78c96fdd --- /dev/null +++ b/src/Sentry/Protocol/Context/Contexts.cs @@ -0,0 +1,66 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Represents Sentry's structured Context + /// + /// + [DataContract] + public class Contexts + { + [DataMember(Name = "app", EmitDefaultValue = false)] + private App _app; + + [DataMember(Name = "browser", EmitDefaultValue = false)] + private Browser _browser; + + [DataMember(Name = "device", EmitDefaultValue = false)] + private Device _device; + + [DataMember(Name = "os", EmitDefaultValue = false)] + private OperatingSystem _operatingSystem; + + [DataMember(Name = "runtime", EmitDefaultValue = false)] + private Runtime _runtime; + + /// + /// Describes the application. + /// + public App App => _app ?? (_app = new App()); + /// + /// Describes the browser. + /// + public Browser Browser => _browser ?? (_browser = new Browser()); + /// + /// Describes the device. + /// + public Device Device => _device ?? (_device = new Device()); + /// + /// Defines the operating system. + /// + /// + /// In web contexts, this is the operating system of the browser (normally pulled from the User-Agent string). + /// + public OperatingSystem OperatingSystem => _operatingSystem ?? (_operatingSystem = new OperatingSystem()); + /// + /// This describes a runtime in more detail. + /// + public Runtime Runtime => _runtime ?? (_runtime = new Runtime()); + + /// + /// Creates a deep clone of this context + /// + /// + internal Contexts Clone() + => new Contexts + { + _app = _app?.Clone(), + _browser = _browser?.Clone(), + _device = _device?.Clone(), + _operatingSystem = _operatingSystem?.Clone(), + _runtime = _runtime?.Clone() + }; + } +} diff --git a/src/Sentry/Protocol/Context/Device.cs b/src/Sentry/Protocol/Context/Device.cs new file mode 100644 index 0000000000..00270c53d2 --- /dev/null +++ b/src/Sentry/Protocol/Context/Device.cs @@ -0,0 +1,143 @@ +using System; +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Describes the device that caused the event. This is most appropriate for mobile applications. + /// + /// + [DataContract] + public class Device + { + [DataMember(Name = "timezone", EmitDefaultValue = false)] + private string TimezoneSerializable => Timezone?.Id; + + /// + /// The name of the device. This is typically a hostname. + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// The family of the device. + /// + /// + /// This is normally the common part of model names across generations. + /// + /// + /// iPhone, Samsung Galaxy + /// + [DataMember(Name = "family", EmitDefaultValue = false)] + public string Family { get; set; } + /// + /// The model name. + /// + /// + /// Samsung Galaxy S3 + /// + [DataMember(Name = "model", EmitDefaultValue = false)] + public string Model { get; set; } + /// + /// An internal hardware revision to identify the device exactly. + /// + [DataMember(Name = "model_id", EmitDefaultValue = false)] + public string ModelId { get; set; } + /// + /// The CPU architecture. + /// + [DataMember(Name = "arch", EmitDefaultValue = false)] + public string Architecture { get; set; } + /// + /// If the device has a battery an integer defining the battery level (in the range 0-100). + /// + [DataMember(Name = "battery_level", EmitDefaultValue = false)] + public short? BatteryLevel { get; set; } + /// + /// This can be a string portrait or landscape to define the orientation of a device. + /// + [DataMember(Name = "orientation", EmitDefaultValue = false)] + public DeviceOrientation? Orientation { get; set; } + /// + /// A boolean defining whether this device is a simulator or an actual device. + /// + [DataMember(Name = "simulator", EmitDefaultValue = false)] + public bool? Simulator { get; set; } + /// + /// Total system memory available in bytes. + /// + [DataMember(Name = "memory_size", EmitDefaultValue = false)] + public long? MemorySize { get; set; } + /// + /// Free system memory in bytes. + /// + [DataMember(Name = "free_memory", EmitDefaultValue = false)] + public long? FreeMemory { get; set; } + /// + /// Memory usable for the app in bytes. + /// + [DataMember(Name = "usable_memory", EmitDefaultValue = false)] + public long? UsableMemory { get; set; } + /// + /// Total device storage in bytes. + /// + [DataMember(Name = "storage_size", EmitDefaultValue = false)] + public long? StorageSize { get; set; } + /// + /// Free device storage in bytes. + /// + [DataMember(Name = "free_storage", EmitDefaultValue = false)] + public long? FreeStorage { get; set; } + /// + /// Total size of an attached external storage in bytes (e.g.: android SDK card). + /// + [DataMember(Name = "external_storage_size", EmitDefaultValue = false)] + public long? ExternalStorageSize { get; set; } + /// + /// Free size of an attached external storage in bytes (e.g.: android SDK card). + /// + [DataMember(Name = "external_free_storage", EmitDefaultValue = false)] + public long? ExternalFreeStorage { get; set; } + /// + /// A formatted UTC timestamp when the system was booted. + /// + /// + /// 018-02-08T12:52:12Z + /// + [DataMember(Name = "boot_time", EmitDefaultValue = false)] + public DateTimeOffset? BootTime { get; set; } + /// + /// The timezone of the device. + /// + /// + /// Europe/Vienna + /// + public TimeZoneInfo Timezone { get; set; } + + /// + /// Clones this instance + /// + /// + internal Device Clone() + => new Device + { + Name = Name, + Architecture = Architecture, + BatteryLevel = BatteryLevel, + BootTime = BootTime, + ExternalFreeStorage = ExternalFreeStorage, + ExternalStorageSize = ExternalStorageSize, + Family = Family, + FreeMemory = FreeMemory, + FreeStorage = FreeStorage, + MemorySize = MemorySize, + Model = Model, + ModelId = ModelId, + Orientation = Orientation, + Simulator = Simulator, + StorageSize = StorageSize, + Timezone = Timezone, + UsableMemory = UsableMemory + }; + } +} diff --git a/src/Sentry/Protocol/Context/DeviceOrientation.cs b/src/Sentry/Protocol/Context/DeviceOrientation.cs new file mode 100644 index 0000000000..abf75e2bb2 --- /dev/null +++ b/src/Sentry/Protocol/Context/DeviceOrientation.cs @@ -0,0 +1,22 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Defines the orientation of a device. + /// + public enum DeviceOrientation + { + /// + /// Portrait + /// + [EnumMember(Value = "portrait")] + Portrait, + /// + /// Landscape + /// + [EnumMember(Value = "landscape")] + Landscape + } +} diff --git a/src/Sentry/Protocol/Context/OperatingSystem.cs b/src/Sentry/Protocol/Context/OperatingSystem.cs new file mode 100644 index 0000000000..5eb5831308 --- /dev/null +++ b/src/Sentry/Protocol/Context/OperatingSystem.cs @@ -0,0 +1,67 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// Represents Sentry's context for OS + /// + /// + /// Defines the operating system that caused the event. In web contexts, this is the operating system of the browser (normally pulled from the User-Agent string). + /// + /// + [DataContract] + public class OperatingSystem + { + /// + /// The name of the operating system. + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// The version of the operating system. + /// + [DataMember(Name = "version", EmitDefaultValue = false)] + public string Version { get; set; } + /// + /// An optional raw description that Sentry can use in an attempt to normalize OS info. + /// + /// + /// When the system doesn't expose a clear API for and + /// this field can be used to provide a raw system info (e.g: uname) + /// + [DataMember(Name = "raw_description", EmitDefaultValue = false)] + public string RawDescription { get; set; } + /// + /// The internal build revision of the operating system. + /// + [DataMember(Name = "build", EmitDefaultValue = false)] + public string Build { get; set; } + /// + /// If known, this can be an independent kernel version string. Typically + /// this is something like the entire output of the 'uname' tool. + /// + [DataMember(Name = "kernel_version", EmitDefaultValue = false)] + public string KernelVersion { get; set; } + /// + /// An optional boolean that defines if the OS has been jailbroken or rooted. + /// + [DataMember(Name = "rooted", EmitDefaultValue = false)] + public bool? Rooted { get; set; } + + /// + /// Clones this instance + /// + /// + internal OperatingSystem Clone() + => new OperatingSystem + { + Name = Name, + Version = Version, + RawDescription = RawDescription, + Build = Build, + KernelVersion = KernelVersion, + Rooted = Rooted + }; + } +} diff --git a/src/Sentry/Protocol/Context/Runtime.cs b/src/Sentry/Protocol/Context/Runtime.cs new file mode 100644 index 0000000000..696f6c8bdb --- /dev/null +++ b/src/Sentry/Protocol/Context/Runtime.cs @@ -0,0 +1,55 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry.Protocol +{ + /// + /// This describes a runtime in more detail. + /// + /// + /// Typically this context is used multiple times if multiple runtimes are involved (for instance if you have a JavaScript application running on top of JVM) + /// + /// + [DataContract] + public class Runtime + { + /// + /// The name of the runtime. + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// The version identifier of the runtime. + /// + [DataMember(Name = "version", EmitDefaultValue = false)] + public string Version { get; set; } + /// + /// An optional raw description that Sentry can use in an attempt to normalize Runtime info. + /// + /// + /// When the system doesn't expose a clear API for and + /// this field can be used to provide a raw system info (e.g: .NET Framework 4.7.1) + /// + [DataMember(Name = "raw_description", EmitDefaultValue = false)] + public string RawDescription { get; set; } + /// + /// An optional build number + /// + /// + [DataMember(Name = "build", EmitDefaultValue = false)] + public string Build { get; set; } + + /// + /// Clones this instance + /// + /// + internal Runtime Clone() + => new Runtime + { + Name = Name, + Version = Version, + Build = Build, + RawDescription = RawDescription + }; + } +} diff --git a/src/Sentry/Protocol/SdkVersion.cs b/src/Sentry/Protocol/SdkVersion.cs new file mode 100644 index 0000000000..7177425ee1 --- /dev/null +++ b/src/Sentry/Protocol/SdkVersion.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Sentry.Protocol +{ + /// + /// Information about the SDK to be sent with the SentryEvent + /// + /// Requires Sentry version 8.4 or higher + [DataContract] + public class SdkVersion + { + [DataMember(Name = "integrations", EmitDefaultValue = false)] + internal ICollection InternalIntegrations { get; private set; } + + /// + /// SDK name + /// + [DataMember(Name = "name", EmitDefaultValue = false)] + public string Name { get; set; } + /// + /// SDK Version + /// + [DataMember(Name = "version", EmitDefaultValue = false)] + public string Version { get; set; } + + /// + /// Any integration configured with the SDK + /// + /// This property is not required + public ICollection Integrations + { + get => InternalIntegrations ?? (InternalIntegrations = new List()); + set => InternalIntegrations = value; + } + } +} diff --git a/src/Sentry/Protocol/SentryEvent.cs b/src/Sentry/Protocol/SentryEvent.cs new file mode 100644 index 0000000000..a1d70b6270 --- /dev/null +++ b/src/Sentry/Protocol/SentryEvent.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; +using Sentry.Protocol; + +// ReSharper disable once CheckNamespace +namespace Sentry +{ + /// + /// An event to be sent to Sentry + /// + /// + [DataContract] + public class SentryEvent + { + [DataMember(Name = "contexts", EmitDefaultValue = false)] + internal Contexts InternalContexts { get; private set; } + + [DataMember(Name = "user", EmitDefaultValue = false)] + internal User InternalUser { get; private set; } + + // Used internally when serializing to avoid allocating the dictionary (and serializing empty) in case no data was written. + [DataMember(Name = "tags", EmitDefaultValue = false)] + internal IDictionary InternalTags { get; private set; } + + [DataMember(Name = "modules", EmitDefaultValue = false)] + internal IDictionary InternalModules { get; private set; } + + [DataMember(Name = "extra", EmitDefaultValue = false)] + internal IDictionary InternalExtra { get; private set; } + + [DataMember(Name = "fingerprint", EmitDefaultValue = false)] + internal ICollection InternalFingerprint { get; private set; } + + [DataMember(Name = "event_id", EmitDefaultValue = false)] + private string SerializableEventId => EventId.ToString("N"); + + /// + /// The unique identifier of this event + /// + /// + /// Hexadecimal string representing a uuid4 value. + /// The length is exactly 32 characters (no dashes!) + /// + public Guid EventId { get; set; } + + /// + /// Gets the message that describes this event + /// + [DataMember(Name = "message", EmitDefaultValue = false)] + public string Message { get; set; } + + /// + /// Gets the structured Sentry context + /// + /// + /// The contexts. + /// + public Contexts Contexts => InternalContexts ?? (InternalContexts = new Contexts()); + + /// + /// Gets the user information + /// + /// + /// The user. + /// + public User User => InternalUser ?? (InternalUser = new User()); + + /// + /// Indicates when the event was created + /// + /// 2018-04-03T17:41:36 + [DataMember(Name = "timestamp", EmitDefaultValue = false)] + public DateTimeOffset Timestamp { get; set; } + + /// + /// Name of the logger (or source) of the event + /// + [DataMember(Name = "logger", EmitDefaultValue = false)] + public string Logger { get; set; } + + /// + /// The name of the platform + /// + [DataMember(Name = "platform", EmitDefaultValue = false)] + public string Platform { get; set; } + + /// + /// SDK information + /// + /// New in Sentry version: 8.4 + [DataMember(Name = "sdk", EmitDefaultValue = false)] + public SdkVersion Sdk { get; set; } = new SdkVersion(); + + /// + /// Sentry level + /// + [DataMember(Name = "level", EmitDefaultValue = false)] + public SentryLevel? Level { get; set; } + + /// + /// The name of the transaction (or culprit) which caused this exception. + /// + [DataMember(Name = "culprit", EmitDefaultValue = false)] + public string Culprit { get; set; } + + /// + /// Identifies the host SDK from which the event was recorded. + /// + [DataMember(Name = "server_name", EmitDefaultValue = false)] + public string ServerName { get; set; } + + /// + /// The release version of the application. + /// + [DataMember(Name = "release", EmitDefaultValue = false)] + public string Release { get; set; } + + /// + /// The environment name, such as 'production' or 'staging'. + /// + /// Requires Sentry 8.0 or higher + [DataMember(Name = "environment", EmitDefaultValue = false)] + public string Environment { get; set; } + + /// + /// Arbitrary key-value for this event + /// + public IDictionary Tags + { + get => InternalTags = InternalTags ?? new Dictionary(); + set => InternalTags = value; + } + + /// + /// A list of relevant modules and their versions. + /// + public IDictionary Modules + { + get => InternalModules = InternalModules ?? new Dictionary(); + set => InternalModules = value; + } + + /// + /// An arbitrary mapping of additional metadata to store with the event. + /// + public IDictionary Extra + { + get => InternalExtra = InternalExtra ?? new Dictionary(); + set => InternalExtra = value; + } + + /// + /// A list of strings used to dictate the deduplication of this event. + /// + /// + /// + /// A value of {{ default }} will be replaced with the built-in behavior, thus allowing you to extend it, or completely replace it. + /// New in version Protocol: version '7' + /// + /// { "fingerprint": ["myrpc", "POST", "/foo.bar"] } + /// { "fingerprint": ["{{ default }}", "http://example.com/my.url"] } + public ICollection Fingerprint + { + get => InternalFingerprint = InternalFingerprint ?? new List(); + set => InternalFingerprint = value; + } + + /// + /// Creates a Sentry event with default values like Id and Timestamp + /// + public SentryEvent() => Reset(this); + + /// + /// Creates a Sentry event with the Exception details and default values like Id and Timestamp + /// + /// The exception. + public SentryEvent(Exception exception) + { + Reset(this); + Populate(this, exception); + } + + /// + /// Populate fields from Exception + /// + /// The event. + /// The exception. + public static void Populate(SentryEvent @event, Exception exception) + { + if (@event.Message == null) + { + @event.Message = exception.Message; + } + + // e.g: Namespace.Class.Method + if (@event.Culprit == null) + { + @event.Culprit = $"{exception.TargetSite?.ReflectedType?.FullName ?? ""}.{exception.TargetSite?.Name ?? ""}"; + } + + foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + if (assembly.IsDynamic) + { + continue; + } + var asmName = assembly.GetName(); + @event.Modules[asmName.Name] = asmName.Version.ToString(); + } + } + + /// + /// Resets the instance to a new value + /// + /// + public static void Reset(SentryEvent @event) + { + // Set initial value to mandatory properties: + //@event.InternalContexts = // TODO: Load initial context data + + @event.EventId = Guid.NewGuid(); + @event.Timestamp = DateTimeOffset.UtcNow; + // TODO: should this be dotnet instead? + @event.Platform = "csharp"; + @event.Sdk.Name = "Sentry.NET"; + // TODO: Read it off of env var here? Integration's version could be set instead + // Less flexible than using SentryOptions to define this value + @event.Sdk.Version = "0.0.0"; + + } + } +} diff --git a/src/Sentry/Protocol/SentryLevel.cs b/src/Sentry/Protocol/SentryLevel.cs new file mode 100644 index 0000000000..bbc6bc52f3 --- /dev/null +++ b/src/Sentry/Protocol/SentryLevel.cs @@ -0,0 +1,30 @@ +// ReSharper disable once CheckNamespace +namespace Sentry +{ + /// + /// The level of the event sent to Sentry + /// + public enum SentryLevel : short + { + /// + /// Fatal + /// + Fatal = -1, + /// + /// Error + /// + Error, // defaults to 0 + /// + /// Warning + /// + Warning, + /// + /// Informational + /// + Info, + /// + /// Debug + /// + Debug + } +} diff --git a/src/Sentry/Protocol/User.cs b/src/Sentry/Protocol/User.cs new file mode 100644 index 0000000000..fa252ddf26 --- /dev/null +++ b/src/Sentry/Protocol/User.cs @@ -0,0 +1,49 @@ +using System.Runtime.Serialization; + +// ReSharper disable once CheckNamespace +namespace Sentry +{ + /// + /// An interface which describes the authenticated User for a request. + /// + /// + public class User + { + /// + /// The email address of the user. + /// + /// + /// The user's email address. + /// + [DataMember(Name = "email", EmitDefaultValue = false)] + public string Email { get; set; } + + /// + /// The unique ID of the user. + /// + /// + /// The unique identifier. + /// + [DataMember(Name = "id", EmitDefaultValue = false)] + public string Id { get; set; } + + /// + /// The IP of the user. + /// + /// + /// The user's IP address. + /// + [DataMember(Name = "ip_address", EmitDefaultValue = false)] + public string IpAddress { get; set; } + + /// + /// The username of the user + /// + /// + /// The user's username. + /// + [DataMember(Name = "username", EmitDefaultValue = false)] + public string Username { get; set; } + + } +} diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index 9f5c4f4abb..5bb9ac58b4 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -1,7 +1,11 @@ - + netstandard2.0 + + + + diff --git a/src/Sentry/SentryClientExtensions.cs b/src/Sentry/SentryClientExtensions.cs new file mode 100644 index 0000000000..121ef1a4a2 --- /dev/null +++ b/src/Sentry/SentryClientExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.ComponentModel; +using System.Threading.Tasks; + +namespace Sentry +{ + /// + /// Extension methods for + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static class SentryClientExtensions + { + /// + /// Captures the exception. + /// + /// The Sentry client. + /// The exception. + /// + public static Task CaptureExceptionAsync(this ISentryClient client, Exception ex) + { + return client.CaptureEventAsync(new SentryEvent(ex)); + } + + /// + /// Captures the exception. + /// + /// The Sentry client. + /// The exception. + /// + public static SentryResponse CaptureException(this ISentryClient client, Exception ex) + { + return client.CaptureEvent(new SentryEvent(ex)); + } + } +} diff --git a/src/Sentry/SentryResponse.cs b/src/Sentry/SentryResponse.cs new file mode 100644 index 0000000000..ce61488eac --- /dev/null +++ b/src/Sentry/SentryResponse.cs @@ -0,0 +1,66 @@ +using System.Net; + +namespace Sentry +{ + /// + /// Information regarding a response to event submission + /// + public class SentryResponse + { + /// + /// The Id generated by Sentry for the event + /// + public string Id { get; } + /// + /// Whether the request to sentry was successful or not + /// + /// + /// If the transport chosen is based on a background client, + /// a response with Id is generated when the event is queued for submission. + /// That means that whether the actual event capture is unknown to + /// be successful or not at the time of creation. + /// + public bool? Success { get; } + /// + /// The HTTP response message status code + /// + /// + /// In case the HTTP call was not issued yet (background client) or + /// when it was not possible to make the HTTP call this value will be null. + /// An example where this is null is when the rate limit is reached + /// and the SDK throttles event submission + /// + public HttpStatusCode? HttpStatusCode { get; } + /// + /// The error message provided, if any. + /// + /// + /// The Header value of X-Sentry-Error + /// + public string ErrorMessage { get; } + + internal SentryResponse( + bool success, + string id = null, + HttpStatusCode? httpStatusCode = null, + string errorMessage = null) + { + Success = success; + Id = id; + HttpStatusCode = httpStatusCode; + ErrorMessage = errorMessage; + } + + /// + /// Returns a that represents this instance. + /// + /// + /// A that represents this instance. + /// + public override string ToString() + => $"Sentry Response: {nameof(Id)}: '{Id}', " + + $"{nameof(Success)}: '{Success}', " + + $"{nameof(HttpStatusCode)}: '{HttpStatusCode}', " + + $"{nameof(ErrorMessage)}: '{ErrorMessage}'"; + } +} diff --git a/test/Sentry.Tests/DsnSamples.cs b/test/Sentry.Tests/DsnSamples.cs new file mode 100644 index 0000000000..0903151c73 --- /dev/null +++ b/test/Sentry.Tests/DsnSamples.cs @@ -0,0 +1,18 @@ +namespace Sentry.Tests +{ + internal static class DsnSamples + { + /// + /// Sentry has dropped the use of secrets + /// + public const string ValidDsnWithoutSecret = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/2147483647"; + /// + /// Legacy includes secret + /// + public const string ValidDsnWithSecret = "https://d4d82fc1c2c4032a83f3a29aa3a3aff:ed0a8589a0bb4d4793ac4c70375f3d65@fake-sentry.io:65535/2147483647"; + /// + /// Missing ProjectId + /// + public const string InvalidDsn = "https://d4d82fc1c2c4032a83f3a29aa3a3aff@fake-sentry.io:65535/"; + } +} diff --git a/test/Sentry.Tests/DsnTests.cs b/test/Sentry.Tests/DsnTests.cs new file mode 100644 index 0000000000..92a4008aa4 --- /dev/null +++ b/test/Sentry.Tests/DsnTests.cs @@ -0,0 +1,306 @@ +using System; +using Xunit; + +namespace Sentry.Tests +{ + public class DsnTests + { + [Fact] + public void ToString_SameAsInput() + { + var @case = new DsnTestCase(); + var dsn = new Dsn(@case); + Assert.Equal(@case.ToString(), dsn.ToString()); + } + + [Fact] + public void Ctor_SampleValidDsnWithoutSecret_CorrectlyConstructs() + { + var dsn = new Dsn(DsnSamples.ValidDsnWithoutSecret); + Assert.Equal(DsnSamples.ValidDsnWithoutSecret, dsn.ToString()); + } + + [Fact] + public void Ctor_SampleValidDsnWithSecret_CorrectlyConstructs() + { + var dsn = new Dsn(DsnSamples.ValidDsnWithSecret); + Assert.Equal(DsnSamples.ValidDsnWithSecret, dsn.ToString()); + } + + [Fact] + public void Ctor_NotUri_ThrowsUriFormatException() + { + var ex = Assert.Throws(() => new Dsn("Not a URI")); + Assert.Equal("Invalid URI: The format of the URI could not be determined.", ex.Message); + } + + [Fact] + public void Ctor_DisableSdk_ThrowsUriFormatException() + { + var ex = Assert.Throws(() => new Dsn(Constants.DisableSdkDsnValue)); + Assert.Equal("Invalid URI: The format of the URI could not be determined.", ex.Message); + } + + [Fact] + public void Ctor_ValidDsn_CorrectlyConstructs() + { + var @case = new DsnTestCase(); + var dsn = new Dsn(@case); + + AssertEqual(@case, dsn); + } + + [Fact] + public void Ctor_MissingScheme_ThrowsUriFormatException() + { + var @case = new DsnTestCase { Scheme = null }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid URI: The format of the URI could not be determined.", ex.Message); + } + + [Fact] + public void Ctor_FutureScheme_ValidDsn() + { + var @case = new DsnTestCase { Scheme = "hypothetical" }; + var dsn = new Dsn(@case); + AssertEqual(@case, dsn); + } + + [Fact] + public void Ctor_EmptyPath_ValidDsn() + { + var @case = new DsnTestCase { Path = string.Empty }; + var dsn = new Dsn(@case); + AssertEqual(@case, dsn); + } + + [Fact] + public void Ctor_MissingSecretKey_GetterReturnsNull() + { + var @case = new DsnTestCase { SecretKey = null }; + var sut = new Dsn(@case); + Assert.Null(sut.SecretKey); + } + + [Fact] + public void Ctor_MissingPublicKey_ThrowsArgumentException() + { + var @case = new DsnTestCase { PublicKey = null }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid DSN: No public key provided.", ex.Message); + } + + [Fact] + public void Ctor_MissingPublicAndSecretKey_ThrowsArgumentException() + { + var @case = new DsnTestCase { PublicKey = null, SecretKey = null, UserInfoSeparator = null, CredentialSeparator = null }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid DSN: No public key provided.", ex.Message); + } + + [Fact] + public void Ctor_MissingProjectId_ThrowsArgumentException() + { + var @case = new DsnTestCase { ProjectId = null }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid DSN: A Project Id is required.", ex.Message); + } + + [Fact] + public void Ctor_InvalidPort_ThrowsUriFormatException() + { + var @case = new DsnTestCase { Port = -1 }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid URI: Invalid port specified.", ex.Message); + } + + [Fact] + public void Ctor_InvalidHost_ThrowsUriFormatException() + { + var @case = new DsnTestCase { Host = null }; + var ex = Assert.Throws(() => new Dsn(@case)); + Assert.Equal("Invalid URI: The hostname could not be parsed.", ex.Message); + } + + [Fact] + public void Ctor_EmptyStringDsn_ThrowsUriFormatException() + { + var ex = Assert.Throws(() => new Dsn(string.Empty)); + Assert.Equal("Invalid URI: The URI is empty.", ex.Message); + } + + [Fact] + public void Ctor_NullDsn_ThrowsArgumentNull() + { + Assert.Throws(() => new Dsn(null)); + } + + [Fact] + public void TryParse_SampleValidDsnWithoutSecret_Succeeds() + { + Assert.True(Dsn.TryParse(DsnSamples.ValidDsnWithoutSecret, out var dsn)); + Assert.NotNull(dsn); + } + + [Fact] + public void TryParse_SampleValidDsnWithSecret_Succeeds() + { + Assert.True(Dsn.TryParse(DsnSamples.ValidDsnWithSecret, out var dsn)); + Assert.NotNull(dsn); + } + + [Fact] + public void TryParse_SampleInvalidDsn_Fails() + { + Assert.False(Dsn.TryParse(DsnSamples.InvalidDsn, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_NotUri_Fails() + { + Assert.False(Dsn.TryParse("Not a URI", out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_DisabledSdk_Fails() + { + Assert.False(Dsn.TryParse(Constants.DisableSdkDsnValue, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_ValidDsn_Succeeds() + { + var @case = new DsnTestCase(); + Assert.True(Dsn.TryParse(@case, out var dsn)); + + AssertEqual(@case, dsn); + } + + [Fact] + public void TryParse_MissingScheme_Fails() + { + var @case = new DsnTestCase { Scheme = null }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_FutureScheme_Succeeds() + { + var @case = new DsnTestCase { Scheme = "hypothetical" }; + Assert.True(Dsn.TryParse(@case, out var dsn)); + AssertEqual(@case, dsn); + } + + [Fact] + public void TryParse_EmptyPath_Succeeds() + { + var @case = new DsnTestCase { Path = string.Empty }; + Assert.True(Dsn.TryParse(@case, out var dsn)); + AssertEqual(@case, dsn); + } + + [Fact] + public void TryParse_MissingSecretKey_Succeeds() + { + var @case = new DsnTestCase { SecretKey = null }; + Assert.True(Dsn.TryParse(@case, out var dsn)); + AssertEqual(@case, dsn); + } + + [Fact] + public void TryParse_MissingPublicKey_Fails() + { + var @case = new DsnTestCase { PublicKey = null }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_MissingPublicAndSecretKey_Fails() + { + var @case = new DsnTestCase { PublicKey = null, SecretKey = null, UserInfoSeparator = null, CredentialSeparator = null }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_MissingProjectId_Fails() + { + var @case = new DsnTestCase { ProjectId = null }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_InvalidPort_Fails() + { + var @case = new DsnTestCase { Port = -1 }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_InvalidHost_Fails() + { + var @case = new DsnTestCase { Host = null }; + Assert.False(Dsn.TryParse(@case, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_EmptyStringDsn_ThrowsUriFormatException() + { + Assert.False(Dsn.TryParse(string.Empty, out var dsn)); + Assert.Null(dsn); + } + + [Fact] + public void TryParse_NullDsn_ThrowsArgumentNull() + { + Assert.False(Dsn.TryParse(null, out var dsn)); + Assert.Null(dsn); + } + + private static readonly Random Rnd = new Random(); + + private class DsnTestCase + { + public string Scheme { get; set; } = "https"; + public string PublicKey { get; set; } = Guid.NewGuid().ToString("N"); + public string SecretKey { get; set; } = Guid.NewGuid().ToString("N"); + public string Host { get; set; } = "sentry.io"; + public string Path { get; set; } = "/some-path"; + public int? Port { get; set; } = Rnd.Next(1, 65535); + public string ProjectId { get; set; } = Rnd.Next().ToString(); + + public string CredentialSeparator { private get; set; } = ":"; + public string UserInfoSeparator { private get; set; } = "@"; + // -> {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}/{PATH}{PROJECT_ID} <- + public override string ToString() + => $"{Scheme}://{PublicKey}{(SecretKey == null ? null : $"{CredentialSeparator}{SecretKey}")}{UserInfoSeparator}{Host}{(Port != null ? $":{Port}" : "")}{Path}/{ProjectId}"; + + public static implicit operator string(DsnTestCase @case) => @case.ToString(); + public static implicit operator Uri(DsnTestCase @case) => new Uri($"{@case.Scheme}://{@case.Host}:{@case.Port}{@case.Path}/api/{@case.ProjectId}/store/"); + } + + private static void AssertEqual(DsnTestCase @case, Dsn dsn) + { + if (@case == null) throw new ArgumentNullException(nameof(@case)); + if (dsn == null) throw new ArgumentNullException(nameof(dsn)); + + Assert.Equal(@case.Scheme, dsn.SentryUri.Scheme); + Assert.Equal(@case.PublicKey, dsn.PublicKey); + Assert.Equal(@case.SecretKey, dsn.SecretKey); + Assert.Equal(@case.ProjectId, dsn.ProjectId); + Assert.Equal(@case.Path, dsn.Path); + Assert.Equal(@case.Host, dsn.SentryUri.Host); + Assert.Equal(@case.Port, dsn.SentryUri.Port); + + Assert.Equal(@case, dsn.SentryUri); + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/AppTests.cs b/test/Sentry.Tests/Protocol/Contexts/AppTests.cs new file mode 100644 index 0000000000..a575ced7de --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/AppTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class AppTests + { + [Fact] + public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() + { + var sut = new App + { + Version = "8b03fd7", + Build = "1.23152", + BuildType = "nightly", + Hash = "93fd0e9a", + Name = "Sentry.Test.App", + StartTime = DateTimeOffset.MaxValue + }; + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"app_start_time\":\"9999-12-31T23:59:59.9999999+00:00\"," + + "\"device_app_hash\":\"93fd0e9a\"," + + "\"build_type\":\"nightly\"," + + "\"app_name\":\"Sentry.Test.App\"," + + "\"app_version\":\"8b03fd7\"," + + "\"app_build\":\"1.23152\"}", + actual); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SerializeObject_TestCase_SerializesAsExpected((App app, string serialized) @case) + { + var actual = JsonSerializer.SerializeObject(@case.app); + + Assert.Equal(@case.serialized, actual); + } + + public static IEnumerable TestCases() + { + yield return new object[] { (new App(), "{}") }; + yield return new object[] { (new App { Name = "some name" }, "{\"app_name\":\"some name\"}") }; + yield return new object[] { (new App { Build = "some build" }, "{\"app_build\":\"some build\"}") }; + yield return new object[] { (new App { BuildType = "some build type" }, "{\"build_type\":\"some build type\"}") }; + yield return new object[] { (new App { Hash = "some hash" }, "{\"device_app_hash\":\"some hash\"}") }; + yield return new object[] { (new App { StartTime = DateTimeOffset.MaxValue }, "{\"app_start_time\":\"9999-12-31T23:59:59.9999999+00:00\"}") }; + yield return new object[] { (new App { Version = "some version" }, "{\"app_version\":\"some version\"}") }; + yield return new object[] { (new App { Identifier = "some identifier" }, "{\"app_identifier\":\"some identifier\"}") }; + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/BrowserTests.cs b/test/Sentry.Tests/Protocol/Contexts/BrowserTests.cs new file mode 100644 index 0000000000..101577c835 --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/BrowserTests.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class BrowserTests + { + [Fact] + public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() + { + var sut = new Browser + { + Version = "6", + Name = "Internet Explorer", + }; + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"name\":\"Internet Explorer\",\"version\":\"6\"}", actual); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SerializeObject_TestCase_SerializesAsExpected((Browser browser, string serialized) @case) + { + var actual = JsonSerializer.SerializeObject(@case.browser); + + Assert.Equal(@case.serialized, actual); + } + + public static IEnumerable TestCases() + { + yield return new object[] { (new Browser(), "{}") }; + yield return new object[] { (new Browser { Name = "some name" }, "{\"name\":\"some name\"}") }; + yield return new object[] { (new Browser { Version = "some version" }, "{\"version\":\"some version\"}") }; + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/ContextsTests.cs b/test/Sentry.Tests/Protocol/Contexts/ContextsTests.cs new file mode 100644 index 0000000000..35dbaaeb79 --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/ContextsTests.cs @@ -0,0 +1,77 @@ +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class ContextsTests + { + [Fact] + public void SerializeObject_NoPropertyFilled_SerializesEmptyObject() + { + var sut = new Sentry.Protocol.Contexts(); + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{}", actual); + } + + [Fact] + public void SerializeObject_SingleDevicePropertySet_SerializeSingleProperty() + { + var sut = new Sentry.Protocol.Contexts(); + sut.Device.Architecture = "x86"; + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"device\":{\"arch\":\"x86\"}}", actual); + } + + [Fact] + public void SerializeObject_SingleAppPropertySet_SerializeSingleProperty() + { + var sut = new Sentry.Protocol.Contexts(); + sut.App.Name = "My.App"; + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"app\":{\"app_name\":\"My.App\"}}", actual); + } + + [Fact] + public void SerializeObject_SingleOsPropertySet_SerializeSingleProperty() + { + var sut = new Sentry.Protocol.Contexts(); + sut.OperatingSystem.Version = "1.1.1.100"; + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"os\":{\"version\":\"1.1.1.100\"}}", actual); + } + + [Fact] + public void SerializeObject_SingleRuntimePropertySet_SerializeSingleProperty() + { + var sut = new Sentry.Protocol.Contexts(); + sut.Runtime.Version = "2.1.1.100"; + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"runtime\":{\"version\":\"2.1.1.100\"}}", actual); + } + + [Fact] + public void Ctor_SingleBrowserPropertySet_SerializeSingleProperty() + { + var contexts = new Sentry.Protocol.Contexts(); + contexts.Browser.Name = "Netscape 1"; + var actual = JsonSerializer.SerializeObject(contexts); + + Assert.Equal("{\"browser\":{\"name\":\"Netscape 1\"}}", actual); + } + + [Fact] + public void Ctor_SingleOperatingSystemPropertySet_SerializeSingleProperty() + { + var contexts = new Sentry.Protocol.Contexts(); + contexts.OperatingSystem.Name = "BeOS 1"; + var actual = JsonSerializer.SerializeObject(contexts); + + Assert.Equal("{\"os\":{\"name\":\"BeOS 1\"}}", actual); + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/DeviceTests.cs b/test/Sentry.Tests/Protocol/Contexts/DeviceTests.cs new file mode 100644 index 0000000000..5a392bcbcc --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/DeviceTests.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class DeviceTests + { + [Fact] + public void Ctor_NoPropertyFilled_SerializesEmptyObject() + { + var sut = new Device(); + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{}", actual); + } + + [Fact] + public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() + { + var sut = new Device + { + Name = "testing.sentry.io", + Architecture = "x64", + BatteryLevel = 99, + BootTime = DateTimeOffset.MaxValue, + ExternalFreeStorage = 100_000_000_000_000, // 100 TB + ExternalStorageSize = 1_000_000_000_000_000, // 1 PB + Family = "Windows", + FreeMemory = 200_000_000_000, // 200 GB + MemorySize = 500_000_000_000, // 500 GB + StorageSize = 100_000_000, + FreeStorage = 0, + Model = "Windows Server 2012 R2", + ModelId = "0921309128012", + Orientation = DeviceOrientation.Portrait, + Simulator = false, + Timezone = TimeZoneInfo.Local, + UsableMemory = 100 + }; + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal($"{{\"timezone\":\"{TimeZoneInfo.Local.Id}\"," + + "\"name\":\"testing.sentry.io\"," + + "\"family\":\"Windows\"," + + "\"model\":\"Windows Server 2012 R2\"," + + "\"model_id\":\"0921309128012\"," + + "\"arch\":\"x64\"," + + "\"battery_level\":99," + + "\"orientation\":\"portrait\"," + + "\"simulator\":false," + + "\"memory_size\":500000000000," + + "\"free_memory\":200000000000," + + "\"usable_memory\":100," + + "\"storage_size\":100000000," + + "\"free_storage\":0," + + "\"external_storage_size\":1000000000000000," + + "\"external_free_storage\":100000000000000," + + "\"boot_time\":\"9999-12-31T23:59:59.9999999+00:00\"}", + actual); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SerializeObject_TestCase_SerializesAsExpected((Device device, string serialized) @case) + { + var actual = JsonSerializer.SerializeObject(@case.device); + + Assert.Equal(@case.serialized, actual); + } + + public static IEnumerable TestCases() + { + yield return new object[] { (new Device(), "{}") }; + yield return new object[] { (new Device { Name = "some name" }, "{\"name\":\"some name\"}") }; + yield return new object[] { (new Device { Orientation = DeviceOrientation.Landscape }, "{\"orientation\":\"landscape\"}") }; + yield return new object[] { (new Device { Family = "some family" }, "{\"family\":\"some family\"}") }; + yield return new object[] { (new Device { Model = "some model" }, "{\"model\":\"some model\"}") }; + yield return new object[] { (new Device { ModelId = "some model id" }, "{\"model_id\":\"some model id\"}") }; + yield return new object[] { (new Device { Architecture = "some arch" }, "{\"arch\":\"some arch\"}") }; + yield return new object[] { (new Device { BatteryLevel = 1 }, "{\"battery_level\":1}") }; + yield return new object[] { (new Device { Simulator = false }, "{\"simulator\":false}") }; + yield return new object[] { (new Device { MemorySize = 1 }, "{\"memory_size\":1}") }; + yield return new object[] { (new Device { FreeMemory = 1 }, "{\"free_memory\":1}") }; + yield return new object[] { (new Device { UsableMemory = 1 }, "{\"usable_memory\":1}") }; + yield return new object[] { (new Device { StorageSize = 1 }, "{\"storage_size\":1}") }; + yield return new object[] { (new Device { FreeStorage = 1 }, "{\"free_storage\":1}") }; + yield return new object[] { (new Device { ExternalStorageSize = 1 }, "{\"external_storage_size\":1}") }; + yield return new object[] { (new Device { ExternalFreeStorage = 1 }, "{\"external_free_storage\":1}") }; + yield return new object[] { (new Device { BootTime = DateTimeOffset.MaxValue }, "{\"boot_time\":\"9999-12-31T23:59:59.9999999+00:00\"}") }; + yield return new object[] { (new Device { Timezone = TimeZoneInfo.Utc }, "{\"timezone\":\"UTC\"}") }; + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/OperatingSystemTests.cs b/test/Sentry.Tests/Protocol/Contexts/OperatingSystemTests.cs new file mode 100644 index 0000000000..c0897336ea --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/OperatingSystemTests.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class OperatingSystemTests + { + [Fact] + public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() + { + var sut = new OperatingSystem + { + Name = "Windows", + KernelVersion = "who knows", + Version = "2016", + RawDescription = "Windows 2016", + Build = "14393", + Rooted = true + }; + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"name\":\"Windows\"," + + "\"version\":\"2016\"," + + "\"raw_description\":\"Windows 2016\"," + + "\"build\":\"14393\"," + + "\"kernel_version\":\"who knows\"," + + "\"rooted\":true}", + actual); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SerializeObject_TestCase_SerializesAsExpected((OperatingSystem os, string serialized) @case) + { + var actual = JsonSerializer.SerializeObject(@case.os); + + Assert.Equal(@case.serialized, actual); + } + + public static IEnumerable TestCases() + { + yield return new object[] { (new OperatingSystem(), "{}") }; + yield return new object[] { (new OperatingSystem { Name = "some name" }, "{\"name\":\"some name\"}") }; + yield return new object[] { (new OperatingSystem { RawDescription = "some Name, some version" }, "{\"raw_description\":\"some Name, some version\"}") }; + yield return new object[] { (new OperatingSystem { Build = "some build" }, "{\"build\":\"some build\"}") }; + yield return new object[] { (new OperatingSystem { KernelVersion = "some kernel version" }, "{\"kernel_version\":\"some kernel version\"}") }; + yield return new object[] { (new OperatingSystem { Rooted = false }, "{\"rooted\":false}") }; + } + } +} diff --git a/test/Sentry.Tests/Protocol/Contexts/RuntimeTests.cs b/test/Sentry.Tests/Protocol/Contexts/RuntimeTests.cs new file mode 100644 index 0000000000..a14a84ce06 --- /dev/null +++ b/test/Sentry.Tests/Protocol/Contexts/RuntimeTests.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol.Contexts +{ + public class RuntimeTests + { + [Fact] + public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() + { + var sut = new Runtime + { + Version = "4.7.2", + Name = ".NET Framework", + Build = "461814", + RawDescription = ".NET Framework 4.7.2" + }; + + var actual = JsonSerializer.SerializeObject(sut); + + Assert.Equal("{\"name\":\".NET Framework\",\"version\":\"4.7.2\",\"raw_description\":\".NET Framework 4.7.2\",\"build\":\"461814\"}", actual); + } + + [Theory] + [MemberData(nameof(TestCases))] + public void SerializeObject_TestCase_SerializesAsExpected((Runtime runtime, string serialized) @case) + { + var actual = JsonSerializer.SerializeObject(@case.runtime); + + Assert.Equal(@case.serialized, actual); + } + + public static IEnumerable TestCases() + { + yield return new object[] { (new Runtime(), "{}") }; + yield return new object[] { (new Runtime { Name = "some name" }, "{\"name\":\"some name\"}") }; + yield return new object[] { (new Runtime { Version = "some version" }, "{\"version\":\"some version\"}") }; + yield return new object[] { (new Runtime { Build = "some build" }, "{\"build\":\"some build\"}") }; + yield return new object[] { (new Runtime { RawDescription = "some Name, some version" }, "{\"raw_description\":\"some Name, some version\"}") }; + } + } +} diff --git a/test/Sentry.Tests/Sentry.Tests.csproj b/test/Sentry.Tests/Sentry.Tests.csproj index 6fb4b511d8..757ddeddc6 100644 --- a/test/Sentry.Tests/Sentry.Tests.csproj +++ b/test/Sentry.Tests/Sentry.Tests.csproj @@ -1,4 +1,4 @@ - + netcoreapp2.0;net462 diff --git a/test/Sentry.Tests/UnitTest1.cs b/test/Sentry.Tests/UnitTest1.cs deleted file mode 100644 index d3bc7159d4..0000000000 --- a/test/Sentry.Tests/UnitTest1.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System; -using Xunit; - -namespace Sentry.Tests -{ - public class UnitTest1 - { - [Fact] - public void Test1() - { - } - } -}