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
10 changes: 10 additions & 0 deletions docs/examples/order-wire-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Order Wire Tap

The order wire-tap example records audit and metrics side effects while forwarding the original order event unchanged. It demonstrates:

- a fluent `WireTap<OrderWireTapEvent>` for non-generator consumers
- a `[GenerateWireTap]` source-generated factory
- `IServiceCollection` registration through `AddOrderWireTapDemo()`
- aggregate import through `AddPatternKitExamples()`

The example is implemented in `src/PatternKit.Examples/Messaging/OrderWireTapExample.cs` and covered by TinyBDD tests in `test/PatternKit.Examples.Tests/Messaging/OrderWireTapExampleTests.cs`.
3 changes: 3 additions & 0 deletions docs/examples/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@
- name: Order Message Filter
href: order-message-filter.md

- name: Order Wire Tap
href: order-wire-tap.md

- name: Fulfillment Competing Consumers
href: fulfillment-competing-consumers.md

Expand Down
1 change: 1 addition & 0 deletions docs/generators/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato
| [**Dead Letter Channel**](dead-letter-channel.md) | Failed-message capture and replay handoff | `[GenerateDeadLetterChannel]` |
| [**Content Router**](messaging.md#generated-content-router) | Content-based message routing factories | `[GenerateContentRouter]` |
| [**Message Filter**](message-filter.md) | Named allow-rule filters for message consumers | `[GenerateMessageFilter]` |
| [**Wire Tap**](wire-tap.md) | Side-channel message observability factories | `[GenerateWireTap]` |
| [**Recipient List**](messaging.md#generated-recipient-list) | Recipient fan-out factories | `[GenerateRecipientList]` |
| [**Splitter / Aggregator**](messaging.md#generated-splitter-and-aggregator) | Split/rejoin message routing factories | `[GenerateSplitter]` / `[GenerateAggregator]` |
| [**Routing Slip**](messaging.md#generated-routing-slip) | Ordered message itinerary factories | `[GenerateRoutingSlip]` |
Expand Down
3 changes: 3 additions & 0 deletions docs/generators/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
- name: Message Filter
href: message-filter.md

- name: Wire Tap
href: wire-tap.md

- name: Competing Consumers
href: competing-consumers.md

Expand Down
14 changes: 14 additions & 0 deletions docs/generators/wire-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Wire Tap Generator

`[GenerateWireTap]` creates a typed `WireTap<TPayload>` factory from ordered static tap handlers.

```csharp
[GenerateWireTap(typeof(OrderEvent), FactoryName = "Create", TapName = "order-observability")]
public static partial class GeneratedOrderWireTap
{
[WireTapHandler("audit", 10)]
private static void Audit(Message<OrderEvent> message, MessageContext context) { }
}
```

Handlers must be static `void` methods with `(Message<TPayload>, MessageContext)` parameters. Duplicate handler names or orders are reported at compile time.
1 change: 1 addition & 0 deletions docs/guides/pattern-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr
| Enterprise Integration | Dead Letter Channel | `DeadLetterChannel<TPayload>` | Dead Letter Channel generator |
| Enterprise Integration | Content-Based Router | `ContentRouter<TPayload, TResult>` | Messaging generator |
| Enterprise Integration | Message Filter | `MessageFilter<TPayload>` | Message Filter generator |
| Enterprise Integration | Wire Tap | `WireTap<TPayload>` | Wire Tap generator |
| Enterprise Integration | Recipient List | `RecipientList<TPayload>` | Messaging generator |
| Enterprise Integration | Competing Consumers | `CompetingConsumerGroup<TMessage, TResult>` | Competing Consumers generator |
| Enterprise Integration | Pipes and Filters | `PipesAndFiltersPipeline<TContext>` | Pipes and Filters generator |
Expand Down
12 changes: 12 additions & 0 deletions docs/patterns/messaging/wire-tap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Wire Tap

Use `WireTap<TPayload>` when production message flows need audit, metrics, or diagnostics side channels without changing the original message. Each tap is named, invoked in order, and reported in the returned `WireTapResult<TPayload>`.

```csharp
var tap = WireTap<OrderEvent>.Create("order-observability")
.AddTap("audit", (message, context) => audit.Record(message.Payload, context))
.AddTap("metrics", (message, _) => metrics.Record(message.Payload))
.Build();
```

The generated path uses `[GenerateWireTap]` on a partial type and `[WireTapHandler]` on static handler methods. Import the production example through `AddOrderWireTapDemo()` or the aggregate `AddPatternKitExamples()` registration.
2 changes: 2 additions & 0 deletions docs/patterns/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,8 @@
href: messaging/dead-letter-channel.md
- name: Message Filter
href: messaging/message-filter.md
- name: Wire Tap
href: messaging/wire-tap.md
- name: Enterprise Message Routing
href: messaging/message-routing.md
- name: Competing Consumers
Expand Down
100 changes: 100 additions & 0 deletions src/PatternKit.Core/Messaging/Routing/WireTap.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace PatternKit.Messaging.Routing;

/// <summary>
/// Wire tap that observes messages with named side-channel handlers while preserving the original message.
/// </summary>
public sealed class WireTap<TPayload>
{
/// <summary>Handler invoked for each tapped message.</summary>
public delegate void TapHandler(Message<TPayload> message, MessageContext context);

private readonly string _name;
private readonly Tap[] _taps;

private WireTap(string name, Tap[] taps)
=> (_name, _taps) = (name, taps);

/// <summary>Publishes <paramref name="message"/> to all taps and returns the unchanged message.</summary>
public WireTapResult<TPayload> Publish(Message<TPayload> message, MessageContext? context = null)
{
if (message is null)
throw new ArgumentNullException(nameof(message));

var effectiveContext = context ?? MessageContext.From(message);
var invoked = new string[_taps.Length];
for (var i = 0; i < _taps.Length; i++)
{
_taps[i].Handler(message, effectiveContext);
invoked[i] = _taps[i].Name;
}

return new WireTapResult<TPayload>(message, _name, invoked);
}

/// <summary>Creates a new wire-tap builder.</summary>
public static Builder Create(string name = "wire-tap") => new(name);

/// <summary>Fluent builder for <see cref="WireTap{TPayload}"/>.</summary>
public sealed class Builder
{
private readonly string _name;
private readonly List<Tap> _taps = new(4);

internal Builder(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Wire tap name cannot be null, empty, or whitespace.", nameof(name));

_name = name;
}

/// <summary>Adds a named side-channel tap.</summary>
public Builder AddTap(string name, TapHandler handler)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Wire tap handler name cannot be null, empty, or whitespace.", nameof(name));
if (handler is null)
throw new ArgumentNullException(nameof(handler));

_taps.Add(new Tap(name, handler));
return this;
}

/// <summary>Builds an immutable wire tap.</summary>
public WireTap<TPayload> Build()
{
if (_taps.Count == 0)
throw new InvalidOperationException("Wire tap must have at least one tap handler.");

return new WireTap<TPayload>(_name, _taps.ToArray());
}
}

private sealed class Tap
{
public Tap(string name, TapHandler handler)
=> (Name, Handler) = (name, handler);

public string Name { get; }

public TapHandler Handler { get; }
}
}

/// <summary>
/// Result returned by <see cref="WireTap{TPayload}"/>.
/// </summary>
public sealed class WireTapResult<TPayload>
{
public WireTapResult(Message<TPayload> message, string tapName, IReadOnlyList<string> invokedTaps)
=> (Message, TapName, InvokedTaps) = (message, tapName, invokedTaps);

/// <summary>The unchanged message that was observed.</summary>
public Message<TPayload> Message { get; }

/// <summary>The wire-tap name.</summary>
public string TapName { get; }

/// <summary>The ordered tap handlers invoked for the message.</summary>
public IReadOnlyList<string> InvokedTaps { get; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ public sealed record GeneratedDeadLetterChannelExample(FulfillmentDeadLetterChan
public sealed record GeneratedRecipientListExample(RecipientListGeneratorExampleRunner Runner);
public sealed record GeneratedSplitterAggregatorExample(MessageRoutingExampleRunner Runner);
public sealed record OrderMessageFilterExampleService(MessageFilter<OrderMessageFilterCommand> Filter, OrderMessageFilterService Service);
public sealed record OrderWireTapExampleService(WireTap<OrderWireTapEvent> Tap, OrderWireTapService Service);
public sealed record FulfillmentCompetingConsumersExampleService(CompetingConsumerGroup<FulfillmentConsumerWork, FulfillmentConsumerResult> Group, FulfillmentCompetingConsumerService Service);
public sealed record FulfillmentPipesAndFiltersExampleService(PipesAndFiltersPipeline<FulfillmentPipelineContext> Pipeline, FulfillmentPipesAndFiltersService Service);
public sealed record PatternsShowcaseExample(ShowcaseFacade Facade);
Expand Down Expand Up @@ -199,6 +200,7 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s
.AddGeneratedRecipientListExample()
.AddGeneratedSplitterAggregatorExample()
.AddOrderMessageFilterExample()
.AddOrderWireTapExample()
.AddFulfillmentCompetingConsumersExample()
.AddFulfillmentPipesAndFiltersExample()
.AddPatternsShowcaseExample()
Expand Down Expand Up @@ -493,6 +495,15 @@ public static IServiceCollection AddOrderMessageFilterExample(this IServiceColle
return services.RegisterExample<OrderMessageFilterExampleService>("Order Message Filter", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddOrderWireTapExample(this IServiceCollection services)
{
services.AddOrderWireTapDemo();
services.AddSingleton<OrderWireTapExampleService>(sp => new(
sp.GetRequiredService<WireTap<OrderWireTapEvent>>(),
sp.GetRequiredService<OrderWireTapService>()));
return services.RegisterExample<OrderWireTapExampleService>("Order Wire Tap", ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection);
}

public static IServiceCollection AddFulfillmentCompetingConsumersExample(this IServiceCollection services)
{
services.AddFulfillmentCompetingConsumersDemo();
Expand Down
114 changes: 114 additions & 0 deletions src/PatternKit.Examples/Messaging/OrderWireTapExample.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using Microsoft.Extensions.DependencyInjection;
using PatternKit.Generators.Messaging;
using PatternKit.Messaging;
using PatternKit.Messaging.Routing;

namespace PatternKit.Examples.Messaging;

/// <summary>Order event observed by the wire-tap example.</summary>
public sealed record OrderWireTapEvent(string OrderId, string TenantId, decimal Total);

/// <summary>Summary returned by the order wire-tap example.</summary>
public sealed record OrderWireTapSummary(string OrderId, IReadOnlyList<string> InvokedTaps, IReadOnlyList<string> AuditTrail, IReadOnlyList<string> Metrics);

/// <summary>In-memory audit sink used by the example service.</summary>
public sealed class OrderWireTapAuditSink
{
private readonly List<string> _entries = [];

public IReadOnlyList<string> Entries => _entries;

public void Record(OrderWireTapEvent evt, MessageContext context)
=> _entries.Add($"{context.Headers.CorrelationId ?? "uncorrelated"}:{evt.TenantId}:{evt.OrderId}");
}
Comment on lines +17 to +23

/// <summary>In-memory metrics sink used by the example service.</summary>
public sealed class OrderWireTapMetricsSink
{
private readonly List<string> _measurements = [];

public IReadOnlyList<string> Measurements => _measurements;

public void Record(OrderWireTapEvent evt)
=> _measurements.Add($"{evt.TenantId}:{evt.Total:0.00}");
}
Comment on lines +28 to +34

/// <summary>Runtime sink resolver used by generated static tap handlers.</summary>
public static class OrderWireTapSinkRegistry
{
public static OrderWireTapAuditSink Audit { get; set; } = new();

public static OrderWireTapMetricsSink Metrics { get; set; } = new();
}
Comment on lines +37 to +42

/// <summary>Fluent wire-tap builder used by non-generator consumers.</summary>
public static class OrderWireTaps
{
public static WireTap<OrderWireTapEvent> Create(OrderWireTapAuditSink audit, OrderWireTapMetricsSink metrics)
=> WireTap<OrderWireTapEvent>.Create("order-observability")
.AddTap("audit", (m, ctx) => audit.Record(m.Payload, ctx))
.AddTap("metrics", (m, _) => metrics.Record(m.Payload))
.Build();
}

/// <summary>Source-generated wire-tap handlers for order observability.</summary>
[GenerateWireTap(typeof(OrderWireTapEvent), FactoryName = "Create", TapName = "order-observability")]
public static partial class GeneratedOrderWireTap
{
[WireTapHandler("audit", 10)]
private static void Audit(Message<OrderWireTapEvent> message, MessageContext context)
=> OrderWireTapSinkRegistry.Audit.Record(message.Payload, context);

[WireTapHandler("metrics", 20)]
private static void Metrics(Message<OrderWireTapEvent> message, MessageContext context)
=> OrderWireTapSinkRegistry.Metrics.Record(message.Payload);
}

/// <summary>Service that publishes order events through a generated wire tap.</summary>
public sealed class OrderWireTapService(
WireTap<OrderWireTapEvent> tap,
OrderWireTapAuditSink audit,
OrderWireTapMetricsSink metrics)
{
public OrderWireTapSummary Publish(OrderWireTapEvent evt, string correlationId = "corr-order")
{
var context = new MessageContext(MessageHeaders.Empty.WithCorrelationId(correlationId));
var result = tap.Publish(Message<OrderWireTapEvent>.Create(evt), context);
return new OrderWireTapSummary(evt.OrderId, result.InvokedTaps, audit.Entries, metrics.Measurements);
}
}

/// <summary>Runner that demonstrates both fluent and generated wire-tap paths.</summary>
public sealed class OrderWireTapExampleRunner(OrderWireTapService service)
{
public OrderWireTapSummary RunGenerated(OrderWireTapEvent evt) => service.Publish(evt);

public static OrderWireTapSummary RunFluent(OrderWireTapEvent evt)
{
var audit = new OrderWireTapAuditSink();
var metrics = new OrderWireTapMetricsSink();
var tap = OrderWireTaps.Create(audit, metrics);
var context = new MessageContext(MessageHeaders.Empty.WithCorrelationId("corr-order"));
var result = tap.Publish(Message<OrderWireTapEvent>.Create(evt), context);
return new OrderWireTapSummary(evt.OrderId, result.InvokedTaps, audit.Entries, metrics.Measurements);
}
}

/// <summary>DI helpers for importing the order wire-tap example into standard .NET containers.</summary>
public static class OrderWireTapExampleServiceCollectionExtensions
{
public static IServiceCollection AddOrderWireTapDemo(this IServiceCollection services)
{
services.AddSingleton<OrderWireTapAuditSink>();
services.AddSingleton<OrderWireTapMetricsSink>();
services.AddSingleton(sp =>
{
OrderWireTapSinkRegistry.Audit = sp.GetRequiredService<OrderWireTapAuditSink>();
OrderWireTapSinkRegistry.Metrics = sp.GetRequiredService<OrderWireTapMetricsSink>();
return GeneratedOrderWireTap.Create();
});
services.AddSingleton<OrderWireTapService>();
services.AddSingleton<OrderWireTapExampleRunner>();
return services;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog
ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection,
["MessageFilter"],
["fraud-screening allow rules", "source-generated filter", "DI composition"]),
Descriptor(
"Order Wire Tap",
"src/PatternKit.Examples/Messaging/OrderWireTapExample.cs",
"test/PatternKit.Examples.Tests/Messaging/OrderWireTapExampleTests.cs",
"docs/examples/order-wire-tap.md",
ExampleIntegrationSurface.Messaging | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection,
["WireTap"],
["audit side channel", "metrics side channel", "source-generated tap factory", "DI composition"]),
Descriptor(
"Fulfillment Competing Consumers",
"src/PatternKit.Examples/Messaging/FulfillmentCompetingConsumersExample.cs",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog
"test/PatternKit.Examples.Tests/Messaging/OrderMessageFilterExampleTests.cs",
["fluent message filter", "generated allow-rule filter", "DI-importable order fraud-screening example"]),

Pattern("Wire Tap", PatternFamily.EnterpriseIntegration,
"docs/patterns/messaging/wire-tap.md",
"src/PatternKit.Core/Messaging/Routing/WireTap.cs",
"test/PatternKit.Tests/Messaging/Routing/WireTapTests.cs",
"docs/generators/wire-tap.md",
"src/PatternKit.Generators/Messaging/WireTapGenerator.cs",
"test/PatternKit.Generators.Tests/WireTapGeneratorTests.cs",
null,
"docs/examples/order-wire-tap.md",
"src/PatternKit.Examples/Messaging/OrderWireTapExample.cs",
"test/PatternKit.Examples.Tests/Messaging/OrderWireTapExampleTests.cs",
["fluent wire tap", "generated tap handler factory", "DI-importable order observability example"]),

Pattern("Recipient List", PatternFamily.EnterpriseIntegration,
"docs/patterns/messaging/message-routing.md",
"src/PatternKit.Core/Messaging/Routing/RecipientList.cs",
Expand Down
Loading
Loading