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