diff --git a/dictionary.txt b/dictionary.txt index 93419608555..2875c750db8 100644 --- a/dictionary.txt +++ b/dictionary.txt @@ -100,6 +100,7 @@ Linq Liquibase Marek matchesBrics +maxage MCPEXP MediatR Memberwise diff --git a/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs b/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs index e3b6ee5ed02..aa234c509c6 100644 --- a/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs +++ b/src/Mocha/src/Examples/Observability/OpenTelemetry/OpenTelemetryExample.cs @@ -35,17 +35,16 @@ builder.Services .AddMessageBus() - // AddInstrumentation() registers the built-in OpenTelemetryDiagnosticObserver. - // Without this call, Mocha uses a no-op observer with zero overhead. + // AddInstrumentation() registers the built-in ActivityMessagingDiagnosticListener. + // Without this call, Mocha uses a no-op listener with zero overhead. // Spans are emitted to the "Mocha" activity source - subscribe via AddSource("Mocha"). .AddInstrumentation() + // Register a custom listener alongside the built-in one for application-level telemetry. + // Multiple listeners compose automatically — no manual aggregation needed. + .AddDiagnosticEventListener() .AddEventHandler() .AddInMemory(); -// Register a custom observer alongside the built-in one for application-level telemetry. -// The built-in observer emits OpenTelemetry spans. This observer logs to console. -builder.Services.AddSingleton(); - var app = builder.Build(); app.MapGet("/orders", async (IMessageBus bus) => @@ -95,14 +94,15 @@ public ValueTask HandleAsync( } } -// --- Custom diagnostic observer --- +// --- Custom diagnostic listener --- -// Implement IBusDiagnosticObserver to collect telemetry or integrate with a -// non-OpenTelemetry backend. Each method returns an IDisposable whose disposal -// marks the end of the observed scope, enabling duration measurement. -public sealed class ConsoleDiagnosticObserver : IBusDiagnosticObserver +// Extend MessagingDiagnosticEventListener to collect telemetry or integrate with a +// non-OpenTelemetry backend. Override only the methods you care about — the base +// class provides no-op defaults for the rest. Each scope method returns an +// IDisposable whose disposal marks the end of the observed scope. +public sealed class ConsoleDiagnosticObserver : MessagingDiagnosticEventListener { - public IDisposable Dispatch(IDispatchContext context) + public override IDisposable Dispatch(IDispatchContext context) { var startTime = DateTimeOffset.UtcNow; Console.WriteLine($"[Dispatch] -> {context.DestinationAddress}"); @@ -114,25 +114,25 @@ public IDisposable Dispatch(IDispatchContext context) }); } - public IDisposable Receive(IReceiveContext context) + public override IDisposable Receive(IReceiveContext context) { Console.WriteLine($"[Receive] <- {context.Endpoint.Address}"); return new Scope(() => Console.WriteLine("[Receive] completed")); } - public IDisposable Consume(IConsumeContext context) + public override IDisposable Consume(IConsumeContext context) { Console.WriteLine($"[Consume] message {context.MessageId}"); return new Scope(() => Console.WriteLine("[Consume] completed")); } - public void OnDispatchError(IDispatchContext context, Exception exception) + public override void DispatchError(IDispatchContext context, Exception exception) => Console.WriteLine($"[Dispatch] error: {exception.Message}"); - public void OnReceiveError(IReceiveContext context, Exception exception) + public override void ReceiveError(IReceiveContext context, Exception exception) => Console.WriteLine($"[Receive] error: {exception.Message}"); - public void OnConsumeError(IConsumeContext context, Exception exception) + public override void ConsumeError(IConsumeContext context, Exception exception) => Console.WriteLine($"[Consume] error: {exception.Message}"); private sealed class Scope(Action onDispose) : IDisposable diff --git a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs index a39b382e896..d1bebba0c3c 100644 --- a/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs +++ b/src/Mocha/src/Mocha/Builder/MessageBusBuilder.cs @@ -288,10 +288,16 @@ private static void AddCoreServices(IServiceCollection services, IServiceProvide services.AddSingleton(loggerFactory); services.AddSingleton(typeof(ILogger<>), typeof(Logger<>)); - var diagnosticObserver = - applicationServices.GetService() ?? NoOpBusDiagnosticObserver.Instance; + var listeners = applicationServices.GetServices().ToArray(); - services.AddSingleton(diagnosticObserver); + IMessagingDiagnosticEvents diagnosticEvents = listeners.Length switch + { + 0 => NoopMessagingDiagnosticEvents.Instance, + 1 => listeners[0], + _ => new AggregateMessagingDiagnosticEvents(listeners) + }; + + services.AddSingleton(diagnosticEvents); var naming = applicationServices.GetService(); diff --git a/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs b/src/Mocha/src/Mocha/Instrumentation/ActivityMessagingDiagnosticListener.cs similarity index 85% rename from src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs rename to src/Mocha/src/Mocha/Instrumentation/ActivityMessagingDiagnosticListener.cs index e5f956ff201..4c95c85ad6e 100644 --- a/src/Mocha/src/Mocha/Observability/OpenTelemetryDiagnosticObserver.cs +++ b/src/Mocha/src/Mocha/Instrumentation/ActivityMessagingDiagnosticListener.cs @@ -3,50 +3,36 @@ namespace Mocha; -/// -/// Diagnostic observer that emits OpenTelemetry traces and metrics for dispatch, receive, and consume operations. -/// -/// -/// Creates spans for each pipeline stage and records exceptions as span events -/// with an error status. Trace context propagation is handled via message headers on the receive path, -/// enabling distributed tracing across transport boundaries. -/// -public sealed class OpenTelemetryDiagnosticObserver : IBusDiagnosticObserver +internal sealed class ActivityMessagingDiagnosticListener : MessagingDiagnosticEventListener { - /// - public IDisposable Dispatch(IDispatchContext context) + public override IDisposable Dispatch(IDispatchContext context) { return DispatchActivity.Create(context); } - /// - public IDisposable Receive(IReceiveContext context) + public override void DispatchError(IDispatchContext context, Exception exception) { - return ReceiveActivity.Create(context); + Activity.Current?.AddException(exception); + Activity.Current?.SetStatus(ActivityStatusCode.Error); } - /// - public IDisposable Consume(IConsumeContext context) + public override IDisposable Receive(IReceiveContext context) { - return ConsumerActivity.Create(context); + return ReceiveActivity.Create(context); } - /// - public void OnReceiveError(IReceiveContext context, Exception exception) + public override void ReceiveError(IReceiveContext context, Exception exception) { Activity.Current?.AddException(exception); Activity.Current?.SetStatus(ActivityStatusCode.Error); } - /// - public void OnDispatchError(IDispatchContext context, Exception exception) + public override IDisposable Consume(IConsumeContext context) { - Activity.Current?.AddException(exception); - Activity.Current?.SetStatus(ActivityStatusCode.Error); + return ConsumerActivity.Create(context); } - /// - public void OnConsumeError(IConsumeContext context, Exception exception) + public override void ConsumeError(IConsumeContext context, Exception exception) { Activity.Current?.AddException(exception); Activity.Current?.SetStatus(ActivityStatusCode.Error); diff --git a/src/Mocha/src/Mocha/Instrumentation/AggregateMessagingDiagnosticEvents.cs b/src/Mocha/src/Mocha/Instrumentation/AggregateMessagingDiagnosticEvents.cs new file mode 100644 index 00000000000..6c9eebacf8d --- /dev/null +++ b/src/Mocha/src/Mocha/Instrumentation/AggregateMessagingDiagnosticEvents.cs @@ -0,0 +1,85 @@ +using Mocha.Middlewares; + +namespace Mocha; + +internal sealed class AggregateMessagingDiagnosticEvents(IMessagingDiagnosticEventListener[] listeners) + : IMessagingDiagnosticEvents +{ + public IDisposable Dispatch(IDispatchContext context) + { + var scopes = new IDisposable[listeners.Length]; + + for (var i = 0; i < listeners.Length; i++) + { + scopes[i] = listeners[i].Dispatch(context); + } + + return new AggregateActivityScope(scopes); + } + + public void DispatchError(IDispatchContext context, Exception exception) + { + for (var i = 0; i < listeners.Length; i++) + { + listeners[i].DispatchError(context, exception); + } + } + + public IDisposable Receive(IReceiveContext context) + { + var scopes = new IDisposable[listeners.Length]; + + for (var i = 0; i < listeners.Length; i++) + { + scopes[i] = listeners[i].Receive(context); + } + + return new AggregateActivityScope(scopes); + } + + public void ReceiveError(IReceiveContext context, Exception exception) + { + for (var i = 0; i < listeners.Length; i++) + { + listeners[i].ReceiveError(context, exception); + } + } + + public IDisposable Consume(IConsumeContext context) + { + var scopes = new IDisposable[listeners.Length]; + + for (var i = 0; i < listeners.Length; i++) + { + scopes[i] = listeners[i].Consume(context); + } + + return new AggregateActivityScope(scopes); + } + + public void ConsumeError(IConsumeContext context, Exception exception) + { + for (var i = 0; i < listeners.Length; i++) + { + listeners[i].ConsumeError(context, exception); + } + } + + private sealed class AggregateActivityScope(IDisposable[] scopes) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!_disposed) + { + for (var i = 0; i < scopes.Length; i++) + { + scopes[i].Dispose(); + } + + _disposed = true; + } + } + } +} diff --git a/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEventListener.cs b/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEventListener.cs new file mode 100644 index 00000000000..ec42b25d62e --- /dev/null +++ b/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEventListener.cs @@ -0,0 +1,9 @@ +namespace Mocha; + +/// +/// Register an implementation of this interface in the DI container to +/// listen to diagnostic events. Multiple implementations can be registered +/// and they will all be called in registration order. +/// +/// +public interface IMessagingDiagnosticEventListener : IMessagingDiagnosticEvents; diff --git a/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEvents.cs b/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEvents.cs new file mode 100644 index 00000000000..2470ff3cce8 --- /dev/null +++ b/src/Mocha/src/Mocha/Instrumentation/IMessagingDiagnosticEvents.cs @@ -0,0 +1,41 @@ +using Mocha.Middlewares; + +namespace Mocha; + +/// +/// Provides diagnostic events that can be triggered by the messaging pipeline. +/// These events allow monitoring and instrumentation of dispatch, receive, and consume operations. +/// +/// +public interface IMessagingDiagnosticEvents +{ + /// + /// Called when a message begins dispatching. Returns a disposable scope. + /// + IDisposable Dispatch(IDispatchContext context); + + /// + /// Called when an exception occurs during dispatch. + /// + void DispatchError(IDispatchContext context, Exception exception); + + /// + /// Called when a message begins being received. Returns a disposable scope. + /// + IDisposable Receive(IReceiveContext context); + + /// + /// Called when an exception occurs during receive. + /// + void ReceiveError(IReceiveContext context, Exception exception); + + /// + /// Called when a message begins being consumed. Returns a disposable scope. + /// + IDisposable Consume(IConsumeContext context); + + /// + /// Called when an exception occurs during consume. + /// + void ConsumeError(IConsumeContext context, Exception exception); +} diff --git a/src/Mocha/src/Mocha/Instrumentation/MessagingDiagnosticEventListener.cs b/src/Mocha/src/Mocha/Instrumentation/MessagingDiagnosticEventListener.cs new file mode 100644 index 00000000000..fda1be475a2 --- /dev/null +++ b/src/Mocha/src/Mocha/Instrumentation/MessagingDiagnosticEventListener.cs @@ -0,0 +1,37 @@ +using Mocha.Middlewares; + +namespace Mocha; + +/// +/// A base class for diagnostic event listeners with default no-op implementations. +/// Extend this class and override the methods you need. +/// +/// +public class MessagingDiagnosticEventListener : IMessagingDiagnosticEventListener +{ + protected MessagingDiagnosticEventListener() { } + + /// + /// Gets a shared no-op scope. + /// Calling on this instance is safe and performs no operation. + /// Use this as a default return value from diagnostic methods when no diagnostic activity is needed. + /// + protected internal static IDisposable EmptyScope { get; } = new EmptyActivityScope(); + + public virtual IDisposable Dispatch(IDispatchContext context) => EmptyScope; + + public virtual void DispatchError(IDispatchContext context, Exception exception) { } + + public virtual IDisposable Receive(IReceiveContext context) => EmptyScope; + + public virtual void ReceiveError(IReceiveContext context, Exception exception) { } + + public virtual IDisposable Consume(IConsumeContext context) => EmptyScope; + + public virtual void ConsumeError(IConsumeContext context, Exception exception) { } + + private sealed class EmptyActivityScope : IDisposable + { + public void Dispose() { } + } +} diff --git a/src/Mocha/src/Mocha/Instrumentation/NoopMessagingDiagnosticEvents.cs b/src/Mocha/src/Mocha/Instrumentation/NoopMessagingDiagnosticEvents.cs new file mode 100644 index 00000000000..481d64e70f2 --- /dev/null +++ b/src/Mocha/src/Mocha/Instrumentation/NoopMessagingDiagnosticEvents.cs @@ -0,0 +1,11 @@ +namespace Mocha; + +internal sealed class NoopMessagingDiagnosticEvents + : MessagingDiagnosticEventListener +{ + private NoopMessagingDiagnosticEvents() + { + } + + public static NoopMessagingDiagnosticEvents Instance { get; } = new(); +} diff --git a/src/Mocha/src/Mocha/Middlewares/Consume/ConsumerInstrumentationMiddleware.cs b/src/Mocha/src/Mocha/Middlewares/Consume/ConsumerInstrumentationMiddleware.cs index ebf706f2ba9..528627a3ef9 100644 --- a/src/Mocha/src/Mocha/Middlewares/Consume/ConsumerInstrumentationMiddleware.cs +++ b/src/Mocha/src/Mocha/Middlewares/Consume/ConsumerInstrumentationMiddleware.cs @@ -11,21 +11,29 @@ namespace Mocha.Middlewares; /// Without this separation, high receive latency and high handler latency are hard to attribute and /// tune independently. /// -internal sealed class ConsumerInstrumentationMiddleware(IBusDiagnosticObserver observer) +internal sealed class ConsumerInstrumentationMiddleware(IMessagingDiagnosticEvents events) { public async ValueTask InvokeAsync(IConsumeContext context, ConsumerDelegate next) { - using var scope = observer.Consume(context); + using var scope = events.Consume(context); - await next(context); + try + { + await next(context); + } + catch (Exception ex) + { + events.ConsumeError(context, ex); + throw; + } } public static ConsumerMiddlewareConfiguration Create() => new( static (context, next) => { - var observer = context.Services.GetRequiredService(); - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = context.Services.GetRequiredService(); + var middleware = new ConsumerInstrumentationMiddleware(events); return ctx => middleware.InvokeAsync(ctx, next); }, "Instrumentation"); diff --git a/src/Mocha/src/Mocha/Middlewares/Dispatch/DispatchInstrumentationMiddleware.cs b/src/Mocha/src/Mocha/Middlewares/Dispatch/DispatchInstrumentationMiddleware.cs index 4df50bc07b6..7d6ad8165fc 100644 --- a/src/Mocha/src/Mocha/Middlewares/Dispatch/DispatchInstrumentationMiddleware.cs +++ b/src/Mocha/src/Mocha/Middlewares/Dispatch/DispatchInstrumentationMiddleware.cs @@ -11,23 +11,31 @@ namespace Mocha; /// Without activity propagation, downstream services lose causal trace continuity for produced /// messages. /// -internal sealed class DispatchInstrumentationMiddleware(IBusDiagnosticObserver observer) +internal sealed class DispatchInstrumentationMiddleware(IMessagingDiagnosticEvents events) { public async ValueTask InvokeAsync(IDispatchContext context, DispatchDelegate next) { - using var activity = observer.Dispatch(context); + using var scope = events.Dispatch(context); context.Headers.WithActivity(); - await next(context); + try + { + await next(context); + } + catch (Exception ex) + { + events.DispatchError(context, ex); + throw; + } } public static DispatchMiddlewareConfiguration Create() => new( static (context, next) => { - var observer = context.Services.GetRequiredService(); - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = context.Services.GetRequiredService(); + var middleware = new DispatchInstrumentationMiddleware(events); return ctx => middleware.InvokeAsync(ctx, next); }, "Instrumentation"); diff --git a/src/Mocha/src/Mocha/Middlewares/Receive/ReceiveInstrumentationMiddleware.cs b/src/Mocha/src/Mocha/Middlewares/Receive/ReceiveInstrumentationMiddleware.cs index 79f1313c4aa..2b0cfdee757 100644 --- a/src/Mocha/src/Mocha/Middlewares/Receive/ReceiveInstrumentationMiddleware.cs +++ b/src/Mocha/src/Mocha/Middlewares/Receive/ReceiveInstrumentationMiddleware.cs @@ -11,11 +11,11 @@ namespace Mocha.Middlewares; /// Without this middleware, receive-side failures become much harder to correlate with transport, /// endpoint, and handler latency behavior. /// -internal sealed class ReceiveInstrumentationMiddleware(IBusDiagnosticObserver observer) +internal sealed class ReceiveInstrumentationMiddleware(IMessagingDiagnosticEvents events) { public async ValueTask InvokeAsync(IReceiveContext context, ReceiveDelegate next) { - using var activity = observer.Receive(context); + using var activity = events.Receive(context); try { @@ -23,7 +23,7 @@ public async ValueTask InvokeAsync(IReceiveContext context, ReceiveDelegate next } catch (Exception ex) { - observer.OnReceiveError(context, ex); + events.ReceiveError(context, ex); throw; } @@ -33,8 +33,8 @@ public static ReceiveMiddlewareConfiguration Create() => new( static (context, next) => { - var observer = context.Services.GetRequiredService(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = context.Services.GetRequiredService(); + var middleware = new ReceiveInstrumentationMiddleware(events); return ctx => middleware.InvokeAsync(ctx, next); }, "ReceiveInstrumentation"); diff --git a/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs b/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs index ee9e20f4047..d9e64ef62ea 100644 --- a/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs +++ b/src/Mocha/src/Mocha/Observability/Configuration/InstrumentationBusExtensions.cs @@ -1,21 +1,53 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Mocha; /// -/// Extension methods for registering OpenTelemetry instrumentation with the message bus host. +/// Provides extension methods for adding diagnostics and instrumentation +/// to the messaging pipeline via . /// public static class InstrumentationBusExtensions { /// - /// Registers the to emit traces and metrics - /// for all dispatch, receive, and consume operations on the bus. + /// Adds the default OpenTelemetry-compatible diagnostic event listener to the messaging pipeline. /// - /// The host builder to configure. - /// The same instance for chaining. public static IMessageBusHostBuilder AddInstrumentation(this IMessageBusHostBuilder builder) { - builder.Services.AddSingleton(); + ArgumentNullException.ThrowIfNull(builder); + + builder.AddDiagnosticEventListener(); + + return builder; + } + + /// + /// Registers a custom implementation. + /// + public static IMessageBusHostBuilder AddDiagnosticEventListener(this IMessageBusHostBuilder builder) + where T : class, IMessagingDiagnosticEventListener + { + ArgumentNullException.ThrowIfNull(builder); + + builder.Services.TryAddSingleton(); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton()); + + return builder; + } + + /// + /// Registers a diagnostic event listener instance. + /// + public static IMessageBusHostBuilder AddDiagnosticEventListener( + this IMessageBusHostBuilder builder, + IMessagingDiagnosticEventListener listener) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(listener); + + builder.Services.AddSingleton(listener); + return builder; } } diff --git a/src/Mocha/src/Mocha/Observability/IBusDiagnosticObserver.cs b/src/Mocha/src/Mocha/Observability/IBusDiagnosticObserver.cs deleted file mode 100644 index c2c1237bd06..00000000000 --- a/src/Mocha/src/Mocha/Observability/IBusDiagnosticObserver.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Mocha.Middlewares; - -namespace Mocha; - -/// -/// Observes diagnostic events across the dispatch, receive, and consume stages of the messaging pipeline. -/// -/// -/// Implementations can collect telemetry, traces, or metrics at each pipeline stage. The Dispatch, -/// Receive, and Consume methods return an whose disposal marks the -/// end of the observed scope, enabling duration measurement and resource cleanup. -/// -public interface IBusDiagnosticObserver -{ - /// - /// Begins observing a dispatch (outbound send/publish) operation. - /// - /// The dispatch context for the outgoing message. - /// A disposable scope that ends observation when disposed. - IDisposable Dispatch(IDispatchContext context); - - /// - /// Begins observing a receive (inbound message arrival) operation. - /// - /// The receive context for the incoming message. - /// A disposable scope that ends observation when disposed. - IDisposable Receive(IReceiveContext context); - - /// - /// Begins observing a consume (consumer processing) operation. - /// - /// The consume context for the message being processed by a consumer. - /// A disposable scope that ends observation when disposed. - IDisposable Consume(IConsumeContext context); - - /// - /// Called when an error occurs during the receive pipeline. - /// - /// The receive context in which the error occurred. - /// The exception that was thrown. - void OnReceiveError(IReceiveContext context, Exception exception); - - /// - /// Called when an error occurs during the dispatch pipeline. - /// - /// The dispatch context in which the error occurred. - /// The exception that was thrown. - void OnDispatchError(IDispatchContext context, Exception exception); - - /// - /// Called when an error occurs during the consume pipeline. - /// - /// The consume context in which the error occurred. - /// The exception that was thrown. - void OnConsumeError(IConsumeContext context, Exception exception); -} diff --git a/src/Mocha/src/Mocha/Observability/NoOpBusDiagnosticObserver.cs b/src/Mocha/src/Mocha/Observability/NoOpBusDiagnosticObserver.cs deleted file mode 100644 index 7d280e83bfb..00000000000 --- a/src/Mocha/src/Mocha/Observability/NoOpBusDiagnosticObserver.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Mocha.Middlewares; - -namespace Mocha; - -/// -/// Default no-op implementation of used as a fallback -/// when no telemetry or diagnostic observer has been configured for the message bus. -/// -/// -/// All observation methods return a lightweight no-op disposable, and all error handlers -/// are empty. This avoids null checks in the pipeline while incurring minimal overhead. -/// Access the shared instance via . -/// -/// -/// -internal sealed class NoOpBusDiagnosticObserver : IBusDiagnosticObserver -{ - /// - /// Returns a no-op disposable scope; no dispatch telemetry is recorded. - /// - /// The dispatch context for the outgoing message. - /// A no-op that performs no action on disposal. - public IDisposable Dispatch(IDispatchContext context) - { - return NoOpDisposable.Instance; - } - - /// - /// Returns a no-op disposable scope; no receive telemetry is recorded. - /// - /// The receive context for the incoming message. - /// A no-op that performs no action on disposal. - public IDisposable Receive(IReceiveContext context) - { - return NoOpDisposable.Instance; - } - - /// - /// Returns a no-op disposable scope; no consume telemetry is recorded. - /// - /// The consume context for the message being processed. - /// A no-op that performs no action on disposal. - public IDisposable Consume(IConsumeContext context) - { - return NoOpDisposable.Instance; - } - - /// - /// Called when an error occurs during the receive pipeline; intentionally does nothing. - /// - /// The receive context in which the error occurred. - /// The exception that was thrown. - public void OnReceiveError(IReceiveContext context, Exception exception) { } - - /// - /// Called when an error occurs during the dispatch pipeline; intentionally does nothing. - /// - /// The dispatch context in which the error occurred. - /// The exception that was thrown. - public void OnDispatchError(IDispatchContext context, Exception exception) { } - - /// - /// Called when an error occurs during the consume pipeline; intentionally does nothing. - /// - /// The consume context in which the error occurred. - /// The exception that was thrown. - public void OnConsumeError(IConsumeContext context, Exception exception) { } - - /// - /// Lightweight disposable that performs no action on disposal, used by the no-op observer. - /// - private sealed class NoOpDisposable : IDisposable - { - /// - /// Performs no action. Exists solely to satisfy the contract. - /// - public void Dispose() { } - - /// - /// Gets a new instance of the no-op disposable. - /// - public static NoOpDisposable Instance => new(); - } - - /// - /// Gets the shared singleton instance of the no-op diagnostic observer. - /// - public static NoOpBusDiagnosticObserver Instance => field ??= new(); -} diff --git a/src/Mocha/test/Mocha.Tests/Middlewares/Consume/ConsumerInstrumentationMiddlewareTests.cs b/src/Mocha/test/Mocha.Tests/Middlewares/Consume/ConsumerInstrumentationMiddlewareTests.cs index 65ce1f56ea0..97b2cb6c008 100644 --- a/src/Mocha/test/Mocha.Tests/Middlewares/Consume/ConsumerInstrumentationMiddlewareTests.cs +++ b/src/Mocha/test/Mocha.Tests/Middlewares/Consume/ConsumerInstrumentationMiddlewareTests.cs @@ -14,8 +14,8 @@ public sealed class ConsumerInstrumentationMiddlewareTests public async Task InvokeAsync_Should_CallObserverConsume_When_MiddlewareInvoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ConsumerInstrumentationMiddleware(events); var context = new StubConsumeContext(); var nextCalled = false; @@ -29,7 +29,7 @@ public async Task InvokeAsync_Should_CallObserverConsume_When_MiddlewareInvoked( await middleware.InvokeAsync(context, next); // assert - Assert.True(observer.ConsumeCalled, "Observer.Consume should be called"); + Assert.True(events.ConsumeCalled, "Events.Consume should be called"); Assert.True(nextCalled, "Next delegate should be called"); } @@ -38,8 +38,8 @@ public async Task InvokeAsync_Should_DisposeScope_When_ProcessingCompletes() { // arrange var scope = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ScopeToReturn = scope }; - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ScopeToReturn = scope }; + var middleware = new ConsumerInstrumentationMiddleware(events); var context = new StubConsumeContext(); ConsumerDelegate next = _ => ValueTask.CompletedTask; @@ -56,8 +56,8 @@ public async Task InvokeAsync_Should_DisposeScope_When_ExceptionOccurs() { // arrange var scope = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ScopeToReturn = scope }; - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ScopeToReturn = scope }; + var middleware = new ConsumerInstrumentationMiddleware(events); var context = new StubConsumeContext(); ConsumerDelegate next = _ => throw new InvalidOperationException("Test"); @@ -80,8 +80,8 @@ public async Task InvokeAsync_Should_DisposeScope_When_ExceptionOccurs() public async Task InvokeAsync_Should_RethrowException_When_NextThrows() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ConsumerInstrumentationMiddleware(events); var context = new StubConsumeContext(); var expected = new InvalidOperationException("Should be rethrown"); @@ -95,12 +95,34 @@ public async Task InvokeAsync_Should_RethrowException_When_NextThrows() Assert.Same(expected, ex); } + [Fact] + public async Task InvokeAsync_Should_CallConsumeError_When_ExceptionOccurs() + { + // arrange + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ConsumerInstrumentationMiddleware(events); + var context = new StubConsumeContext(); + var expectedException = new InvalidOperationException("Test exception"); + + ConsumerDelegate next = _ => throw expectedException; + + // act & assert + var ex = await Assert.ThrowsAsync(() => + middleware.InvokeAsync(context, next).AsTask() + ); + + Assert.Same(expectedException, ex); + Assert.True(events.ConsumeErrorCalled, "ConsumeError should be called on exception"); + Assert.Same(expectedException, events.RecordedException); + Assert.Same(context, events.RecordedErrorContext); + } + [Fact] public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ConsumerInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ConsumerInstrumentationMiddleware(events); var context = new StubConsumeContext(); ConsumerDelegate next = _ => ValueTask.CompletedTask; @@ -109,7 +131,7 @@ public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() await middleware.InvokeAsync(context, next); // assert - Assert.Same(context, observer.RecordedConsumeContext); + Assert.Same(context, events.RecordedConsumeContext); } [Fact] @@ -128,9 +150,9 @@ public void Create_Should_ReturnConfiguration_WithCorrectKey() public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServiceProvider() { // arrange - var observer = new MockBusDiagnosticObserver(); + var events = new MockMessagingDiagnosticEvents(); var services = new ServiceCollection(); - services.AddSingleton(observer); + services.AddSingleton(events); var provider = services.BuildServiceProvider(); var configuration = ConsumerInstrumentationMiddleware.Create(); @@ -149,7 +171,7 @@ public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServicePro await middlewareDelegate(consumeContext); // assert - Assert.True(observer.ConsumeCalled); + Assert.True(events.ConsumeCalled); Assert.True(nextCalled); } @@ -180,16 +202,23 @@ private sealed class StubConsumeContext : IConsumeContext public IRemoteHostInfo Host { get; set; } = null!; } - private sealed class MockBusDiagnosticObserver : IBusDiagnosticObserver + private sealed class MockMessagingDiagnosticEvents : IMessagingDiagnosticEvents { public bool ConsumeCalled { get; private set; } + public bool ConsumeErrorCalled { get; private set; } public IConsumeContext? RecordedConsumeContext { get; private set; } + public IConsumeContext? RecordedErrorContext { get; private set; } + public Exception? RecordedException { get; private set; } public IDisposable? ScopeToReturn { get; set; } public IDisposable Dispatch(IDispatchContext context) => new MockDisposable(); + public void DispatchError(IDispatchContext context, Exception exception) { } + public IDisposable Receive(IReceiveContext context) => new MockDisposable(); + public void ReceiveError(IReceiveContext context, Exception exception) { } + public IDisposable Consume(IConsumeContext context) { ConsumeCalled = true; @@ -197,11 +226,12 @@ public IDisposable Consume(IConsumeContext context) return ScopeToReturn ?? new MockDisposable(); } - public void OnReceiveError(IReceiveContext context, Exception exception) { } - - public void OnDispatchError(IDispatchContext context, Exception exception) { } - - public void OnConsumeError(IConsumeContext context, Exception exception) { } + public void ConsumeError(IConsumeContext context, Exception exception) + { + ConsumeErrorCalled = true; + RecordedErrorContext = context; + RecordedException = exception; + } } private sealed class MockDisposable : IDisposable diff --git a/src/Mocha/test/Mocha.Tests/Middlewares/Dispatch/DispatchInstrumentationMiddlewareTests.cs b/src/Mocha/test/Mocha.Tests/Middlewares/Dispatch/DispatchInstrumentationMiddlewareTests.cs index ae9de8d8e39..e1527e711a8 100644 --- a/src/Mocha/test/Mocha.Tests/Middlewares/Dispatch/DispatchInstrumentationMiddlewareTests.cs +++ b/src/Mocha/test/Mocha.Tests/Middlewares/Dispatch/DispatchInstrumentationMiddlewareTests.cs @@ -13,8 +13,8 @@ public sealed class DispatchInstrumentationMiddlewareTests public async Task InvokeAsync_Should_CallObserverDispatch_When_MiddlewareInvoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); var nextCalled = false; @@ -28,7 +28,7 @@ public async Task InvokeAsync_Should_CallObserverDispatch_When_MiddlewareInvoked await middleware.InvokeAsync(context, next); // assert - Assert.True(observer.DispatchCalled, "Observer.Dispatch should be called"); + Assert.True(events.DispatchCalled, "Events.Dispatch should be called"); Assert.True(nextCalled, "Next delegate should be called"); } @@ -37,8 +37,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ProcessingCompletes() { // arrange var activity = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ActivityToReturn = activity }; - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ActivityToReturn = activity }; + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); DispatchDelegate next = _ => ValueTask.CompletedTask; @@ -55,8 +55,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ExceptionOccurs() { // arrange var activity = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ActivityToReturn = activity }; - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ActivityToReturn = activity }; + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); DispatchDelegate next = _ => throw new InvalidOperationException("Test"); @@ -79,8 +79,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ExceptionOccurs() public async Task InvokeAsync_Should_RethrowException_When_NextThrows() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); var expected = new InvalidOperationException("Should be rethrown"); @@ -94,12 +94,34 @@ public async Task InvokeAsync_Should_RethrowException_When_NextThrows() Assert.Same(expected, ex); } + [Fact] + public async Task InvokeAsync_Should_CallDispatchError_When_ExceptionOccurs() + { + // arrange + var events = new MockMessagingDiagnosticEvents(); + var middleware = new DispatchInstrumentationMiddleware(events); + var context = new DispatchContext(); + var expectedException = new InvalidOperationException("Test exception"); + + DispatchDelegate next = _ => throw expectedException; + + // act & assert + var ex = await Assert.ThrowsAsync(() => + middleware.InvokeAsync(context, next).AsTask() + ); + + Assert.Same(expectedException, ex); + Assert.True(events.DispatchErrorCalled, "DispatchError should be called on exception"); + Assert.Same(expectedException, events.RecordedException); + Assert.Same(context, events.RecordedErrorContext); + } + [Fact] public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); DispatchDelegate next = _ => ValueTask.CompletedTask; @@ -108,7 +130,7 @@ public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() await middleware.InvokeAsync(context, next); // assert - Assert.Same(context, observer.RecordedDispatchContext); + Assert.Same(context, events.RecordedDispatchContext); } [Fact] @@ -116,8 +138,8 @@ public async Task InvokeAsync_Should_CallWithActivityOnHeaders_When_Invoked() { // arrange - WithActivity() is a no-op when Activity.Current is null, // but we verify the middleware calls next and does not throw. - var observer = new MockBusDiagnosticObserver(); - var middleware = new DispatchInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new DispatchInstrumentationMiddleware(events); var context = new DispatchContext(); DispatchDelegate next = _ => ValueTask.CompletedTask; @@ -126,7 +148,7 @@ public async Task InvokeAsync_Should_CallWithActivityOnHeaders_When_Invoked() await middleware.InvokeAsync(context, next); // assert - Assert.True(observer.DispatchCalled); + Assert.True(events.DispatchCalled); } [Fact] @@ -145,9 +167,9 @@ public void Create_Should_ReturnConfiguration_WithCorrectKey() public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServiceProvider() { // arrange - var observer = new MockBusDiagnosticObserver(); + var events = new MockMessagingDiagnosticEvents(); var services = new ServiceCollection(); - services.AddSingleton(observer); + services.AddSingleton(events); var provider = services.BuildServiceProvider(); var configuration = DispatchInstrumentationMiddleware.Create(); @@ -171,14 +193,17 @@ public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServicePro await middlewareDelegate(dispatchContext); // assert - Assert.True(observer.DispatchCalled); + Assert.True(events.DispatchCalled); Assert.True(nextCalled); } - private sealed class MockBusDiagnosticObserver : IBusDiagnosticObserver + private sealed class MockMessagingDiagnosticEvents : IMessagingDiagnosticEvents { public bool DispatchCalled { get; private set; } + public bool DispatchErrorCalled { get; private set; } public IDispatchContext? RecordedDispatchContext { get; private set; } + public IDispatchContext? RecordedErrorContext { get; private set; } + public Exception? RecordedException { get; private set; } public IDisposable? ActivityToReturn { get; set; } public IDisposable Dispatch(IDispatchContext context) @@ -188,15 +213,20 @@ public IDisposable Dispatch(IDispatchContext context) return ActivityToReturn ?? new MockDisposable(); } - public IDisposable Receive(IReceiveContext context) => new MockDisposable(); + public void DispatchError(IDispatchContext context, Exception exception) + { + DispatchErrorCalled = true; + RecordedErrorContext = context; + RecordedException = exception; + } - public IDisposable Consume(IConsumeContext context) => new MockDisposable(); + public IDisposable Receive(IReceiveContext context) => new MockDisposable(); - public void OnReceiveError(IReceiveContext context, Exception exception) { } + public void ReceiveError(IReceiveContext context, Exception exception) { } - public void OnDispatchError(IDispatchContext context, Exception exception) { } + public IDisposable Consume(IConsumeContext context) => new MockDisposable(); - public void OnConsumeError(IConsumeContext context, Exception exception) { } + public void ConsumeError(IConsumeContext context, Exception exception) { } } private sealed class MockDisposable : IDisposable diff --git a/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveInstrumentationMiddlewareTests.cs b/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveInstrumentationMiddlewareTests.cs index ae6b10d77f7..c156994cd87 100644 --- a/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveInstrumentationMiddlewareTests.cs +++ b/src/Mocha/test/Mocha.Tests/Middlewares/Receive/ReceiveInstrumentationMiddlewareTests.cs @@ -17,8 +17,8 @@ public sealed class ReceiveInstrumentationMiddlewareTests : ReceiveMiddlewareTes public async Task InvokeAsync_Should_CallObserverReceive_When_MiddlewareInvoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); var nextCalled = false; @@ -32,7 +32,7 @@ public async Task InvokeAsync_Should_CallObserverReceive_When_MiddlewareInvoked( await middleware.InvokeAsync(context, next); // assert - Assert.True(observer.ReceiveCalled, "Observer.Receive should be called"); + Assert.True(events.ReceiveCalled, "Events.Receive should be called"); Assert.True(nextCalled, "Next delegate should be called"); } @@ -41,8 +41,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ProcessingCompletes() { // arrange var activity = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ActivityToReturn = activity }; - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ActivityToReturn = activity }; + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); ReceiveDelegate next = _ => ValueTask.CompletedTask; @@ -55,11 +55,11 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ProcessingCompletes() } [Fact] - public async Task InvokeAsync_Should_CallOnReceiveError_When_ExceptionOccurs() + public async Task InvokeAsync_Should_CallReceiveError_When_ExceptionOccurs() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); var expectedException = new InvalidOperationException("Test exception"); @@ -71,17 +71,17 @@ public async Task InvokeAsync_Should_CallOnReceiveError_When_ExceptionOccurs() ); Assert.Same(expectedException, ex); - Assert.True(observer.OnReceiveErrorCalled, "OnReceiveError should be called on exception"); - Assert.Same(expectedException, observer.RecordedException); - Assert.Same(context, observer.RecordedErrorContext); + Assert.True(events.ReceiveErrorCalled, "ReceiveError should be called on exception"); + Assert.Same(expectedException, events.RecordedException); + Assert.Same(context, events.RecordedErrorContext); } [Fact] public async Task InvokeAsync_Should_RethrowException_When_ExceptionOccurs() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); var expectedException = new InvalidOperationException("Should be rethrown"); @@ -100,8 +100,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ExceptionOccurs() { // arrange var activity = new MockDisposable(); - var observer = new MockBusDiagnosticObserver { ActivityToReturn = activity }; - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents { ActivityToReturn = activity }; + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); ReceiveDelegate next = _ => throw new InvalidOperationException("Test"); @@ -124,8 +124,8 @@ public async Task InvokeAsync_Should_DisposeActivity_When_ExceptionOccurs() public async Task InvokeAsync_Should_PassContextToNext_When_Invoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); IReceiveContext? receivedContext = null; @@ -146,8 +146,8 @@ public async Task InvokeAsync_Should_PassContextToNext_When_Invoked() public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() { // arrange - var observer = new MockBusDiagnosticObserver(); - var middleware = new ReceiveInstrumentationMiddleware(observer); + var events = new MockMessagingDiagnosticEvents(); + var middleware = new ReceiveInstrumentationMiddleware(events); var context = new StubReceiveContext(); ReceiveDelegate next = _ => ValueTask.CompletedTask; @@ -156,16 +156,16 @@ public async Task InvokeAsync_Should_PassContextToObserver_When_Invoked() await middleware.InvokeAsync(context, next); // assert - Assert.Same(context, observer.RecordedReceiveContext); + Assert.Same(context, events.RecordedReceiveContext); } [Fact] public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServiceProvider() { // arrange - var observer = new MockBusDiagnosticObserver(); + var events = new MockMessagingDiagnosticEvents(); var services = new ServiceCollection(); - services.AddSingleton(observer); + services.AddSingleton(events); var provider = services.BuildServiceProvider(); var configuration = ReceiveInstrumentationMiddleware.Create(); @@ -189,7 +189,7 @@ public async Task Create_Should_ProduceWorkingMiddleware_When_UsedWithServicePro await middlewareDelegate(receiveContext); // assert - Assert.True(observer.ReceiveCalled); + Assert.True(events.ReceiveCalled); Assert.True(nextCalled); } @@ -391,10 +391,10 @@ private static async Task CreateBusWithInstrumentationAsync( return provider; } - private sealed class MockBusDiagnosticObserver : IBusDiagnosticObserver + private sealed class MockMessagingDiagnosticEvents : IMessagingDiagnosticEvents { public bool ReceiveCalled { get; private set; } - public bool OnReceiveErrorCalled { get; private set; } + public bool ReceiveErrorCalled { get; private set; } public IReceiveContext? RecordedReceiveContext { get; private set; } public IReceiveContext? RecordedErrorContext { get; private set; } public Exception? RecordedException { get; private set; } @@ -402,6 +402,8 @@ private sealed class MockBusDiagnosticObserver : IBusDiagnosticObserver public IDisposable Dispatch(IDispatchContext context) => new MockDisposable(); + public void DispatchError(IDispatchContext context, Exception exception) { } + public IDisposable Receive(IReceiveContext context) { ReceiveCalled = true; @@ -409,18 +411,16 @@ public IDisposable Receive(IReceiveContext context) return ActivityToReturn ?? new MockDisposable(); } - public IDisposable Consume(IConsumeContext context) => new MockDisposable(); - - public void OnReceiveError(IReceiveContext context, Exception exception) + public void ReceiveError(IReceiveContext context, Exception exception) { - OnReceiveErrorCalled = true; + ReceiveErrorCalled = true; RecordedErrorContext = context; RecordedException = exception; } - public void OnDispatchError(IDispatchContext context, Exception exception) { } + public IDisposable Consume(IConsumeContext context) => new MockDisposable(); - public void OnConsumeError(IConsumeContext context, Exception exception) { } + public void ConsumeError(IConsumeContext context, Exception exception) { } } private sealed class MockDisposable : IDisposable diff --git a/website/src/docs/hotchocolate/v16/server/cache-control.md b/website/src/docs/hotchocolate/v16/server/cache-control.md index 1ef8f37246d..9a006d23dce 100644 --- a/website/src/docs/hotchocolate/v16/server/cache-control.md +++ b/website/src/docs/hotchocolate/v16/server/cache-control.md @@ -23,19 +23,6 @@ To make caching work safely and predictably, you need two things: GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity. -A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the gateway has to compute one safe final policy that represents everything selected in that operation. - -That means one response can include: - -- Public data (safe for shared caches), and -- User-specific data (not safe for shared caches). - -The GraphQL operation type matters. Query operations are side-effect free reads, so they are the primary target for HTTP and CDN caching. Mutations change data and should not be cached as shared HTTP responses. Subscriptions are long-running streams and are not HTTP-cacheable. - -# Why GraphQL Needs Extra Care - -GraphQL usually exposes one endpoint, but each request can ask for different fields. Two requests to the same URL can therefore return very different response shapes and different data sensitivity. - A single GraphQL response can also mix public data and user-specific data. Since HTTP cache headers apply to the full response, the server has to compute one safe final policy that represents everything selected in that operation. That means one response can include: