// // Copyright The OpenTelemetry Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // using System.Net; using System.Text; using IntegrationTests.Helpers; using OpenTelemetry.Proto.Trace.V1; using Xunit.Abstractions; namespace IntegrationTests; public class GraphQLTests : TestHelper { public GraphQLTests(ITestOutputHelper output) : base("GraphQL", output) { } [Theory] [InlineData(false)] [InlineData(true)] [Trait("Category", "EndToEnd")] public async Task SubmitsTraces(bool setDocument) { var requests = new List(); using var collector = new MockSpansCollector(Output); SetExporter(collector); // SUCCESS: query using GET Request(requests, method: "GET", url: "/graphql?query=" + WebUtility.UrlEncode("query{hero{name appearsIn}}")); Expect(collector, spanName: "query", graphQLOperationType: "query", graphQLOperationName: null, graphQLDocument: "query{hero{name appearsIn}}", setDocument: setDocument); // SUCCESS: query using POST (default) Request(requests, body: @"{""query"":""query HeroQuery{hero{name appearsIn}}"",""operationName"": ""HeroQuery""}"); Expect(collector, spanName: "query HeroQuery", graphQLOperationType: "query", graphQLOperationName: "HeroQuery", graphQLDocument: "query HeroQuery{hero{name appearsIn}}", setDocument: setDocument); // SUCCESS: mutation Request(requests, body: @"{""query"":""mutation AddBobaFett($human:HumanInput!){createHuman(human: $human){id name}}"",""variables"":{""human"":{""name"": ""Boba Fett""}}}"); Expect(collector, spanName: "mutation AddBobaFett", graphQLOperationType: "mutation", graphQLOperationName: "AddBobaFett", graphQLDocument: "mutation AddBobaFett($human:HumanInput!){createHuman(human: $human){id name}}", setDocument: setDocument); // SUCCESS: subscription Request(requests, body: @"{ ""query"":""subscription HumanAddedSub{humanAdded{name}}""}"); Expect(collector, spanName: "subscription HumanAddedSub", graphQLOperationType: "subscription", graphQLOperationName: "HumanAddedSub", graphQLDocument: "subscription HumanAddedSub{humanAdded{name}}", setDocument: setDocument); // FAILURE: query fails 'execute' step Request(requests, body: @"{""query"":""subscription NotImplementedSub{throwNotImplementedException{name}}""}"); Expect(collector, spanName: "subscription NotImplementedSub", graphQLOperationType: "subscription", graphQLOperationName: "NotImplementedSub", graphQLDocument: "subscription NotImplementedSub{throwNotImplementedException{name}}", setDocument: setDocument, verifyFailure: VerifyNotImplementedException); SetEnvironmentVariable("OTEL_DOTNET_AUTO_GRAPHQL_SET_DOCUMENT", setDocument.ToString()); SetEnvironmentVariable("OTEL_DOTNET_AUTO_TRACES_ENABLED_INSTRUMENTATIONS", "GraphQL"); SetEnvironmentVariable("OTEL_TRACES_SAMPLER", "always_on"); SetEnvironmentVariable("OTEL_DOTNET_AUTO_NETFX_REDIRECT_ENABLED", "false"); int aspNetCorePort = TcpPortProvider.GetOpenPort(); SetEnvironmentVariable("ASPNETCORE_URLS", $"http://127.0.0.1:{aspNetCorePort}/"); EnableBytecodeInstrumentation(); using var process = StartTestApplication(); using var helper = new ProcessHelper(process); try { await HealthzHelper.TestAsync($"http://localhost:{aspNetCorePort}/alive-check", Output); await SubmitRequestsAsync(aspNetCorePort, requests); collector.AssertExpectations(); } finally { if (process != null && !process.HasExited) { process.Kill(); process.WaitForExit(); Output.WriteLine("Exit Code: " + process.ExitCode); } Output.WriteResult(helper); } } private static void Request(List requests, string method = "POST", string url = "/graphql", string? body = null) { requests.Add(new RequestInfo { Url = url, HttpMethod = method, RequestBody = body }); } private static bool VerifyNotImplementedException(Span span) { var exceptionEvent = span.Events.SingleOrDefault(); if (exceptionEvent == null) { return false; } return exceptionEvent.Attributes.Any(x => x.Key == "exception.type" && x.Value?.StringValue == "System.NotImplementedException") && exceptionEvent.Attributes.Any(x => x.Key == "exception.message") && exceptionEvent.Attributes.Any(x => x.Key == "exception.stacktrace"); } private static void Expect( MockSpansCollector collector, string spanName, string graphQLOperationType, string? graphQLOperationName, string graphQLDocument, bool setDocument, Predicate? verifyFailure = null) { bool Predicate(Span span) { #if NETFRAMEWORK // There is no parent Span. There is no parent Activity on .NET Fx if (span.Kind != Span.Types.SpanKind.Server) #else // AspNetCore instrumentation always creates parent Activity. The activity is not recorded if instrumentation is disabled. if (span.Kind != Span.Types.SpanKind.Internal) #endif { return false; } if (span.Name != spanName) { return false; } if (verifyFailure != null) { return verifyFailure(span); } if (!span.Attributes.Any(attr => attr.Key == "graphql.operation.type" && attr.Value?.StringValue == graphQLOperationType)) { return false; } if (graphQLOperationName != null && !span.Attributes.Any(attr => attr.Key == "graphql.operation.name" && attr.Value?.StringValue == graphQLOperationName)) { return false; } if (setDocument && !span.Attributes.Any(attr => attr.Key == "graphql.document" && attr.Value?.StringValue == graphQLDocument)) { return false; } return true; } collector.Expect("OpenTelemetry.AutoInstrumentation.GraphQL", Predicate, spanName); } private async Task SubmitRequestsAsync(int aspNetCorePort, IEnumerable requests) { var client = new HttpClient(); foreach (var requestInfo in requests) { await SubmitRequestAsync(client, aspNetCorePort, requestInfo); } } private async Task SubmitRequestAsync(HttpClient client, int aspNetCorePort, RequestInfo requestInfo, bool printResponseText = true) { try { var url = $"http://localhost:{aspNetCorePort}{requestInfo.Url}"; var method = requestInfo.HttpMethod; HttpResponseMessage response; if (method == "GET") { response = await client.GetAsync(url); } else if (method == "POST") { if (requestInfo.RequestBody == null) { throw new NotSupportedException("RequestBody cannot be null when you are using POST method"); } response = await client.PostAsync(url, new StringContent(requestInfo.RequestBody, Encoding.UTF8, "application/json")); } else { // If additional logic is needed, implement it here. throw new NotImplementedException($"{method} is not supported."); } if (printResponseText) { var content = await response.Content.ReadAsStringAsync(); Output.WriteLine($"[http] {response.StatusCode} {content}"); } } catch (HttpRequestException ex) { Output.WriteLine($"[http] exception: {ex}"); } } private class RequestInfo { public string? Url { get; set; } public string? HttpMethod { get; set; } public string? RequestBody { get; set; } } }