diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d63f7d71f..4c371dc5ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Graphql client ([#2538](https://github.com/getsentry/sentry-dotnet/pull/2538)) + ### Dependencies - Bump CLI from v2.20.4 to v2.20.5 ([#2539](https://github.com/getsentry/sentry-dotnet/pull/2539)) diff --git a/Sentry.sln b/Sentry.sln index 7f13d1000a..38d753d9b4 100644 --- a/Sentry.sln +++ b/Sentry.sln @@ -170,6 +170,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sentry.Samples.OpenTelemetr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.OpenTelemetry.Console", "samples\Sentry.Samples.OpenTelemetry.Console\Sentry.Samples.OpenTelemetry.Console.csproj", "{D62E79F4-FC3C-4D75-ABFE-CDE76EF46DDE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.GraphQL.Server", "samples\Sentry.Samples.GraphQL.Server\Sentry.Samples.GraphQL.Server.csproj", "{B3BFB7BA-1A5E-468F-8C47-F0841AA75848}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sentry.Samples.GraphQL.Client.Http", "samples\Sentry.Samples.GraphQL.Client.Http\Sentry.Samples.GraphQL.Client.Http.csproj", "{B01C5D8F-62EE-4E63-AE96-745BA1D2E175}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -404,6 +408,14 @@ Global {D62E79F4-FC3C-4D75-ABFE-CDE76EF46DDE}.Debug|Any CPU.Build.0 = Debug|Any CPU {D62E79F4-FC3C-4D75-ABFE-CDE76EF46DDE}.Release|Any CPU.ActiveCfg = Release|Any CPU {D62E79F4-FC3C-4D75-ABFE-CDE76EF46DDE}.Release|Any CPU.Build.0 = Release|Any CPU + {B3BFB7BA-1A5E-468F-8C47-F0841AA75848}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B3BFB7BA-1A5E-468F-8C47-F0841AA75848}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B3BFB7BA-1A5E-468F-8C47-F0841AA75848}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B3BFB7BA-1A5E-468F-8C47-F0841AA75848}.Release|Any CPU.Build.0 = Release|Any CPU + {B01C5D8F-62EE-4E63-AE96-745BA1D2E175}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B01C5D8F-62EE-4E63-AE96-745BA1D2E175}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B01C5D8F-62EE-4E63-AE96-745BA1D2E175}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B01C5D8F-62EE-4E63-AE96-745BA1D2E175}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -469,6 +481,8 @@ Global {A58DE854-6576-4E07-98BF-03B9DCCDBF9A} = {83263231-1A2A-4733-B759-EEFF14E8C5D5} {6F791E40-49A8-4A67-81DB-6913E519310A} = {77454495-55EE-4B40-A089-71B9E8F82E89} {D62E79F4-FC3C-4D75-ABFE-CDE76EF46DDE} = {77454495-55EE-4B40-A089-71B9E8F82E89} + {B3BFB7BA-1A5E-468F-8C47-F0841AA75848} = {77454495-55EE-4B40-A089-71B9E8F82E89} + {B01C5D8F-62EE-4E63-AE96-745BA1D2E175} = {77454495-55EE-4B40-A089-71B9E8F82E89} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0C652B1A-DF72-4EE5-A98B-194FE2C054F6} diff --git a/Sentry.sln.DotSettings b/Sentry.sln.DotSettings index 95f44cab85..818cc2c727 100644 --- a/Sentry.sln.DotSettings +++ b/Sentry.sln.DotSettings @@ -1,5 +1,6 @@  ExplicitlyExcluded + QL True 2.1.7 diff --git a/samples/Sentry.Samples.GraphQL.Client.Http/Program.cs b/samples/Sentry.Samples.GraphQL.Client.Http/Program.cs new file mode 100644 index 0000000000..81f8fb61ea --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Client.Http/Program.cs @@ -0,0 +1,130 @@ +/* + * This sample demonstrates using Sentry to capture traces and exceptions from a GraphQL over HTTP client. + * It assumes the Sentry.Samples.GraphQL.Server is running on http://localhost:5051 + * (see `/Samples/Sentry.Samples.GraphQL.Server/Properties/launchSettings.json`) + */ + +using System.Text.Json; +using GraphQL; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.SystemTextJson; +using Sentry; + +SentrySdk.Init(options => +{ + // options.Dsn = "... Your DSN ..."; + options.CaptureFailedRequests = true; + options.SendDefaultPii = true; + options.TracesSampleRate = 1.0; + options.EnableTracing = true; +}); + +var transaction = SentrySdk.StartTransaction("Program Main", "function"); +SentrySdk.ConfigureScope(scope => scope.Transaction = transaction); + +var graphClient = new GraphQLHttpClient( + options => + { + options.EndPoint = new Uri("http://localhost:5051/graphql"); // Assumes Sentry.Samples.GraphQL.Server is running + options.HttpMessageHandler = new SentryGraphQLHttpMessageHandler(); // <-- Configure GraphQL use Sentry Message Handler + }, + new SystemTextJsonSerializer() + ); + +char input = ' '; +do +{ + Console.WriteLine("Select a query to send:"); + Console.WriteLine("1. Add a note"); + Console.WriteLine("2. Get all notes"); + Console.WriteLine("3. Generate a GraphQL Error"); + Console.WriteLine("0. Exit Note Master 3000"); + input = Console.ReadKey().KeyChar; + Console.WriteLine(); + switch (input) + { + case '0': + Console.WriteLine("Bye!"); + break; + case '1': + await CreateNewNote(); + break; + case '2': + await GetAllNotes(); + break; + case '3': + await CreateError(); + break; + default: + Console.WriteLine("Invalid selection."); + break; + } +} while (input != '0'); +transaction.Finish(SpanStatus.Ok); + +async Task CreateError() +{ + // var query = new GraphQLRequest(@"{ test { id } }"); + var query = new GraphQLRequest{ + Query = @"mutation fakeMutation($note:NoteInput!) { playNote(note: $note) { id } }", + OperationName = "fakeMutation", + Variables = new + { + note = new + { + message = "This should put a spanner in the works" + } + } + }; + var response = await graphClient!.SendQueryAsync(query); + var result = JsonSerializer.Serialize(response); + Console.WriteLine(result); +} + +async Task CreateNewNote() +{ + Console.WriteLine("What do you want the note to say?"); + var message = Console.ReadLine(); + var mutation = new GraphQLRequest{ + Query = @"mutation addANote($note:NoteInput!) { createNote(note: $note) {id message } }", + OperationName = "addANote", + Variables = new + { + note = new + { + message = message + } + } + }; + var newNote = await graphClient!.SendQueryAsync(mutation); + Console.WriteLine("Note added:"); + Console.WriteLine("{0,3} | {1}", newNote.Data.CreateNote.Id, newNote.Data.CreateNote.Message); +} + +async Task GetAllNotes() +{ + var query = new GraphQLRequest(@"query getAllNotes { notes { id, message } }"); + var allNotesResponse = await graphClient.SendQueryAsync(query); + Console.WriteLine(); + foreach (var note in allNotesResponse.Data.Notes) + { + Console.WriteLine("{0,3} | {1}", note.Id, note.Message); + } + Console.WriteLine(); +} + +public class Note +{ + public int Id { get; set; } + public string? Message { get; set; } +} + +public class NotesResult +{ + public List Notes { get; set; } = new(); +} + +public class CreateNoteResult +{ + public Note CreateNote { get; set; } = new (); +} diff --git a/samples/Sentry.Samples.GraphQL.Client.Http/Sentry.Samples.GraphQL.Client.Http.csproj b/samples/Sentry.Samples.GraphQL.Client.Http/Sentry.Samples.GraphQL.Client.Http.csproj new file mode 100644 index 0000000000..f43ebd3f05 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Client.Http/Sentry.Samples.GraphQL.Client.Http.csproj @@ -0,0 +1,20 @@ + + + + Exe + net7.0 + enable + enable + + + + + + + + + + + + + diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/Note.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/Note.cs new file mode 100644 index 0000000000..53dffda98e --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/Note.cs @@ -0,0 +1,7 @@ +namespace Sentry.Samples.GraphQL.Server.Notes; + +public class Note +{ + public int Id { get; set; } + public string? Message { get; set; } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/NoteType.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/NoteType.cs new file mode 100644 index 0000000000..bcb2954c44 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/NoteType.cs @@ -0,0 +1,14 @@ +using GraphQL.Types; + +namespace Sentry.Samples.GraphQL.Server.Notes; + +public class NoteType : ObjectGraphType +{ + public NoteType() + { + Name = "Note"; + Description = "Note Type"; + Field(d => d.Id, nullable: false).Description("Note Id"); + Field(d => d.Message, nullable: true).Description("Note Message"); + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/NotesData.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesData.cs new file mode 100644 index 0000000000..0211603ff4 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesData.cs @@ -0,0 +1,27 @@ +using System.Collections.Concurrent; + +namespace Sentry.Samples.GraphQL.Server.Notes; + +public class NotesData +{ + private static int NextId = 0; + private readonly ICollection _notes = new List () + { + new() { Id = NextId++, Message = "Hello World!" }, + new() { Id = NextId++, Message = "Hello World! How are you?" } + }; + + public ICollection GetAll() => _notes; + + public Task GetNoteByIdAsync(int id) + { + return Task.FromResult(_notes.FirstOrDefault(n => n.Id == id)); + } + + public Note AddNote(Note note) + { + note.Id = NextId++; + _notes.Add(note); + return note; + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/NotesMutation.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesMutation.cs new file mode 100644 index 0000000000..088d825dde --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesMutation.cs @@ -0,0 +1,27 @@ +using GraphQL; +using GraphQL.Types; + +namespace Sentry.Samples.GraphQL.Server.Notes; + +public sealed class NotesMutation : ObjectGraphType +{ + public NotesMutation(NotesData data) + { + Field("createNote") + .Argument>("note") + .Resolve(context => + { + var note = context.GetArgument("note"); + return data.AddNote(note); + }); + } +} + +public class NoteInputType : InputObjectGraphType +{ + public NoteInputType() + { + Name = "NoteInput"; + Field>("message"); + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/NotesQuery.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesQuery.cs new file mode 100644 index 0000000000..05d470bc64 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesQuery.cs @@ -0,0 +1,11 @@ +using GraphQL.Types; + +namespace Sentry.Samples.GraphQL.Server.Notes; + +public sealed class NotesQuery : ObjectGraphType +{ + public NotesQuery(NotesData data) + { + Field>("notes").Resolve(context => data.GetAll()); + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Notes/NotesSchema.cs b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesSchema.cs new file mode 100644 index 0000000000..6d89d80126 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Notes/NotesSchema.cs @@ -0,0 +1,12 @@ +using GraphQL.Types; + +namespace Sentry.Samples.GraphQL.Server.Notes; + +public class NotesSchema : Schema +{ + public NotesSchema(IServiceProvider serviceProvider) : base(serviceProvider) + { + Query = serviceProvider.GetRequiredService(); + Mutation = serviceProvider.GetRequiredService(); + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Program.cs b/samples/Sentry.Samples.GraphQL.Server/Program.cs new file mode 100644 index 0000000000..77f4d9e3c8 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Program.cs @@ -0,0 +1,119 @@ +using System.Text.Json; +using GraphQL; +using GraphQL.Client.Http; +using GraphQL.Client.Serializer.SystemTextJson; +using GraphQL.MicrosoftDI; +using GraphQL.Types; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Sentry.OpenTelemetry; +using Sentry.Samples.GraphQL.Server.Notes; + +namespace Sentry.Samples.GraphQL.Server; + +public static class Program +{ + public static void Main(string[] args) + { + BuildWebApplication(args).Run(); + } + + // public static IWebHost BuildWebHost(string[] args) => + public static WebApplication BuildWebApplication(string[] args) + { + var builder = WebApplication.CreateBuilder(args); + + builder.Services.AddOpenTelemetry() + .WithTracing(tracerProviderBuilder => + tracerProviderBuilder + .AddSource(Telemetry.ActivitySource.Name) + .ConfigureResource(resource => resource.AddService(Telemetry.ServiceName)) + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddSentry() // <-- Configure OpenTelemetry to send traces to Sentry + ); + + builder.WebHost.UseSentry(o => + { + // A DSN is required. You can set it here, or in configuration, or in an environment variable. + // o.Dsn = "...Your DSN Here..."; + o.EnableTracing = true; + o.Debug = true; + o.UseOpenTelemetry(); // <-- Configure Sentry to use OpenTelemetry trace information + }); + + builder.Services + // Add our data store + .AddSingleton() + // add notes schema + .AddSingleton(services => + new NotesSchema(new SelfActivatingServiceProvider(services)) + ) + // register graphQL + .AddGraphQL(options => options + .AddAutoSchema() + .AddSystemTextJson() + .UseTelemetry(telemetryOptions => + { + telemetryOptions.RecordDocument = true; // <-- Configure GraphQL to use OpenTelemetry + }) + ); + + // Permit any Origin - not appropriate for production!!! + builder.Services.AddCors(cors => cors.AddDefaultPolicy(policy => policy.WithOrigins("*").AllowAnyHeader())); + builder.Services.AddControllers(); + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new() + { + Title = "Sentry.Samples.GraphQL", + Version = "v1" + }); + }); + + var app = builder.Build(); + + // Configure the HTTP request pipeline. + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(c => + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Sentry.Samples.GraphQL v1")); + app.UseGraphQLAltair(); // Exposed at /ui/altair + } + + app.UseAuthorization(); + app.MapControllers(); + + app.MapGet("/", () => "Hello world!"); + app.MapGet("/request", async context => + { + var url = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.PathBase}/graphql"; + var graphClient = new GraphQLHttpClient(url, new SystemTextJsonSerializer()); + var notesRequest = new GraphQLRequest + { + Query = @" + { + notes { + id, + message + } + }" + }; + var graphResponse = await graphClient.SendQueryAsync(notesRequest); + var result = JsonSerializer.Serialize(graphResponse.Data); + await context.Response.WriteAsync(result); + }); + app.MapGet("/throw", () => { throw new NotImplementedException(); }); + + // make sure all our schemas registered to route + app.UseGraphQL("/graphql"); + + return app; + } + + public class NotesResult + { + public List Notes { get; set; } = new(); + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Properties/launchSettings.json b/samples/Sentry.Samples.GraphQL.Server/Properties/launchSettings.json new file mode 100644 index 0000000000..5fca709c1c --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Properties/launchSettings.json @@ -0,0 +1,37 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:19903", + "sslPort": 44302 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5051", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7146;http://localhost:5051", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/Sentry.Samples.GraphQL.Server.csproj b/samples/Sentry.Samples.GraphQL.Server/Sentry.Samples.GraphQL.Server.csproj new file mode 100644 index 0000000000..50c1172173 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Sentry.Samples.GraphQL.Server.csproj @@ -0,0 +1,28 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Sentry.Samples.GraphQL.Server/Telemetry.cs b/samples/Sentry.Samples.GraphQL.Server/Telemetry.cs new file mode 100644 index 0000000000..49d5eeefb5 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/Telemetry.cs @@ -0,0 +1,9 @@ +using System.Diagnostics; + +namespace Sentry.Samples.GraphQL.Server; + +public static class Telemetry +{ + public const string ServiceName = "Sentry.Samples.GraphQL"; + public static ActivitySource ActivitySource { get; } = new(ServiceName); +} diff --git a/samples/Sentry.Samples.GraphQL.Server/appsettings.Development.json b/samples/Sentry.Samples.GraphQL.Server/appsettings.Development.json new file mode 100644 index 0000000000..0c208ae918 --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/Sentry.Samples.GraphQL.Server/appsettings.json b/samples/Sentry.Samples.GraphQL.Server/appsettings.json new file mode 100644 index 0000000000..10f68b8c8b --- /dev/null +++ b/samples/Sentry.Samples.GraphQL.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Sentry/GraphQLContentExtractor.cs b/src/Sentry/GraphQLContentExtractor.cs new file mode 100644 index 0000000000..12cf63bdef --- /dev/null +++ b/src/Sentry/GraphQLContentExtractor.cs @@ -0,0 +1,74 @@ +using Sentry.Extensibility; + +namespace Sentry; + +internal static class GraphQLContentExtractor +{ + internal static async Task ExtractRequestContentAsync(HttpRequestMessage request, SentryOptions? options) + { + var json = await ExtractContentAsync(request?.Content, options).ConfigureAwait(false); + return json is not null ? new GraphQLRequestContent(json, options) : null; + } + + internal static async Task ExtractResponseContentAsync(HttpResponseMessage response, SentryOptions? options) + { + var json = await ExtractContentAsync(response?.Content, options).ConfigureAwait(false); + return (json is not null) ? JsonDocument.Parse(json).RootElement.Clone() : null; + } + + private static void TrySeek(Stream? stream, long position) + { + if (stream?.CanSeek ?? false) + { + stream.Position = position; + } + } + + private static async Task ExtractContentAsync(HttpContent? content, SentryOptions? options) + { + if (content is null) + { + return null; + } + + Stream contentStream; + try + { + await content.LoadIntoBufferAsync().ConfigureAwait(false); + contentStream = await content.ReadAsStreamAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + options?.LogDebug($"Unable to read GraphQL content stream: {exception.Message}"); + return null; + } + + if (!contentStream.CanRead) + { + return null; + } + + var originalPosition = (contentStream.CanSeek) ? contentStream.Position : 0; + try + { + TrySeek(contentStream, 0); +#if NETFRAMEWORK + // On .NET Framework a positive buffer size needs to be specified + using var reader = new StreamReader(contentStream, Encoding.UTF8, true, 128, true); +#else + // For .NET Core Apps, setting the buffer size to -1 uses the default buffer size + using var reader = new StreamReader(contentStream, Encoding.UTF8, true, -1, true); +#endif + return await reader.ReadToEndAsync().ConfigureAwait(false); + } + catch (Exception exception) + { + options?.LogDebug($"Unable to extract GraphQL content: {exception.Message}"); + return null; + } + finally + { + TrySeek(contentStream, originalPosition); + } + } +} diff --git a/src/Sentry/GraphQLHttpRequestException.cs b/src/Sentry/GraphQLHttpRequestException.cs new file mode 100644 index 0000000000..6133e758e0 --- /dev/null +++ b/src/Sentry/GraphQLHttpRequestException.cs @@ -0,0 +1,22 @@ +namespace Sentry; + +internal class GraphQLHttpRequestException : Exception +{ + public GraphQLHttpRequestException() + : this(null, null) + { } + + public GraphQLHttpRequestException(string? message) + : this(message, null) + { } + + public GraphQLHttpRequestException(string? message, Exception? inner) + : base(message, inner) + { + if (inner != null) + { + HResult = inner.HResult; + } + } + +} diff --git a/src/Sentry/GraphQLRequestContent.cs b/src/Sentry/GraphQLRequestContent.cs new file mode 100644 index 0000000000..865e8d11b1 --- /dev/null +++ b/src/Sentry/GraphQLRequestContent.cs @@ -0,0 +1,84 @@ +using Sentry.Extensibility; +using Sentry.Internal.Extensions; + +namespace Sentry; + +internal class GraphQLRequestContent +{ + private static JsonSerializerOptions SerializerOptions => new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + private static readonly Regex Expression = new ( + @"\s*(?\bquery\b|\bmutation\b|\bsubscription\b)\s*(?\w+)?\s*(?{.*})\s*", + RegexOptions.IgnorePatternWhitespace | RegexOptions.Multiline | RegexOptions.IgnoreCase + ); + + private IReadOnlyDictionary Items { get; } + + public GraphQLRequestContent(string? requestContent, SentryOptions? options = null) + { + RequestContent = requestContent; + if (requestContent is null) + { + Items = new Dictionary().AsReadOnly(); + return; + } + + try + { + var deserialized = JsonSerializer.Deserialize>(requestContent, SerializerOptions); + Items = (deserialized ?? new Dictionary()).AsReadOnly(); + } + catch (Exception e) + { + options?.LogDebug($"Unable to parse GraphQL request content: {e.Message}"); + Items = new Dictionary().AsReadOnly(); + return; + } + + // Try to read the values directly from the array (in case they've been supplied explicitly) + if (Items.TryGetValue("operationName", out var operationName)) + { + OperationName = operationName?.ToString(); + } + // TODO: The query can be null... see https://www.apollographql.com/docs/apollo-server/performance/apq/ + if (Items.TryGetValue("query", out var query)) + { + Query = query?.ToString(); + } + + var match = Expression.Match(Query ?? requestContent); + + if (match.Success) + { + OperationType ??= match.Groups["operationType"].Value; + OperationName ??= match.Groups["operationName"].Value; + } + + // Default to "query" if the operation type wasn't explicitly specified + if (string.IsNullOrEmpty(OperationType)) + { + OperationType = "query"; + } + } + + internal string? RequestContent { get; } + + /// + /// Document containing GraphQL to execute. + /// It can be null for automatic persisted queries, in which case a SHA-256 hash of the query would be sent in the + /// Extensions. See https://www.apollographql.com/docs/apollo-server/performance/apq/ for details. + /// + public string? Query { get; } + public string? OperationName { get; } + public string? OperationType { get; } + + /// + /// Returns the OperationName if present or "graphql" otherwise. + /// + public string OperationNameOrFallback() => OperationName ?? "graphql"; + + /// + /// Returns the OperationType if present or "graphql.operation" otherwise. + /// + public string OperationTypeOrFallback() => OperationType ?? "graphql.operation"; +} diff --git a/src/Sentry/Internal/ObjectExtensions.cs b/src/Sentry/Internal/ObjectExtensions.cs new file mode 100644 index 0000000000..c5496cf05f --- /dev/null +++ b/src/Sentry/Internal/ObjectExtensions.cs @@ -0,0 +1,28 @@ +using Sentry.Internal.Extensions; + +namespace Sentry.Internal; + +/// +/// Copied/Modified from +/// https://github.com/mentaldesk/fuse/blob/91af00dc9bc7e1deb2f11ab679c536194f85dd4a/MentalDesk.Fuse/ObjectExtensions.cs +/// +internal static class ObjectExtensions +{ + private static ConditionalWeakTable> Map { get; } = new(); + + private static Dictionary AssociatedProperties(this object source) => + Map.GetValue(source, _ => new Dictionary()); + + public static void SetFused(this object source, string propertyName, object? value) => + source.AssociatedProperties()[propertyName] = value; + + public static void SetFused(this object source, T value) => SetFused(source, typeof(T).Name, value); + + public static T? GetFused(this object source, string? propertyName = null) + { + propertyName ??= typeof(T).Name; + return source.AssociatedProperties().TryGetTypedValue(propertyName, out var value) + ? value + : default; + } +} diff --git a/src/Sentry/Protocol/Response.cs b/src/Sentry/Protocol/Response.cs index 6b424fabc5..ef3b3807e6 100644 --- a/src/Sentry/Protocol/Response.cs +++ b/src/Sentry/Protocol/Response.cs @@ -42,6 +42,15 @@ public sealed class Response : IJsonSerializable, ICloneable, IUpdatab /// public string? Cookies { get; set; } + /// + /// Submitted data in whatever format makes most sense. + /// + /// + /// This data should not be provided by default as it can get quite large. + /// + /// The request payload. + public object? Data { get; set; } + /// /// Gets or sets the headers. /// @@ -87,6 +96,7 @@ public void UpdateFrom(Response source) { BodySize ??= source.BodySize; Cookies ??= source.Cookies; + Data ??= source.Data; StatusCode ??= source.StatusCode; source.InternalHeaders?.TryCopyTo(Headers); } @@ -111,6 +121,7 @@ public void WriteTo(Utf8JsonWriter writer, IDiagnosticLogger? logger) writer.WriteString("type", Type); writer.WriteNumberIfNotNull("body_size", BodySize); writer.WriteStringIfNotWhiteSpace("cookies", Cookies); + writer.WriteDynamicIfNotNull("data", Data, logger); writer.WriteStringDictionaryIfNotEmpty("headers", InternalHeaders!); writer.WriteNumberIfNotNull("status_code", StatusCode); @@ -124,6 +135,7 @@ public static Response FromJson(JsonElement json) { var bodySize = json.GetPropertyOrNull("body_size")?.GetInt64(); var cookies = json.GetPropertyOrNull("cookies")?.GetString(); + var data = json.GetPropertyOrNull("data")?.GetDynamicOrNull(); var headers = json.GetPropertyOrNull("headers")?.GetStringDictionaryOrNull(); var statusCode = json.GetPropertyOrNull("status_code")?.GetInt16(); @@ -131,6 +143,7 @@ public static Response FromJson(JsonElement json) { BodySize = bodySize, Cookies = cookies, + Data = data, InternalHeaders = headers?.WhereNotNullValue().ToDictionary(), StatusCode = statusCode }; diff --git a/src/Sentry/Request.cs b/src/Sentry/Request.cs index 9cd8ded3f3..7c78e81a83 100644 --- a/src/Sentry/Request.cs +++ b/src/Sentry/Request.cs @@ -10,6 +10,7 @@ namespace Sentry; /// "request": { /// "url": "http://absolute.uri/foo", /// "method": "POST", +/// "api_target": "apiType", /// "data": { /// "foo": "bar" /// }, @@ -44,6 +45,12 @@ public sealed class Request : IJsonSerializable /// The HTTP method. public string? Method { get; set; } + /// + /// Gets or sets the API target for the request (e.g. "graphql") + /// + /// The API Target. + public string? ApiTarget { get; set; } + // byte[] or Memory? // TODO: serializable object or string? /// @@ -122,6 +129,7 @@ internal void CopyTo(Request? request) return; } + request.ApiTarget ??= ApiTarget; request.Url ??= Url; request.Method ??= Method; request.Data ??= Data; diff --git a/src/Sentry/Sentry.csproj b/src/Sentry/Sentry.csproj index db2b56f2e1..14252bbd80 100644 --- a/src/Sentry/Sentry.csproj +++ b/src/Sentry/Sentry.csproj @@ -94,27 +94,13 @@ <_OSArchitecture>$([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) - - - - - - - + + + + + + + @@ -154,6 +140,8 @@ + + diff --git a/src/Sentry/SentryFailedRequestHandler.cs b/src/Sentry/SentryFailedRequestHandler.cs index 3b9c2c664c..9ff93bc231 100644 --- a/src/Sentry/SentryFailedRequestHandler.cs +++ b/src/Sentry/SentryFailedRequestHandler.cs @@ -1,20 +1,18 @@ -using Sentry.Protocol; - namespace Sentry; -internal class SentryFailedRequestHandler : ISentryFailedRequestHandler +internal abstract class SentryFailedRequestHandler : ISentryFailedRequestHandler { - private readonly IHub _hub; - private readonly SentryOptions _options; - - public const string MechanismType = "SentryFailedRequestHandler"; + protected IHub Hub { get; } + protected SentryOptions Options { get; } internal SentryFailedRequestHandler(IHub hub, SentryOptions options) { - _hub = hub; - _options = options; + Hub = hub; + Options = options; } + protected internal abstract void DoEnsureSuccessfulResponse(HttpRequestMessage request, HttpResponseMessage response); + public void HandleResponse(HttpResponseMessage response) { // Ensure request is not null @@ -24,13 +22,7 @@ public void HandleResponse(HttpResponseMessage response) } // Don't capture if the option is disabled - if (!_options.CaptureFailedRequests) - { - return; - } - - // Don't capture events for successful requests - if (!_options.FailedRequestStatusCodes.Any(range => range.Contains(response.StatusCode))) + if (!Options.CaptureFailedRequests) { return; } @@ -39,72 +31,19 @@ public void HandleResponse(HttpResponseMessage response) var uri = response.RequestMessage.RequestUri; if (uri != null) { - if (_options.Dsn is { } dsn && new Uri(dsn).Host.Equals(uri.Host, StringComparison.OrdinalIgnoreCase)) + if (Options.Dsn is { } dsn && new Uri(dsn).Host.Equals(uri.Host, StringComparison.OrdinalIgnoreCase)) { return; } // Ignore requests that don't match the FailedRequestTargets var requestString = uri.ToString(); - if (!_options.FailedRequestTargets.ContainsMatch(requestString)) + if (!Options.FailedRequestTargets.ContainsMatch(requestString)) { return; } } -#if NET5_0_OR_GREATER - // Starting with .NET 5, the content and headers are guaranteed to not be null. - var bodySize = response.Content.Headers.ContentLength; -#else - // We have to get the content body size before calling EnsureSuccessStatusCode, - // because older implementations of EnsureSuccessStatusCode disposes the content. - // See https://github.com/dotnet/runtime/issues/24845 - - // The ContentLength might be null (but that's ok). - // See https://github.com/dotnet/runtime/issues/16162 - var bodySize = response.Content?.Headers?.ContentLength; -#endif - - // Capture the event - try - { - response.EnsureSuccessStatusCode(); - } - catch (HttpRequestException exception) - { - exception.SetSentryMechanism(MechanismType); - - var @event = new SentryEvent(exception); - var hint = new Hint(HintTypes.HttpResponseMessage, response); - - var sentryRequest = new Request - { - QueryString = uri?.Query, - Method = response.RequestMessage.Method.Method, - }; - - var responseContext = new Response { - StatusCode = (short)response.StatusCode, - BodySize = bodySize - }; - - if (!_options.SendDefaultPii) - { - sentryRequest.Url = uri?.GetComponents(UriComponents.HttpRequestUrl, UriFormat.Unescaped); - } - else - { - sentryRequest.Url = uri?.AbsoluteUri; - sentryRequest.Cookies = response.RequestMessage.Headers.GetCookies(); - sentryRequest.AddHeaders(response.RequestMessage.Headers); - responseContext.Cookies = response.Headers.GetCookies(); - responseContext.AddHeaders(response.Headers); - } - - @event.Request = sentryRequest; - @event.Contexts[Response.Type] = responseContext; - - _hub.CaptureEvent(@event, hint); - } + DoEnsureSuccessfulResponse(response.RequestMessage, response); } } diff --git a/src/Sentry/SentryGraphQLHttpFailedRequestHandler.cs b/src/Sentry/SentryGraphQLHttpFailedRequestHandler.cs new file mode 100644 index 0000000000..019c85d70f --- /dev/null +++ b/src/Sentry/SentryGraphQLHttpFailedRequestHandler.cs @@ -0,0 +1,95 @@ +using Sentry.Internal; +using Sentry.Protocol; + +namespace Sentry; + +internal class SentryGraphQLHttpFailedRequestHandler : SentryFailedRequestHandler +{ + private readonly IHub _hub; + private readonly SentryOptions _options; + internal const string MechanismType = "GraphqlInstrumentation"; + private readonly SentryHttpFailedRequestHandler _httpFailedRequestHandler; + + internal SentryGraphQLHttpFailedRequestHandler(IHub hub, SentryOptions options) + : base(hub, options) + { + _hub = hub; + _options = options; + _httpFailedRequestHandler = new SentryHttpFailedRequestHandler(hub, options); + } + + protected internal override void DoEnsureSuccessfulResponse([NotNull]HttpRequestMessage request, [NotNull]HttpResponseMessage response) + { + JsonElement? json = null; + try + { + json = GraphQLContentExtractor.ExtractResponseContentAsync(response, _options).Result; + if (json is { } jsonElement) + { + if (jsonElement.TryGetProperty("errors", out var errorsElement)) + { + // We just show the first error... maybe there's a better way to do this when multiple errors exist. + // We should check what the Java code is doing. + var errorMessage = errorsElement[0].GetProperty("message").GetString() ?? "GraphQL Error"; + throw new GraphQLHttpRequestException(errorMessage); + } + } + // No GraphQL errors, but we still might have an HTTP error status + _httpFailedRequestHandler.DoEnsureSuccessfulResponse(request, response); + } + catch (Exception exception) + { + exception.SetSentryMechanism(MechanismType, "GraphQL Failed Request Handler", false); + + var @event = new SentryEvent(exception); + var hint = new Hint(HintTypes.HttpResponseMessage, response); + + var sentryRequest = new Request + { + QueryString = request.RequestUri?.Query, + Method = request.Method.Method.ToUpperInvariant(), + ApiTarget = "graphql" + }; + + var responseContext = new Response + { + StatusCode = (short)response.StatusCode, +#if NET5_0_OR_GREATER + // Starting with .NET 5, the content and headers are guaranteed to not be null. + BodySize = response.Content?.Headers.ContentLength, +#else + BodySize = response.Content?.Headers?.ContentLength, +#endif + }; + + var requestContent = request.GetFused(); + if (!_options.SendDefaultPii) + { + sentryRequest.Url = request.RequestUri?.GetComponents(UriComponents.HttpRequestUrl, UriFormat.Unescaped); + } + else + { + sentryRequest.Cookies = request.Headers.GetCookies(); + sentryRequest.Data = requestContent?.RequestContent; + sentryRequest.Url = request.RequestUri?.AbsoluteUri; + sentryRequest.AddHeaders(request.Headers); + responseContext.Cookies = response.Headers.GetCookies(); + responseContext.Data = json; + responseContext.AddHeaders(response.Headers); + } + + @event.Request = sentryRequest; + @event.Contexts[Response.Type] = responseContext; + if (requestContent is not null) + { + @event.Fingerprint = new[] + { + requestContent.OperationNameOrFallback(), + requestContent.OperationTypeOrFallback(), + ((int)response.StatusCode).ToString() + }; + } + Hub.CaptureEvent(@event, hint); + } + } +} diff --git a/src/Sentry/SentryGraphQLHttpMessageHandler.cs b/src/Sentry/SentryGraphQLHttpMessageHandler.cs new file mode 100644 index 0000000000..0c94edcb40 --- /dev/null +++ b/src/Sentry/SentryGraphQLHttpMessageHandler.cs @@ -0,0 +1,111 @@ +using Sentry.Extensibility; +using Sentry.Internal; + +namespace Sentry; + +/// +/// Special HTTP message handler that can be used to propagate Sentry headers and other contextual information. +/// +public class SentryGraphQLHttpMessageHandler : SentryMessageHandler +{ + private readonly IHub _hub; + private readonly SentryOptions? _options; + private readonly ISentryFailedRequestHandler? _failedRequestHandler; + + /// + /// Constructs an instance of . + /// + /// An inner message handler to delegate calls to. + /// The Sentry hub. + public SentryGraphQLHttpMessageHandler(HttpMessageHandler? innerHandler = default, IHub? hub = default) + : this(hub, default, innerHandler) + { + } + + internal SentryGraphQLHttpMessageHandler(IHub? hub, SentryOptions? options, + HttpMessageHandler? innerHandler = default, + ISentryFailedRequestHandler? failedRequestHandler = null) + : base(hub, options, innerHandler) + { + _hub = hub ?? HubAdapter.Instance; + _options = options ?? _hub.GetSentryOptions(); + _failedRequestHandler = failedRequestHandler; + if (_options != null) + { + _failedRequestHandler ??= new SentryGraphQLHttpFailedRequestHandler(_hub, _options); + } + } + + /// + protected internal override ISpan? ProcessRequest(HttpRequestMessage request, string method, string url) + { + var content = GraphQLContentExtractor.ExtractRequestContentAsync(request, _options).Result; + if (content is not { } graphQlRequestContent) + { + _options?.LogDebug("Unable to process non GraphQL request content"); + return null; + } + request.SetFused(graphQlRequestContent); + + // Start a span that tracks this request + // (may be null if transaction is not set on the scope) + return _hub.GetSpan()?.StartChild( + "http.client", + $"{method} {url}" // e.g. "GET https://example.com" + ); + } + + /// + protected internal override void HandleResponse(HttpResponseMessage response, ISpan? span, string method, string url) + { + var graphqlInfo = response.RequestMessage?.GetFused(); + var breadcrumbData = new Dictionary + { + {"url", url}, + {"method", method}, + {"status_code", ((int) response.StatusCode).ToString()} + }; + AddIfExists(breadcrumbData, "request_body_size", response.RequestMessage?.Content?.Headers.ContentLength?.ToString()); +#if NET5_0_OR_GREATER + // Starting with .NET 5, the content and headers are guaranteed to not be null. + AddIfExists(breadcrumbData, "response_body_size", response.Content.Headers.ContentLength?.ToString()); +#else + AddIfExists(breadcrumbData, "response_body_size", response.Content?.Headers.ContentLength?.ToString()); +#endif + AddIfExists(breadcrumbData, "operation_name", graphqlInfo?.OperationName); // The GraphQL operation name + AddIfExists(breadcrumbData, "operation_type", graphqlInfo?.OperationType); // i.e. `query`, `mutation`, `subscription` + _hub.AddBreadcrumb( + string.Empty, + graphqlInfo?.OperationType ?? "graphql.operation", + "graphql", + breadcrumbData + ); + + // Create events for failed requests + _failedRequestHandler?.HandleResponse(response); + + // This will handle unsuccessful status codes as well + if (span is not null) + { + // TODO: See how we can determine the span status for a GraphQL request... + span.Status = SpanStatusConverter.FromHttpStatusCode(response.StatusCode); // TODO: Don't do this if the span is errored + span.Description = GetSpanDescriptionOrDefault(graphqlInfo, response.StatusCode) ?? span.Description; + span.Finish(); + } + } + + private string? GetSpanDescriptionOrDefault(GraphQLRequestContent? graphqlInfo, HttpStatusCode statusCode) => + string.Join(" ", + graphqlInfo?.OperationNameOrFallback(), + graphqlInfo?.OperationTypeOrFallback(), + ((int)statusCode).ToString() + ); + + private void AddIfExists(Dictionary breadcrumbData, string key, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + breadcrumbData[key] = value; + } + } +} diff --git a/src/Sentry/SentryHttpFailedRequestHandler.cs b/src/Sentry/SentryHttpFailedRequestHandler.cs new file mode 100644 index 0000000000..6828311c4c --- /dev/null +++ b/src/Sentry/SentryHttpFailedRequestHandler.cs @@ -0,0 +1,79 @@ +using Sentry.Internal; +using Sentry.Protocol; + +namespace Sentry; + +internal class SentryHttpFailedRequestHandler : SentryFailedRequestHandler +{ + public const string MechanismType = "SentryHttpFailedRequestHandler"; + + internal SentryHttpFailedRequestHandler(IHub hub, SentryOptions options) + : base(hub, options) + { + } + + protected internal override void DoEnsureSuccessfulResponse([NotNull]HttpRequestMessage request, [NotNull]HttpResponseMessage response) + { + // Don't capture events for successful requests + if (!Options.FailedRequestStatusCodes.Any(range => range.Contains(response.StatusCode))) + { + return; + } + +#if NET5_0_OR_GREATER + // Starting with .NET 5, the content and headers are guaranteed to not be null. + var bodySize = response.Content.Headers.ContentLength; +#else + // We have to get the content body size before calling EnsureSuccessStatusCode, + // because older implementations of EnsureSuccessStatusCode disposes the content. + // See https://github.com/dotnet/runtime/issues/24845 + + // The ContentLength might be null (but that's ok). + // See https://github.com/dotnet/runtime/issues/16162 + var bodySize = response.Content?.Headers?.ContentLength; +#endif + + // Capture the event + try + { + response.EnsureSuccessStatusCode(); + } + catch (HttpRequestException exception) + { + exception.SetSentryMechanism(MechanismType); + + var @event = new SentryEvent(exception); + var hint = new Hint(HintTypes.HttpResponseMessage, response); + + var uri = response.RequestMessage?.RequestUri; + var sentryRequest = new Request + { + QueryString = uri?.Query, + Method = response.RequestMessage?.Method.Method.ToUpperInvariant() + }; + + var responseContext = new Response { + StatusCode = (short)response.StatusCode, + BodySize = bodySize + }; + + if (!Options.SendDefaultPii) + { + sentryRequest.Url = uri?.GetComponents(UriComponents.HttpRequestUrl, UriFormat.Unescaped); + } + else + { + sentryRequest.Url = uri?.AbsoluteUri; + sentryRequest.Cookies = request.Headers.GetCookies(); + sentryRequest.AddHeaders(request.Headers); + responseContext.Cookies = response.Headers.GetCookies(); + responseContext.AddHeaders(response.Headers); + } + + @event.Request = sentryRequest; + @event.Contexts[Response.Type] = responseContext; + + Hub.CaptureEvent(@event, hint); + } + } +} diff --git a/src/Sentry/SentryHttpMessageHandler.cs b/src/Sentry/SentryHttpMessageHandler.cs index 7540d5866a..415194ed78 100644 --- a/src/Sentry/SentryHttpMessageHandler.cs +++ b/src/Sentry/SentryHttpMessageHandler.cs @@ -1,4 +1,5 @@ using Sentry.Extensibility; +using Sentry.Internal; using Sentry.Internal.Extensions; namespace Sentry; @@ -6,7 +7,7 @@ namespace Sentry; /// /// Special HTTP message handler that can be used to propagate Sentry headers and other contextual information. /// -public class SentryHttpMessageHandler : DelegatingHandler +public class SentryHttpMessageHandler : SentryMessageHandler { private readonly IHub _hub; private readonly SentryOptions? _options; @@ -45,89 +46,32 @@ public SentryHttpMessageHandler(HttpMessageHandler innerHandler, IHub hub) } internal SentryHttpMessageHandler(IHub? hub, SentryOptions? options, HttpMessageHandler? innerHandler = default, ISentryFailedRequestHandler? failedRequestHandler = null) + : base(hub, options, innerHandler) { _hub = hub ?? HubAdapter.Instance; _options = options ?? _hub.GetSentryOptions(); _failedRequestHandler = failedRequestHandler; - // Only assign the inner handler if it is supplied. We can't assign null or it will throw. - // We also cannot assign a default value here, or it will throw when used with HttpMessageHandlerBuilderFilter. - if (innerHandler is not null) - { - InnerHandler = innerHandler; - } - // Use the default failed request handler if none was supplied - but options is required. if (_failedRequestHandler == null && _options != null) { - _failedRequestHandler = new SentryFailedRequestHandler(_hub, _options); - } - } - - /// - protected override async Task SendAsync( - HttpRequestMessage request, - CancellationToken cancellationToken) - { - var (span, method, url) = ProcessRequest(request); - - try - { - var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); - HandleResponse(response, span, method, url); - return response; - } - catch (Exception ex) - { - span?.Finish(ex); - throw; + _failedRequestHandler = new SentryHttpFailedRequestHandler(_hub, _options); } } -#if NET5_0_OR_GREATER /// - protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + protected internal override ISpan? ProcessRequest(HttpRequestMessage request, string method, string url) { - var (span, method, url) = ProcessRequest(request); - - try - { - var response = base.Send(request, cancellationToken); - HandleResponse(response, span, method, url); - return response; - } - catch (Exception ex) - { - span?.Finish(ex); - throw; - } - } -#endif - - private (ISpan? Span, string Method, string Url) ProcessRequest(HttpRequestMessage request) - { - // Assign a default inner handler for convenience the first time this is used. - // We can't do this in a constructor, or it will throw when used with HttpMessageHandlerBuilderFilter. - InnerHandler ??= new HttpClientHandler(); - - var method = request.Method.Method.ToUpperInvariant(); - var url = request.RequestUri?.ToString() ?? string.Empty; - - if (_options?.TracePropagationTargets.ContainsMatch(url) is true or null) - { - AddSentryTraceHeader(request); - AddBaggageHeader(request); - } - // Start a span that tracks this request // (may be null if transaction is not set on the scope) - // e.g. "GET https://example.com" - var span = _hub.GetSpan()?.StartChild("http.client", $"{method} {url}"); - - return (span, method, url); + return _hub.GetSpan()?.StartChild( + "http.client", + $"{method} {url}" // e.g. "GET https://example.com" + ); } - private void HandleResponse(HttpResponseMessage response, ISpan? span, string method, string url) + /// + protected internal override void HandleResponse(HttpResponseMessage response, ISpan? span, string method, string url) { var breadcrumbData = new Dictionary { @@ -144,44 +88,4 @@ private void HandleResponse(HttpResponseMessage response, ISpan? span, string me var status = SpanStatusConverter.FromHttpStatusCode(response.StatusCode); span?.Finish(status); } - - private void AddSentryTraceHeader(HttpRequestMessage request) - { - // Set trace header if it hasn't already been set - if (!request.Headers.Contains(SentryTraceHeader.HttpHeaderName) && _hub.GetTraceHeader() is { } traceHeader) - { - request.Headers.Add(SentryTraceHeader.HttpHeaderName, traceHeader.ToString()); - } - } - - private void AddBaggageHeader(HttpRequestMessage request) - { - var baggage = _hub.GetBaggage(); - if (baggage is null) - { - return; - } - - if (request.Headers.TryGetValues(BaggageHeader.HttpHeaderName, out var baggageHeaders)) - { - var headers = baggageHeaders.ToList(); - if (headers.Any(h => h.StartsWith(BaggageHeader.SentryKeyPrefix))) - { - // The Sentry headers have already been added to this request. Do nothing. - return; - } - - // Merge existing baggage headers with ours. - var allBaggage = headers - .Select(s => BaggageHeader.TryParse(s)).ExceptNulls() - .Append(baggage); - baggage = BaggageHeader.Merge(allBaggage); - - // Remove the existing header so we can replace it with the merged one. - request.Headers.Remove(BaggageHeader.HttpHeaderName); - } - - // Set the baggage header - request.Headers.Add(BaggageHeader.HttpHeaderName, baggage.ToString()); - } } diff --git a/src/Sentry/SentryMessageHandler.cs b/src/Sentry/SentryMessageHandler.cs new file mode 100644 index 0000000000..d641bbbee2 --- /dev/null +++ b/src/Sentry/SentryMessageHandler.cs @@ -0,0 +1,176 @@ +using Sentry.Extensibility; +using Sentry.Internal; +using Sentry.Internal.Extensions; + +namespace Sentry; + +/// +/// Special HTTP message handler that can be used to propagate Sentry headers and other contextual information. +/// +public abstract class SentryMessageHandler : DelegatingHandler +{ + private readonly IHub _hub; + private readonly SentryOptions? _options; + + /// + /// Constructs an instance of . + /// + protected SentryMessageHandler() + : this(default, default, default) { } + + /// + /// Constructs an instance of . + /// + /// An inner message handler to delegate calls to. + protected SentryMessageHandler(HttpMessageHandler innerHandler) + : this(default, default, innerHandler) { } + + /// + /// Constructs an instance of . + /// + /// The Sentry hub. + protected SentryMessageHandler(IHub hub) + : this(hub, default) + { + } + + /// + /// Constructs an instance of . + /// + /// An inner message handler to delegate calls to. + /// The Sentry hub. + protected SentryMessageHandler(HttpMessageHandler innerHandler, IHub hub) + : this(hub, default, innerHandler) + { + } + + internal SentryMessageHandler(IHub? hub, SentryOptions? options, HttpMessageHandler? innerHandler = default) + { + _hub = hub ?? HubAdapter.Instance; + _options = options ?? _hub.GetSentryOptions(); + + // Only assign the inner handler if it is supplied. We can't assign null or it will throw. + // We also cannot assign a default value here, or it will throw when used with HttpMessageHandlerBuilderFilter. + if (innerHandler is not null) + { + InnerHandler = innerHandler; + } + } + + /// + /// Starts a span for a request + /// + /// The + /// The request method (e.g. "GET") + /// The request URL + /// An + protected internal abstract ISpan? ProcessRequest(HttpRequestMessage request, string method, string url); + + /// + /// Provides an opportunity for further processing of the span once a response is received. + /// + /// The + /// The created in + /// The request method (e.g. "GET") + /// The request URL + protected internal abstract void HandleResponse(HttpResponseMessage response, ISpan? span, string method, string url); + + /// + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var method = request.Method.Method.ToUpperInvariant(); + var url = request.RequestUri?.ToString() ?? string.Empty; + + PropagateTraceHeaders(request, url); + var span = ProcessRequest(request, method, url); + try + { + var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + HandleResponse(response, span, method, url); + return response; + } + catch (Exception ex) + { + span?.Finish(ex); + throw; + } + } + +#if NET5_0_OR_GREATER + /// + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + var method = request.Method.Method.ToUpperInvariant(); + var url = request.RequestUri?.ToString() ?? string.Empty; + + PropagateTraceHeaders(request, url); + var span = ProcessRequest(request, method, url); + try + { + var response = base.Send(request, cancellationToken); + HandleResponse(response, span, method, url); + return response; + } + catch (Exception ex) + { + span?.Finish(ex); + throw; + } + } +#endif + + private void PropagateTraceHeaders(HttpRequestMessage request, string url) + { + // Assign a default inner handler for convenience the first time this is used. + // We can't do this in a constructor, or it will throw when used with HttpMessageHandlerBuilderFilter. + InnerHandler ??= new HttpClientHandler(); + + if (_options?.TracePropagationTargets.ContainsMatch(url) is true or null) + { + AddSentryTraceHeader(request); + AddBaggageHeader(request); + } + } + + private void AddSentryTraceHeader(HttpRequestMessage request) + { + // Set trace header if it hasn't already been set + if (!request.Headers.Contains(SentryTraceHeader.HttpHeaderName) && _hub.GetTraceHeader() is { } traceHeader) + { + request.Headers.Add(SentryTraceHeader.HttpHeaderName, traceHeader.ToString()); + } + } + + private void AddBaggageHeader(HttpRequestMessage request) + { + var baggage = _hub.GetBaggage(); + if (baggage is null) + { + return; + } + + if (request.Headers.TryGetValues(BaggageHeader.HttpHeaderName, out var baggageHeaders)) + { + var headers = baggageHeaders.ToList(); + if (headers.Any(h => h.StartsWith(BaggageHeader.SentryKeyPrefix))) + { + // The Sentry headers have already been added to this request. Do nothing. + return; + } + + // Merge existing baggage headers with ours. + var allBaggage = headers + .Select(s => BaggageHeader.TryParse(s)).ExceptNulls() + .Append(baggage); + baggage = BaggageHeader.Merge(allBaggage); + + // Remove the existing header so we can replace it with the merged one. + request.Headers.Remove(BaggageHeader.HttpHeaderName); + } + + // Set the baggage header + request.Headers.Add(BaggageHeader.HttpHeaderName, baggage.ToString()); + } +} diff --git a/test/Sentry.Testing/JsonSerializableExtensions.cs b/test/Sentry.Testing/JsonSerializableExtensions.cs index 50d9df3b0a..632de335c9 100644 --- a/test/Sentry.Testing/JsonSerializableExtensions.cs +++ b/test/Sentry.Testing/JsonSerializableExtensions.cs @@ -1,3 +1,5 @@ +namespace Sentry.Testing; + internal static class JsonSerializableExtensions { private static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); diff --git a/test/Sentry.Testing/Sentry.Testing.csproj b/test/Sentry.Testing/Sentry.Testing.csproj index 7712a12e27..566c822de3 100644 --- a/test/Sentry.Testing/Sentry.Testing.csproj +++ b/test/Sentry.Testing/Sentry.Testing.csproj @@ -16,6 +16,7 @@ + diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Core3_1.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Core3_1.verified.txt index c11c70c6c4..e11c0c2b43 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Core3_1.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Core3_1.verified.txt @@ -404,6 +404,7 @@ namespace Sentry public sealed class Request : Sentry.IJsonSerializable { public Request() { } + public string? ApiTarget { get; set; } public string? Cookies { get; set; } public object? Data { get; set; } public System.Collections.Generic.IDictionary Env { get; } @@ -540,13 +541,20 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } - public class SentryHttpMessageHandler : System.Net.Http.DelegatingHandler + public class SentryGraphQLHttpMessageHandler : Sentry.SentryMessageHandler + { + public SentryGraphQLHttpMessageHandler(System.Net.Http.HttpMessageHandler? innerHandler = null, Sentry.IHub? hub = null) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } + } + public class SentryHttpMessageHandler : Sentry.SentryMessageHandler { public SentryHttpMessageHandler() { } public SentryHttpMessageHandler(Sentry.IHub hub) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } - protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } } public readonly struct SentryId : Sentry.IJsonSerializable, System.IEquatable { @@ -588,6 +596,16 @@ namespace Sentry public static Sentry.SentryMessage FromJson(System.Text.Json.JsonElement json) { } public static Sentry.SentryMessage op_Implicit(string? message) { } } + public abstract class SentryMessageHandler : System.Net.Http.DelegatingHandler + { + protected SentryMessageHandler() { } + protected SentryMessageHandler(Sentry.IHub hub) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } + protected abstract void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url); + protected abstract Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url); + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + } public class SentryOptions { public SentryOptions() { } @@ -1623,6 +1641,7 @@ namespace Sentry.Protocol public Response() { } public long? BodySize { get; set; } public string? Cookies { get; set; } + public object? Data { get; set; } public System.Collections.Generic.IDictionary Headers { get; } public short? StatusCode { get; set; } public Sentry.Protocol.Response Clone() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt index 8b7b362331..1ba3e6a72e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet6_0.verified.txt @@ -404,6 +404,7 @@ namespace Sentry public sealed class Request : Sentry.IJsonSerializable { public Request() { } + public string? ApiTarget { get; set; } public string? Cookies { get; set; } public object? Data { get; set; } public System.Collections.Generic.IDictionary Env { get; } @@ -540,14 +541,20 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } - public class SentryHttpMessageHandler : System.Net.Http.DelegatingHandler + public class SentryGraphQLHttpMessageHandler : Sentry.SentryMessageHandler + { + public SentryGraphQLHttpMessageHandler(System.Net.Http.HttpMessageHandler? innerHandler = null, Sentry.IHub? hub = null) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } + } + public class SentryHttpMessageHandler : Sentry.SentryMessageHandler { public SentryHttpMessageHandler() { } public SentryHttpMessageHandler(Sentry.IHub hub) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } - protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } - protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } } public readonly struct SentryId : Sentry.IJsonSerializable, System.IEquatable { @@ -589,6 +596,17 @@ namespace Sentry public static Sentry.SentryMessage FromJson(System.Text.Json.JsonElement json) { } public static Sentry.SentryMessage op_Implicit(string? message) { } } + public abstract class SentryMessageHandler : System.Net.Http.DelegatingHandler + { + protected SentryMessageHandler() { } + protected SentryMessageHandler(Sentry.IHub hub) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } + protected abstract void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url); + protected abstract Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url); + protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + } public class SentryOptions { public SentryOptions() { } @@ -1624,6 +1642,7 @@ namespace Sentry.Protocol public Response() { } public long? BodySize { get; set; } public string? Cookies { get; set; } + public object? Data { get; set; } public System.Collections.Generic.IDictionary Headers { get; } public short? StatusCode { get; set; } public Sentry.Protocol.Response Clone() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt index 8b7b362331..1ba3e6a72e 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.DotNet7_0.verified.txt @@ -404,6 +404,7 @@ namespace Sentry public sealed class Request : Sentry.IJsonSerializable { public Request() { } + public string? ApiTarget { get; set; } public string? Cookies { get; set; } public object? Data { get; set; } public System.Collections.Generic.IDictionary Env { get; } @@ -540,14 +541,20 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } - public class SentryHttpMessageHandler : System.Net.Http.DelegatingHandler + public class SentryGraphQLHttpMessageHandler : Sentry.SentryMessageHandler + { + public SentryGraphQLHttpMessageHandler(System.Net.Http.HttpMessageHandler? innerHandler = null, Sentry.IHub? hub = null) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } + } + public class SentryHttpMessageHandler : Sentry.SentryMessageHandler { public SentryHttpMessageHandler() { } public SentryHttpMessageHandler(Sentry.IHub hub) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } - protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } - protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } } public readonly struct SentryId : Sentry.IJsonSerializable, System.IEquatable { @@ -589,6 +596,17 @@ namespace Sentry public static Sentry.SentryMessage FromJson(System.Text.Json.JsonElement json) { } public static Sentry.SentryMessage op_Implicit(string? message) { } } + public abstract class SentryMessageHandler : System.Net.Http.DelegatingHandler + { + protected SentryMessageHandler() { } + protected SentryMessageHandler(Sentry.IHub hub) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } + protected abstract void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url); + protected abstract Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url); + protected override System.Net.Http.HttpResponseMessage Send(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + } public class SentryOptions { public SentryOptions() { } @@ -1624,6 +1642,7 @@ namespace Sentry.Protocol public Response() { } public long? BodySize { get; set; } public string? Cookies { get; set; } + public object? Data { get; set; } public System.Collections.Generic.IDictionary Headers { get; } public short? StatusCode { get; set; } public Sentry.Protocol.Response Clone() { } diff --git a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt index 05f94c169b..70ae520273 100644 --- a/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt +++ b/test/Sentry.Tests/ApiApprovalTests.Run.Net4_8.verified.txt @@ -403,6 +403,7 @@ namespace Sentry public sealed class Request : Sentry.IJsonSerializable { public Request() { } + public string? ApiTarget { get; set; } public string? Cookies { get; set; } public object? Data { get; set; } public System.Collections.Generic.IDictionary Env { get; } @@ -539,13 +540,20 @@ namespace Sentry public void WriteTo(System.Text.Json.Utf8JsonWriter writer, Sentry.Extensibility.IDiagnosticLogger? logger) { } public static Sentry.SentryEvent FromJson(System.Text.Json.JsonElement json) { } } - public class SentryHttpMessageHandler : System.Net.Http.DelegatingHandler + public class SentryGraphQLHttpMessageHandler : Sentry.SentryMessageHandler + { + public SentryGraphQLHttpMessageHandler(System.Net.Http.HttpMessageHandler? innerHandler = null, Sentry.IHub? hub = null) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } + } + public class SentryHttpMessageHandler : Sentry.SentryMessageHandler { public SentryHttpMessageHandler() { } public SentryHttpMessageHandler(Sentry.IHub hub) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } public SentryHttpMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } - protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + protected override void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url) { } + protected override Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url) { } } public readonly struct SentryId : Sentry.IJsonSerializable, System.IEquatable { @@ -587,6 +595,16 @@ namespace Sentry public static Sentry.SentryMessage FromJson(System.Text.Json.JsonElement json) { } public static Sentry.SentryMessage op_Implicit(string? message) { } } + public abstract class SentryMessageHandler : System.Net.Http.DelegatingHandler + { + protected SentryMessageHandler() { } + protected SentryMessageHandler(Sentry.IHub hub) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler) { } + protected SentryMessageHandler(System.Net.Http.HttpMessageHandler innerHandler, Sentry.IHub hub) { } + protected abstract void HandleResponse(System.Net.Http.HttpResponseMessage response, Sentry.ISpan? span, string method, string url); + protected abstract Sentry.ISpan? ProcessRequest(System.Net.Http.HttpRequestMessage request, string method, string url); + protected override System.Threading.Tasks.Task SendAsync(System.Net.Http.HttpRequestMessage request, System.Threading.CancellationToken cancellationToken) { } + } public class SentryOptions { public SentryOptions() { } @@ -1623,6 +1641,7 @@ namespace Sentry.Protocol public Response() { } public long? BodySize { get; set; } public string? Cookies { get; set; } + public object? Data { get; set; } public System.Collections.Generic.IDictionary Headers { get; } public short? StatusCode { get; set; } public Sentry.Protocol.Response Clone() { } diff --git a/test/Sentry.Tests/GraphQlRequestContentExtractorTests.cs b/test/Sentry.Tests/GraphQlRequestContentExtractorTests.cs new file mode 100644 index 0000000000..f5fec34f27 --- /dev/null +++ b/test/Sentry.Tests/GraphQlRequestContentExtractorTests.cs @@ -0,0 +1,68 @@ +namespace Sentry.Tests; + +public class GraphQlRequestContentExtractorTests +{ + private const string ValidQuery = "query { notes { id } }"; + private const string ValidQueryWithName = "query getAllNotes { notes { id } }"; + private const string ValidShorthandQuery = "{ notes { id } }"; + private const string ValidMutation = "mutation saveSomething { id }"; + + [Theory] + [InlineData(ValidQuery, "query", "")] + [InlineData(ValidShorthandQuery, "query", "")] + [InlineData(ValidQueryWithName, "query", "getAllNotes")] + [InlineData(ValidMutation, "mutation", "saveSomething")] + public async Task ExtractContent_ValidQuery_UnpacksRequest(string query, string operationType, string operationName) + { + // Arrange + var request = SentryGraphQlTestHelpers.GetRequestQuery(query); + + // Act + var result = await GraphQLContentExtractor.ExtractRequestContentAsync(request, null); + + // Assert + result.Should().NotBeNull(); + (result!.OperationType ?? "").Should().Be(operationType); + (result!.OperationName ?? "").Should().Be(operationName); + result!.Query.Should().Be(query); + } + + [Fact] + public async Task ExtractContent_WithNullRequest_ReturnsNull() + { + // Act + var result = await GraphQLContentExtractor.ExtractRequestContentAsync(null!, null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task ExtractContent_WithNullRequestContent_ReturnsNull() + { + // Arrange + var request = SentryGraphQlTestHelpers.GetRequest(null); + + // Act + var result = await GraphQLContentExtractor.ExtractRequestContentAsync(request, null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public async Task ExtractContent_WithUnreadableStream_ReturnsNull() + { + // Arrange + var stream = new MemoryStream(); + stream.Close(); + var content = new StreamContent(stream); + var request = SentryGraphQlTestHelpers.GetRequest(content); + + // Act + var result = await GraphQLContentExtractor.ExtractRequestContentAsync(request, null); + + // Assert + result.Should().BeNull(); + } +} diff --git a/test/Sentry.Tests/Internals/ObjectExtensionsTests.cs b/test/Sentry.Tests/Internals/ObjectExtensionsTests.cs new file mode 100644 index 0000000000..bf2421c3d7 --- /dev/null +++ b/test/Sentry.Tests/Internals/ObjectExtensionsTests.cs @@ -0,0 +1,51 @@ +namespace Sentry.Tests.Internals; + +/// +/// Copied/Modified from: +/// https://github.com/mentaldesk/fuse/blob/91af00dc9bc7e1deb2f11ab679c536194f85dd4a/MentalDesk.Fuse.Tests/ObjectExtensionsTests.cs +/// +public class ObjectExtensionsTests +{ + [Fact] + public void SetFused_AutoPropertyName_StoresProperty() + { + var obj = new object(); + obj.SetFused("Value"); + + var result = obj.GetFused(); + + Assert.Equal("Value", result); + } + + [Fact] + public void GetFused_ValidProperty_ReturnsValue() + { + var obj = new object(); + obj.SetFused("Test", "Value"); + + var result = obj.GetFused("Test"); + + Assert.Equal("Value", result); + } + + [Fact] + public void GetFused_UnassignedProperty_ReturnsNull() + { + var obj = new object(); + + var result = obj.GetFused("Invalid"); + + result.Should().BeNull(); + } + + [Fact] + public void GetFused_InvalidPropertyType_ReturnsNull() + { + var obj = new object(); + obj.SetFused("StringProperty", "StringValue"); + + var result = obj.GetFused("StringProperty"); + + result.Should().BeNull(); + } +} diff --git a/test/Sentry.Tests/Protocol/RequestTests.cs b/test/Sentry.Tests/Protocol/RequestTests.cs index 7d3836c278..bd606cebdf 100644 --- a/test/Sentry.Tests/Protocol/RequestTests.cs +++ b/test/Sentry.Tests/Protocol/RequestTests.cs @@ -7,6 +7,7 @@ public void Clone_CopyValues() { var sut = new Request { + ApiTarget = "graphql", Url = "https://sentry.io", Method = "OPTIONS", Data = new object(), @@ -18,6 +19,7 @@ public void Clone_CopyValues() var clone = sut.Clone(); + Assert.Equal(sut.ApiTarget, clone.ApiTarget); Assert.Equal(sut.Url, clone.Url); Assert.Equal(sut.Method, clone.Method); Assert.Same(sut.Data, clone.Data); diff --git a/test/Sentry.Tests/Protocol/ResponseTests.cs b/test/Sentry.Tests/Protocol/ResponseTests.cs index c357bb41d2..27cc5cb617 100644 --- a/test/Sentry.Tests/Protocol/ResponseTests.cs +++ b/test/Sentry.Tests/Protocol/ResponseTests.cs @@ -10,6 +10,7 @@ public void Clone_CopyValues() { BodySize = 42, Cookies = "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;", + Data = "foo", StatusCode = 500 }; sut.Headers.Add("X-Test", "header"); @@ -20,6 +21,7 @@ public void Clone_CopyValues() // Assert clone.BodySize.Should().Be(sut.BodySize); clone.Cookies.Should().Be(sut.Cookies); + clone.Data.Should().Be(sut.Data); clone.StatusCode.Should().Be(sut.StatusCode); clone.InternalHeaders.Should().NotBeSameAs(sut.InternalHeaders); clone.Headers.Should().BeEquivalentTo(sut.Headers); @@ -33,6 +35,7 @@ public void ToJson_CopyValues() { BodySize = 42, Cookies = "PHPSESSID=298zf09hf012fh2; csrftoken=u32t4o3tb3gg43; _gat=1;", + Data = "foo", StatusCode = 500 }; expected.Headers.Add("X-Test", "header"); diff --git a/test/Sentry.Tests/SentryGraphQlHttpFailedRequestHandlerTests.cs b/test/Sentry.Tests/SentryGraphQlHttpFailedRequestHandlerTests.cs new file mode 100644 index 0000000000..7f8980d858 --- /dev/null +++ b/test/Sentry.Tests/SentryGraphQlHttpFailedRequestHandlerTests.cs @@ -0,0 +1,256 @@ +namespace Sentry.Tests; + +public class SentryGraphQlHttpFailedRequestHandlerTests +{ + private const string ValidQuery = "query getAllNotes { notes { id } }"; + private static HttpResponseMessage ForbiddenResponse() + => new(HttpStatusCode.Forbidden); + + private static HttpResponseMessage InternalServerErrorResponse() + => new(HttpStatusCode.InternalServerError); + + private HttpResponseMessage PreconditionFailedResponse() + => new(HttpStatusCode.PreconditionFailed) + { + Content = SentryGraphQlTestHelpers.ErrorContent("Bad query", "BAD_OP") + }; + + [Fact] + public void HandleResponse_Disabled_DontCapture() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = false + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = InternalServerErrorResponse(); + response.RequestMessage = new HttpRequestMessage(); + + // Act + sut.HandleResponse(response); + + // Assert + hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + + [Fact] + public void HandleResponse_RequestsToSentryDsn_DontCapture() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true, + Dsn = "https://eb18e953812b41c3aeb042e666fd3b5c@o447951.ingest.sentry.io/5428537" + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = InternalServerErrorResponse(); + response.RequestMessage = new HttpRequestMessage(HttpMethod.Post, options.Dsn); + + // Act + sut.HandleResponse(response); + + // Assert + hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + + [Fact] + public void HandleResponse_NoMatchingTarget_DontCapture() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true, + FailedRequestTargets = new List { "http://foo/" } + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = InternalServerErrorResponse(); + response.RequestMessage = new HttpRequestMessage(HttpMethod.Get, "http://bar/"); + + // Act + sut.HandleResponse(response); + + // Assert + hub.DidNotReceive().CaptureEvent(Arg.Any()); + } + + [Fact] + public void HandleResponse_NoError_BaseCapturesFailedRequests() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = InternalServerErrorResponse(); + response.RequestMessage = new HttpRequestMessage(); + + // Act + sut.HandleResponse(response); + + // Assert + hub.Received(1).CaptureEvent( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public void HandleResponse_Error_Capture() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = PreconditionFailedResponse(); + response.RequestMessage = new HttpRequestMessage(); + + // Act + sut.HandleResponse(response); + + // Assert + hub.Received(1).CaptureEvent( + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + + [Fact] + public void HandleResponse_Error_DontSendPii() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = PreconditionFailedResponse(); + var uri = new Uri("http://admin:1234@localhost/test/path?query=string#fragment"); + response.RequestMessage = new HttpRequestMessage(HttpMethod.Get, uri); + + // Act + SentryEvent @event = null; + hub.CaptureEvent(Arg.Do(e => @event = e), Arg.Any()); + sut.HandleResponse(response); + + // Assert + @event.Request.Url.Should().Be("http://localhost/test/path?query=string"); // No admin:1234 + @event.Request.Data.Should().BeNull(); + var responseContext = @event.Contexts[Response.Type] as Response; + responseContext?.Data.Should().BeNull(); + } + + [Fact] + public void HandleResponse_Error_CaptureRequestAndResponse() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true, + SendDefaultPii = true + }; + + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var query = ValidQuery; + var url = "http://foo/bar/hello"; + var response = PreconditionFailedResponse(); + response.RequestMessage = SentryGraphQlTestHelpers.GetRequestQuery(query, url); + var requestContent = new GraphQLRequestContent(SentryGraphQlTestHelpers.WrapRequestContent(query)); + response.RequestMessage!.SetFused(requestContent); + response.Headers.Add("myHeader", "myValue"); + + SentryEvent @event = null; + hub.CaptureEvent( + Arg.Do(e => @event = e), + Arg.Any() + ); + + // Act + sut.HandleResponse(response); + + // Assert + using (new AssertionScope()) + { + @event.Should().NotBeNull(); + + // Ensure the mechanism is set + @event.Exception?.Data[Mechanism.MechanismKey].Should().Be(SentryGraphQLHttpFailedRequestHandler.MechanismType); + @event.Exception?.Data[Mechanism.HandledKey].Should().Be(false); + + // Ensure the request properties are captured + @event.Request.Method.Should().Be(HttpMethod.Post.ToString()); + @event.Request.Url.Should().Be(url); + @event.Request.ApiTarget.Should().Be("graphql"); + @event.Request.Data.Should().Be(SentryGraphQlTestHelpers.WrapRequestContent(query)); + + // Ensure the response context is captured + @event.Contexts.Should().Contain(x => x.Key == Response.Type && x.Value is Response); + + var responseContext = @event.Contexts[Response.Type] as Response; + responseContext?.StatusCode.Should().Be((short)response.StatusCode); + responseContext?.BodySize.Should().Be(response.Content.Headers.ContentLength); + responseContext?.Data?.ToString().Should().Be( + SentryGraphQlTestHelpers.ErrorContent("Bad query", "BAD_OP").ReadAsJson().ToString() + ); + + @event.Contexts.Response.Headers.Should().ContainKey("myHeader"); + @event.Contexts.Response.Headers.Should().ContainValue("myValue"); + + // The fingerprints field should be set to ["$operationName", "$operationType", "$statusCode"]. + @event.Fingerprint.Should().BeEquivalentTo( + "getAllNotes", "query", $"{(int)HttpStatusCode.PreconditionFailed}" + ); + } + } + + [Fact] + public void HandleResponse_Error_ResponseAsHint() + { + // Arrange + var hub = Substitute.For(); + var options = new SentryOptions + { + CaptureFailedRequests = true + }; + var sut = new SentryGraphQLHttpFailedRequestHandler(hub, options); + + var response = PreconditionFailedResponse(); // This is in the range + response.RequestMessage = SentryGraphQlTestHelpers.GetRequestQuery(ValidQuery); + + // Act + Hint hint = null; + hub.CaptureEvent( + Arg.Any(), + Arg.Do(h => hint = h) + ); + sut.HandleResponse(response); + + // Assert + using (new AssertionScope()) + { + hint.Should().NotBeNull(); + + // Response should be captured + hint.Items[HintTypes.HttpResponseMessage].Should().Be(response); + } + } +} diff --git a/test/Sentry.Tests/SentryGraphQlHttpMessageHandlerTests.cs b/test/Sentry.Tests/SentryGraphQlHttpMessageHandlerTests.cs new file mode 100644 index 0000000000..25856c0ba9 --- /dev/null +++ b/test/Sentry.Tests/SentryGraphQlHttpMessageHandlerTests.cs @@ -0,0 +1,136 @@ +namespace Sentry.Tests; + +/* + * NOTE: All tests should be done for both asynchronous `SendAsync` and synchronous `Send` methods. + * TODO: Find a way to consolidate these tests cleanly. + */ + +public class SentryGraphQlHttpMessageHandlerTests +{ + private const string ValidQuery = "query getAllNotes { notes { id } }"; + private const string ValidResponse = @"{ + ""notes"": [ + { + ""id"": 0 + }, + { + ""id"": 1 + } + ] +}"; + private StringContent ValidResponseContent => SentryGraphQlTestHelpers.ResponesContent(ValidResponse); + + [Fact] + public void ProcessRequest_ExtractsGraphQlRequestContent() + { + // Arrange + var hub = Substitute.For(); + var method = "POST"; + var url = "http://example.com/graphql"; + var sut = new SentryGraphQLHttpMessageHandler(hub, null); + var query = ValidQuery; + var request = SentryGraphQlTestHelpers.GetRequestQuery(query); + + // Act + sut.ProcessRequest(request, method, url); + + // Assert + var graphqlInfo = request.GetFused(); + graphqlInfo.OperationName.Should().Be("getAllNotes"); + graphqlInfo.OperationType.Should().Be("query"); + graphqlInfo.Query.Should().Be(query); + } + + [Fact] + public void ProcessRequest_StartsSpan() + { + // Arrange + var hub = Substitute.For(); + var parentSpan = Substitute.For(); + hub.GetSpan().Returns(parentSpan); + var childSpan = Substitute.For(); + parentSpan.When(p => p.StartChild(Arg.Any())) + .Do(op => childSpan.Operation = op.Arg()); + parentSpan.StartChild(Arg.Any()).Returns(childSpan); + var sut = new SentryGraphQLHttpMessageHandler(hub, null); + + var method = "POST"; + var url = "http://example.com/graphql"; + var query = ValidQuery; + var request = SentryGraphQlTestHelpers.GetRequestQuery(query); + + // Act + var returnedSpan = sut.ProcessRequest(request, method, url); + + // Assert + returnedSpan.Should().NotBeNull(); + returnedSpan!.Operation.Should().Be("http.client"); + returnedSpan.Description.Should().Be($"{method} {url}"); + } + + // [Theory] + // [InlineData(ValidQuery)] + [Fact] + public void HandleResponse_AddsBreadcrumb() + { + // Arrange + var method = "POST"; + var url = "http://foo/bar"; + + var scope = new Scope(); + var hub = Substitute.For(); + hub.When(h => h.ConfigureScope(Arg.Any>())) + .Do(c => c.Arg>()(scope)); + + var query = ValidQuery; + var request = SentryGraphQlTestHelpers.GetRequestQuery(query, url); + var response = new HttpResponseMessage { Content = ValidResponseContent, StatusCode = HttpStatusCode.OK, RequestMessage = request}; + var wrappedQuery = SentryGraphQlTestHelpers.WrapRequestContent(query); + request.SetFused(new GraphQLRequestContent(wrappedQuery)); + + var options = new SentryOptions() + { + CaptureFailedRequests = true + }; + var sut = new SentryGraphQLHttpMessageHandler(hub, options); + + // Act + sut.HandleResponse(response, null, method, url); + + // Assert + var breadcrumb = scope.Breadcrumbs.First(); + breadcrumb.Should().NotBeNull(); + breadcrumb.Type.Should().Be("graphql"); + breadcrumb.Category.Should().Be("query"); + breadcrumb.Data.Should().Contain("url", url); + breadcrumb.Data.Should().Contain("method", method); + breadcrumb.Data.Should().Contain("status_code", ((int)response.StatusCode).ToString()); + breadcrumb.Data.Should().Contain("request_body_size", SentryGraphQlTestHelpers.WrapRequestContent(query).Length.ToString()); + breadcrumb.Data.Should().Contain("response_body_size", response.Content.Headers.ContentLength?.ToString()); + breadcrumb.Data.Should().Contain("operation_name", "getAllNotes"); + breadcrumb.Data.Should().Contain("operation_type", "query"); + } + + [Fact] + public void HandleResponse_SetsSpanStatusAndDescription() + { + // Arrange + var hub = Substitute.For(); + var response = new HttpResponseMessage(HttpStatusCode.OK); + var method = "POST"; + var url = "http://example.com/graphql"; + var request = SentryGraphQlTestHelpers.GetRequestQuery(ValidQuery, url); + response.RequestMessage = request; + var sut = new SentryGraphQLHttpMessageHandler(hub, null); + + // Act + var span = sut.ProcessRequest(request, method, url); // HandleResponse relies on this having been called first + sut.HandleResponse(response, span, method, url); + + // Assert + span.Should().NotBeNull(); + span!.Status.Should().Be(SpanStatus.Ok); + span.Description.Should().Be("getAllNotes query 200"); + } + +} diff --git a/test/Sentry.Tests/SentryGraphQlTestHelpers.cs b/test/Sentry.Tests/SentryGraphQlTestHelpers.cs new file mode 100644 index 0000000000..5485983f6e --- /dev/null +++ b/test/Sentry.Tests/SentryGraphQlTestHelpers.cs @@ -0,0 +1,64 @@ +namespace Sentry.Tests; + +internal static class SentryGraphQlTestHelpers +{ + /// + /// GraphQL Queries get sent in a dictionary using the GraphQL over HTTP protocol + /// + public static string WrapRequestContent(string queryText) + { + var wrapped = new Dictionary() + { + { "query", queryText } + }; + return wrapped.ToJsonString(); + } + + public static HttpRequestMessage GetRequestQuery(string query, string url = "http://foo") + { + var content = query is not null + ? new StringContent(WrapRequestContent(query)) + : null; + return GetRequest(content, url); + } + + public static HttpRequestMessage GetRequest(HttpContent content, string url = "http://foo") => new (HttpMethod.Post, url) + { + Content = content + }; + + public static StringContent JsonContent(dynamic json) + { + var serialised = JsonSerializer.Serialize(json); + return new (serialised, Encoding.UTF8, + "application/json") + { + Headers = { ContentLength = serialised.Length } + }; + } + + public static StringContent ResponesContent(string responseText) => JsonContent( + new + { + data = responseText + } + ); + + /// + /// e.g. + /// "[{"message":"Query does not contain operation \u0027getAllNotes\u0027.","extensions":{"code":"INVALID_OPERATION","codes":["INVALID_OPERATION"]}}]" + /// + public static StringContent ErrorContent(string errorMessage, string errorCode) => JsonContent( + new dynamic[] + { + new + { + message = errorMessage, + extensions = new { + code = errorCode, + codes = new dynamic[]{ errorCode } + } + } + } + ); +} diff --git a/test/Sentry.Tests/SentryFailedRequestHandlerTests.cs b/test/Sentry.Tests/SentryHttpFailedRequestHandlerTests.cs similarity index 96% rename from test/Sentry.Tests/SentryFailedRequestHandlerTests.cs rename to test/Sentry.Tests/SentryHttpFailedRequestHandlerTests.cs index 9fa415dcd4..54e88f40dc 100644 --- a/test/Sentry.Tests/SentryFailedRequestHandlerTests.cs +++ b/test/Sentry.Tests/SentryHttpFailedRequestHandlerTests.cs @@ -1,17 +1,17 @@ namespace Sentry.Tests; -public class SentryFailedRequestHandlerTests +public class SentryHttpFailedRequestHandlerTests { private readonly IHub _hub; - public SentryFailedRequestHandlerTests() + public SentryHttpFailedRequestHandlerTests() { _hub = Substitute.For(); } - private SentryFailedRequestHandler GetSut(SentryOptions options) + private SentryHttpFailedRequestHandler GetSut(SentryOptions options) { - return new SentryFailedRequestHandler(_hub, options); + return new SentryHttpFailedRequestHandler(_hub, options); } private static HttpResponseMessage ForbiddenResponse() @@ -182,7 +182,7 @@ public void HandleResponse_Capture_RequestAndResponse() @event.Should().NotBeNull(); // Ensure the mechanism is set - @event.Exception?.Data[Mechanism.MechanismKey].Should().Be(SentryFailedRequestHandler.MechanismType); + @event.Exception?.Data[Mechanism.MechanismKey].Should().Be(SentryHttpFailedRequestHandler.MechanismType); // Ensure the request properties are captured @event.Request.Method.Should().Be(HttpMethod.Post.ToString());