diff --git a/src/All.slnx b/src/All.slnx index 58ab5c044d7..d7a3a9b9f1d 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -177,9 +177,11 @@ + + diff --git a/src/HotChocolate/Diagnostics/HotChocolate.Diagnostics.slnx b/src/HotChocolate/Diagnostics/HotChocolate.Diagnostics.slnx index 3c4f93bf3f9..d95977030e8 100644 --- a/src/HotChocolate/Diagnostics/HotChocolate.Diagnostics.slnx +++ b/src/HotChocolate/Diagnostics/HotChocolate.Diagnostics.slnx @@ -1,8 +1,10 @@ + + diff --git a/src/HotChocolate/Diagnostics/src/Diagnostics/ActivityEnricher.cs b/src/HotChocolate/Diagnostics/src/Diagnostics/ActivityEnricher.cs index 0357532fffc..531406760fa 100644 --- a/src/HotChocolate/Diagnostics/src/Diagnostics/ActivityEnricher.cs +++ b/src/HotChocolate/Diagnostics/src/Diagnostics/ActivityEnricher.cs @@ -31,13 +31,13 @@ public class ActivityEnricher /// /// Initializes a new instance of . /// - /// + /// /// protected ActivityEnricher( - ObjectPool stringBuilderPoolPool, + ObjectPool stringBuilderPool, InstrumentationOptions options) { - StringBuilderPool = stringBuilderPoolPool; + StringBuilderPool = stringBuilderPool; _options = options; } diff --git a/src/HotChocolate/Diagnostics/src/Diagnostics/Extensions/DiagnosticsRequestExecutorBuilderExtensions.cs b/src/HotChocolate/Diagnostics/src/Diagnostics/Extensions/DiagnosticsRequestExecutorBuilderExtensions.cs index 0040e709956..d05a66a6406 100644 --- a/src/HotChocolate/Diagnostics/src/Diagnostics/Extensions/DiagnosticsRequestExecutorBuilderExtensions.cs +++ b/src/HotChocolate/Diagnostics/src/Diagnostics/Extensions/DiagnosticsRequestExecutorBuilderExtensions.cs @@ -82,9 +82,9 @@ public static IRequestExecutorBuilder AddInstrumentation( private sealed class InternalActivityEnricher : ActivityEnricher { public InternalActivityEnricher( - ObjectPool stringBuilderPoolPool, + ObjectPool stringBuilderPool, InstrumentationOptions options) - : base(stringBuilderPoolPool, options) + : base(stringBuilderPool, options) { } } diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/ContextKeys.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/ContextKeys.cs new file mode 100644 index 00000000000..f35bd4946f0 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/ContextKeys.cs @@ -0,0 +1,10 @@ +namespace HotChocolate.Fusion.Diagnostics; + +internal static class ContextKeys +{ + public const string HttpRequestActivity = "HotChocolate.Fusion.Diagnostics.HttpRequest"; + public const string ParseHttpRequestActivity = "HotChocolate.Fusion.Diagnostics.ParseHttpRequest"; + public const string FormatHttpResponseActivity = "HotChocolate.Fusion.Diagnostics.FormatHttpResponse"; + public const string RequestActivity = "HotChocolate.Fusion.Diagnostics.Request"; + public const string ValidateActivity = "HotChocolate.Fusion.Diagnostics.Validate"; +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/DiagnosticsFusionGatewayBuilderExtensions.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/DiagnosticsFusionGatewayBuilderExtensions.cs new file mode 100644 index 00000000000..f90bec222c3 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/DiagnosticsFusionGatewayBuilderExtensions.cs @@ -0,0 +1,58 @@ +using System.Text; +using HotChocolate.Fusion.Diagnostics; +using HotChocolate.Fusion.Diagnostics.Listeners; +using HotChocolate.Fusion.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.ObjectPool; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class DiagnosticsFusionGatewayBuilderExtensions +{ + public static IFusionGatewayBuilder AddInstrumentation( + this IFusionGatewayBuilder builder, + Action? options = null) + => AddInstrumentation(builder, (_, opt) => options?.Invoke(opt)); + + public static IFusionGatewayBuilder AddInstrumentation( + this IFusionGatewayBuilder builder, + Action options) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(options); + + builder.Services.TryAddSingleton( + sp => + { + var optionInst = new InstrumentationOptions(); + options(sp, optionInst); + return optionInst; + }); + + builder.Services.TryAddSingleton(); + + builder.AddDiagnosticEventListener( + sp => new ActivityFusionExecutionDiagnosticEventListener( + sp.GetService() ?? + sp.GetRequiredService(), + sp.GetRequiredService())); + + builder.AddDiagnosticEventListener( + sp => new ActivityServerDiagnosticListener( + sp.GetService() ?? + sp.GetRequiredService(), + sp.GetRequiredService())); + + return builder; + } + + private sealed class InternalActivityEnricher : FusionActivityEnricher + { + public InternalActivityEnricher( + ObjectPool stringBuilderPool, + InstrumentationOptions options) + : base(stringBuilderPool, options) + { + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/TracerProviderBuilderExtensions.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/TracerProviderBuilderExtensions.cs new file mode 100644 index 00000000000..e02cee949d2 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Extensions/TracerProviderBuilderExtensions.cs @@ -0,0 +1,27 @@ +using HotChocolate.Fusion.Diagnostics; + +namespace OpenTelemetry.Trace; + +/// +/// Provides configuration methods to open-telemetry. +/// +public static class TracerProviderBuilderExtensions +{ + /// + /// Adds the Hot Chocolate Fusion instrumentation to open-telemetry. + /// + /// + /// The tracing builder. + /// + /// + /// Returns the tracing builder for configuration chaining. + /// + public static TracerProviderBuilder AddHotChocolateFusionInstrumentation( + this TracerProviderBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.AddSource(HotChocolateFusionActivitySource.GetName()); + return builder; + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityEnricher.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityEnricher.cs new file mode 100644 index 00000000000..64be8d71bb5 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityEnricher.cs @@ -0,0 +1,587 @@ +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.ObjectPool; +using HotChocolate.AspNetCore.Instrumentation; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using HotChocolate.Language; +using HotChocolate.Language.Utilities; +using OpenTelemetry.Trace; +using static HotChocolate.Fusion.Diagnostics.SemanticConventions; +using static HotChocolate.WellKnownContextData; + +namespace HotChocolate.Fusion.Diagnostics; + +/// +/// The activity enricher is used to add information to the activity spans. +/// You can inherit from this class and override the enricher methods to provide more or +/// less information. +/// +public class FusionActivityEnricher +{ + private readonly InstrumentationOptions _options; + private readonly ConditionalWeakTable _queryCache = []; + + /// + /// Initializes a new instance of . + /// + /// + /// + protected FusionActivityEnricher( + ObjectPool stringBuilderPool, + InstrumentationOptions options) + { + StringBuilderPool = stringBuilderPool; + _options = options; + } + + /// + /// Gets the pool used by this enricher. + /// + protected ObjectPool StringBuilderPool { get; } + + public virtual void EnrichExecuteHttpRequest( + HttpContext context, + HttpRequestKind kind, + Activity activity) + { + switch (kind) + { + case HttpRequestKind.HttpPost: + activity.DisplayName = "GraphQL HTTP POST"; + break; + case HttpRequestKind.HttpMultiPart: + activity.DisplayName = "GraphQL HTTP POST MultiPart"; + break; + case HttpRequestKind.HttpGet: + activity.DisplayName = "GraphQL HTTP GET"; + break; + case HttpRequestKind.HttpGetSchema: + activity.DisplayName = "GraphQL HTTP GET SDL"; + break; + } + + if (_options.RenameRootActivity) + { + UpdateRootActivityName(activity, $"Begin {activity.DisplayName}"); + } + + activity.SetTag("graphql.http.kind", kind); + + var isDefault = false; + if (!(context.Items.TryGetValue(SchemaName, out var value) + && value is string schemaName)) + { + schemaName = ISchemaDefinition.DefaultName; + isDefault = true; + } + + activity.SetTag("graphql.schema.name", schemaName); + activity.SetTag("graphql.schema.isDefault", isDefault); + } + + public virtual void EnrichSingleRequest( + HttpContext context, + GraphQLRequest request, + Activity activity) + { + activity.SetTag("graphql.http.request.type", "single"); + + if (request.DocumentId is not null + && (_options.RequestDetails & RequestDetails.Id) == RequestDetails.Id) + { + activity.SetTag("graphql.http.request.query.id", request.DocumentId.Value); + } + + if (request.DocumentHash is not null + && (_options.RequestDetails & RequestDetails.Hash) == RequestDetails.Hash) + { + activity.SetTag("graphql.http.request.query.hash", request.DocumentHash.Value); + } + + if (request.Document is not null + && (_options.RequestDetails & RequestDetails.Query) == RequestDetails.Query) + { + if (!_queryCache.TryGetValue(request.Document, out var query)) + { + query = request.Document.Print(); + _queryCache.Add(request.Document, query); + } + + activity.SetTag("graphql.http.request.query.body", query); + } + + if (request.OperationName is not null + && (_options.RequestDetails & RequestDetails.Operation) == RequestDetails.Operation) + { + activity.SetTag("graphql.http.request.operation", request.OperationName); + } + + if (request.Variables is not null + && (_options.RequestDetails & RequestDetails.Variables) == RequestDetails.Variables) + { + var node = CreateVariablesNode(request.Variables); + EnrichRequestVariables(context, request, node, activity); + } + + if (request.Extensions is not null + && (_options.RequestDetails & RequestDetails.Extensions) == RequestDetails.Extensions) + { + EnrichRequestExtensions(context, request, request.Extensions, activity); + } + } + + public virtual void EnrichBatchRequest( + HttpContext context, + IReadOnlyList batch, + Activity activity) + { + activity.SetTag("graphql.http.request.type", "batch"); + + for (var i = 0; i < batch.Count; i++) + { + var request = batch[i]; + + if (request.DocumentId is not null + && (_options.RequestDetails & RequestDetails.Id) == RequestDetails.Id) + { + activity.SetTag($"graphql.http.request[{i}].query.id", request.DocumentId.Value); + } + + if (request.DocumentHash is not null + && (_options.RequestDetails & RequestDetails.Hash) == RequestDetails.Hash) + { + activity.SetTag($"graphql.http.request[{i}].query.hash", request.DocumentHash.Value); + } + + if (request.Document is not null + && (_options.RequestDetails & RequestDetails.Query) == RequestDetails.Query) + { + activity.SetTag($"graphql.http.request[{i}].query.body", request.Document.Print()); + } + + if (request.OperationName is not null + && (_options.RequestDetails & RequestDetails.Operation) == RequestDetails.Operation) + { + activity.SetTag($"graphql.http.request[{i}].operation", request.OperationName); + } + + if (request.Variables is not null + && (_options.RequestDetails & RequestDetails.Variables) == RequestDetails.Variables) + { + var node = CreateVariablesNode(request.Variables); + EnrichBatchVariables(context, request, node, i, activity); + } + + if (request.Extensions is not null + && (_options.RequestDetails & RequestDetails.Extensions) == RequestDetails.Extensions) + { + EnrichBatchExtensions(context, request, request.Extensions, i, activity); + } + } + } + + public virtual void EnrichOperationBatchRequest( + HttpContext context, + GraphQLRequest request, + IReadOnlyList operations, + Activity activity) + { + activity.SetTag("graphql.http.request.type", "operationBatch"); + + if (request.DocumentId is not null + && (_options.RequestDetails & RequestDetails.Id) == RequestDetails.Id) + { + activity.SetTag("graphql.http.request.query.id", request.DocumentId.Value); + } + + if (request.DocumentHash is not null + && (_options.RequestDetails & RequestDetails.Hash) == RequestDetails.Hash) + { + activity.SetTag("graphql.http.request.query.hash", request.DocumentHash.Value); + } + + if (request.Document is not null + && (_options.RequestDetails & RequestDetails.Query) == RequestDetails.Query) + { + activity.SetTag("graphql.http.request.query.body", request.Document.Print()); + } + + if (request.OperationName is not null + && (_options.RequestDetails & RequestDetails.Operation) == RequestDetails.Operation) + { + activity.SetTag("graphql.http.request.operations", string.Join(" -> ", operations)); + } + + if (request.Variables is not null + && (_options.RequestDetails & RequestDetails.Variables) == RequestDetails.Variables) + { + var node = CreateVariablesNode(request.Variables); + EnrichRequestVariables(context, request, node, activity); + } + + if (request.Extensions is not null + && (_options.RequestDetails & RequestDetails.Extensions) == RequestDetails.Extensions) + { + EnrichRequestExtensions(context, request, request.Extensions, activity); + } + } + + protected virtual void EnrichRequestVariables( + HttpContext context, + GraphQLRequest request, + ISyntaxNode variables, + Activity activity) + { + activity.SetTag("graphql.http.request.variables", variables.Print()); + } + + protected virtual void EnrichBatchVariables( + HttpContext context, + GraphQLRequest request, + ISyntaxNode variables, + int index, + Activity activity) + { + activity.SetTag($"graphql.http.request[{index}].variables", variables.Print()); + } + + protected virtual void EnrichRequestExtensions( + HttpContext context, + GraphQLRequest request, + IReadOnlyDictionary extensions, + Activity activity) + { + try + { + activity.SetTag( + "graphql.http.request.extensions", + JsonSerializer.Serialize(extensions)); + } + catch + { + // Ignore any errors + } + } + + protected virtual void EnrichBatchExtensions( + HttpContext context, + GraphQLRequest request, + IReadOnlyDictionary extensions, + int index, + Activity activity) + { + try + { + activity.SetTag( + $"graphql.http.request[{index}].extensions", + JsonSerializer.Serialize(extensions)); + } + catch + { + // Ignore any errors + } + } + + public virtual void EnrichHttpRequestError( + HttpContext context, + IError error, + Activity activity) + => EnrichError(error, activity); + + public virtual void EnrichHttpRequestError( + HttpContext context, + Exception exception, + Activity activity) + { + } + + public virtual void EnrichParseHttpRequest(HttpContext context, Activity activity) + { + activity.DisplayName = "Parse HTTP Request"; + + if (_options.RenameRootActivity) + { + UpdateRootActivityName(activity, $"Begin {activity.DisplayName}"); + } + } + + public virtual void EnrichParserErrors(HttpContext context, IError error, Activity activity) + => EnrichError(error, activity); + + public virtual void EnrichFormatHttpResponse(HttpContext context, Activity activity) + { + activity.DisplayName = "Format HTTP Response"; + } + + public virtual void EnrichExecuteRequest(RequestContext context, Activity activity) + { + var plan = context.GetOperationPlan(); + var documentInfo = context.OperationDocumentInfo; + // TODO: Why do we do this? + var operationDisplayName = CreateOperationDisplayName(context, plan); + + if (_options.RenameRootActivity && operationDisplayName is not null) + { + UpdateRootActivityName(activity, operationDisplayName); + } + + activity.DisplayName = operationDisplayName ?? "Execute Request"; + activity.SetTag("graphql.document.id", documentInfo.Id.Value); + activity.SetTag("graphql.document.hash", documentInfo.Hash.Value); + activity.SetTag("graphql.document.valid", documentInfo.IsValidated); + activity.SetTag("graphql.operation.id", plan?.Id); + activity.SetTag("graphql.operation.kind", plan?.Operation.Definition.Operation); // TODO: This is wrong + activity.SetTag("graphql.operation.name", plan?.OperationName); + + if (_options.IncludeDocument && documentInfo.Document is not null) + { + activity.SetTag("graphql.document.body", documentInfo.Document.Print()); + } + + if (context.Result is IOperationResult result) + { + var errorCount = result.Errors?.Count ?? 0; + activity.SetTag("graphql.errors.count", errorCount); + } + } + + protected virtual string? CreateOperationDisplayName(RequestContext context, OperationPlan? plan) + { + if (plan is null) + { + return null; + } + + var displayName = StringBuilderPool.Get(); + + try + { + var rootSelectionSet = plan.Operation.RootSelectionSet; + + displayName.Append('{'); + displayName.Append(' '); + + foreach (var selection in rootSelectionSet.Selections[..3]) + { + if (displayName.Length > 2) + { + displayName.Append(' '); + } + + displayName.Append(selection.ResponseName); + } + + if (rootSelectionSet.Selections.Length > 3) + { + displayName.Append(' '); + displayName.Append('.'); + displayName.Append('.'); + displayName.Append('.'); + } + + displayName.Append(' '); + displayName.Append('}'); + + if (plan.OperationName is { } name) + { + displayName.Insert(0, ' '); + displayName.Insert(0, name); + } + + displayName.Insert(0, ' '); + displayName.Insert(0, plan.Operation.Definition.Operation.ToString().ToLowerInvariant()); + + return displayName.ToString(); + } + finally + { + StringBuilderPool.Return(displayName); + } + } + + private void UpdateRootActivityName(Activity activity, string displayName) + { + var current = activity; + + while (current.Parent is not null) + { + current = current.Parent; + } + + if (current != activity) + { + current.DisplayName = CreateRootActivityName(activity, current, displayName); + } + } + + protected virtual string CreateRootActivityName( + Activity activity, + Activity root, + string displayName) + { + const string key = "originalDisplayName"; + + if (root.GetCustomProperty(key) is not string rootDisplayName) + { + rootDisplayName = root.DisplayName; + root.SetCustomProperty(key, rootDisplayName); + } + + return $"{rootDisplayName}: {displayName}"; + } + + public virtual void EnrichParseDocument(RequestContext context, Activity activity) + { + activity.DisplayName = "Parse Document"; + + if (_options.RenameRootActivity) + { + UpdateRootActivityName(activity, $"Begin {activity.DisplayName}"); + } + } + + public virtual void EnrichRequestError( + RequestContext context, + Activity activity, + Exception error) + => EnrichError(ErrorBuilder.FromException(error).Build(), activity); + + public virtual void EnrichRequestError( + RequestContext context, + Activity activity, + IError error) + => EnrichError(error, activity); + + public virtual void EnrichValidateDocument(RequestContext context, Activity activity) + { + activity.DisplayName = "Validate Document"; + + if (_options.RenameRootActivity) + { + UpdateRootActivityName(activity, $"Begin {activity.DisplayName}"); + } + + var documentInfo = context.OperationDocumentInfo; + activity.SetTag("graphql.document.id", documentInfo.Id.Value); + activity.SetTag("graphql.document.hash", documentInfo.Hash.Value); + } + + public virtual void EnrichValidationError( + RequestContext context, + Activity activity, + IError error) + => EnrichError(error, activity); + + public virtual void EnrichAnalyzeOperationComplexity(RequestContext context, Activity activity) + { + activity.DisplayName = "Analyze Operation Complexity"; + } + + public virtual void EnrichCoerceVariables(RequestContext context, Activity activity) + { + activity.DisplayName = "Coerce Variable"; + } + + public virtual void EnrichCompileOperation(RequestContext context, Activity activity) + { + activity.DisplayName = "Compile Operation"; + } + + public virtual void EnrichExecuteOperation(RequestContext context, Activity activity) + { + var plan = context.GetOperationPlan(); + activity.DisplayName = + plan?.OperationName is { } op + ? $"Execute Operation {op}" + : "Execute Operation"; + } + + protected virtual void EnrichError(IError error, Activity activity) + { + if (error.Exception is { } exception) + { + activity.RecordException(exception); + } + + var tags = new ActivityTagsCollection + { + new(AttributeExceptionMessage, error.Message), + new(AttributeExceptionType, error.Code ?? "GRAPHQL_ERROR") + }; + + if (error.Path is not null) + { + tags["graphql.error.path"] = error.Path.ToString(); + } + + if (error.Locations is { Count: > 0 }) + { + tags["graphql.error.location.column"] = error.Locations[0].Column; + tags["graphql.error.location.line"] = error.Locations[0].Line; + } + + activity.AddEvent(new ActivityEvent(AttributeExceptionEventName, default, tags)); + } + + private static ISyntaxNode CreateVariablesNode( + IReadOnlyList>? variableSet) + { + if (variableSet is null or { Count: 0 }) + { + return NullValueNode.Default; + } + + if (variableSet.Count == 1) + { + var variables = variableSet[0]; + var variablesCount = variables.Count; + var fields = new ObjectFieldNode[variablesCount]; + var index = 0; + + foreach (var (name, value) in variables) + { + // since we are in the HTTP context here we know that it will always be an IValueNode. + var valueNode = value is null ? NullValueNode.Default : (IValueNode)value; + fields[index++] = new ObjectFieldNode(name, valueNode); + } + + return new ObjectValueNode(fields); + } + + if (variableSet.Count > 0) + { + var variableSetCount = variableSet.Count; + var items = new IValueNode[variableSetCount]; + + for (var i = 0; i < variableSetCount; i++) + { + var variables = variableSet[i]; + var variablesCount = variables.Count; + var fields = new ObjectFieldNode[variablesCount]; + var index = 0; + + foreach (var (name, value) in variables) + { + // since we are in the HTTP context here we know that it will always be an IValueNode. + var valueNode = value is null ? NullValueNode.Default : (IValueNode)value; + fields[index++] = new ObjectFieldNode(name, valueNode); + } + + items[i] = new ObjectValueNode(fields); + } + } + + throw new InvalidOperationException(); + } +} + +file static class SemanticConventions +{ + public const string AttributeExceptionEventName = "exception"; + public const string AttributeExceptionType = "exception.type"; + public const string AttributeExceptionMessage = "exception.message"; +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityScopes.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityScopes.cs new file mode 100644 index 00000000000..d38cd4737f7 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/FusionActivityScopes.cs @@ -0,0 +1,34 @@ +namespace HotChocolate.Fusion.Diagnostics; + +[Flags] +public enum FusionActivityScopes +{ + None = 0, + ExecuteHttpRequest = 1, + ParseHttpRequest = 2, + FormatHttpResponse = 4, + ExecuteRequest = 8, + ParseDocument = 16, + ValidateDocument = 32, + AnalyzeComplexity = 64, + CoerceVariables = 128, + PlanOperation = 256, + ExecuteOperation = 512, + Default = + ExecuteHttpRequest + | ParseHttpRequest + | ValidateDocument + | PlanOperation + | FormatHttpResponse, + All = + ExecuteHttpRequest + | ParseHttpRequest + | FormatHttpResponse + | ExecuteRequest + | ParseDocument + | ValidateDocument + | AnalyzeComplexity + | CoerceVariables + | PlanOperation + | ExecuteOperation +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolate.Fusion.Diagnostics.csproj b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolate.Fusion.Diagnostics.csproj new file mode 100644 index 00000000000..0d84beeb354 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolate.Fusion.Diagnostics.csproj @@ -0,0 +1,23 @@ + + + + HotChocolate.Fusion.Diagnostics + HotChocolate.Fusion.Diagnostics + HotChocolate.Fusion.Diagnostics + Provides Hot Chocolate Fusion Diagnostics. + + + + + + + + + + + + + + + + diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolateFusionActivitySource.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolateFusionActivitySource.cs new file mode 100644 index 00000000000..dda5993f72b --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/HotChocolateFusionActivitySource.cs @@ -0,0 +1,15 @@ +using System.Diagnostics; +using HotChocolate.Fusion.Diagnostics.Listeners; + +namespace HotChocolate.Fusion.Diagnostics; + +internal static class HotChocolateFusionActivitySource +{ + public static ActivitySource Source { get; } = new(GetName(), GetVersion()); + + public static string GetName() + => typeof(ActivityFusionExecutionDiagnosticEventListener).Assembly.GetName().Name!; + + private static string GetVersion() + => typeof(ActivityFusionExecutionDiagnosticEventListener).Assembly.GetName().Version!.ToString(); +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/InstrumentationOptions.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/InstrumentationOptions.cs new file mode 100644 index 00000000000..17b3d7ab1bc --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/InstrumentationOptions.cs @@ -0,0 +1,49 @@ +using static HotChocolate.Fusion.Diagnostics.FusionActivityScopes; + +namespace HotChocolate.Fusion.Diagnostics; + +/// +/// The Hot Chocolate Fusion instrumentation options. +/// +public sealed class InstrumentationOptions +{ + /// + /// Specifies the request detail that shall be included into the tracing activities. + /// + public RequestDetails RequestDetails { get; set; } = RequestDetails.Default; + + /// + /// Specifies the activity scopes that shall be instrumented. + /// + public FusionActivityScopes Scopes { get; set; } = Default; + + /// + /// Specifies if the parsed document shall be included into the tracing data. + /// + public bool IncludeDocument { get; set; } + + /// + /// Defines if the operation display name shall be included in the root activity. + /// + public bool RenameRootActivity { get; set; } + + internal bool IncludeRequestDetails => RequestDetails is not RequestDetails.None; + + internal bool SkipExecuteHttpRequest => (Scopes & ExecuteHttpRequest) != ExecuteHttpRequest; + + internal bool SkipParseHttpRequest => (Scopes & ParseHttpRequest) != ParseHttpRequest; + + internal bool SkipFormatHttpResponse => (Scopes & FormatHttpResponse) != FormatHttpResponse; + + internal bool SkipExecuteRequest => (Scopes & ExecuteRequest) != ExecuteRequest; + + internal bool SkipParseDocument => (Scopes & ParseDocument) != ParseDocument; + + internal bool SkipValidateDocument => (Scopes & ValidateDocument) != ValidateDocument; + + internal bool SkipCoerceVariables => (Scopes & CoerceVariables) != CoerceVariables; + + internal bool SkipPlanOperation => (Scopes & PlanOperation) != PlanOperation; + + internal bool SkipExecuteOperation => (Scopes & ExecuteOperation) != ExecuteOperation; +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityFusionExecutionDiagnosticEventListener.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityFusionExecutionDiagnosticEventListener.cs new file mode 100644 index 00000000000..7103a9bd8bc --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityFusionExecutionDiagnosticEventListener.cs @@ -0,0 +1,247 @@ +using System.Diagnostics; +using HotChocolate.Fusion.Diagnostics.Scopes; +using HotChocolate.Execution; +using HotChocolate.Fusion.Execution; +using HotChocolate.Fusion.Execution.Nodes; +using Microsoft.AspNetCore.Http; +using OpenTelemetry.Trace; +using static HotChocolate.Fusion.Diagnostics.ContextKeys; +using static HotChocolate.Fusion.Diagnostics.HotChocolateFusionActivitySource; + +namespace HotChocolate.Fusion.Diagnostics.Listeners; + +// TODO: Add more items +// TODO: Check if AddedOperationPlanToCache is correct +internal sealed class ActivityFusionExecutionDiagnosticEventListener : FusionExecutionDiagnosticEventListener +{ + private readonly InstrumentationOptions _options; + private readonly FusionActivityEnricher _enricher; + + public ActivityFusionExecutionDiagnosticEventListener( + FusionActivityEnricher enricher, + InstrumentationOptions options) + { + ArgumentNullException.ThrowIfNull(enricher); + ArgumentNullException.ThrowIfNull(options); + + _enricher = enricher; + _options = options; + } + + public override IDisposable ExecuteRequest(RequestContext context) + { + Activity? activity = null; + + if (_options.SkipExecuteRequest) + { + if (!_options.SkipExecuteHttpRequest + && context.ContextData.TryGetValue(nameof(HttpContext), out var value) + && value is HttpContext httpContext + && httpContext.Items.TryGetValue(HttpRequestActivity, out value) + && value is not null) + { + activity = (Activity)value; + } + else + { + return EmptyScope; + } + } + + activity ??= Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + context.ContextData[RequestActivity] = activity; + + return new ExecuteRequestScope(_enricher, context, activity); + } + + public override void RetrievedDocumentFromCache(RequestContext context) + { + if (context.ContextData.TryGetValue(RequestActivity, out var activity)) + { + Debug.Assert(activity is not null, "The activity mustn't be null!"); + ((Activity)activity).AddEvent(new(nameof(RetrievedDocumentFromCache))); + } + } + + public override void RetrievedDocumentFromStorage(RequestContext context) + { + if (context.ContextData.TryGetValue(RequestActivity, out var activity)) + { + Debug.Assert(activity is not null, "The activity mustn't be null!"); + ((Activity)activity).AddEvent(new(nameof(RetrievedDocumentFromStorage))); + } + } + + public override void AddedDocumentToCache(RequestContext context) + { + if (context.ContextData.TryGetValue(RequestActivity, out var activity)) + { + Debug.Assert(activity is not null, "The activity mustn't be null!"); + ((Activity)activity).AddEvent(new(nameof(AddedDocumentToCache))); + } + } + + public override void AddedOperationPlanToCache(RequestContext context, string operationPlanId) + { + if (context.ContextData.TryGetValue(RequestActivity, out var activity)) + { + Debug.Assert(activity is not null, "The activity mustn't be null!"); + ((Activity)activity).AddEvent(new(nameof(AddedOperationPlanToCache))); + } + } + + public override IDisposable ParseDocument(RequestContext context) + { + if (_options.SkipParseDocument) + { + return EmptyScope; + } + + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + context.ContextData[RequestActivity] = activity; + + return new ParseDocumentScope(_enricher, context, activity); + } + + public override void RequestError(RequestContext context, Exception error) + { + if (context.ContextData.TryGetValue(RequestActivity, out var value)) + { + Debug.Assert(value is not null, "The activity mustn't be null!"); + + var activity = (Activity)value; + _enricher.EnrichRequestError(context, activity, error); + activity.SetStatus(Status.Error); + activity.SetStatus(ActivityStatusCode.Error); + } + } + + public override void RequestError(RequestContext context, IError error) + { + if (context.ContextData.TryGetValue(RequestActivity, out var value)) + { + Debug.Assert(value is not null, "The activity mustn't be null!"); + + var activity = (Activity)value; + _enricher.EnrichRequestError(context, activity, error); + activity.SetStatus(Status.Error); + activity.SetStatus(ActivityStatusCode.Error); + } + } + + public override void ValidationErrors(RequestContext context, IReadOnlyList errors) + { + if (context.ContextData.TryGetValue(ValidateActivity, out var value)) + { + Debug.Assert(value is not null, "The activity mustn't be null!"); + + var activity = (Activity)value; + + foreach (var error in errors) + { + _enricher.EnrichValidationError(context, activity, error); + } + + activity.SetStatus(Status.Error); + activity.SetStatus(ActivityStatusCode.Error); + } + } + + public override IDisposable ValidateDocument(RequestContext context) + { + if (_options.SkipValidateDocument) + { + return EmptyScope; + } + + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + context.ContextData[ValidateActivity] = activity; + + return new ValidateDocumentScope(_enricher, context, activity); + } + + public override IDisposable CoerceVariables(RequestContext context) + { + if (_options.SkipCoerceVariables) + { + return EmptyScope; + } + + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + return new CoerceVariablesScope(_enricher, context, activity); + } + + public override IDisposable PlanOperation(RequestContext context, string operationPlanId) + { + if (_options.SkipPlanOperation) + { + return EmptyScope; + } + + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + return new PlanOperationScope(_enricher, context, activity); + } + + public override IDisposable ExecuteOperation(RequestContext context) + { + if (_options.SkipExecuteOperation) + { + return EmptyScope; + } + + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + return new ExecuteOperationScope(_enricher, context, activity); + } + + public override IDisposable OnSubscriptionEvent( + OperationPlanContext context, + ExecutionNode node, + string schemaName, + ulong subscriptionId) + { + var activity = Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + return activity; + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityServerDiagnosticListener.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityServerDiagnosticListener.cs new file mode 100644 index 00000000000..d3135287ae0 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Listeners/ActivityServerDiagnosticListener.cs @@ -0,0 +1,158 @@ +using System.Diagnostics; +using Microsoft.AspNetCore.Http; +using HotChocolate.AspNetCore.Instrumentation; +using HotChocolate.Execution; +using HotChocolate.Language; +using OpenTelemetry.Trace; +using static HotChocolate.Fusion.Diagnostics.ContextKeys; + +namespace HotChocolate.Fusion.Diagnostics.Listeners; + +// TODO: Variable batching events? +internal sealed class ActivityServerDiagnosticListener : ServerDiagnosticEventListener +{ + private readonly InstrumentationOptions _options; + private readonly FusionActivityEnricher _enricher; + + public ActivityServerDiagnosticListener( + FusionActivityEnricher enricher, + InstrumentationOptions options) + { + _enricher = enricher ?? throw new ArgumentNullException(nameof(enricher)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + public override IDisposable ExecuteHttpRequest(HttpContext context, HttpRequestKind kind) + { + if (_options.SkipExecuteHttpRequest) + { + return EmptyScope; + } + + var activity = HotChocolateFusionActivitySource.Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + _enricher.EnrichExecuteHttpRequest(context, kind, activity); + activity.SetStatus(ActivityStatusCode.Ok); + context.Items[HttpRequestActivity] = activity; + + return activity; + } + + public override void StartSingleRequest(HttpContext context, GraphQLRequest request) + { + if (_options.IncludeRequestDetails + && context.Items.TryGetValue(HttpRequestActivity, out var activity)) + { + _enricher.EnrichSingleRequest(context, request, (Activity)activity!); + } + } + + public override void StartBatchRequest(HttpContext context, IReadOnlyList batch) + { + if (_options.IncludeRequestDetails + && context.Items.TryGetValue(HttpRequestActivity, out var activity)) + { + _enricher.EnrichBatchRequest(context, batch, (Activity)activity!); + } + } + + public override void StartOperationBatchRequest( + HttpContext context, + GraphQLRequest request, + IReadOnlyList operations) + { + if (_options.IncludeRequestDetails + && context.Items.TryGetValue(HttpRequestActivity, out var activity)) + { + _enricher.EnrichOperationBatchRequest( + context, + request, + operations, + (Activity)activity!); + } + } + + public override void HttpRequestError(HttpContext context, IError error) + { + if (context.Items.TryGetValue(HttpRequestActivity, out var value)) + { + var activity = (Activity)value!; + _enricher.EnrichHttpRequestError(context, error, activity); + activity.SetStatus(Status.Error); + } + } + + public override void HttpRequestError(HttpContext context, Exception exception) + { + if (context.Items.TryGetValue(HttpRequestActivity, out var value)) + { + var activity = (Activity)value!; + _enricher.EnrichHttpRequestError(context, exception, activity); + activity.SetStatus(Status.Error); + } + } + + public override IDisposable ParseHttpRequest(HttpContext context) + { + if (_options.SkipParseHttpRequest) + { + return EmptyScope; + } + + var activity = HotChocolateFusionActivitySource.Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + _enricher.EnrichParseHttpRequest(context, activity); + activity.SetStatus(Status.Ok); + activity.SetStatus(ActivityStatusCode.Ok); + context.Items[ParseHttpRequestActivity] = activity; + + return activity; + } + + public override void ParserErrors(HttpContext context, IReadOnlyList errors) + { + if (context.Items.TryGetValue(ParseHttpRequestActivity, out var value)) + { + var activity = (Activity)value!; + + foreach (var error in errors) + { + _enricher.EnrichParserErrors(context, error, activity); + } + + activity.SetStatus(Status.Error); + activity.SetStatus(ActivityStatusCode.Error); + } + } + + public override IDisposable FormatHttpResponse(HttpContext context, IOperationResult result) + { + if (_options.SkipFormatHttpResponse) + { + return EmptyScope; + } + + var activity = HotChocolateFusionActivitySource.Source.StartActivity(); + + if (activity is null) + { + return EmptyScope; + } + + _enricher.EnrichFormatHttpResponse(context, activity); + activity.SetStatus(ActivityStatusCode.Ok); + context.Items[FormatHttpResponseActivity] = activity; + + return activity; + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/RequestDetails.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/RequestDetails.cs new file mode 100644 index 00000000000..dd05a951ba2 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/RequestDetails.cs @@ -0,0 +1,15 @@ +namespace HotChocolate.Fusion.Diagnostics; + +[Flags] +public enum RequestDetails +{ + None = 0, + Id = 1, + Hash = 2, + Operation = 4, + Variables = 8, + Extensions = 16, + Query = 32, + Default = Id | Hash | Operation | Extensions, + All = Id | Hash | Operation | Variables | Extensions | Query +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/CoerceVariablesScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/CoerceVariablesScope.cs new file mode 100644 index 00000000000..ebee57d1a10 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/CoerceVariablesScope.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class CoerceVariablesScope : RequestScopeBase +{ + public CoerceVariablesScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichCoerceVariables(Context, Activity); + + protected override void SetStatus() + { + if (Context.VariableValues.Length > 0) + { + Activity.SetStatus(Status.Ok); + Activity.SetStatus(ActivityStatusCode.Ok); + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteOperationScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteOperationScope.cs new file mode 100644 index 00000000000..ddca7f05bbd --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteOperationScope.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class ExecuteOperationScope : RequestScopeBase +{ + public ExecuteOperationScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichExecuteOperation(Context, Activity); + + protected override void SetStatus() + { + if (Context.Result is null or IOperationResult { Errors: [_, ..] }) + { + Activity.SetStatus(Status.Error); + Activity.SetStatus(ActivityStatusCode.Error); + } + else + { + Activity.SetStatus(Status.Ok); + Activity.SetStatus(ActivityStatusCode.Ok); + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteRequestScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteRequestScope.cs new file mode 100644 index 00000000000..4d3946fee8c --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ExecuteRequestScope.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class ExecuteRequestScope : RequestScopeBase +{ + public ExecuteRequestScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichExecuteRequest(Context, Activity); + + protected override void SetStatus() + { + if (Context.Result is null or IOperationResult { Errors: [_, ..] }) + { + Activity.SetStatus(Status.Error); + Activity.SetStatus(ActivityStatusCode.Error); + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ParseDocumentScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ParseDocumentScope.cs new file mode 100644 index 00000000000..b795f894f06 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ParseDocumentScope.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class ParseDocumentScope : RequestScopeBase +{ + public ParseDocumentScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichParseDocument(Context, Activity); + + protected override void SetStatus() + { + if (Context.TryGetOperationDocument(out _, out _)) + { + Activity.SetStatus(Status.Ok); + Activity.SetStatus(ActivityStatusCode.Ok); + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/PlanOperationScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/PlanOperationScope.cs new file mode 100644 index 00000000000..5a09079ef7e --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/PlanOperationScope.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class PlanOperationScope : RequestScopeBase +{ + public PlanOperationScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichCompileOperation(Context, Activity); + + protected override void SetStatus() + { + if (Context.GetOperationPlan() is not null) + { + Activity.SetStatus(Status.Ok); + Activity.SetStatus(ActivityStatusCode.Ok); + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/RequestScopeBase.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/RequestScopeBase.cs new file mode 100644 index 00000000000..69403a62676 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/RequestScopeBase.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; +using HotChocolate.Execution; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal class RequestScopeBase : IDisposable +{ + private bool _disposed; + + protected RequestScopeBase( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + { + Enricher = enricher ?? throw new ArgumentNullException(nameof(enricher)); + Context = context ?? throw new ArgumentNullException(nameof(context)); + Activity = activity ?? throw new ArgumentNullException(nameof(activity)); + } + + protected FusionActivityEnricher Enricher { get; } + + protected RequestContext Context { get; } + + protected Activity Activity { get; } + + protected virtual void EnrichActivity() { } + + protected virtual void SetStatus() { } + + public void Dispose() + { + if (!_disposed) + { + EnrichActivity(); + SetStatus(); + Activity.Dispose(); + _disposed = true; + } + } +} diff --git a/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ValidateDocumentScope.cs b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ValidateDocumentScope.cs new file mode 100644 index 00000000000..a4ff4d161b3 --- /dev/null +++ b/src/HotChocolate/Diagnostics/src/Fusion.Diagnostics/Scopes/ValidateDocumentScope.cs @@ -0,0 +1,28 @@ +using System.Diagnostics; +using HotChocolate.Execution; +using OpenTelemetry.Trace; + +namespace HotChocolate.Fusion.Diagnostics.Scopes; + +internal sealed class ValidateDocumentScope : RequestScopeBase +{ + public ValidateDocumentScope( + FusionActivityEnricher enricher, + RequestContext context, + Activity activity) + : base(enricher, context, activity) + { + } + + protected override void EnrichActivity() + => Enricher.EnrichValidateDocument(Context, Activity); + + protected override void SetStatus() + { + if (Context.IsOperationDocumentValid()) + { + Activity.SetStatus(Status.Ok); + Activity.SetStatus(ActivityStatusCode.Ok); + } + } +} diff --git a/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs new file mode 100644 index 00000000000..2751d1e7c94 --- /dev/null +++ b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/ActivityTestHelper.cs @@ -0,0 +1,100 @@ +using System.Diagnostics; +using HotChocolate.Utilities; + +namespace HotChocolate.Fusion.Diagnostics; + +public static class ActivityTestHelper +{ + public static IDisposable CaptureActivities(out object activities) + { + var sync = new object(); + var listener = new ActivityListener(); + var root = new OrderedDictionary(); + var lookup = new Dictionary>(); + Activity rootActivity = null!; + + listener.ShouldListenTo = source => + string.Equals(source.Name, "HotChocolate.Fusion.Diagnostics", StringComparison.Ordinal); + listener.ActivityStarted = a => + { + lock (sync) + { + if (a.Parent is null + && string.Equals(a.OperationName, "ExecuteHttpRequest", StringComparison.Ordinal) + && lookup.TryGetValue(rootActivity, out var parentData)) + { + RegisterActivity(a, parentData); + lookup[a] = (OrderedDictionary)a.GetCustomProperty("test.data")!; + } + + if (a.Parent is not null + && lookup.TryGetValue(a.Parent, out parentData)) + { + RegisterActivity(a, parentData); + lookup[a] = (OrderedDictionary)a.GetCustomProperty("test.data")!; + } + } + }; + listener.ActivityStopped = SerializeActivity; + listener.Sample = (ref ActivityCreationOptions _) => + ActivitySamplingResult.AllData; + ActivitySource.AddActivityListener(listener); + + rootActivity = HotChocolateFusionActivitySource.Source.StartActivity()!; + rootActivity.SetCustomProperty("test.data", root); + lookup[rootActivity] = root; + + activities = root; + return new Session(rootActivity, listener); + } + + private static void RegisterActivity( + Activity activity, + OrderedDictionary parent) + { + if (!(parent.TryGetValue("activities", out var value) && value is List children)) + { + children = []; + parent["activities"] = children; + } + + var data = new OrderedDictionary(); + activity.SetCustomProperty("test.data", data); + SerializeActivity(activity); + children.Add(data); + } + + private static void SerializeActivity(Activity activity) + { + var data = (OrderedDictionary?)activity.GetCustomProperty("test.data"); + + if (data is null) + { + return; + } + + data["OperationName"] = activity.OperationName; + data["DisplayName"] = activity.DisplayName; + data["Status"] = activity.Status; + data["tags"] = activity.Tags; + data["event"] = activity.Events.Select(t => new { t.Name, t.Tags }); + } + + private sealed class Session : IDisposable + { + private readonly Activity _activity; + private readonly ActivityListener _listener; + + public Session(Activity activity, ActivityListener listener) + { + _activity = activity; + _listener = listener; + } + + public void Dispose() + { + _activity.Dispose(); + _listener.Dispose(); + } + } +} diff --git a/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/HotChocolate.Fusion.Diagnostics.Tests.csproj b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/HotChocolate.Fusion.Diagnostics.Tests.csproj new file mode 100644 index 00000000000..6a25057e402 --- /dev/null +++ b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/HotChocolate.Fusion.Diagnostics.Tests.csproj @@ -0,0 +1,12 @@ + + + + HotChocolate.Fusion.Diagnostics.Tests + HotChocolate.FusionDiagnostics + + + + + + + diff --git a/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/QueryInstrumentationTests.cs b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/QueryInstrumentationTests.cs new file mode 100644 index 00000000000..a197e5c0b7f --- /dev/null +++ b/src/HotChocolate/Diagnostics/test/Fusion.Diagnostics.Tests/QueryInstrumentationTests.cs @@ -0,0 +1,34 @@ +using HotChocolate.Execution; +using Microsoft.Extensions.DependencyInjection; +using static HotChocolate.Fusion.Diagnostics.ActivityTestHelper; + +namespace HotChocolate.Fusion.Diagnostics; + +[Collection("Instrumentation")] +public class QueryInstrumentationTests +{ + [Fact] + public async Task Track_events_of_a_simple_query_detailed() + { + using (CaptureActivities(out var activities)) + { + // arrange & act + var services = new ServiceCollection(); + services.AddGraphQLGateway() + .AddInMemoryConfiguration(null) + .AddInstrumentation(o => o.Scopes = FusionActivityScopes.All); + + var provider = services.BuildServiceProvider().GetRequiredService(); + var executor = await provider.GetExecutorAsync(); + + var request = OperationRequestBuilder.New() + .SetDocument("{ sayHello }") + .Build(); + + await executor.ExecuteAsync(request); + + // assert + activities.MatchSnapshot(); + } + } +}