Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,16 @@ public int MaxErrorEvents
set => _maxErrorEvents = value < 0 ? 0 : value;
}

/// <summary>
/// Specifies whether the root GraphQL request span is given a more descriptive
/// display name. When enabled, the span is named using the
/// <c>{graphql.operation.type} {graphql.operation.name}</c> format when the
/// operation name is available and the operation is successfully identified in
/// the document. The default is
/// <c>false</c>. Only enable this for operation domains with bounded
/// cardinality (e.g. persisted operations) to avoid high-cardinality span names.
/// </summary>
public bool IncludeOperationNameInSpanName { get; set; }

internal bool IncludeRequestDetails => RequestDetails is not RequestDetails.None;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ protected override void OnComplete()
if (TryGetOperationInfo(out var operationType, out var operationName))
{
operationTypeValue = GraphQL.Operation.TypeValues[operationType];
Activity.DisplayName = operationTypeValue;
Activity.DisplayName = options.IncludeOperationNameInSpanName && !string.IsNullOrEmpty(operationName)
? $"{operationTypeValue} {operationName}"
: operationTypeValue;
Activity.EnrichOperation(operationType, operationName);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,71 @@ public async Task AllScopes_IncludesExecuteRequestAndParseDocumentSpans()
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_BeOperationType_When_OperationNameInSpanNameDisabled()
{
using (CaptureActivities(out var activities))
{
// arrange & act
await new ServiceCollection()
.AddGraphQL()
.AddInstrumentation(o => o.Scopes = ActivityScopes.All)
.AddQueryType<SimpleQuery>()
.ExecuteRequestAsync("query GetHeroName { sayHello }");

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query", requestSpan.DisplayName);
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_IncludeOperationName_When_OperationNameInSpanNameEnabledAndNamed()
{
using (CaptureActivities(out var activities))
{
// arrange & act
await new ServiceCollection()
.AddGraphQL()
.AddInstrumentation(o =>
{
o.Scopes = ActivityScopes.All;
o.IncludeOperationNameInSpanName = true;
})
.AddQueryType<SimpleQuery>()
.ExecuteRequestAsync("query GetHeroName { sayHello }");

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query GetHeroName", requestSpan.DisplayName);
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_FallBackToOperationType_When_OperationNameInSpanNameEnabledAndAnonymous()
{
using (CaptureActivities(out var activities))
{
// arrange & act
await new ServiceCollection()
.AddGraphQL()
.AddInstrumentation(o =>
{
o.Scopes = ActivityScopes.All;
o.IncludeOperationNameInSpanName = true;
})
.AddQueryType<SimpleQuery>()
.ExecuteRequestAsync("{ sayHello }");

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query", requestSpan.DisplayName);
}
}

[Fact]
public async Task CustomScopes_OnlyValidateAndCompile_LimitsSpans()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static partial class ActivityTestHelper
[GeneratedRegex(@"lambda_method\d+", RegexOptions.CultureInvariant)]
private static partial Regex LambdaMethodRegex();

public static IDisposable CaptureActivities(out object activities)
public static IDisposable CaptureActivities(out Capture activities)
{
var exported = new List<Activity>();

Expand Down Expand Up @@ -90,7 +90,7 @@ public static IDisposable CaptureActivities(out object activities)
}
}

private sealed class Capture : IDisposable
public sealed class Capture : IDisposable
{
private readonly TracerProvider _tracerProvider;
private readonly List<Activity> _exported;
Expand All @@ -101,6 +101,9 @@ public Capture(TracerProvider tracerProvider, List<Activity> exported)
_exported = exported;
}

[JsonIgnore]
public IReadOnlyList<Activity> Exported => _exported;

[JsonProperty("source", Order = 0)]
public OrderedDictionary<string, object?> Source
=> new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static partial class ActivityTestHelper
[GeneratedRegex(@"lambda_method\d+", RegexOptions.CultureInvariant)]
private static partial Regex LambdaMethodRegex();

public static IDisposable CaptureActivities(out object activities)
public static IDisposable CaptureActivities(out Capture activities)
{
var exported = new List<Activity>();

Expand Down Expand Up @@ -90,7 +90,7 @@ public static IDisposable CaptureActivities(out object activities)
}
}

private sealed class Capture : IDisposable
public sealed class Capture : IDisposable
{
private readonly TracerProvider _tracerProvider;
private readonly List<Activity> _exported;
Expand All @@ -101,6 +101,9 @@ public Capture(TracerProvider tracerProvider, List<Activity> exported)
_exported = exported;
}

[JsonIgnore]
public IReadOnlyList<Activity> Exported => _exported;

[JsonProperty("source", Order = 0)]
public OrderedDictionary<string, object?> Source
=> new()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,111 @@ public async Task AllScopes_IncludesAllSpans()
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_BeOperationType_When_OperationNameInSpanNameDisabled()
{
using (CaptureActivities(out var activities))
{
// arrange
using var server1 = CreateSourceSchema(
"a",
b => b.AddQueryType<Query>());

using var gateway = await CreateCompositeSchemaAsync(
[
("a", server1)
],
configureGatewayBuilder: b => b.AddInstrumentation(o =>
o.Scopes = FusionActivityScopes.All));

var executor = await gateway.Services.GetRequestExecutorAsync();

var request = OperationRequestBuilder.New()
.SetDocument("query GetHeroName { sayHello }")
.Build();

// act
await executor.ExecuteAsync(request);

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query", requestSpan.DisplayName);
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_IncludeOperationName_When_OperationNameInSpanNameEnabledAndNamed()
{
using (CaptureActivities(out var activities))
{
// arrange
using var server1 = CreateSourceSchema(
"a",
b => b.AddQueryType<Query>());

using var gateway = await CreateCompositeSchemaAsync(
[
("a", server1)
],
configureGatewayBuilder: b => b.AddInstrumentation(o =>
{
o.Scopes = FusionActivityScopes.All;
o.IncludeOperationNameInSpanName = true;
}));

var executor = await gateway.Services.GetRequestExecutorAsync();

var request = OperationRequestBuilder.New()
.SetDocument("query GetHeroName { sayHello }")
.Build();

// act
await executor.ExecuteAsync(request);

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query GetHeroName", requestSpan.DisplayName);
}
}

[Fact]
public async Task RequestSpanDisplayName_Should_FallBackToOperationType_When_OperationNameInSpanNameEnabledAndAnonymous()
{
using (CaptureActivities(out var activities))
{
// arrange
using var server1 = CreateSourceSchema(
"a",
b => b.AddQueryType<Query>());

using var gateway = await CreateCompositeSchemaAsync(
[
("a", server1)
],
configureGatewayBuilder: b => b.AddInstrumentation(o =>
{
o.Scopes = FusionActivityScopes.All;
o.IncludeOperationNameInSpanName = true;
}));

var executor = await gateway.Services.GetRequestExecutorAsync();

var request = OperationRequestBuilder.New()
.SetDocument("{ sayHello }")
.Build();

// act
await executor.ExecuteAsync(request);

// assert
var requestSpan = activities.Exported
.Single(a => a.OperationName == "GraphQL Operation");
Assert.Equal("query", requestSpan.DisplayName);
}
}

[Fact]
public async Task CustomScopes_OnlyValidateAndPlan_LimitsSpans()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1086,10 +1086,67 @@ If you prefer, you can still register the remaining scalar types individually in

### InstrumentationOptions changes

- `RenameRootActivity` was removed.
- `RenameRootActivity` was removed. See [Recreating `RenameRootActivity`](#recreating-renamerootactivity) to reproduce the previous behavior in user code.
- `RequestDetails.Operation` was renamed to `RequestDetails.OperationName`.
- `RequestDetails.Query` was renamed to `RequestDetails.Document`.

### Recreating `RenameRootActivity`

The root span is usually owned by the transport instrumentation (for example ASP.NET Core), not Hot Chocolate. Recreate the old behavior with a diagnostic event listener that publishes the operation name, then apply it from the transport instrumentation in `EnrichWithHttpResponse` (not `EnrichWithHttpRequest`, since the operation is only known after execution):

```csharp
using System.Diagnostics;
using HotChocolate.Execution;
using HotChocolate.Execution.Instrumentation;

public sealed class RenameRootActivityListener : ExecutionDiagnosticEventListener
{
public override IDisposable ExecuteRequest(RequestContext context) => new Scope(context);

private sealed class Scope(RequestContext context) : IDisposable
{
public void Dispose()
{
if (Activity.Current is not { } activity
|| !context.TryGetOperation(out var operation)
|| string.IsNullOrEmpty(operation.Name))
{
return;
}

var name = $"{operation.Kind.ToString().ToLowerInvariant()} {operation.Name}";

var root = activity;
while (root.Parent is { } parent)
{
root = parent;
}

root.SetCustomProperty("graphqlDisplayName", name);
}
}
}
```

```csharp
builder.Services
.AddGraphQLServer()
.AddInstrumentation()
.AddDiagnosticEventListener<RenameRootActivityListener>();

builder.Services
.AddOpenTelemetry()
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(o => o.EnrichWithHttpResponse = (activity, _) =>
{
if (activity.GetCustomProperty("graphqlDisplayName") is string name)
{
activity.DisplayName = name;
}
})
.AddHotChocolateInstrumentation());
```

## OpenTelemetry span and status changes

The OpenTelemetry spans and attributes emitted by `AddInstrumentation()` have been updated to align with the [proposed OpenTelemetry semantic conventions for GraphQL](https://github.com/graphql/otel-wg/blob/main/spec).
Expand Down
Loading
Loading