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