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
22 changes: 19 additions & 3 deletions docs/guide/grpc/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,23 @@ builder.Services.AddWolverineGrpcClient<IPingService>(o =>
});
```

On the server side of a Wolverine→Wolverine hop, the envelope headers are read back in the
`WolverineGrpcServicePropagationInterceptor` already shipped with the adapter, so a call chain
spanning multiple Wolverine services keeps a single correlation identity without any user wiring.
On the server side of a Wolverine→Wolverine hop, `WolverineGrpcServicePropagationInterceptor`
(registered automatically by `AddWolverineGrpc()`) reads the `correlation-id` and `tenant-id`
headers back off the inbound call and applies them to the ambient `IMessageContext` before the
service method runs. Only those two identifiers round-trip this way — `parent-id` and
`conversation-id` live on an `Envelope`, and there is no envelope yet at this point in the
pipeline (`IMessageContext.Envelope` is null outside of message handling). Once the service
method calls `Bus.InvokeAsync(...)`, Wolverine copies the now-set `CorrelationId`/`TenantId` onto
the freshly created envelope automatically, and Marten/Polecat tenant-scoped session frames pick
it up the same way they would for any other transport — no further wiring needed.

Cross-process parent/trace correlation for gRPC hops is handled separately by OpenTelemetry
`Activity` propagation over HTTP/2 (W3C traceparent) — see [How gRPC Handlers Work](./handlers).
The `parent-id`/`conversation-id` headers still go out on the wire for diagnostic/non-Wolverine
consumers, but nothing on the Wolverine side reads them back in.

Server-side propagation can be disabled with `WolverineGrpcOptions.PropagateEnvelopeHeaders =
false`, the counterpart to the client-side `PropagateEnvelopeHeaders` option above.

## `RpcException` → typed-exception translation

Expand Down Expand Up @@ -226,6 +240,8 @@ it lands inside both Wolverine interceptors, which is what you almost always wan
| `WolverineGrpcClientPropagationInterceptor` | Stamps envelope headers on each call. |
| `WolverineGrpcClientExceptionInterceptor` | Translates `RpcException` to typed .NET exceptions per `MapRpcException` + the default table. |
| `WolverineGrpcExceptionMapper.MapToException(rpc)` | Public default mapping table; use from custom interceptors if needed. |
| `WolverineGrpcServicePropagationInterceptor` | Server-side counterpart — reads `correlation-id`/`tenant-id` off inbound calls onto `IMessageContext`. Registered by `AddWolverineGrpc()`. |
| `WolverineGrpcOptions.PropagateEnvelopeHeaders` | Server-side off-switch for the interceptor above. Defaults to `true`. |

## See also

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ namespace OrderChainWithGrpc.InventoryServer;
/// Wolverine handler for <see cref="ReserveStock"/>. The gRPC service forwards to the bus
/// and the bus invokes this handler — the handler itself has no gRPC coupling.
/// <para>
/// The envelope identifiers read from <see cref="IMessageContext"/> below were *not*
/// stamped by code in this process. They were unpacked from inbound gRPC metadata by
/// <c>WolverineGrpcServicePropagationInterceptor</c>, which is wired up automatically
/// by <c>AddWolverineGrpc()</c>. Echoing them back on the reply is what lets the sample
/// visually prove the chain preserved identity end-to-end.
/// <see cref="IMessageContext.CorrelationId"/> and <see cref="IMessageContext.TenantId"/>
/// below were *not* stamped by code in this process. They were unpacked from inbound gRPC
/// metadata by <c>WolverineGrpcServicePropagationInterceptor</c>, which is wired up
/// automatically by <c>AddWolverineGrpc()</c>, before <c>Bus.InvokeAsync</c> ran this
/// handler. Echoing them back on the reply is what lets the sample visually prove those two
/// identifiers preserved identity end-to-end. <c>ParentIdFromUpstream</c> is a different
/// mechanism — it reflects OpenTelemetry <see cref="System.Diagnostics.Activity"/>
/// propagation over the HTTP/2 <c>traceparent</c> header, not this interceptor.
/// </para>
/// </summary>
public static class ReserveStockHandler
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using Grpc.Net.Client;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using ProtoBuf.Grpc.Client;
using ProtoBuf.Grpc.Server;
using Xunit;

namespace Wolverine.Grpc.Tests.ServerPropagation;

/// <summary>
/// A standalone in-process host (not the shared <see cref="Client.WolverineGrpcClientFixture"/>)
/// with <see cref="WolverineGrpcOptions.PropagateEnvelopeHeaders"/> turned off, so tests can
/// prove the interceptor honors the off-switch without affecting the shared fixture's
/// default-on host used by every other propagation test.
/// </summary>
public class DisabledPropagationFixture : IAsyncLifetime
{
private WebApplication? _app;
public GrpcChannel? Channel { get; private set; }

public async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder([]);
builder.WebHost.UseTestServer();

builder.Host.UseWolverine(opts =>
{
opts.ApplicationAssembly = typeof(DisabledPropagationFixture).Assembly;
opts.Discovery.DisableConventionalDiscovery();
opts.Discovery.IncludeType(typeof(PropagationEchoHandler));
});

builder.Services.AddCodeFirstGrpc();
builder.Services.AddWolverineGrpc(o => o.PropagateEnvelopeHeaders = false);

_app = builder.Build();
_app.UseRouting();
_app.MapGrpcService<PropagationEchoGrpcService>();

await _app.StartAsync();

var handler = _app.GetTestServer().CreateHandler();
Channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
{
HttpHandler = handler
});
}

public async Task DisposeAsync()
{
Channel?.Dispose();
if (_app != null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}

public IPropagationEchoService CreateClient() => Channel!.CreateGrpcService<IPropagationEchoService>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using ProtoBuf;
using ProtoBuf.Grpc;
using System.ServiceModel;

namespace Wolverine.Grpc.Tests.ServerPropagation;

/// <summary>
/// Code-first gRPC contract whose server implementation forwards to the Wolverine bus. The
/// invoked handler reads the ambient <see cref="IMessageContext"/> and echoes back whatever
/// <see cref="WolverineGrpcServicePropagationInterceptor"/> put there from inbound headers —
/// used to prove the server-side half of a Wolverine-to-Wolverine propagation hop actually
/// lands in the handler, not just on the wire.
/// </summary>
[ServiceContract]
public interface IPropagationEchoService
{
Task<PropagationEchoReply> Echo(PropagationEchoRequest request, CallContext context = default);
}

[ProtoContract]
public class PropagationEchoRequest
{
}

[ProtoContract]
public class PropagationEchoReply
{
[ProtoMember(1)]
public string? CorrelationId { get; set; }

[ProtoMember(2)]
public string? TenantId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using ProtoBuf.Grpc;
using Wolverine.Grpc;

namespace Wolverine.Grpc.Tests.ServerPropagation;

/// <summary>
/// Code-first gRPC service that delegates to the Wolverine bus. The "GrpcService" suffix lets
/// <c>MapWolverineGrpcServices()</c> discover and map this type automatically.
/// </summary>
public class PropagationEchoGrpcService : WolverineGrpcServiceBase, IPropagationEchoService
{
public PropagationEchoGrpcService(IMessageBus bus) : base(bus)
{
}

public Task<PropagationEchoReply> Echo(PropagationEchoRequest request, CallContext context = default)
=> Bus.InvokeAsync<PropagationEchoReply>(request, context.CancellationToken);
}

/// <summary>
/// Wolverine handler for <see cref="PropagationEchoRequest"/>. Reads whatever
/// <see cref="WolverineGrpcServicePropagationInterceptor"/> already stamped onto the ambient
/// <see cref="IMessageContext"/> before this handler ran.
/// </summary>
public static class PropagationEchoHandler
{
public static PropagationEchoReply Handle(PropagationEchoRequest request, IMessageContext context)
{
return new PropagationEchoReply
{
CorrelationId = context.CorrelationId,
TenantId = context.TenantId
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Grpc.Core;
using Shouldly;
using Xunit;

namespace Wolverine.Grpc.Tests.ServerPropagation;

/// <summary>
/// Proves <see cref="WolverineGrpcOptions.PropagateEnvelopeHeaders"/> = false actually turns the
/// server-side interceptor off, even when a caller sends the envelope headers on the wire.
/// </summary>
public class server_propagation_disabled_tests : IClassFixture<DisabledPropagationFixture>
{
private readonly DisabledPropagationFixture _fixture;

public server_propagation_disabled_tests(DisabledPropagationFixture fixture)
{
_fixture = fixture;
}

[Fact]
public async Task handler_does_not_see_headers_when_propagation_is_disabled()
{
var headers = new Metadata
{
{ EnvelopeConstants.CorrelationIdKey, "corr-should-be-ignored" },
{ EnvelopeConstants.TenantIdKey, "tenant-should-be-ignored" }
};

var client = _fixture.CreateClient();
var reply = await client.Echo(new PropagationEchoRequest(), new CallOptions(headers: headers));

// Neither field is null/empty by default — MessageBus always auto-assigns a CorrelationId,
// and an untenanted context reads back Wolverine's internal default-tenant sentinel rather
// than null. The meaningful assertion is that neither header's specific value won.
reply.CorrelationId.ShouldNotBe("corr-should-be-ignored");
reply.TenantId.ShouldNotBe("tenant-should-be-ignored");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.Extensions.DependencyInjection;
using ProtoBuf.Grpc.Client;
using Shouldly;
using Wolverine.Grpc.Client;
using Wolverine.Grpc.Tests.Client;
using Xunit;

namespace Wolverine.Grpc.Tests.ServerPropagation;

/// <summary>
/// End-to-end tests for <see cref="WolverineGrpcServicePropagationInterceptor"/>. Unlike
/// <see cref="Client.propagation_interceptor_tests"/> (which only asserts what lands on the
/// wire), these assert what the invoked Wolverine <em>handler</em> actually sees on
/// <see cref="IMessageContext"/> after both the client-side and server-side interceptors have
/// run — this is the full Wolverine-to-Wolverine hop the OrderChainWithGrpc sample and the
/// gRPC client docs describe.
/// </summary>
[Collection("grpc-client")]
public class server_propagation_interceptor_tests
{
private readonly WolverineGrpcClientFixture _fixture;

public server_propagation_interceptor_tests(WolverineGrpcClientFixture fixture)
{
_fixture = fixture;
}

private ServiceProvider BuildContainer(TestMessageContext? ctx)
{
var services = new ServiceCollection();
services.AddLogging();

if (ctx != null)
{
services.AddScoped<IMessageContext>(_ => ctx);
services.AddScoped<IMessageBus>(sp => sp.GetRequiredService<IMessageContext>());
}

services.AddWolverineGrpcClient<IPropagationEchoService>(o =>
{
o.Address = new Uri("http://localhost");
}).ConfigureChannel(c => c.HttpHandler = _fixture.ServerHandler);

return services.BuildServiceProvider();
}

[Fact]
public async Task handler_sees_correlation_and_tenant_id_propagated_from_the_caller()
{
var ctx = new TestMessageContext
{
CorrelationId = "corr-hop-1",
TenantId = "tenant-hop-1"
};

await using var provider = BuildContainer(ctx);
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IPropagationEchoService>();

var reply = await client.Echo(new PropagationEchoRequest());

// These values only round-trip if the client interceptor stamped the headers AND the
// server interceptor read them back onto the ambient IMessageContext BEFORE Bus.InvokeAsync
// ran the handler — proving the full hop, not just the wire format.
reply.CorrelationId.ShouldBe("corr-hop-1");
reply.TenantId.ShouldBe("tenant-hop-1");
}

[Fact]
public async Task handler_does_not_see_a_specific_tenant_id_when_caller_has_no_message_context_in_scope()
{
await using var provider = BuildContainer(ctx: null);
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IPropagationEchoService>();

var reply = await client.Echo(new PropagationEchoRequest());

// Neither field is asserted as null/empty here: MessageBus always assigns a CorrelationId
// from Activity.Current?.RootId (or a fresh GUID), and an untenanted context reads back
// Wolverine's internal default-tenant sentinel rather than null — neither is something this
// interceptor controls or suppresses. What it does control is whether a *specific* caller
// value shows up uninvited, which is what the round-trip tests above already prove positively.
reply.TenantId.ShouldNotBe("tenant-hop-1");
reply.TenantId.ShouldNotBe("tenant-only");
}

[Fact]
public async Task tenant_id_alone_propagates_without_a_correlation_id_header()
{
// TestMessageContext's parameterless constructor auto-generates a CorrelationId — clear
// it explicitly so this test isolates tenant-id propagation on its own (no correlation-id
// header goes out on the wire).
var ctx = new TestMessageContext
{
CorrelationId = null,
TenantId = "tenant-only"
};

await using var provider = BuildContainer(ctx);
using var scope = provider.CreateScope();
var client = scope.ServiceProvider.GetRequiredService<IPropagationEchoService>();

var reply = await client.Echo(new PropagationEchoRequest());

reply.TenantId.ShouldBe("tenant-only");
}

[Fact]
public async Task an_arbitrary_non_wolverine_caller_can_also_drive_tenant_id_via_the_raw_header()
{
// No AddWolverineGrpcClient, no IMessageContext anywhere in this process — an ordinary
// grpc-dotnet caller setting the header by hand, same as an external service or gateway
// would. This is the scenario from the original Discord question: does something read
// an inbound tenant-id header at all? It does, independent of who sent it.
using var channel = GrpcChannel.ForAddress("http://localhost", new GrpcChannelOptions
{
HttpHandler = _fixture.ServerHandler
});
var client = channel.CreateGrpcService<IPropagationEchoService>();

var headers = new Metadata
{
{ EnvelopeConstants.TenantIdKey, "external-caller-tenant" }
};

var reply = await client.Echo(new PropagationEchoRequest(), new CallOptions(headers: headers));

reply.TenantId.ShouldBe("external-caller-tenant");
}
}
Loading
Loading