From 3bc6c66a14e653982a6a25df033d68d8ad12c4af Mon Sep 17 00:00:00 2001 From: Alexey Golub Date: Wed, 16 Dec 2020 05:02:29 +0200 Subject: [PATCH] Support performance (#633) --- CHANGELOG.md | 3 + .../Program.cs | 29 +- .../Properties/launchSettings.json | 4 +- .../Sentry.Samples.AspNetCore.Basic.csproj | 6 +- .../Program.cs | 2 +- .../Extensions/HttpContextExtensions.cs | 43 +++ src/Sentry.AspNetCore/ScopeExtensions.cs | 38 +- src/Sentry.AspNetCore/SentryMiddleware.cs | 37 +- src/Sentry.AspNetCore/SentryStartupFilter.cs | 11 +- src/Sentry/Extensibility/DisabledHub.cs | 19 + src/Sentry/Extensibility/HubAdapter.cs | 22 ++ src/Sentry/IHub.cs | 12 + src/Sentry/ISentryClient.cs | 7 + src/Sentry/ISentryTraceSampler.cs | 13 + .../Internal/Extensions/JsonExtensions.cs | 9 + .../Internal/Extensions/StringExtensions.cs | 11 + src/Sentry/Internal/Hub.cs | 47 +++ .../Internal/MainSentryEventProcessor.cs | 25 +- src/Sentry/Internal/Polyfills.cs | 13 +- src/Sentry/Protocol/Context/Contexts.cs | 9 + src/Sentry/Protocol/Context/Trace.cs | 85 +++++ src/Sentry/Protocol/Envelopes/Envelope.cs | 18 + src/Sentry/Protocol/Envelopes/EnvelopeItem.cs | 36 +- src/Sentry/Protocol/IScope.cs | 27 +- src/Sentry/Protocol/ISpan.cs | 46 +++ src/Sentry/Protocol/ISpanContext.cs | 41 +++ src/Sentry/Protocol/SentryEvent.cs | 17 +- src/Sentry/Protocol/SentryId.cs | 24 +- src/Sentry/Protocol/SentryTraceHeader.cs | 50 +++ src/Sentry/Protocol/Span.cs | 164 +++++++++ src/Sentry/Protocol/SpanId.cs | 84 +++++ src/Sentry/Protocol/SpanRecorder.cs | 40 +++ src/Sentry/Protocol/SpanStatus.cs | 59 ++++ src/Sentry/Protocol/Transaction.cs | 327 ++++++++++++++++++ src/Sentry/Scope.cs | 9 +- src/Sentry/ScopeExtensions.cs | 1 + src/Sentry/SentryClient.cs | 59 +++- src/Sentry/SentryOptions.cs | 29 ++ src/Sentry/SentrySdk.cs | 21 ++ src/Sentry/TraceSamplingContext.cs | 31 ++ .../ScopeExtensionsTests.cs | 2 +- .../SentryMiddlewareTests.cs | 2 + test/Sentry.Tests/HubTests.cs | 2 +- test/Sentry.Tests/Protocol/BaseScopeTests.cs | 4 +- .../Protocol/Envelopes/EnvelopeTests.cs | 4 +- .../Protocol/ScopeExtensionsTests.cs | 14 +- .../Sentry.Tests/Protocol/SentryEventTests.cs | 2 +- test/Sentry.Tests/Protocol/SentryIdTests.cs | 2 +- .../Sentry.Tests/Protocol/TransactionTests.cs | 37 ++ test/Sentry.Tests/SentryClientTests.cs | 43 +++ 50 files changed, 1512 insertions(+), 128 deletions(-) create mode 100644 src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs create mode 100644 src/Sentry/ISentryTraceSampler.cs create mode 100644 src/Sentry/Internal/Extensions/StringExtensions.cs create mode 100644 src/Sentry/Protocol/Context/Trace.cs create mode 100644 src/Sentry/Protocol/ISpan.cs create mode 100644 src/Sentry/Protocol/ISpanContext.cs create mode 100644 src/Sentry/Protocol/SentryTraceHeader.cs create mode 100644 src/Sentry/Protocol/Span.cs create mode 100644 src/Sentry/Protocol/SpanId.cs create mode 100644 src/Sentry/Protocol/SpanRecorder.cs create mode 100644 src/Sentry/Protocol/SpanStatus.cs create mode 100644 src/Sentry/Protocol/Transaction.cs create mode 100644 src/Sentry/TraceSamplingContext.cs create mode 100644 test/Sentry.Tests/Protocol/TransactionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index e552d4ef47..a7a53e3bc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * Ref moved SentryId from namespace Sentry.Protocol to Sentry (#643) @lucas-zimerman * Ref renamed `CacheFlushTimeout` to `InitCacheFlushTimeout` (#638) @lucas-zimerman +* Add support for performance. ([#633](https://github.com/getsentry/sentry-dotnet/pull/633)) +* Transaction (of type `string`) on Scope and Event now is called TransactionName. ([#633](https://github.com/getsentry/sentry-dotnet/pull/633)) ## 3.0.0-alpha.6 @@ -12,6 +14,7 @@ * Add `SentryScopeStateProcessor` #603 * Add net5.0 TFM to libraries #606 * Add more logging to CachingTransport #619 +* Bump Microsoft.Bcl.AsyncInterfaces to 5.0.0 #618 * Bump `Microsoft.Bcl.AsyncInterfaces` to 5.0.0 #618 * `DefaultTags` moved from `SentryLoggingOptions` to `SentryOptions` (#637) @PureKrome * `Sentry.Serilog` can accept DefaultTags (#637) @PureKrome diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs index c86a7e7d2b..f8561ecc9b 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Program.cs +++ b/samples/Sentry.Samples.AspNetCore.Basic/Program.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.DependencyInjection; @@ -26,20 +27,24 @@ public static IWebHost BuildWebHost(string[] args) => // .UseSentry("dsn") or .UseSentry(o => o.Dsn = ""; o.Release = "1.0"; ...) // The App: - .Configure(a => + .Configure(app => { // An example ASP.NET Core middleware that throws an // exception when serving a request to path: /throw - a.Use(async (context, next) => + app.UseRouting(); + app.UseEndpoints(endpoints => { - var log = context.RequestServices.GetService() - .CreateLogger(); + // Reported events will be grouped by route pattern + endpoints.MapGet("/throw/{message?}", context => + { + var exceptionMessage = context.GetRouteValue("message") as string; - log.LogInformation("Handling some request..."); + var log = context.RequestServices.GetRequiredService() + .CreateLogger(); - if (context.Request.Path == "/throw") - { - var hub = context.RequestServices.GetService(); + log.LogInformation("Handling some request..."); + + var hub = context.RequestServices.GetRequiredService(); hub.ConfigureScope(s => { // More data can be added to the scope like this: @@ -53,10 +58,10 @@ public static IWebHost BuildWebHost(string[] args) => // The following exception will be captured by the SDK and the event // will include the Log messages and any custom scope modifications // as exemplified above. - throw new Exception("An exception thrown from the ASP.NET Core pipeline"); - } - - await next(); + throw new Exception( + exceptionMessage ?? "An exception thrown from the ASP.NET Core pipeline" + ); + }); }); }) .Build(); diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Properties/launchSettings.json b/samples/Sentry.Samples.AspNetCore.Basic/Properties/launchSettings.json index b4f2ba4fd1..0506d1660c 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Properties/launchSettings.json +++ b/samples/Sentry.Samples.AspNetCore.Basic/Properties/launchSettings.json @@ -3,7 +3,7 @@ "windowsAuthentication": false, "anonymousAuthentication": true, "iisExpress": { - "applicationUrl": "http://localhost:59739/", + "applicationUrl": "http://localhost:59739", "sslPort": 0 } }, @@ -27,7 +27,7 @@ "SENTRY_RELEASE": "e386dfd", "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "http://localhost:59740/" + "applicationUrl": "http://localhost:59740" } } } diff --git a/samples/Sentry.Samples.AspNetCore.Basic/Sentry.Samples.AspNetCore.Basic.csproj b/samples/Sentry.Samples.AspNetCore.Basic/Sentry.Samples.AspNetCore.Basic.csproj index c4100bab04..f5e993077c 100644 --- a/samples/Sentry.Samples.AspNetCore.Basic/Sentry.Samples.AspNetCore.Basic.csproj +++ b/samples/Sentry.Samples.AspNetCore.Basic/Sentry.Samples.AspNetCore.Basic.csproj @@ -1,13 +1,9 @@  - netcoreapp2.1 + net5.0 - - - - diff --git a/samples/Sentry.Samples.Console.Customized/Program.cs b/samples/Sentry.Samples.Console.Customized/Program.cs index ede8e48c1e..e0825237e4 100644 --- a/samples/Sentry.Samples.Console.Customized/Program.cs +++ b/samples/Sentry.Samples.Console.Customized/Program.cs @@ -139,7 +139,7 @@ await SentrySdk.ConfigureScopeAsync(async scope => SentrySdk.WithScope(s => { s.Level = SentryLevel.Fatal; - s.Transaction = "main"; + s.TransactionName = "main"; s.Environment = "SpecialEnvironment"; SentrySdk.CaptureMessage("Fatal message!"); diff --git a/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs new file mode 100644 index 0000000000..73112f8726 --- /dev/null +++ b/src/Sentry.AspNetCore/Extensions/HttpContextExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +#if !NETSTANDARD2_0 +using Microsoft.AspNetCore.Http.Features; +#endif + +namespace Sentry.AspNetCore.Extensions +{ + internal static class HttpContextExtensions + { + public static string? TryGetRouteTemplate(this HttpContext context) + { +#if !NETSTANDARD2_0 + // Requires .UseRouting()/.UseEndpoints() + var endpoint = context.Features.Get()?.Endpoint as RouteEndpoint; + var routePattern = endpoint?.RoutePattern.RawText; + + if (!string.IsNullOrWhiteSpace(routePattern)) + { + return routePattern; + } +#endif + + // Requires legacy .UseMvc() + var routeData = context.Features.Get()?.RouteData; + var controller = routeData?.Values["controller"]?.ToString(); + var action = routeData?.Values["action"]?.ToString(); + var area = routeData?.Values["area"]?.ToString(); + + if (!string.IsNullOrWhiteSpace(action)) + { + return !string.IsNullOrWhiteSpace(area) + ? $"{area}.{controller}.{action}" + : $"{controller}.{action}"; + } + + // If the handler doesn't use routing (i.e. it checks `context.Request.Path` directly), + // then there is no way for us to extract anything that resembles a route template. + return null; + } + } +} diff --git a/src/Sentry.AspNetCore/ScopeExtensions.cs b/src/Sentry.AspNetCore/ScopeExtensions.cs index 10c7ce706f..43c8579d03 100644 --- a/src/Sentry.AspNetCore/ScopeExtensions.cs +++ b/src/Sentry.AspNetCore/ScopeExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Net.Http.Headers; +using Sentry.AspNetCore.Extensions; using Sentry.Extensibility; using Sentry.Protocol; @@ -68,27 +69,26 @@ public static void Populate(this Scope scope, HttpContext context, SentryAspNetC try { var routeData = context.GetRouteData(); - if (routeData != null) + var controller = routeData.Values["controller"]?.ToString(); + var action = routeData.Values["action"]?.ToString(); + var area = routeData.Values["area"]?.ToString(); + + if (controller != null) + { + scope.SetTag("route.controller", controller); + } + + if (action != null) { - var controller = routeData.Values["controller"]?.ToString(); - var action = routeData.Values["action"]?.ToString(); - var area = routeData.Values["area"]?.ToString(); - - if (controller != null) - { - scope.SetTag("route.controller", controller); - } - if (action != null) - { - scope.SetTag("route.action", action); - } - if (area != null) - { - scope.SetTag("route.area", area); - } - - scope.Transaction = area == null ? $"{controller}.{action}" : $"{area}.{controller}.{action}"; + scope.SetTag("route.action", action); } + + if (area != null) + { + scope.SetTag("route.area", area); + } + + scope.TransactionName = context.TryGetRouteTemplate() ?? context.Request.Path; } catch(Exception e) { diff --git a/src/Sentry.AspNetCore/SentryMiddleware.cs b/src/Sentry.AspNetCore/SentryMiddleware.cs index 76cd437e62..0e1d0bd7cf 100644 --- a/src/Sentry.AspNetCore/SentryMiddleware.cs +++ b/src/Sentry.AspNetCore/SentryMiddleware.cs @@ -9,8 +9,10 @@ using IHostingEnvironment = Microsoft.AspNetCore.Hosting.IWebHostEnvironment; #endif using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Sentry.AspNetCore.Extensions; using Sentry.Extensibility; using Sentry.Protocol; using Sentry.Reflection; @@ -85,6 +87,7 @@ public async Task InvokeAsync(HttpContext context) { context.Request.EnableBuffering(); } + if (_options.FlushOnCompletedRequest) { context.Response.OnCompleted(async () => @@ -105,12 +108,19 @@ public async Task InvokeAsync(HttpContext context) scope.OnEvaluating += (_, __) => PopulateScope(context, scope); }); + + var transaction = hub.CreateTransaction( + // Try to get the route template or fallback to the request path + context.TryGetRouteTemplate() ?? context.Request.Path, + "http.server" + ); + try { await _next(context).ConfigureAwait(false); // When an exception was handled by other component (i.e: UseExceptionHandler feature). - var exceptionFeature = context.Features.Get(); + var exceptionFeature = context.Features.Get(); if (exceptionFeature?.Error != null) { CaptureException(exceptionFeature.Error); @@ -122,6 +132,12 @@ public async Task InvokeAsync(HttpContext context) ExceptionDispatchInfo.Capture(e).Throw(); } + finally + { + transaction.Finish( + GetSpanStatusFromCode(context.Response.StatusCode) + ); + } void CaptureException(Exception e) { @@ -161,5 +177,24 @@ internal void PopulateScope(HttpContext context, Scope scope) scope.Populate(Activity.Current); } } + + private static SpanStatus GetSpanStatusFromCode(int statusCode) => statusCode switch + { + < 400 => SpanStatus.Ok, + 400 => SpanStatus.InvalidArgument, + 401 => SpanStatus.Unauthenticated, + 403 => SpanStatus.PermissionDenied, + 404 => SpanStatus.NotFound, + 409 => SpanStatus.AlreadyExists, + 429 => SpanStatus.ResourceExhausted, + 499 => SpanStatus.Cancelled, + < 500 => SpanStatus.InvalidArgument, + 500 => SpanStatus.InternalError, + 501 => SpanStatus.Unimplemented, + 503 => SpanStatus.Unavailable, + 504 => SpanStatus.DeadlineExceeded, + < 600 => SpanStatus.InternalError, + _ => SpanStatus.UnknownError + }; } } diff --git a/src/Sentry.AspNetCore/SentryStartupFilter.cs b/src/Sentry.AspNetCore/SentryStartupFilter.cs index 80f0a84726..23ab0aa2c6 100644 --- a/src/Sentry.AspNetCore/SentryStartupFilter.cs +++ b/src/Sentry.AspNetCore/SentryStartupFilter.cs @@ -7,12 +7,11 @@ namespace Sentry.AspNetCore /// internal class SentryStartupFilter : IStartupFilter { - public Action Configure(Action next) - => e => - { - _ = e.UseSentry(); + public Action Configure(Action next) => e => + { + e.UseSentry(); - next(e); - }; + next(e); + }; } } diff --git a/src/Sentry/Extensibility/DisabledHub.cs b/src/Sentry/Extensibility/DisabledHub.cs index eadf3f1a9a..03e261afc4 100644 --- a/src/Sentry/Extensibility/DisabledHub.cs +++ b/src/Sentry/Extensibility/DisabledHub.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Sentry.Protocol; namespace Sentry.Extensibility { @@ -51,6 +52,17 @@ public void WithScope(Action scopeCallback) { } + /// + /// Returns a dummy transaction. + /// + public Transaction CreateTransaction(string name, string operation) => + new Transaction(this, null); + + /// + /// Returns null. + /// + public SentryTraceHeader? GetSentryTrace() => null; + /// /// No-Op. /// @@ -63,6 +75,13 @@ public void BindClient(ISentryClient client) /// public SentryId CaptureEvent(SentryEvent evt, Scope? scope = null) => SentryId.Empty; + /// + /// No-Op. + /// + public void CaptureTransaction(Transaction transaction) + { + } + /// /// No-Op. /// diff --git a/src/Sentry/Extensibility/HubAdapter.cs b/src/Sentry/Extensibility/HubAdapter.cs index f0d77482f6..f884744429 100644 --- a/src/Sentry/Extensibility/HubAdapter.cs +++ b/src/Sentry/Extensibility/HubAdapter.cs @@ -71,6 +71,20 @@ public IDisposable PushScope(TState state) public void WithScope(Action scopeCallback) => SentrySdk.WithScope(scopeCallback); + /// + /// Forwards the call to . + /// + [DebuggerStepThrough] + public Transaction CreateTransaction(string name, string operation) + => SentrySdk.CreateTransaction(name, operation); + + /// + /// Forwards the call to . + /// + [DebuggerStepThrough] + public SentryTraceHeader? GetSentryTrace() + => SentrySdk.GetTraceHeader(); + /// /// Forwards the call to . /// @@ -132,6 +146,14 @@ public SentryId CaptureException(Exception exception) public SentryId CaptureEvent(SentryEvent evt, Scope? scope) => SentrySdk.CaptureEvent(evt, scope); + /// + /// Forwards the call to . + /// + [DebuggerStepThrough] + [EditorBrowsable(EditorBrowsableState.Never)] + public void CaptureTransaction(Transaction transaction) + => SentrySdk.CaptureTransaction(transaction); + /// /// Forwards the call to /// diff --git a/src/Sentry/IHub.cs b/src/Sentry/IHub.cs index 1e62bdb91e..174b28ebe2 100644 --- a/src/Sentry/IHub.cs +++ b/src/Sentry/IHub.cs @@ -1,3 +1,5 @@ +using Sentry.Protocol; + namespace Sentry { /// @@ -18,5 +20,15 @@ public interface IHub : /// Last event id recorded in the current scope. /// SentryId LastEventId { get; } + + /// + /// Creates a transaction. + /// + Transaction CreateTransaction(string name, string operation); + + /// + /// Gets the sentry trace header. + /// + SentryTraceHeader? GetSentryTrace(); } } diff --git a/src/Sentry/ISentryClient.cs b/src/Sentry/ISentryClient.cs index 4ee1deffc7..47e412e479 100644 --- a/src/Sentry/ISentryClient.cs +++ b/src/Sentry/ISentryClient.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using Sentry.Protocol; namespace Sentry { @@ -27,6 +28,12 @@ public interface ISentryClient /// The user feedback to send to Sentry. void CaptureUserFeedback(UserFeedback userFeedback); + /// + /// Captures a transaction. + /// + /// The transaction. + void CaptureTransaction(Transaction transaction); + /// /// Flushes events queued up. /// diff --git a/src/Sentry/ISentryTraceSampler.cs b/src/Sentry/ISentryTraceSampler.cs new file mode 100644 index 0000000000..8b26d3d045 --- /dev/null +++ b/src/Sentry/ISentryTraceSampler.cs @@ -0,0 +1,13 @@ +namespace Sentry +{ + /// + /// Trace sampler. + /// + public interface ISentryTraceSampler + { + /// + /// Gets the sample rate based on context. + /// + double GetSampleRate(TraceSamplingContext context); + } +} diff --git a/src/Sentry/Internal/Extensions/JsonExtensions.cs b/src/Sentry/Internal/Extensions/JsonExtensions.cs index a1d728ff02..fe86c70e30 100644 --- a/src/Sentry/Internal/Extensions/JsonExtensions.cs +++ b/src/Sentry/Internal/Extensions/JsonExtensions.cs @@ -57,6 +57,15 @@ public static void WriteDictionaryValue( } } + public static void WriteDictionary( + this Utf8JsonWriter writer, + string propertyName, + IEnumerable>? dic) + { + writer.WritePropertyName(propertyName); + writer.WriteDictionaryValue(dic); + } + public static void WriteDictionary( this Utf8JsonWriter writer, string propertyName, diff --git a/src/Sentry/Internal/Extensions/StringExtensions.cs b/src/Sentry/Internal/Extensions/StringExtensions.cs new file mode 100644 index 0000000000..30e49f5359 --- /dev/null +++ b/src/Sentry/Internal/Extensions/StringExtensions.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace Sentry.Internal.Extensions +{ + internal static class StringExtensions + { + // Used to convert enum value into snake case, which is how Sentry represents them + public static string ToSnakeCase(this string str) => + Regex.Replace(str, @"(\p{Ll})(\p{Lu})", "$1_$2").ToLowerInvariant(); + } +} diff --git a/src/Sentry/Internal/Hub.cs b/src/Sentry/Internal/Hub.cs index 987766f18b..2ad46c6495 100644 --- a/src/Sentry/Internal/Hub.cs +++ b/src/Sentry/Internal/Hub.cs @@ -1,8 +1,10 @@ using System; +using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; using Sentry.Extensibility; using Sentry.Integrations; +using Sentry.Protocol; namespace Sentry.Internal { @@ -99,6 +101,39 @@ public void WithScope(Action scopeCallback) public void BindClient(ISentryClient client) => ScopeManager.BindClient(client); + public Transaction CreateTransaction(string name, string operation) + { + var trans = new Transaction(this, _options) + { + Name = name, + Operation = operation + }; + + var nameAndVersion = MainSentryEventProcessor.NameAndVersion; + var protocolPackageName = MainSentryEventProcessor.ProtocolPackageName; + + if (trans.Sdk.Version == null && trans.Sdk.Name == null) + { + trans.Sdk.Name = Constants.SdkName; + trans.Sdk.Version = nameAndVersion.Version; + } + + if (nameAndVersion.Version != null) + { + trans.Sdk.AddPackage(protocolPackageName, nameAndVersion.Version); + } + + ConfigureScope(scope => scope.Transaction = trans); + + return trans; + } + + public SentryTraceHeader? GetSentryTrace() + { + var (currentScope, _) = ScopeManager.GetCurrent(); + return currentScope.Transaction?.GetTraceHeader(); + } + public SentryId CaptureEvent(SentryEvent evt, Scope? scope = null) { try @@ -128,6 +163,18 @@ public void CaptureUserFeedback(UserFeedback userFeedback) } } + public void CaptureTransaction(Transaction transaction) + { + try + { + _ownedClient.CaptureTransaction(transaction); + } + catch (Exception e) + { + _options.DiagnosticLogger?.LogError("Failure to capture transaction: {0}", e, transaction.SpanId); + } + } + public async Task FlushAsync(TimeSpan timeout) { try diff --git a/src/Sentry/Internal/MainSentryEventProcessor.cs b/src/Sentry/Internal/MainSentryEventProcessor.cs index 4a13869f10..3e2ce272c9 100644 --- a/src/Sentry/Internal/MainSentryEventProcessor.cs +++ b/src/Sentry/Internal/MainSentryEventProcessor.cs @@ -31,10 +31,10 @@ internal class MainSentryEventProcessor : ISentryEventProcessor : null; }); - private static readonly SdkVersion NameAndVersion + internal static readonly SdkVersion NameAndVersion = typeof(ISentryClient).Assembly.GetNameAndVersion(); - private static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; + internal static readonly string ProtocolPackageName = "nuget:" + NameAndVersion.Name; private readonly SentryOptions _options; internal Func SentryStackTraceFactoryAccessor { get; } @@ -96,20 +96,17 @@ public SentryEvent Process(SentryEvent @event) @event.Platform = Protocol.Constants.Platform; - if (@event.Sdk != null) + // SDK Name/Version might have be already set by an outer package + // e.g: ASP.NET Core can set itself as the SDK + if (@event.Sdk.Version == null && @event.Sdk.Name == null) { - // SDK Name/Version might have be already set by an outer package - // e.g: ASP.NET Core can set itself as the SDK - if (@event.Sdk.Version == null && @event.Sdk.Name == null) - { - @event.Sdk.Name = Constants.SdkName; - @event.Sdk.Version = NameAndVersion.Version; - } + @event.Sdk.Name = Constants.SdkName; + @event.Sdk.Version = NameAndVersion.Version; + } - if (NameAndVersion.Version != null) - { - @event.Sdk.AddPackage(ProtocolPackageName, NameAndVersion.Version); - } + if (NameAndVersion.Version != null) + { + @event.Sdk.AddPackage(ProtocolPackageName, NameAndVersion.Version); } // Report local user if opt-in PII, no user was already set to event and feature not opted-out: diff --git a/src/Sentry/Internal/Polyfills.cs b/src/Sentry/Internal/Polyfills.cs index f4600119f1..3da977007c 100644 --- a/src/Sentry/Internal/Polyfills.cs +++ b/src/Sentry/Internal/Polyfills.cs @@ -3,9 +3,16 @@ // Polyfills to bridge the missing APIs in older versions of the framework/standard. // In some cases, these just proxy calls to existing methods but also provide a signature that matches .netstd2.1 -using System.Linq; - #if NET461 || NETSTANDARD2_0 +namespace System +{ + internal static class Extensions + { + public static string[] Split(this string str, char c, StringSplitOptions options = StringSplitOptions.None) => + str.Split(new[] {c}, options); + } +} + namespace System.IO { using Threading; @@ -26,6 +33,8 @@ public static Task WriteAsync(this Stream stream, byte[] buffer, CancellationTok namespace System.Collections.Generic { + using Linq; + internal static class Extensions { public static void Deconstruct( diff --git a/src/Sentry/Protocol/Context/Contexts.cs b/src/Sentry/Protocol/Context/Contexts.cs index 6535624926..d04cafe3ac 100644 --- a/src/Sentry/Protocol/Context/Contexts.cs +++ b/src/Sentry/Protocol/Context/Contexts.cs @@ -45,6 +45,11 @@ public sealed class Contexts : ConcurrentDictionary, IJsonSerial /// public Gpu Gpu => this.GetOrCreate(Gpu.Type); + /// + /// This describes trace information. + /// + public Trace Trace => this.GetOrCreate(Trace.Type); + /// /// Initializes an instance of . /// @@ -123,6 +128,10 @@ public static Contexts FromJson(JsonElement json) { result[name] = Gpu.FromJson(value); } + else if (string.Equals(type, Trace.Type, StringComparison.OrdinalIgnoreCase)) + { + result[name] = Trace.FromJson(value); + } else { // Unknown context - parse as dictionary diff --git a/src/Sentry/Protocol/Context/Trace.cs b/src/Sentry/Protocol/Context/Trace.cs new file mode 100644 index 0000000000..df6998929a --- /dev/null +++ b/src/Sentry/Protocol/Context/Trace.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol +{ + /// + /// Trace context data. + /// + public class Trace : ISpanContext, IJsonSerializable + { + /// + /// Tells Sentry which type of context this is. + /// + public const string Type = "trace"; + + /// + public SpanId SpanId { get; set; } + + /// + public SpanId? ParentSpanId { get; set; } + + /// + public SentryId TraceId { get; set; } + + /// + public string Operation { get; set; } = "unknown"; + + /// + public SpanStatus? Status { get; set; } + + /// + public bool IsSampled { get; set; } + + /// + public void WriteTo(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + writer.WriteString("type", Type); + writer.WriteSerializable("span_id", SpanId); + + if (ParentSpanId is {} parentSpanId) + { + writer.WriteSerializable("parent_span_id", parentSpanId); + } + + writer.WriteSerializable("trace_id", TraceId); + + if (!string.IsNullOrWhiteSpace(Operation)) + { + writer.WriteString("op", Operation); + } + + if (Status is {} status) + { + writer.WriteString("status", status.ToString().ToSnakeCase()); + } + + writer.WriteBoolean("sampled", IsSampled); + + writer.WriteEndObject(); + } + + /// + /// Parses trace context from JSON. + /// + public static Trace FromJson(JsonElement json) + { + var spanId = json.GetPropertyOrNull("span_id")?.Pipe(SpanId.FromJson) ?? SpanId.Empty; + var parentSpanId = json.GetPropertyOrNull("parent_span_id")?.Pipe(SpanId.FromJson); + var traceId = json.GetPropertyOrNull("trace_id")?.Pipe(SentryId.FromJson) ?? SentryId.Empty; + var operation = json.GetPropertyOrNull("op")?.GetString() ?? "unknown"; + var status = json.GetPropertyOrNull("status")?.GetString()?.Pipe(s => s.Replace("_", "").ParseEnum()); + + return new Trace + { + SpanId = spanId, + ParentSpanId = parentSpanId, + TraceId = traceId, + Operation = operation, + Status = status + }; + } + } +} diff --git a/src/Sentry/Protocol/Envelopes/Envelope.cs b/src/Sentry/Protocol/Envelopes/Envelope.cs index d49708322d..e66a9e4c57 100644 --- a/src/Sentry/Protocol/Envelopes/Envelope.cs +++ b/src/Sentry/Protocol/Envelopes/Envelope.cs @@ -106,6 +106,24 @@ public static Envelope FromUserFeedback(UserFeedback sentryUserFeedback) return new Envelope(header, items); } + /// + /// Creates an envelope that contains a single transaction. + /// + public static Envelope FromTransaction(Transaction transaction) + { + var header = new Dictionary(StringComparer.Ordinal) + { + // TODO: this should include transaction's event id + }; + + var items = new[] + { + EnvelopeItem.FromTransaction(transaction) + }; + + return new Envelope(header, items); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) diff --git a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs index 641b43e065..b2c1d42af5 100644 --- a/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs +++ b/src/Sentry/Protocol/Envelopes/EnvelopeItem.cs @@ -18,6 +18,7 @@ internal sealed class EnvelopeItem : ISerializable, IDisposable private const string TypeKey = "type"; private const string TypeValueEvent = "event"; private const string TypeValueUserReport = "user_report"; + private const string TypeValueTransaction = "transaction"; private const string LengthKey = "length"; private const string FileNameKey = "file_name"; @@ -176,6 +177,19 @@ public static EnvelopeItem FromUserFeedback(UserFeedback sentryUserFeedback) return new EnvelopeItem(header, new JsonSerializable(sentryUserFeedback)); } + /// + /// Creates an envelope item from transaction. + /// + public static EnvelopeItem FromTransaction(Transaction transaction) + { + var header = new Dictionary(StringComparer.Ordinal) + { + [TypeKey] = TypeValueTransaction + }; + + return new EnvelopeItem(header, new JsonSerializable(transaction)); + } + private static async Task> DeserializeHeaderAsync( Stream stream, CancellationToken cancellationToken = default) @@ -218,11 +232,9 @@ private static async Task DeserializePayloadAsync( { var bufferLength = (int)(payloadLength ?? stream.Length); var buffer = await stream.ReadByteChunkAsync(bufferLength, cancellationToken).ConfigureAwait(false); - using var jsonDocument = JsonDocument.Parse(buffer); + var json = Json.Parse(buffer); - return new JsonSerializable( - SentryEvent.FromJson(jsonDocument.RootElement.Clone()) - ); + return new JsonSerializable(SentryEvent.FromJson(json)); } // User report @@ -230,11 +242,19 @@ private static async Task DeserializePayloadAsync( { var bufferLength = (int)(payloadLength ?? stream.Length); var buffer = await stream.ReadByteChunkAsync(bufferLength, cancellationToken).ConfigureAwait(false); - using var jsonDocument = JsonDocument.Parse(buffer); + var json = Json.Parse(buffer); + + return new JsonSerializable(UserFeedback.FromJson(json)); + } + + // Transaction + if (string.Equals(payloadType, TypeValueTransaction, StringComparison.OrdinalIgnoreCase)) + { + var bufferLength = (int)(payloadLength ?? stream.Length); + var buffer = await stream.ReadByteChunkAsync(bufferLength, cancellationToken).ConfigureAwait(false); + var json = Json.Parse(buffer); - return new JsonSerializable( - UserFeedback.FromJson(jsonDocument.RootElement.Clone()) - ); + return new JsonSerializable(Transaction.FromJson(json)); } // Arbitrary payload diff --git a/src/Sentry/Protocol/IScope.cs b/src/Sentry/Protocol/IScope.cs index c69f302a09..c17f8a9126 100644 --- a/src/Sentry/Protocol/IScope.cs +++ b/src/Sentry/Protocol/IScope.cs @@ -28,17 +28,6 @@ public interface IScope /// SentryLevel? Level { get; set; } - /// - /// The name of the transaction in which there was an event. - /// - /// - /// A transaction should only be defined when it can be well defined. - /// On a Web framework, for example, a transaction is the route template - /// rather than the actual request path. That is so GET /user/10 and /user/20 - /// (which have route template /user/{id}) are identified as the same transaction. - /// - string? Transaction { get; set; } - /// /// Gets or sets the HTTP. /// @@ -69,6 +58,22 @@ public interface IScope /// Requires Sentry 8.0 or higher. string? Environment { get; set; } + /// + /// The name of the transaction in which there was an event. + /// + /// + /// A transaction should only be defined when it can be well defined. + /// On a Web framework, for example, a transaction is the route template + /// rather than the actual request path. That is so GET /user/10 and /user/20 + /// (which have route template /user/{id}) are identified as the same transaction. + /// + string? TransactionName { get; set; } + + /// + /// Transaction. + /// + Transaction? Transaction { get; set; } + /// /// SDK information. /// diff --git a/src/Sentry/Protocol/ISpan.cs b/src/Sentry/Protocol/ISpan.cs new file mode 100644 index 0000000000..4708ddd3a9 --- /dev/null +++ b/src/Sentry/Protocol/ISpan.cs @@ -0,0 +1,46 @@ +using System; +using System.Collections.Generic; + +namespace Sentry.Protocol +{ + /// + /// Span. + /// + public interface ISpan : ISpanContext + { + /// + /// Description. + /// + string? Description { get; set; } + + /// + /// Start timestamp. + /// + DateTimeOffset StartTimestamp { get; } + + /// + /// End timestamp. + /// + DateTimeOffset? EndTimestamp { get; } + + /// + /// Tags. + /// + IReadOnlyDictionary Tags { get; } + + /// + /// Data. + /// + IReadOnlyDictionary Extra { get; } + + /// + /// Starts a child span. + /// + ISpan StartChild(string operation); + + /// + /// Finishes the span. + /// + void Finish(SpanStatus status = SpanStatus.Ok); + } +} diff --git a/src/Sentry/Protocol/ISpanContext.cs b/src/Sentry/Protocol/ISpanContext.cs new file mode 100644 index 0000000000..b4ea8e07bd --- /dev/null +++ b/src/Sentry/Protocol/ISpanContext.cs @@ -0,0 +1,41 @@ +namespace Sentry.Protocol +{ + // Parts of transaction (which is a span) are stored in a context + // for some unknown reason. This interface defines those fields. + + /// + /// Span metadata. + /// + public interface ISpanContext + { + /// + /// Span ID. + /// + SpanId SpanId { get; } + + /// + /// Parent ID. + /// + SpanId? ParentSpanId { get; } + + /// + /// Trace ID. + /// + SentryId TraceId { get; } + + /// + /// Operation. + /// + string Operation { get; } + + /// + /// Status. + /// + SpanStatus? Status { get; } + + /// + /// Is sampled. + /// + bool IsSampled { get; set; } + } +} diff --git a/src/Sentry/Protocol/SentryEvent.cs b/src/Sentry/Protocol/SentryEvent.cs index c8d087d11d..d8a0d2b795 100644 --- a/src/Sentry/Protocol/SentryEvent.cs +++ b/src/Sentry/Protocol/SentryEvent.cs @@ -112,7 +112,7 @@ public IEnumerable? SentryThreads public SentryLevel? Level { get; set; } /// - public string? Transaction { get; set; } + public string? TransactionName { get; set; } private Request? _request; /// @@ -169,6 +169,13 @@ public IEnumerable Fingerprint /// public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); + // TODO: this is a workaround, ideally Event should not inherit from IScope + Transaction? IScope.Transaction + { + get => null; + set {} + } + /// /// Creates a new instance of . /// @@ -193,7 +200,7 @@ internal SentryEvent( { Exception = exception; Timestamp = timestamp ?? DateTimeOffset.UtcNow; - EventId = eventId != default ? eventId : (SentryId)Guid.NewGuid(); + EventId = eventId != default ? eventId : SentryId.Create(); ScopeOptions = options; Platform = Constants.Platform; } @@ -265,9 +272,9 @@ public void WriteTo(Utf8JsonWriter writer) } // Transaction - if (!string.IsNullOrWhiteSpace(Transaction)) + if (!string.IsNullOrWhiteSpace(TransactionName)) { - writer.WriteString("transaction", Transaction); + writer.WriteString("transaction", TransactionName); } // Request @@ -390,7 +397,7 @@ public static SentryEvent FromJson(JsonElement json) SentryExceptionValues = exceptionValues, SentryThreadValues = threadValues, Level = level, - Transaction = transaction, + TransactionName = transaction, _request = request, _contexts = contexts, _user = user, diff --git a/src/Sentry/Protocol/SentryId.cs b/src/Sentry/Protocol/SentryId.cs index 3a3aeb9391..60bd96ed5f 100644 --- a/src/Sentry/Protocol/SentryId.cs +++ b/src/Sentry/Protocol/SentryId.cs @@ -9,7 +9,7 @@ namespace Sentry /// public readonly struct SentryId : IEquatable, IJsonSerializable { - private readonly Guid _eventId; + private readonly Guid _guid; /// /// An empty sentry id. @@ -19,7 +19,7 @@ namespace Sentry /// /// Creates a new instance of a Sentry Id. /// - public SentryId(Guid guid) => _eventId = guid; + public SentryId(Guid guid) => _guid = guid; /// /// Sentry Id in the format Sentry recognizes. @@ -29,20 +29,30 @@ namespace Sentry /// dashes which sentry doesn't expect when searching events. /// /// String representation of the event id. - public override string ToString() => _eventId.ToString("n"); + public override string ToString() => _guid.ToString("n"); /// - public bool Equals(SentryId other) => _eventId.Equals(other._eventId); + public bool Equals(SentryId other) => _guid.Equals(other._guid); /// public override bool Equals(object? obj) => obj is SentryId other && Equals(other); /// - public override int GetHashCode() => _eventId.GetHashCode(); + public override int GetHashCode() => _guid.GetHashCode(); + + /// + /// Generates a new Sentry ID. + /// + public static SentryId Create() => new(Guid.NewGuid()); /// public void WriteTo(Utf8JsonWriter writer) => writer.WriteStringValue(ToString()); + /// + /// Parses from string. + /// + public static SentryId Parse(string value) => new(Guid.Parse(value)); + /// /// Parses from JSON. /// @@ -68,11 +78,11 @@ public static SentryId FromJson(JsonElement json) /// /// The from the . /// - public static implicit operator Guid(SentryId sentryId) => sentryId._eventId; + public static implicit operator Guid(SentryId sentryId) => sentryId._guid; /// /// A from a . /// - public static implicit operator SentryId(Guid guid) => new SentryId(guid); + public static implicit operator SentryId(Guid guid) => new(guid); } } diff --git a/src/Sentry/Protocol/SentryTraceHeader.cs b/src/Sentry/Protocol/SentryTraceHeader.cs new file mode 100644 index 0000000000..e08d4aaaec --- /dev/null +++ b/src/Sentry/Protocol/SentryTraceHeader.cs @@ -0,0 +1,50 @@ +using System; + +namespace Sentry.Protocol +{ + /// + /// Sentry trace header. + /// + public class SentryTraceHeader + { + private readonly SentryId _traceId; + private readonly SpanId _spanId; + private readonly bool? _isSampled; + + /// + /// Initializes an instance of . + /// + public SentryTraceHeader(SentryId traceId, SpanId spanId, bool? isSampled) + { + _traceId = traceId; + _spanId = spanId; + _isSampled = isSampled; + } + + /// + public override string ToString() => _isSampled is {} isSampled + ? $"{_traceId}-{_spanId}-{(isSampled ? 1 : 0)}" + : $"{_traceId}-{_spanId}"; + + /// + /// Parses from string. + /// + public static SentryTraceHeader Parse(string value) + { + var components = value.Split('-', StringSplitOptions.RemoveEmptyEntries); + if (components.Length < 2) + { + throw new FormatException($"Invalid Sentry trace header: {value}."); + } + + var traceId = SentryId.Parse(components[0]); + var spanId = SpanId.Parse(components[1]); + + var isSampled = components.Length >= 3 + ? string.Equals(components[2], "1", StringComparison.OrdinalIgnoreCase) + : (bool?)null; + + return new SentryTraceHeader(traceId, spanId, isSampled); + } + } +} diff --git a/src/Sentry/Protocol/Span.cs b/src/Sentry/Protocol/Span.cs new file mode 100644 index 0000000000..9e568dd448 --- /dev/null +++ b/src/Sentry/Protocol/Span.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol +{ + // https://develop.sentry.dev/sdk/event-payloads/span + /// + /// Transaction span. + /// + public class Span : ISpan, IJsonSerializable + { + private readonly SpanRecorder _parentSpanRecorder; + + /// + public SpanId SpanId { get; } + + /// + public SpanId? ParentSpanId { get; } + + /// + public SentryId TraceId { get; private set; } + + /// + public DateTimeOffset StartTimestamp { get; private set; } = DateTimeOffset.UtcNow; + + /// + public DateTimeOffset? EndTimestamp { get; private set; } + + /// + public string Operation { get; } + + /// + public string? Description { get; set; } + + /// + public SpanStatus? Status { get; private set; } + + /// + public bool IsSampled { get; set; } + + private ConcurrentDictionary? _tags; + + /// + public IReadOnlyDictionary Tags => _tags ??= new ConcurrentDictionary(); + + private ConcurrentDictionary? _data; + + /// + public IReadOnlyDictionary Extra => _data ??= new ConcurrentDictionary(); + + internal Span(SpanRecorder parentSpanRecorder, SpanId? spanId = null, SpanId? parentSpanId = null, string operation = "unknown") + { + _parentSpanRecorder = parentSpanRecorder; + SpanId = spanId ?? SpanId.Create(); + ParentSpanId = parentSpanId; + TraceId = SentryId.Create(); + Operation = operation; + } + + /// + public ISpan StartChild(string operation) + { + var span = new Span(_parentSpanRecorder, null, SpanId, operation); + _parentSpanRecorder.Add(span); + + return span; + } + + /// + public void Finish(SpanStatus status = SpanStatus.Ok) + { + EndTimestamp = DateTimeOffset.UtcNow; + Status = status; + } + + /// + public void WriteTo(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + writer.WriteSerializable("span_id", SpanId); + + if (ParentSpanId is {} parentSpanId) + { + writer.WriteSerializable("parent_span_id", parentSpanId); + } + + writer.WriteSerializable("trace_id", TraceId); + + if (!string.IsNullOrWhiteSpace(Operation)) + { + writer.WriteString("op", Operation); + } + + if (!string.IsNullOrWhiteSpace(Description)) + { + writer.WriteString("description", Description); + } + + if (Status is {} status) + { + writer.WriteString("status", status.ToString().ToSnakeCase()); + } + + writer.WriteBoolean("sampled", IsSampled); + + writer.WriteString("start_timestamp", StartTimestamp); + + if (EndTimestamp is {} endTimestamp) + { + writer.WriteString("timestamp", endTimestamp); + } + + if (_tags is {} tags && tags.Any()) + { + writer.WriteDictionary("tags", tags!); + } + + if (_data is {} data && data.Any()) + { + writer.WriteDictionary("data", data!); + } + + writer.WriteEndObject(); + } + + /// + /// Parses a span from JSON. + /// + public static Span FromJson(JsonElement json) + { + // TODO + var parentSpanRecorder = new SpanRecorder(); + + var spanId = json.GetPropertyOrNull("span_id")?.Pipe(SpanId.FromJson) ?? SpanId.Empty; + var parentSpanId = json.GetPropertyOrNull("parent_span_id")?.Pipe(SpanId.FromJson); + var traceId = json.GetPropertyOrNull("trace_id")?.Pipe(SentryId.FromJson) ?? SentryId.Empty; + var startTimestamp = json.GetProperty("start_timestamp").GetDateTimeOffset(); + var endTimestamp = json.GetProperty("timestamp").GetDateTimeOffset(); + var operation = json.GetPropertyOrNull("op")?.GetString() ?? "unknown"; + var description = json.GetPropertyOrNull("description")?.GetString(); + var status = json.GetPropertyOrNull("status")?.GetString()?.Pipe(s => s.Replace("_", "").ParseEnum()); + var isSampled = json.GetPropertyOrNull("sampled")?.GetBoolean() ?? false; + var tags = json.GetPropertyOrNull("tags")?.GetDictionary()?.Pipe(v => new ConcurrentDictionary(v!)); + var data = json.GetPropertyOrNull("data")?.GetObjectDictionary()?.Pipe(v => new ConcurrentDictionary(v!)); + + return new Span(parentSpanRecorder, spanId, parentSpanId, operation) + { + TraceId = traceId, + StartTimestamp = startTimestamp, + EndTimestamp = endTimestamp, + Description = description, + Status = status, + IsSampled = isSampled, + _tags = tags, + _data = data + }; + } + } +} diff --git a/src/Sentry/Protocol/SpanId.cs b/src/Sentry/Protocol/SpanId.cs new file mode 100644 index 0000000000..e7cab657ff --- /dev/null +++ b/src/Sentry/Protocol/SpanId.cs @@ -0,0 +1,84 @@ +using System; +using System.Text.Json; + +namespace Sentry.Protocol +{ + /// + /// Sentry span ID. + /// + public readonly struct SpanId : IEquatable, IJsonSerializable + { + private readonly string _value; + + /// + /// An empty Sentry span ID. + /// + public static readonly SpanId Empty = new("0000000000000000"); + + /// + /// Creates a new instance of a Sentry span Id. + /// + public SpanId(string value) => _value = value; + + /// + public bool Equals(SpanId other) => StringComparer.Ordinal.Equals(_value, other._value); + + /// + public override bool Equals(object? obj) => obj is SpanId other && Equals(other); + + /// + public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(_value); + + /// + public override string ToString() => _value; + + // Note: spans are sentry IDs with only 16 characters, rest being truncated. + // This is obviously a bad idea as it invalidates GUID's uniqueness properties + // (https://devblogs.microsoft.com/oldnewthing/20080627-00/?p=21823) + // but all other SDKs do it this way, so we have no choice but to comply. + /// + /// Generates a new Sentry ID. + /// + public static SpanId Create() => new(Guid.NewGuid().ToString("n").Substring(0, 16)); + + /// + public void WriteTo(Utf8JsonWriter writer) => writer.WriteStringValue(_value); + + /// + /// Parses from string. + /// + public static SpanId Parse(string value) => new(value); + + /// + /// Parses from JSON. + /// + public static SpanId FromJson(JsonElement json) + { + var value = json.GetString(); + + return !string.IsNullOrWhiteSpace(value) + ? new SpanId(value) + : Empty; + } + + /// + /// Equality operator. + /// + public static bool operator ==(SpanId left, SpanId right) => left.Equals(right); + + /// + /// Equality operator. + /// + public static bool operator !=(SpanId left, SpanId right) => !(left == right); + + /// + /// The from the . + /// + public static implicit operator string(SpanId id) => id._value; + + /// + /// A from a . + /// + public static implicit operator SpanId(string value) => new(value); + } +} diff --git a/src/Sentry/Protocol/SpanRecorder.cs b/src/Sentry/Protocol/SpanRecorder.cs new file mode 100644 index 0000000000..f285afa8aa --- /dev/null +++ b/src/Sentry/Protocol/SpanRecorder.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace Sentry.Protocol +{ + /// + /// Records spans that belong to the same transaction. + /// + public class SpanRecorder + { + private const int MaxSpans = 1000; + + private readonly object _lock = new object(); + private readonly List _spans = new List(); + + /// + /// Records a span. + /// + public void Add(Span span) + { + lock (_lock) + { + if (_spans.Count < MaxSpans) + { + _spans.Add(span); + } + } + } + + /// + /// Gets all recorded spans. + /// + public IReadOnlyList GetAll() + { + lock (_lock) + { + return _spans; + } + } + } +} diff --git a/src/Sentry/Protocol/SpanStatus.cs b/src/Sentry/Protocol/SpanStatus.cs new file mode 100644 index 0000000000..7510941b7f --- /dev/null +++ b/src/Sentry/Protocol/SpanStatus.cs @@ -0,0 +1,59 @@ +namespace Sentry.Protocol +{ + /// + /// Span status. + /// + public enum SpanStatus + { + /// The operation completed successfully. + Ok, + + /// Deadline expired before operation could complete. + DeadlineExceeded, + + /// 401 Unauthorized (actually does mean unauthenticated according to RFC 7235). + Unauthenticated, + + /// 403 Forbidden + PermissionDenied, + + /// 404 Not Found. Some requested entity (file or directory) was not found. + NotFound, + + /// 429 Too Many Requests + ResourceExhausted, + + /// Client specified an invalid argument. 4xx. + InvalidArgument, + + /// 501 Not Implemented + Unimplemented, + + /// 503 Service Unavailable + Unavailable, + + /// Other/generic 5xx. + InternalError, + + /// Unknown. Any non-standard HTTP status code. + UnknownError, + + /// The operation was cancelled (typically by the user). + Cancelled, + + /// Already exists (409). + AlreadyExists, + + /// Operation was rejected because the system is not in a state required for the operation's + FailedPrecondition, + + /// The operation was aborted, typically due to a concurrency issue. + Aborted, + + /// Operation was attempted past the valid range. + OutOfRange, + + /// Unrecoverable data loss or corruption + DataLoss, + } +} diff --git a/src/Sentry/Protocol/Transaction.cs b/src/Sentry/Protocol/Transaction.cs new file mode 100644 index 0000000000..085426864d --- /dev/null +++ b/src/Sentry/Protocol/Transaction.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry.Protocol +{ + // https://develop.sentry.dev/sdk/event-payloads/transaction + /// + /// Sentry performance transaction. + /// + public class Transaction : ISpan, IScope, IJsonSerializable + { + private readonly IHub _hub; + private readonly SpanRecorder _spanRecorder = new SpanRecorder(); + + /// + public IScopeOptions? ScopeOptions { get; } + + /// + /// Transaction name. + /// + public string Name { get; set; } = "unnamed"; + + /// + public SpanId SpanId + { + get => Contexts.Trace.SpanId; + private set => Contexts.Trace.SpanId = value; + } + + /// + public SentryId TraceId + { + get => Contexts.Trace.TraceId; + private set => Contexts.Trace.TraceId = value; + } + + /// + public DateTimeOffset StartTimestamp { get; private set; } = DateTimeOffset.UtcNow; + + /// + public DateTimeOffset? EndTimestamp { get; private set; } + + /// + public string Operation + { + get => Contexts.Trace.Operation; + internal set => Contexts.Trace.Operation = value; + } + + /// + public string? Description { get; set; } + + /// + public SpanStatus? Status + { + get => Contexts.Trace.Status; + private set => Contexts.Trace.Status = value; + } + + /// + public bool IsSampled + { + get => Contexts.Trace.IsSampled; + set => Contexts.Trace.IsSampled = value; + } + + /// + public SentryLevel? Level { get; set; } + + private Request? _request; + + /// + public Request Request + { + get => _request ??= new Request(); + set => _request = value; + } + + private Contexts? _contexts; + + /// + public Contexts Contexts + { + get => _contexts ??= new Contexts(); + set => _contexts = value; + } + + private User? _user; + + /// + public User User + { + get => _user ??= new User(); + set => _user = value; + } + + /// + public string? Environment { get; set; } + + /// + public SdkVersion Sdk { get; internal set; } = new SdkVersion(); + + private IEnumerable? _fingerprint; + + /// + public IEnumerable Fingerprint + { + get => _fingerprint ?? Enumerable.Empty(); + set => _fingerprint = value; + } + + private List? _breadcrumbs; + + /// + public IEnumerable Breadcrumbs => _breadcrumbs ??= new List(); + + private Dictionary? _extra; + + /// + public IReadOnlyDictionary Extra => _extra ??= new Dictionary(); + + private Dictionary? _tags; + + /// + public IReadOnlyDictionary Tags => _tags ??= new Dictionary(); + + // Transaction never has a parent + SpanId? ISpanContext.ParentSpanId => null; + + string? IScope.TransactionName + { + get => Name; + set => Name = value ?? "unnamed"; + } + + // TODO: this is a workaround, ideally Transaction should not inherit from IScope + Transaction? IScope.Transaction + { + get => null; + set {} + } + + internal Transaction(IHub hub, IScopeOptions? scopeOptions) + { + _hub = hub; + ScopeOptions = scopeOptions; + + SpanId = SpanId.Create(); + TraceId = SentryId.Create(); + } + + /// + public ISpan StartChild(string operation) + { + var span = new Span(_spanRecorder, null, SpanId, operation); + _spanRecorder.Add(span); + + return span; + } + + /// + public void Finish(SpanStatus status = SpanStatus.Ok) + { + EndTimestamp = DateTimeOffset.UtcNow; + Status = status; + + _hub.CaptureTransaction(this); + } + + /// + /// Get Sentry trace header. + /// + public SentryTraceHeader GetTraceHeader() => new SentryTraceHeader( + TraceId, + SpanId, + IsSampled + ); + + /// + public void WriteTo(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + + writer.WriteString("type", "transaction"); + writer.WriteSerializable("event_id", SentryId.Create()); + + if (Level is {} level) + { + writer.WriteString("level", level.ToString().ToLowerInvariant()); + } + + if (!string.IsNullOrWhiteSpace(Name)) + { + writer.WriteString("transaction", Name); + } + + if (!string.IsNullOrWhiteSpace(Description)) + { + writer.WriteString("description", Description); + } + + writer.WriteString("start_timestamp", StartTimestamp); + + if (EndTimestamp is {} endTimestamp) + { + writer.WriteString("timestamp", endTimestamp); + } + + if (_request is {} request) + { + writer.WriteSerializable("request", request); + } + + if (_contexts is {} contexts) + { + writer.WriteSerializable("contexts", contexts); + } + + if (_user is {} user) + { + writer.WriteSerializable("user", user); + } + + if (!string.IsNullOrWhiteSpace(Environment)) + { + writer.WriteString("environment", Environment); + } + + writer.WriteSerializable("sdk", Sdk); + + if (_fingerprint is {} fingerprint && fingerprint.Any()) + { + writer.WriteStartArray("fingerprint"); + + foreach (var i in fingerprint) + { + writer.WriteStringValue(i); + } + + writer.WriteEndArray(); + } + + if (_breadcrumbs is {} breadcrumbs && breadcrumbs.Any()) + { + writer.WriteStartArray("breadcrumbs"); + + foreach (var i in breadcrumbs) + { + writer.WriteSerializableValue(i); + } + + writer.WriteEndArray(); + } + + if (_extra is {} extra && extra.Any()) + { + writer.WriteStartObject("extra"); + + foreach (var (key, value) in extra) + { + writer.WriteDynamic(key, value); + } + + writer.WriteEndObject(); + } + + if (_tags is {} tags && tags.Any()) + { + writer.WriteStartObject("tags"); + + foreach (var (key, value) in tags) + { + writer.WriteString(key, value); + } + + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + + /// + /// Parses transaction from JSON. + /// + public static Transaction FromJson(JsonElement json) + { + var hub = HubAdapter.Instance; + + var name = json.GetProperty("transaction").GetStringOrThrow(); + var description = json.GetPropertyOrNull("description")?.GetString(); + var startTimestamp = json.GetProperty("start_timestamp").GetDateTimeOffset(); + var endTimestamp = json.GetPropertyOrNull("timestamp")?.GetDateTimeOffset(); + var level = json.GetPropertyOrNull("level")?.GetString()?.Pipe(s => s.ParseEnum()); + var request = json.GetPropertyOrNull("request")?.Pipe(Request.FromJson); + var contexts = json.GetPropertyOrNull("contexts")?.Pipe(Contexts.FromJson); + var user = json.GetPropertyOrNull("user")?.Pipe(User.FromJson); + var environment = json.GetPropertyOrNull("environment")?.GetString(); + var sdk = json.GetPropertyOrNull("sdk")?.Pipe(SdkVersion.FromJson) ?? new SdkVersion(); + var fingerprint = json.GetPropertyOrNull("fingerprint")?.EnumerateArray().Select(j => j.GetString()).ToArray(); + var breadcrumbs = json.GetPropertyOrNull("breadcrumbs")?.EnumerateArray().Select(Breadcrumb.FromJson).ToList(); + var extra = json.GetPropertyOrNull("extra")?.GetObjectDictionary()?.ToDictionary(); + var tags = json.GetPropertyOrNull("tags")?.GetDictionary()?.ToDictionary(); + + return new Transaction(hub, null) + { + Name = name, + Description = description, + StartTimestamp = startTimestamp, + EndTimestamp = endTimestamp, + Level = level, + _request = request, + _contexts = contexts, + _user = user, + Environment = environment, + Sdk = sdk, + _fingerprint = fingerprint!, + _breadcrumbs = breadcrumbs!, + _extra = extra!, + _tags = tags! + }; + } + } +} diff --git a/src/Sentry/Scope.cs b/src/Sentry/Scope.cs index 7408aa7d5e..02d568d17e 100644 --- a/src/Sentry/Scope.cs +++ b/src/Sentry/Scope.cs @@ -83,9 +83,6 @@ internal SentryId LastEventId /// public SentryLevel? Level { get; set; } - /// - public string? Transaction { get; set; } - private Request? _request; /// @@ -131,6 +128,12 @@ public User User /// public IReadOnlyDictionary Tags { get; } = new ConcurrentDictionary(); + /// + public Transaction? Transaction { get; set; } + + /// + public string? TransactionName { get; set; } + /// /// Creates a scope with the specified options. /// diff --git a/src/Sentry/ScopeExtensions.cs b/src/Sentry/ScopeExtensions.cs index 73f4c00d5d..3b73450bbc 100644 --- a/src/Sentry/ScopeExtensions.cs +++ b/src/Sentry/ScopeExtensions.cs @@ -325,6 +325,7 @@ public static void Apply(this IScope from, IScope to) to.Environment ??= from.Environment; to.Transaction ??= from.Transaction; + to.TransactionName ??= from.TransactionName; to.Level ??= from.Level; if (from.Sdk is null || to.Sdk is null) diff --git a/src/Sentry/SentryClient.cs b/src/Sentry/SentryClient.cs index 2cda0c6069..cfe7d63d4c 100644 --- a/src/Sentry/SentryClient.cs +++ b/src/Sentry/SentryClient.cs @@ -63,14 +63,6 @@ internal SentryClient( } } - /// - /// Queues the event to be sent to Sentry. - /// - /// - /// An optional scope, if provided, will be applied to the event. - /// - /// The event to send to Sentry. - /// The optional scope to augment the event with. /// public SentryId CaptureEvent(SentryEvent? @event, Scope? scope = null) { @@ -95,10 +87,7 @@ public SentryId CaptureEvent(SentryEvent? @event, Scope? scope = null) } } - /// - /// Captures a user feedback. - /// - /// The user feedback to send to Sentry. + /// public void CaptureUserFeedback(UserFeedback userFeedback) { if (_disposed) @@ -108,19 +97,53 @@ public void CaptureUserFeedback(UserFeedback userFeedback) if (userFeedback.EventId.Equals(SentryId.Empty)) { - //Ignore the userfeedback if EventId is empty + // Ignore the user feedback if EventId is empty _options.DiagnosticLogger?.LogWarning("User feedback dropped due to empty id."); return; } - else if (string.IsNullOrWhiteSpace(userFeedback.Email) || - string.IsNullOrWhiteSpace(userFeedback.Comments)) + + if (string.IsNullOrWhiteSpace(userFeedback.Email) || + string.IsNullOrWhiteSpace(userFeedback.Comments)) { - //Ignore the userfeedback if a required field is null or empty. + // Ignore the user feedback if a required field is null or empty. _options.DiagnosticLogger?.LogWarning("User feedback discarded due to one or more required fields missing."); return; } - _ = CaptureEnvelope(Envelope.FromUserFeedback(userFeedback)); + CaptureEnvelope(Envelope.FromUserFeedback(userFeedback)); + } + + /// + public void CaptureTransaction(Transaction transaction) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(SentryClient)); + } + + if (transaction.SpanId.Equals(SentryId.Empty)) + { + _options.DiagnosticLogger?.LogWarning("Transaction dropped due to empty id."); + return; + } + + if (string.IsNullOrWhiteSpace(transaction.Name) || + string.IsNullOrWhiteSpace(transaction.Operation)) + { + _options.DiagnosticLogger?.LogWarning("Transaction discarded due to one or more required fields missing."); + return; + } + + if (_options.TraceSampleRate < 1) + { + if (Random.NextDouble() > _options.TraceSampleRate) + { + _options.DiagnosticLogger?.LogDebug("Transaction sampled."); + return; + } + } + + CaptureEnvelope(Envelope.FromTransaction(transaction)); } /// @@ -141,6 +164,7 @@ private SentryId DoSendEvent(SentryEvent @event, Scope? scope) return SentryId.Empty; } } + if (@event.Exception != null && _options.ExceptionFilters?.Length > 0) { if (_options.ExceptionFilters.Any(f => f.Filter(@event.Exception))) @@ -150,6 +174,7 @@ private SentryId DoSendEvent(SentryEvent @event, Scope? scope) return SentryId.Empty; } } + scope ??= new Scope(_options); _options.DiagnosticLogger?.LogInfo("Capturing event."); diff --git a/src/Sentry/SentryOptions.cs b/src/Sentry/SentryOptions.cs index c4d1b65f99..9c8be99228 100644 --- a/src/Sentry/SentryOptions.cs +++ b/src/Sentry/SentryOptions.cs @@ -393,6 +393,35 @@ public IDiagnosticLogger? DiagnosticLogger /// public Dictionary DefaultTags => _defaultTags ??= new Dictionary(); + private double _traceSampleRate = 1.0; + + /// + /// Indicates the percentage of the tracing data that is collected. + /// Setting this to 0 discards all trace data. + /// Setting this to 1.0 collects all trace data. + /// Values outside of this range are invalid. + /// + public double TraceSampleRate + { + get => _traceSampleRate; + set + { + if (value < 0 || value > 1) + { + throw new InvalidOperationException( + $"The value {value} is not a valid tracing sample rate. Use values between 0 and 1." + ); + } + + _traceSampleRate = value; + } + } + + /// + /// Custom logic that determines trace sample rate depending on the context. + /// + public ISentryTraceSampler? TraceSampler { get; set; } + /// /// Creates a new instance of /// diff --git a/src/Sentry/SentrySdk.cs b/src/Sentry/SentrySdk.cs index c003adf8a1..263bcb3522 100644 --- a/src/Sentry/SentrySdk.cs +++ b/src/Sentry/SentrySdk.cs @@ -307,5 +307,26 @@ public static void CaptureUserFeedback(UserFeedback userFeedback) [DebuggerStepThrough] public static void CaptureUserFeedback(SentryId eventId, string email, string comments, string? name = null) => _hub.CaptureUserFeedback(new UserFeedback(eventId, email, comments, name)); + + /// + /// Captures a transaction. + /// + [DebuggerStepThrough] + public static void CaptureTransaction(Transaction transaction) + => _hub.CaptureTransaction(transaction); + + /// + /// Creates a transaction. + /// + [DebuggerStepThrough] + public static Transaction CreateTransaction(string name, string operation) + => _hub.CreateTransaction(name, operation); + + /// + /// Gets the Sentry trace header. + /// + [DebuggerStepThrough] + public static SentryTraceHeader? GetTraceHeader() + => _hub.GetSentryTrace(); } } diff --git a/src/Sentry/TraceSamplingContext.cs b/src/Sentry/TraceSamplingContext.cs new file mode 100644 index 0000000000..99860a5ded --- /dev/null +++ b/src/Sentry/TraceSamplingContext.cs @@ -0,0 +1,31 @@ +using Sentry.Protocol; + +namespace Sentry +{ + /// + /// Trace sampling context. + /// + public class TraceSamplingContext + { + /// + /// Span. + /// + public ISpan Span { get; } + + /// + /// Span's parent. + /// + public ISpan? ParentSpan { get; } + + /// + /// Initializes an instance of . + /// + /// + /// + public TraceSamplingContext(ISpan span, ISpan? parentSpan = null) + { + Span = span; + ParentSpan = parentSpan; + } + } +} diff --git a/test/Sentry.AspNetCore.Tests/ScopeExtensionsTests.cs b/test/Sentry.AspNetCore.Tests/ScopeExtensionsTests.cs index b2898a8ac9..f410e9d40a 100644 --- a/test/Sentry.AspNetCore.Tests/ScopeExtensionsTests.cs +++ b/test/Sentry.AspNetCore.Tests/ScopeExtensionsTests.cs @@ -280,7 +280,7 @@ public void Populate_RouteData_SetToScope() _sut.Populate(_httpContext, SentryAspNetCoreOptions); - Assert.Equal($"{controller}.{action}", _sut.Transaction); + Assert.Equal($"{controller}.{action}", _sut.TransactionName); } public static IEnumerable InvalidRequestBodies() diff --git a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs index 0e73de0f26..08a9148ed4 100644 --- a/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs +++ b/test/Sentry.AspNetCore.Tests/SentryMiddlewareTests.cs @@ -14,6 +14,7 @@ using NSubstitute; using NSubstitute.ReturnsExtensions; using Sentry.Extensibility; +using Sentry.Protocol; using Xunit; namespace Sentry.AspNetCore.Tests @@ -35,6 +36,7 @@ public Fixture() { HubAccessor = () => Hub; _ = Hub.IsEnabled.Returns(true); + _ = Hub.CreateTransaction(default, default).ReturnsForAnyArgs(new Transaction(Hub, Options)); _ = HttpContext.Features.Returns(FeatureCollection); } diff --git a/test/Sentry.Tests/HubTests.cs b/test/Sentry.Tests/HubTests.cs index b908fe7e65..0ebb17e24f 100644 --- a/test/Sentry.Tests/HubTests.cs +++ b/test/Sentry.Tests/HubTests.cs @@ -28,7 +28,7 @@ public bool EnqueueEnvelope(Envelope envelope) return true; } - public Task FlushAsync(TimeSpan timeout) => default; + public Task FlushAsync(TimeSpan timeout) => Task.CompletedTask; } [Fact] diff --git a/test/Sentry.Tests/Protocol/BaseScopeTests.cs b/test/Sentry.Tests/Protocol/BaseScopeTests.cs index 5f17157194..75c618c8ba 100644 --- a/test/Sentry.Tests/Protocol/BaseScopeTests.cs +++ b/test/Sentry.Tests/Protocol/BaseScopeTests.cs @@ -77,8 +77,8 @@ public void Request_Settable() public void Transaction_Settable() { var expected = "Transaction"; - _sut.Transaction = expected; - Assert.Same(expected, _sut.Transaction); + _sut.TransactionName = expected; + Assert.Same(expected, _sut.TransactionName); } [Fact] diff --git a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs index 4c3af34d5d..d423a36b5f 100644 --- a/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs +++ b/test/Sentry.Tests/Protocol/Envelopes/EnvelopeTests.cs @@ -372,7 +372,7 @@ public async Task Roundtrip_WithEvent_Success() SentryExceptions = new [] { new SentryException { Value = "exception_value" } }, SentryThreads = new[] { new SentryThread { Crashed = true } }, ServerName = "server_name", - Transaction = "transaction", + TransactionName = "transaction", }; @event.SetExtra("extra_key", "extra_value"); @@ -405,7 +405,7 @@ public async Task Roundtrip_WithUserFeedback_Success() { // Arrange var feedback = new UserFeedback( - new SentryId(Guid.NewGuid()), + SentryId.Create(), "foo@bar.com", "Everything sucks", "Donald J. Trump" diff --git a/test/Sentry.Tests/Protocol/ScopeExtensionsTests.cs b/test/Sentry.Tests/Protocol/ScopeExtensionsTests.cs index 17fdb9c257..f0e66509e6 100644 --- a/test/Sentry.Tests/Protocol/ScopeExtensionsTests.cs +++ b/test/Sentry.Tests/Protocol/ScopeExtensionsTests.cs @@ -943,12 +943,12 @@ public void Apply_Environment_OnTarget_NotOverwritten() public void Apply_Transaction_Null() { var sut = _fixture.GetSut(); - sut.Transaction = null; + sut.TransactionName = null; var target = _fixture.GetSut(); sut.Apply(target); - Assert.Null(target.Transaction); + Assert.Null(target.TransactionName); } [Fact] @@ -957,11 +957,11 @@ public void Apply_Transaction_NotOnTarget_SetFromSource() const string expected = "transaction"; var sut = _fixture.GetSut(); - sut.Transaction = expected; + sut.TransactionName = expected; var target = _fixture.GetSut(); sut.Apply(target); - Assert.Equal(expected, target.Transaction); + Assert.Equal(expected, target.TransactionName); } [Fact] @@ -970,12 +970,12 @@ public void Apply_Transaction_OnTarget_NotOverwritten() const string expected = "transaction"; var sut = _fixture.GetSut(); var target = _fixture.GetSut(); - target.Transaction = expected; + target.TransactionName = expected; - sut.Transaction = "other"; + sut.TransactionName = "other"; sut.Apply(target); - Assert.Equal(expected, target.Transaction); + Assert.Equal(expected, target.TransactionName); } [Fact] diff --git a/test/Sentry.Tests/Protocol/SentryEventTests.cs b/test/Sentry.Tests/Protocol/SentryEventTests.cs index b1e447d2bd..df44c343c3 100644 --- a/test/Sentry.Tests/Protocol/SentryEventTests.cs +++ b/test/Sentry.Tests/Protocol/SentryEventTests.cs @@ -43,7 +43,7 @@ public void SerializeObject_AllPropertiesSetToNonDefault_SerializesValidObject() SentryExceptions = new[] { new SentryException { Value = "exception_value"} }, SentryThreads = new[] { new SentryThread { Crashed = true } }, ServerName = "server_name", - Transaction = "transaction", + TransactionName = "transaction", }; sut.Sdk.AddPackage(new Package("name", "version")); diff --git a/test/Sentry.Tests/Protocol/SentryIdTests.cs b/test/Sentry.Tests/Protocol/SentryIdTests.cs index e2db36416f..13823f8c91 100644 --- a/test/Sentry.Tests/Protocol/SentryIdTests.cs +++ b/test/Sentry.Tests/Protocol/SentryIdTests.cs @@ -16,7 +16,7 @@ public void ToString_Equal_GuidToStringN() [Fact] public void Implicit_ToGuid() { - var expected = new SentryId(Guid.NewGuid()); + var expected = SentryId.Create(); Guid actual = expected; Assert.Equal(expected.ToString(), actual.ToString("N")); } diff --git a/test/Sentry.Tests/Protocol/TransactionTests.cs b/test/Sentry.Tests/Protocol/TransactionTests.cs new file mode 100644 index 0000000000..514018eac3 --- /dev/null +++ b/test/Sentry.Tests/Protocol/TransactionTests.cs @@ -0,0 +1,37 @@ +using FluentAssertions; +using Sentry.Extensibility; +using Sentry.Internal; +using Sentry.Protocol; +using Xunit; + +namespace Sentry.Tests.Protocol +{ + public class TransactionTests + { + [Fact] + public void Serialization_Roundtrip_Success() + { + // Arrange + var transaction = new Transaction(DisabledHub.Instance, null) + { + Name = "my transaction", + Operation = "some op", + Description = "description", + Request = new Request {Method = "GET", Url = "https://example.com"}, + User = new User {Email = "test@sentry.example", Username = "john"}, + Environment = "release", + Fingerprint = new[] {"foo", "bar"}, + Sdk = new SdkVersion {Name = "SDK", Version = "1.1.1"} + }; + + transaction.Finish(); + + // Act + var json = transaction.ToJsonString(); + var transactionRoundtrip = Transaction.FromJson(Json.Parse(json)); + + // Assert + transactionRoundtrip.Should().BeEquivalentTo(transaction); + } + } +} diff --git a/test/Sentry.Tests/SentryClientTests.cs b/test/Sentry.Tests/SentryClientTests.cs index cd2277d4a6..7aa7ff0ed2 100644 --- a/test/Sentry.Tests/SentryClientTests.cs +++ b/test/Sentry.Tests/SentryClientTests.cs @@ -5,6 +5,7 @@ using NSubstitute; using Sentry.Extensibility; using Sentry.Internal; +using Sentry.Protocol; using Sentry.Protocol.Envelopes; using VerifyXunit; using Xunit; @@ -365,6 +366,48 @@ public void CaptureUserFeedback_DisposedClient_ThrowsObjectDisposedException() _ = Assert.Throws(() => sut.CaptureUserFeedback(null)); } + [Fact] + public void CaptureTransaction_ValidTransaction_Sent() + { + // Arrange + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(new Transaction(DisabledHub.Instance, null) + { + Name = "test name", + Operation = "test operation" + }); + + // Assert + _ = sut.Worker.Received(1).EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureTransaction_InvalidTransaction_Ignored() + { + // Arrange + var sut = _fixture.GetSut(); + + // Act + sut.CaptureTransaction(new Transaction(DisabledHub.Instance, null) + { + Name = null!, + Operation = null! + }); + + // Assert + _ = sut.Worker.DidNotReceive().EnqueueEnvelope(Arg.Any()); + } + + [Fact] + public void CaptureTransaction_DisposedClient_ThrowsObjectDisposedException() + { + var sut = _fixture.GetSut(); + sut.Dispose(); + _ = Assert.Throws(() => sut.CaptureTransaction(null)); + } + [Fact] public void Dispose_Worker_DisposeCalled() {