Skip to content
8 changes: 7 additions & 1 deletion .github/workflows/alpha-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,16 @@ jobs:
echo "version=$ALPHA_VERSION" >> $GITHUB_OUTPUT
echo "Generated version: $ALPHA_VERSION"

- name: Pack
- name: Pack CLI
env:
VERSION: ${{ steps.version.outputs.version }}
run: dotnet pack src/ServiceBusToolset.CLI -c Release --no-build -o ./artifacts -p:Version="$VERSION"

- name: Pack Application
env:
VERSION: ${{ steps.version.outputs.version }}
run: dotnet pack src/ServiceBusToolset.Application -c Release --no-build -o ./artifacts -p:Version="$VERSION"

- name: Push to NuGet
env:
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
Expand All @@ -133,3 +138,4 @@ jobs:
exit 0
fi
dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push ./artifacts/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
8 changes: 7 additions & 1 deletion .github/workflows/stable-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,16 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Releasing version: $VERSION"

- name: Pack
- name: Pack CLI
env:
VERSION: ${{ steps.version.outputs.version }}
run: dotnet pack src/ServiceBusToolset.CLI -c Release --no-build -o ./artifacts -p:Version="$VERSION"

- name: Pack Application
env:
VERSION: ${{ steps.version.outputs.version }}
run: dotnet pack src/ServiceBusToolset.Application -c Release --no-build -o ./artifacts -p:Version="$VERSION"

- name: Generate SBOM
env:
VERSION: ${{ steps.version.outputs.version }}
Expand All @@ -91,6 +96,7 @@ jobs:
exit 1
fi
dotnet nuget push ./artifacts/*.nupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
dotnet nuget push ./artifacts/*.snupkg --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate

- name: Upload package to GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
Expand Down
4 changes: 2 additions & 2 deletions docs/integration-tests/integration_test_strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ Each test instance builds a fresh `ServiceCollection`. This mirrors how the prod
var services = new ServiceCollection();

// 1. Register the full Application layer — Mediator pipeline, DlqMessageService, IAppInsightsService
services.AddApplication();
services.AddServiceBusToolsetApplication();

// 2. Replace the client factory with the emulator-backed implementation
services.AddSingleton<IServiceBusClientFactory>(
Expand Down Expand Up @@ -384,7 +384,7 @@ public class DiagnoseDlqIntegrationShould(ServiceBusEmulatorFixture fixture)
}
```

**How DI override order works:** `AddApplication()` registers `IAppInsightsService` as scoped (via `services.AddScoped<IAppInsightsService, AppInsightsService>()`). The `configureServices` delegate runs *after* `AddApplication()` and registers an NSubstitute singleton for the same interface. Microsoft's DI container resolves the *last* registration for a given service type, so the mock wins. The real `AppInsightsService` (which requires Azure credentials) is never instantiated.
**How DI override order works:** `AddServiceBusToolsetApplication()` registers `IAppInsightsService` as scoped (via `services.AddScoped<IAppInsightsService, AppInsightsService>()`). The `configureServices` delegate runs *after* `AddServiceBusToolsetApplication()` and registers an NSubstitute singleton for the same interface. Microsoft's DI container resolves the *last* registration for a given service type, so the mock wins. The real `AppInsightsService` (which requires Azure credentials) is never instantiated.

### Complete Test Example

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ namespace ServiceBusToolset.Application;

public static class ApplicationDependencyInjectionExtensions
{
public static IServiceCollection AddApplication(this IServiceCollection services)
public static IServiceCollection AddServiceBusToolsetApplication(this IServiceCollection services)
{
services.AddMediator(options =>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,25 @@ internal static class MessageDiagnostics
return (results, skipped);
}

public static List<DiagnosticResult> CreateBasicResults(IReadOnlyList<ServiceBusReceivedMessage> messages)
{
var results = new List<DiagnosticResult>(messages.Count);
foreach (var message in messages)
{
results.Add(new DiagnosticResult
{
MessageId = message.MessageId,
Subject = message.Subject,
DeadLetterReason = message.DeadLetterReason,
Body = TryDecodeBody(message),
EnqueuedTime = message.EnqueuedTime,
OperationId = ExtractOperationId(message)
});
}

return results;
}

public static string? ExtractOperationId(ServiceBusReceivedMessage message)
{
if (message.ApplicationProperties.TryGetValue("Diagnostic-Id", out var diagnosticId) &&
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Ardalis.Result;
using Mediator;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models;

namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq;

public sealed record DiagnoseBatchCommand(string AppInsightsResourceId,
IReadOnlyList<OperationInfo> Operations) : ICommand<Result<IReadOnlyList<DiagnosticResult>>>;

public sealed record OperationInfo(string OperationId,
DateTimeOffset EnqueuedTime,
string? MessageId,
string? Subject,
string? DeadLetterReason);
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using Ardalis.Result;
using Mediator;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models;

namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq;

public sealed class DiagnoseBatchCommandHandler(IAppInsightsService appInsightsService)
: ICommandHandler<DiagnoseBatchCommand, Result<IReadOnlyList<DiagnosticResult>>>
{
public async ValueTask<Result<IReadOnlyList<DiagnosticResult>>> Handle(
DiagnoseBatchCommand command,
CancellationToken cancellationToken)
{
if (command.Operations.Count == 0)
{
return Result.Success<IReadOnlyList<DiagnosticResult>>([]);
}

appInsightsService.Initialize(command.AppInsightsResourceId);

var operations = command.Operations
.Select(op => (op.OperationId, op.EnqueuedTime))
.ToList();

var diagnosticResults = await appInsightsService.DiagnoseBatchAsync(operations,
null,
cancellationToken);

var results = new List<DiagnosticResult>();
// Deduplicate by OperationId — take the first occurrence if duplicates exist
var operationsById = command.Operations
.DistinctBy(op => op.OperationId)
.ToDictionary(op => op.OperationId);

foreach (var (operationId, result) in diagnosticResults)
{
if (operationsById.TryGetValue(operationId, out var operation))
{
result.MessageId = operation.MessageId;
result.Subject = operation.Subject;
result.DeadLetterReason = operation.DeadLetterReason;
results.Add(result);
}
}

return Result.Success<IReadOnlyList<DiagnosticResult>>(results);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq;

public sealed record DiagnoseDlqCommand(string FullyQualifiedNamespace,
EntityTarget Target,
string AppInsightsResourceId,
string? AppInsightsResourceId,
int MaxMessages,
DateTimeOffset? BeforeTime = null,
IReadOnlySet<DlqCategoryKey>? CategoryFilter = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using ServiceBusToolset.Application.DeadLetters.Common;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models;

namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq;

Expand All @@ -28,8 +29,10 @@ public async ValueTask<Result<DiagnoseDlqResult>> Handle(
DiagnoseDlqCommand command,
CancellationToken cancellationToken)
{
// Initialize Application Insights connection
appInsightsService.Initialize(command.AppInsightsResourceId);
if (!string.IsNullOrEmpty(command.AppInsightsResourceId))
{
appInsightsService.Initialize(command.AppInsightsResourceId);
}

await using var client = clientFactory.CreateClient(command.FullyQualifiedNamespace);
await using var receiver = ReceiverFactory.CreateDlqReceiver(client, command.Target);
Expand Down Expand Up @@ -60,11 +63,21 @@ public async ValueTask<Result<DiagnoseDlqResult>> Handle(
0));
}

// Diagnose messages
var (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService,
List<DiagnosticResult> results;
int skipped;

if (string.IsNullOrEmpty(command.AppInsightsResourceId))
{
results = MessageDiagnostics.CreateBasicResults(filteredMessages);
skipped = 0;
}
else
{
(results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService,
filteredMessages,
command.BatchProgress,
cancellationToken);
}

var resultsWithTelemetry = results
.Count(r => r.Exceptions.Count > 0 || r.Traces.Count > 0 || r.FailedDependencies.Count > 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
using Mediator;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Common.AppInsights;
using ServiceBusToolset.Application.DeadLetters.DiagnoseDlq.Models;

namespace ServiceBusToolset.Application.DeadLetters.DiagnoseDlq;

public sealed record DiagnoseFromCacheCommand(string AppInsightsResourceId,
public sealed record DiagnoseFromCacheCommand(string? AppInsightsResourceId,
IReadOnlyList<ServiceBusReceivedMessage> MessagesToDiagnose,
IProgress<(int Current, int Total)>? BatchProgress = null) : ICommand<Result<DiagnoseDlqResult>>;

Expand All @@ -17,7 +18,10 @@ public async ValueTask<Result<DiagnoseDlqResult>> Handle(
DiagnoseFromCacheCommand command,
CancellationToken cancellationToken)
{
appInsightsService.Initialize(command.AppInsightsResourceId);
if (!string.IsNullOrEmpty(command.AppInsightsResourceId))
{
appInsightsService.Initialize(command.AppInsightsResourceId);
}

var messages = command.MessagesToDiagnose.ToList();

Expand All @@ -29,10 +33,21 @@ public async ValueTask<Result<DiagnoseDlqResult>> Handle(
0));
}

var (results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService,
List<DiagnosticResult> results;
int skipped;

if (string.IsNullOrEmpty(command.AppInsightsResourceId))
{
results = MessageDiagnostics.CreateBasicResults(messages);
skipped = 0;
}
else
{
(results, skipped) = await MessageDiagnostics.DiagnoseMessagesAsync(appInsightsService,
messages,
command.BatchProgress,
cancellationToken);
}

var resultsWithTelemetry = results
.Count(r => r.Exceptions.Count > 0 || r.Traces.Count > 0 || r.FailedDependencies.Count > 0);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Ardalis.Result;
using Mediator;
using ServiceBusToolset.Application.Common.ServiceBus.Models;

namespace ServiceBusToolset.Application.DeadLetters.PeekDlq;

public sealed record PeekDlqBatchCommand(string FullyQualifiedNamespace,
EntityTarget Target,
int BatchSize = 500,
long? FromSequenceNumber = null,
long? KnownDeadLetterCount = null) : ICommand<Result<PeekDlqBatchResult>>;
Loading
Loading