diff --git a/Directory.Packages.props b/Directory.Packages.props index 011756f..3917b7a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -24,6 +24,7 @@ + diff --git a/TournamentAPI.Benchmarks/packages.lock.json b/TournamentAPI.Benchmarks/packages.lock.json index 811b8ef..009b122 100644 --- a/TournamentAPI.Benchmarks/packages.lock.json +++ b/TournamentAPI.Benchmarks/packages.lock.json @@ -1314,19 +1314,19 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==", + "resolved": "1.18.0", + "contentHash": "Dhzm5oughIY9EWckt2J1yXcEbXpki5WCL2q7prJoYxzH6C7jdkfGtPMTkUVZZ4AhjJKzQnlC+kLSIlmIVwC4Vw==", "dependencies": { "System.Diagnostics.DiagnosticSource": "10.0.0" } }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "resolved": "1.18.0", + "contentHash": "io6A6PJFp3vIObRGf6oPjdHsdglkinXt/0SpTDJHrVnsLxUtOzwh/QwTsnhBJ43wJ6QcGMkyNzsSSY8XEB1Q5Q==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.3" + "OpenTelemetry.Api": "1.18.0" } }, "Perfolizer": { @@ -1577,6 +1577,7 @@ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.18.0-beta.1, )", "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "Serilog": "[4.3.1, )", "Serilog.AspNetCore": "[9.0.0, )", @@ -1791,6 +1792,17 @@ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" } }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[1.18.0-beta.1, )", + "resolved": "1.18.0-beta.1", + "contentHash": "n15cNXxkSl9G6KD41wZYmujjERYOBiIgn2VeesTFhLtHZ+QQQE3baLrCk7UyzidEw/uGObRcW3SKhqpyMpGe1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.18.0, 2.0.0)" + } + }, "OpenTelemetry.Instrumentation.Http": { "type": "CentralTransitive", "requested": "[1.15.1, )", diff --git a/TournamentAPI.IntegrationTests/packages.lock.json b/TournamentAPI.IntegrationTests/packages.lock.json index 2f5e815..21b5958 100644 --- a/TournamentAPI.IntegrationTests/packages.lock.json +++ b/TournamentAPI.IntegrationTests/packages.lock.json @@ -1345,19 +1345,19 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==", + "resolved": "1.18.0", + "contentHash": "Dhzm5oughIY9EWckt2J1yXcEbXpki5WCL2q7prJoYxzH6C7jdkfGtPMTkUVZZ4AhjJKzQnlC+kLSIlmIVwC4Vw==", "dependencies": { "System.Diagnostics.DiagnosticSource": "10.0.0" } }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "resolved": "1.18.0", + "contentHash": "io6A6PJFp3vIObRGf6oPjdHsdglkinXt/0SpTDJHrVnsLxUtOzwh/QwTsnhBJ43wJ6QcGMkyNzsSSY8XEB1Q5Q==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.3" + "OpenTelemetry.Api": "1.18.0" } }, "Serilog.Extensions.Hosting": { @@ -1641,6 +1641,7 @@ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.18.0-beta.1, )", "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "Serilog": "[4.3.1, )", "Serilog.AspNetCore": "[9.0.0, )", @@ -1855,6 +1856,17 @@ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" } }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[1.18.0-beta.1, )", + "resolved": "1.18.0-beta.1", + "contentHash": "n15cNXxkSl9G6KD41wZYmujjERYOBiIgn2VeesTFhLtHZ+QQQE3baLrCk7UyzidEw/uGObRcW3SKhqpyMpGe1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.18.0, 2.0.0)" + } + }, "OpenTelemetry.Instrumentation.Http": { "type": "CentralTransitive", "requested": "[1.15.1, )", diff --git a/TournamentAPI.LoadTests/packages.lock.json b/TournamentAPI.LoadTests/packages.lock.json index 1348a22..89fdfeb 100644 --- a/TournamentAPI.LoadTests/packages.lock.json +++ b/TournamentAPI.LoadTests/packages.lock.json @@ -1595,19 +1595,19 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==", + "resolved": "1.18.0", + "contentHash": "Dhzm5oughIY9EWckt2J1yXcEbXpki5WCL2q7prJoYxzH6C7jdkfGtPMTkUVZZ4AhjJKzQnlC+kLSIlmIVwC4Vw==", "dependencies": { "System.Diagnostics.DiagnosticSource": "10.0.0" } }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "resolved": "1.18.0", + "contentHash": "io6A6PJFp3vIObRGf6oPjdHsdglkinXt/0SpTDJHrVnsLxUtOzwh/QwTsnhBJ43wJ6QcGMkyNzsSSY8XEB1Q5Q==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.3" + "OpenTelemetry.Api": "1.18.0" } }, "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": { @@ -2742,6 +2742,7 @@ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.18.0-beta.1, )", "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "Serilog": "[4.3.1, )", "Serilog.AspNetCore": "[9.0.0, )", @@ -2956,6 +2957,17 @@ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" } }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[1.18.0-beta.1, )", + "resolved": "1.18.0-beta.1", + "contentHash": "n15cNXxkSl9G6KD41wZYmujjERYOBiIgn2VeesTFhLtHZ+QQQE3baLrCk7UyzidEw/uGObRcW3SKhqpyMpGe1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.18.0, 2.0.0)" + } + }, "OpenTelemetry.Instrumentation.Http": { "type": "CentralTransitive", "requested": "[1.15.1, )", diff --git a/TournamentAPI.UnitTests/Services/BracketServiceTests.cs b/TournamentAPI.UnitTests/Services/BracketServiceTests.cs index a624dc7..645a8be 100644 --- a/TournamentAPI.UnitTests/Services/BracketServiceTests.cs +++ b/TournamentAPI.UnitTests/Services/BracketServiceTests.cs @@ -1,13 +1,16 @@ +using Microsoft.Extensions.Logging.Abstractions; using TournamentAPI.Brackets; namespace TournamentAPI.UnitTests.Services; public class BracketServiceTests { + private readonly BracketService _sut = new(NullLogger.Instance); + [Fact] public void CreateBracket_SetsTournamentId() { - var bracket = BracketService.CreateBracket(tournamentId: 42, participantIds: [1, 2]); + var bracket = _sut.CreateBracket(tournamentId: 42, participantIds: [1, 2]); Assert.Equal(42, bracket.TournamentId); } @@ -15,7 +18,7 @@ public void CreateBracket_SetsTournamentId() [Fact] public void CreateBracket_WithEvenParticipantCount_CreatesCorrectNumberOfMatches() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4]); Assert.Equal(2, bracket.Matches.Count); } @@ -23,7 +26,7 @@ public void CreateBracket_WithEvenParticipantCount_CreatesCorrectNumberOfMatches [Fact] public void CreateBracket_WithOddParticipantCount_CreatesCorrectNumberOfMatches() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); Assert.Equal(2, bracket.Matches.Count); } @@ -31,7 +34,7 @@ public void CreateBracket_WithOddParticipantCount_CreatesCorrectNumberOfMatches( [Fact] public void CreateBracket_WithOddParticipantCount_LastMatchHasNullPlayer2() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); var byeMatch = bracket.Matches.SingleOrDefault(m => m.Player2Id == null); Assert.NotNull(byeMatch); @@ -40,7 +43,7 @@ public void CreateBracket_WithOddParticipantCount_LastMatchHasNullPlayer2() [Fact] public void CreateBracket_WithOddParticipantCount_ByeMatchIsAutoResolved() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3]); var byeMatch = bracket.Matches.Single(m => m.Player2Id == null); Assert.Equal(byeMatch.Player1Id, byeMatch.WinnerId); @@ -49,7 +52,7 @@ public void CreateBracket_WithOddParticipantCount_ByeMatchIsAutoResolved() [Fact] public void CreateBracket_AllMatchesAreInRoundOne() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2, 3, 4]); Assert.All(bracket.Matches, m => Assert.Equal(1, m.Round)); } @@ -58,7 +61,7 @@ public void CreateBracket_AllMatchesAreInRoundOne() public void CreateBracket_AllParticipantIdsAppearInMatches() { var participantIds = new List { 1, 2, 3, 4 }; - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: participantIds); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: participantIds); var usedIds = bracket.Matches .SelectMany(m => new[] { m.Player1Id, m.Player2Id ?? -1 }) @@ -71,7 +74,7 @@ public void CreateBracket_AllParticipantIdsAppearInMatches() [Fact] public void CreateBracket_SetsMatchBracketReference() { - var bracket = BracketService.CreateBracket(tournamentId: 1, participantIds: [1, 2]); + var bracket = _sut.CreateBracket(tournamentId: 1, participantIds: [1, 2]); Assert.All(bracket.Matches, m => Assert.Same(bracket, m.Bracket)); } @@ -79,7 +82,7 @@ public void CreateBracket_SetsMatchBracketReference() [Fact] public void CreateNextRoundMatches_WithEvenWinnerCount_CreatesCorrectNumberOfMatches() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3, 4]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3, 4]); Assert.Equal(2, matches.Count); } @@ -87,7 +90,7 @@ public void CreateNextRoundMatches_WithEvenWinnerCount_CreatesCorrectNumberOfMat [Fact] public void CreateNextRoundMatches_WithOddWinnerCount_LastMatchHasNullPlayer2() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3]); var byeMatch = matches.SingleOrDefault(m => m.Player2Id == null); Assert.NotNull(byeMatch); @@ -96,7 +99,7 @@ public void CreateNextRoundMatches_WithOddWinnerCount_LastMatchHasNullPlayer2() [Fact] public void CreateNextRoundMatches_WithOddWinnerCount_ByeMatchIsAutoResolved() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 2, 3]); var byeMatch = matches.Single(m => m.Player2Id == null); Assert.Equal(byeMatch.Player1Id, byeMatch.WinnerId); @@ -105,7 +108,7 @@ public void CreateNextRoundMatches_WithOddWinnerCount_ByeMatchIsAutoResolved() [Fact] public void CreateNextRoundMatches_SetsCorrectRoundNumber() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 2, winners: [1, 2]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 2, winners: [1, 2]); Assert.All(matches, m => Assert.Equal(3, m.Round)); } @@ -113,7 +116,7 @@ public void CreateNextRoundMatches_SetsCorrectRoundNumber() [Fact] public void CreateNextRoundMatches_SetsBracketId() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 7, roundNumber: 1, winners: [1, 2]); + var matches = _sut.CreateNextRoundMatches(bracketId: 7, roundNumber: 1, winners: [1, 2]); Assert.All(matches, m => Assert.Equal(7, m.BracketId)); } @@ -122,7 +125,7 @@ public void CreateNextRoundMatches_SetsBracketId() public void CreateNextRoundMatches_NormalizesPlayerOrder_LowerIdIsPlayer1() { // Winners [3, 1] — higher ID first, so after normalization Player1Id should be 1 - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [3, 1]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [3, 1]); var match = matches.Single(); Assert.Equal(1, match.Player1Id); @@ -132,7 +135,7 @@ public void CreateNextRoundMatches_NormalizesPlayerOrder_LowerIdIsPlayer1() [Fact] public void CreateNextRoundMatches_WhenPlayerOrderIsAlreadyNormalized_DoesNotSwap() { - var matches = BracketService.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 3]); + var matches = _sut.CreateNextRoundMatches(bracketId: 1, roundNumber: 1, winners: [1, 3]); var match = matches.Single(); Assert.Equal(1, match.Player1Id); diff --git a/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs b/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs index 2998132..5999028 100644 --- a/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs +++ b/TournamentAPI.UnitTests/Services/MatchCascadePositionCalculatorTests.cs @@ -1,16 +1,19 @@ +using Microsoft.Extensions.Logging.Abstractions; using TournamentAPI.Matches; namespace TournamentAPI.UnitTests.Services; public class MatchCascadePositionCalculatorTests { + private readonly MatchCascadePositionCalculator _sut = new(NullLogger.Instance); + [Fact] public void GetDownstreamMatchId_WhenMatchIsAtEvenPosition_ReturnsFirstNextRoundMatch() { var currentRound = new List { 10, 11, 12, 13 }; var nextRound = new List { 20, 21 }; - var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 10); + var result = _sut.GetDownstreamMatchId(currentRound, nextRound, 10); Assert.Equal(20, result); } @@ -21,7 +24,7 @@ public void GetDownstreamMatchId_WhenMatchIsAtOddPosition_ReturnsSameNextRoundMa var currentRound = new List { 10, 11, 12, 13 }; var nextRound = new List { 20, 21 }; - var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 11); + var result = _sut.GetDownstreamMatchId(currentRound, nextRound, 11); Assert.Equal(20, result); } @@ -32,7 +35,7 @@ public void GetDownstreamMatchId_WhenNextRoundNotGenerated_ReturnsNull() var currentRound = new List { 10, 11 }; var nextRound = new List(); - var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 10); + var result = _sut.GetDownstreamMatchId(currentRound, nextRound, 10); Assert.Null(result); } @@ -43,7 +46,7 @@ public void GetDownstreamMatchId_WhenMatchIdNotInCurrentRound_ReturnsNull() var currentRound = new List { 10, 11 }; var nextRound = new List { 20 }; - var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 999); + var result = _sut.GetDownstreamMatchId(currentRound, nextRound, 999); Assert.Null(result); } @@ -54,7 +57,7 @@ public void GetDownstreamMatchId_WhenPositionIsTrailingByeNotYetGenerated_Return var currentRound = new List { 10, 11, 12 }; var nextRound = new List { 20 }; - var result = MatchCascadePositionCalculator.GetDownstreamMatchId(currentRound, nextRound, 12); + var result = _sut.GetDownstreamMatchId(currentRound, nextRound, 12); Assert.Null(result); } diff --git a/TournamentAPI.UnitTests/packages.lock.json b/TournamentAPI.UnitTests/packages.lock.json index 7ee6651..db15d15 100644 --- a/TournamentAPI.UnitTests/packages.lock.json +++ b/TournamentAPI.UnitTests/packages.lock.json @@ -1102,19 +1102,19 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==", + "resolved": "1.18.0", + "contentHash": "Dhzm5oughIY9EWckt2J1yXcEbXpki5WCL2q7prJoYxzH6C7jdkfGtPMTkUVZZ4AhjJKzQnlC+kLSIlmIVwC4Vw==", "dependencies": { "System.Diagnostics.DiagnosticSource": "10.0.0" } }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "resolved": "1.18.0", + "contentHash": "io6A6PJFp3vIObRGf6oPjdHsdglkinXt/0SpTDJHrVnsLxUtOzwh/QwTsnhBJ43wJ6QcGMkyNzsSSY8XEB1Q5Q==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.3" + "OpenTelemetry.Api": "1.18.0" } }, "Serilog.Extensions.Hosting": { @@ -1372,6 +1372,7 @@ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )", "OpenTelemetry.Extensions.Hosting": "[1.15.3, )", "OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )", + "OpenTelemetry.Instrumentation.EntityFrameworkCore": "[1.18.0-beta.1, )", "OpenTelemetry.Instrumentation.Http": "[1.15.1, )", "Serilog": "[4.3.1, )", "Serilog.AspNetCore": "[9.0.0, )", @@ -1583,6 +1584,17 @@ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" } }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "CentralTransitive", + "requested": "[1.18.0-beta.1, )", + "resolved": "1.18.0-beta.1", + "contentHash": "n15cNXxkSl9G6KD41wZYmujjERYOBiIgn2VeesTFhLtHZ+QQQE3baLrCk7UyzidEw/uGObRcW3SKhqpyMpGe1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.18.0, 2.0.0)" + } + }, "OpenTelemetry.Instrumentation.Http": { "type": "CentralTransitive", "requested": "[1.15.1, )", diff --git a/TournamentAPI/Brackets/BracketCompletionService.cs b/TournamentAPI/Brackets/BracketCompletionService.cs index 9c46309..94ed8cf 100644 --- a/TournamentAPI/Brackets/BracketCompletionService.cs +++ b/TournamentAPI/Brackets/BracketCompletionService.cs @@ -1,18 +1,22 @@ using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; using TournamentAPI.Data.Models; +using TournamentAPI.Tracing; namespace TournamentAPI.Brackets; -public static class BracketCompletionService +public class BracketCompletionService(ILogger logger) { - public static async Task SyncChampionAsync( + public async Task SyncChampionAsync( ApplicationDbContext context, Tournament tournament, int bracketId, int frontierRound, CancellationToken token) { + using var activity = TournamentActivitySource.Instance.StartActivity("BracketCompletionService.SyncChampion"); + activity?.SetTag("bracket.id", bracketId); + var hasLaterRound = await context.Matches .AnyAsync(m => m.BracketId == bracketId && m.Round > frontierRound, token); @@ -32,6 +36,12 @@ public static async Task SyncChampionAsync( { tournament.Status = TournamentStatus.Completed; tournament.ChampionId = finalMatch.WinnerId; + + logger.LogInformation( + "Bracket {BracketId} marked complete for tournament {TournamentId}: champion is participant {ChampionId}", + bracketId, + tournament.Id, + finalMatch.WinnerId); } else if (tournament.Status == TournamentStatus.Completed) { diff --git a/TournamentAPI/Brackets/BracketMutations.cs b/TournamentAPI/Brackets/BracketMutations.cs index 3c6fcd1..e9168d6 100644 --- a/TournamentAPI/Brackets/BracketMutations.cs +++ b/TournamentAPI/Brackets/BracketMutations.cs @@ -6,7 +6,9 @@ using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; +using TournamentAPI.Metrics; using TournamentAPI.Tournaments; +using TournamentAPI.Tracing; namespace TournamentAPI.Brackets; @@ -21,8 +23,14 @@ public static partial class BracketMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, + BracketService bracketService, + BracketMetrics bracketMetrics, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Tournament", tournamentId); + using var activity = TournamentActivitySource.Instance.StartActivity("Bracket.GenerateBracket"); + activity?.SetTag("tournament.id", tournamentId); + var userId = userClaims.GetUserId(); var tournament = await context.Tournaments @@ -37,7 +45,7 @@ public static partial class BracketMutations if (resolverContext.TryReportError(BracketMutationValidations.ValidateEnoughParticipants(tournament.Participants.Count, tournamentId))) return null; var participantIds = tournament.Participants.Select(p => p.ParticipantId).ToList(); - var bracket = BracketService.CreateBracket(tournamentId, participantIds); + var bracket = bracketService.CreateBracket(tournamentId, participantIds); try { @@ -46,10 +54,13 @@ public static partial class BracketMutations } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation()) { + bracketMetrics.GenerationFailed(); resolverContext.ReportError(BracketErrors.BracketAlreadyExistsForTournament(tournament.Id)); return null; } + bracketMetrics.GenerationSucceeded(); + return context.Brackets.AsNoTracking().Where(b => b.Id == bracket.Id).With(query); } @@ -62,8 +73,14 @@ public static partial class BracketMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, + BracketService bracketService, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Bracket", bracketId); + using var activity = TournamentActivitySource.Instance.StartActivity("Bracket.UpdateRound"); + activity?.SetTag("bracket.id", bracketId); + activity?.SetTag("bracket.round", roundNumber); + var userId = userClaims.GetUserId(); var bracket = await context.Brackets @@ -85,7 +102,7 @@ public static partial class BracketMutations if (resolverContext.TryReportError(BracketMutationValidations.ValidateNotFinalRound(winners, bracketId))) return null; - var newMatches = BracketService.CreateNextRoundMatches(bracket.Id, roundNumber, winners); + var newMatches = bracketService.CreateNextRoundMatches(bracket.Id, roundNumber, winners); foreach (var match in matchesInRound) { diff --git a/TournamentAPI/Brackets/BracketService.cs b/TournamentAPI/Brackets/BracketService.cs index d6f31b9..eab0b82 100644 --- a/TournamentAPI/Brackets/BracketService.cs +++ b/TournamentAPI/Brackets/BracketService.cs @@ -1,11 +1,16 @@ using TournamentAPI.Data.Models; +using TournamentAPI.Tracing; namespace TournamentAPI.Brackets; -public static class BracketService +public class BracketService(ILogger logger) { - public static Bracket CreateBracket(int tournamentId, IList participantIds) + public Bracket CreateBracket(int tournamentId, IList participantIds) { + using var activity = TournamentActivitySource.Instance.StartActivity("BracketService.CreateBracket"); + activity?.SetTag("tournament.id", tournamentId); + activity?.SetTag("bracket.participant_count", participantIds.Count); + var bracket = new Bracket { TournamentId = tournamentId, @@ -29,11 +34,21 @@ public static Bracket CreateBracket(int tournamentId, IList participantIds) }); } + logger.LogInformation( + "Bracket generated for tournament {TournamentId}: {ParticipantCount} participants seeded into {MatchCount} first-round matches", + tournamentId, + participantIds.Count, + bracket.Matches.Count); + return bracket; } - public static IList CreateNextRoundMatches(int bracketId, int roundNumber, IList winners) + public IList CreateNextRoundMatches(int bracketId, int roundNumber, IList winners) { + using var activity = TournamentActivitySource.Instance.StartActivity("BracketService.CreateNextRoundMatches"); + activity?.SetTag("bracket.id", bracketId); + activity?.SetTag("bracket.round", roundNumber + 1); + var matches = new List(); for (int i = 0; i < winners.Count; i += 2) @@ -57,6 +72,12 @@ public static IList CreateNextRoundMatches(int bracketId, int roundNumber }); } + logger.LogInformation( + "Round {Round} generated for bracket {BracketId}: {MatchCount} matches", + roundNumber + 1, + bracketId, + matches.Count); + return matches; } } diff --git a/TournamentAPI/Configuration/Extensions/GraphQLExtensions.cs b/TournamentAPI/Configuration/Extensions/GraphQLExtensions.cs index 30b6080..855f597 100644 --- a/TournamentAPI/Configuration/Extensions/GraphQLExtensions.cs +++ b/TournamentAPI/Configuration/Extensions/GraphQLExtensions.cs @@ -1,5 +1,6 @@ using TournamentAPI.Data; using TournamentAPI.EventListeners; +using TournamentAPI.Metrics; namespace TournamentAPI.Configuration.Extensions; @@ -19,9 +20,13 @@ internal static IServiceCollection AddApplicationGraphQL(this IServiceCollection options.ExecutionTimeout = TimeSpan.FromSeconds(30); }) .DisableIntrospection(!isDevelopment) + .AddApplicationService>() .AddHttpRequestInterceptor() .AddApplicationService>() + .AddApplicationService() .AddDiagnosticEventListener() + .AddApplicationService>() + .AddErrorFilter() .AddAuthorization() .RegisterDbContextFactory() .AddMutationConventions() diff --git a/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs b/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs index 6505bef..e61ebdf 100644 --- a/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs +++ b/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs @@ -7,6 +7,11 @@ internal static class MetricsExtensions internal static IServiceCollection AddApplicationMetrics(this IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/TournamentAPI/Configuration/Extensions/RateLimiterExtensions.cs b/TournamentAPI/Configuration/Extensions/RateLimiterExtensions.cs index b9304d2..537d9e0 100644 --- a/TournamentAPI/Configuration/Extensions/RateLimiterExtensions.cs +++ b/TournamentAPI/Configuration/Extensions/RateLimiterExtensions.cs @@ -9,6 +9,21 @@ internal static IServiceCollection AddApplicationRateLimiting(this IServiceColle services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.OnRejected = (context, _) => + { + var logger = context.HttpContext.RequestServices + .GetRequiredService() + .CreateLogger(typeof(RateLimiterExtensions).FullName!); + + var clientIp = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + logger.LogWarning( + "Rate limit exceeded for {ClientIp} on {Path}", + clientIp, + context.HttpContext.Request.Path); + + return ValueTask.CompletedTask; + }; options.GlobalLimiter = PartitionedRateLimiter.CreateChained( PartitionedRateLimiter.Create(_ => RateLimitPartition.GetConcurrencyLimiter( diff --git a/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs b/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs index cad5d11..e19cbea 100644 --- a/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs +++ b/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs @@ -2,6 +2,7 @@ using OpenTelemetry.Resources; using OpenTelemetry.Trace; using TournamentAPI.Metrics; +using TournamentAPI.Tracing; namespace TournamentAPI.Configuration.Extensions; @@ -13,9 +14,11 @@ internal static IServiceCollection AddApplicationTelemetry(this IServiceCollecti .ConfigureResource(resource => resource.AddService("TournamentAPI")) .WithTracing(tracing => { + tracing.AddSource(TournamentActivitySource.Name); tracing.AddHttpClientInstrumentation(); tracing.AddAspNetCoreInstrumentation(); tracing.AddHotChocolateInstrumentation(); + tracing.AddEntityFrameworkCoreInstrumentation(); tracing.AddOtlpExporter(o => { o.Endpoint = new Uri("http://localhost:4318/v1/traces"); @@ -25,6 +28,11 @@ internal static IServiceCollection AddApplicationTelemetry(this IServiceCollecti .WithMetrics(metrics => { metrics.AddMeter(MetricConstants.TournamentMeterName); + metrics.AddMeter(MetricConstants.BracketMeterName); + metrics.AddMeter(MetricConstants.MatchMeterName); + metrics.AddMeter(MetricConstants.ParticipantMeterName); + metrics.AddMeter(MetricConstants.UserMeterName); + metrics.AddMeter(MetricConstants.GraphQLMeterName); metrics.AddAspNetCoreInstrumentation(); metrics.AddOtlpExporter(o => { diff --git a/TournamentAPI/EventListeners/ExecutionEventListener.cs b/TournamentAPI/EventListeners/ExecutionEventListener.cs index b76ca11..64d2fee 100644 --- a/TournamentAPI/EventListeners/ExecutionEventListener.cs +++ b/TournamentAPI/EventListeners/ExecutionEventListener.cs @@ -1,16 +1,19 @@ using HotChocolate.Execution; using HotChocolate.Execution.Instrumentation; using System.Diagnostics; +using TournamentAPI.Metrics; namespace TournamentAPI.EventListeners; public sealed class ExecutionEventListener : ExecutionDiagnosticEventListener { private readonly ILogger _logger; + private readonly GraphQLMetrics _graphQLMetrics; - public ExecutionEventListener(ILogger logger) + public ExecutionEventListener(ILogger logger, GraphQLMetrics graphQLMetrics) { _logger = logger; + _graphQLMetrics = graphQLMetrics; } public override IDisposable ExecuteRequest(RequestContext context) @@ -22,50 +25,60 @@ public override IDisposable ExecuteRequest(RequestContext context) stopwatch.Stop(); var duration = stopwatch.ElapsedMilliseconds; - var operationType = context.TryGetOperation(out var operation) ? operation.Kind.ToString() : "Unknown"; - var requestId = context.ContextData.TryGetValue("requestId", out var reqId) ? reqId : "Unknown"; + var operationName = context.TryGetOperation(out var operation) + ? string.IsNullOrEmpty(operation.Name) ? "Anonymous" : operation.Name + : "Unknown"; + var operationType = operation is not null ? operation.Kind.ToString().ToLowerInvariant() : "unknown"; + var traceId = Activity.Current?.TraceId.ToString() ?? "Unknown"; + var spanId = Activity.Current?.SpanId.ToString() ?? "Unknown"; var userId = context.ContextData.TryGetValue("userId", out var uid) ? uid : "Anonymous"; + var entityType = context.ContextData.TryGetValue("EntityType", out var etype) ? etype : null; + var entityId = context.ContextData.TryGetValue("EntityId", out var eid) ? eid : null; + + _graphQLMetrics.RecordRequestDuration(stopwatch.Elapsed.TotalSeconds, operationType, operationName); _logger.LogInformation( - "GraphQL request started: {RequestId} | Operation: {OperationType} | User: {UserId}", - requestId, - operationType, + "GraphQL request started: {TraceId} | {SpanId} | Operation: {OperationName} | User: {UserId}", + traceId, + spanId, + operationName, userId ?? "Anonymous"); var errorCount = 0; string[] errorCodes = []; - if (context.Result != null) + if (context.Result is OperationResult { Errors.Count: > 0 } result) { - var resultType = context.Result.GetType(); - var errorsProperty = resultType.GetProperty("Errors"); + errorCount = result.Errors.Count; + errorCodes = [.. result.Errors.Select(e => e.Code ?? "UNKNOWN_ERROR")]; - if (errorsProperty != null) + foreach (var errorCode in errorCodes) { - if (errorsProperty.GetValue(context.Result) is IEnumerable errors) - { - var errorList = errors.ToList(); - errorCount = errorList.Count; - errorCodes = [.. errorList.Select(e => e.Code ?? "UNKNOWN_ERROR")]; - } + _graphQLMetrics.RecordError(operationName, errorCode); } } if (errorCount > 0) { _logger.LogWarning( - "GraphQL request completed with errors: {RequestId} | Duration: {Duration}ms | Errors: {ErrorCount} | Error codes: {ErrorCodes}", - requestId, + "GraphQL request completed with errors: {TraceId} | {SpanId} | Duration: {Duration}ms | Errors: {ErrorCount} | Error codes: {ErrorCodes} | Entity: {EntityType}/{EntityId}", + traceId, + spanId, duration, errorCount, - errorCodes); + errorCodes, + entityType, + entityId); } else { _logger.LogInformation( - "GraphQL request completed: {RequestId} | Duration: {Duration}ms", - requestId, - duration); + "GraphQL request completed: {TraceId} | {SpanId} | Duration: {Duration}ms | Entity: {EntityType}/{EntityId}", + traceId, + spanId, + duration, + entityType, + entityId); } }); } diff --git a/TournamentAPI/EventListeners/HttpRequestInterceptor.cs b/TournamentAPI/EventListeners/HttpRequestInterceptor.cs index 10551c0..e934362 100644 --- a/TournamentAPI/EventListeners/HttpRequestInterceptor.cs +++ b/TournamentAPI/EventListeners/HttpRequestInterceptor.cs @@ -8,6 +8,13 @@ namespace TournamentAPI.EventListeners; public class HttpRequestInterceptor : DefaultHttpRequestInterceptor { + private readonly ILogger _logger; + + public HttpRequestInterceptor(ILogger logger) + { + _logger = logger; + } + public override async ValueTask OnCreateAsync( HttpContext context, IRequestExecutor requestExecutor, @@ -17,6 +24,9 @@ public override async ValueTask OnCreateAsync( var apiKeyResult = await context.AuthenticateAsync(ApiKeyAuthenticationOptions.DefaultScheme); if (!apiKeyResult.Succeeded) { + var clientIp = context.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + _logger.LogWarning("Rejected GraphQL request from {ClientIp}: invalid or missing API key", clientIp); + throw new GraphQLException( ErrorBuilder.New() .SetMessage("A valid API key is required.") @@ -25,15 +35,12 @@ public override async ValueTask OnCreateAsync( } var userId = context.User?.FindFirstValue(ClaimTypes.NameIdentifier); - var requestId = Guid.NewGuid().ToString(); if (userId != null) { requestBuilder.AddGlobalState("userId", userId); } - requestBuilder.AddGlobalState("requestId", requestId); - await base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken); } } \ No newline at end of file diff --git a/TournamentAPI/EventListeners/UnhandledExceptionErrorFilter.cs b/TournamentAPI/EventListeners/UnhandledExceptionErrorFilter.cs new file mode 100644 index 0000000..5c1e858 --- /dev/null +++ b/TournamentAPI/EventListeners/UnhandledExceptionErrorFilter.cs @@ -0,0 +1,30 @@ +using HotChocolate.Execution; + +namespace TournamentAPI.EventListeners; + +public sealed class UnhandledExceptionErrorFilter : IErrorFilter +{ + private readonly ILogger _logger; + + public UnhandledExceptionErrorFilter(ILogger logger) + { + _logger = logger; + } + + public IError OnError(IError error) + { + if (error.Exception is null) + { + return error; + } + + _logger.LogError( + error.Exception, + "Unhandled exception in GraphQL request: {Message}", + error.Exception.Message); + + return error.Code is null + ? ErrorBuilder.FromError(error).SetCode("UNHANDLED_EXCEPTION").Build() + : error; + } +} diff --git a/TournamentAPI/Extensions/ResolverContextExtensions.cs b/TournamentAPI/Extensions/ResolverContextExtensions.cs index 5ec8ce5..0ccbe7a 100644 --- a/TournamentAPI/Extensions/ResolverContextExtensions.cs +++ b/TournamentAPI/Extensions/ResolverContextExtensions.cs @@ -1,4 +1,5 @@ using HotChocolate.Resolvers; +using Serilog.Context; namespace TournamentAPI.Extensions; @@ -10,4 +11,18 @@ public static bool TryReportError(this IResolverContext resolverContext, IError? resolverContext.ReportError(error); return true; } + + /// + /// Pushes the entity into the Serilog LogContext (for logs emitted during this resolver) + /// and into the request's GraphQL context data (so ExecutionEventListener's request-level + /// completion log, which runs after the resolver's LogContext scope has already unwound, + /// can still report it). + /// + public static IDisposable PushEntityContext(this IResolverContext resolverContext, string entityType, object entityId) + { + resolverContext.ContextData["EntityType"] = entityType; + resolverContext.ContextData["EntityId"] = entityId; + + return LogContext.PushProperty(entityType + "Id", entityId); + } } diff --git a/TournamentAPI/Matches/MatchCascadePositionCalculator.cs b/TournamentAPI/Matches/MatchCascadePositionCalculator.cs index e3f3606..8ec4ecd 100644 --- a/TournamentAPI/Matches/MatchCascadePositionCalculator.cs +++ b/TournamentAPI/Matches/MatchCascadePositionCalculator.cs @@ -1,8 +1,8 @@ namespace TournamentAPI.Matches; -public static class MatchCascadePositionCalculator +public class MatchCascadePositionCalculator(ILogger logger) { - public static int? GetDownstreamMatchId( + public int? GetDownstreamMatchId( IReadOnlyList currentRoundMatchIdsAscending, IReadOnlyList nextRoundMatchIdsAscending, int matchId) @@ -24,6 +24,13 @@ public static class MatchCascadePositionCalculator if (downstreamPosition >= nextRoundMatchIdsAscending.Count) return null; - return nextRoundMatchIdsAscending[downstreamPosition]; + var downstreamMatchId = nextRoundMatchIdsAscending[downstreamPosition]; + + logger.LogInformation( + "Cascade applied: match {MatchId} maps to downstream match {DownstreamMatchId}", + matchId, + downstreamMatchId); + + return downstreamMatchId; } } diff --git a/TournamentAPI/Matches/MatchCorrectionService.cs b/TournamentAPI/Matches/MatchCorrectionService.cs index eb7ec7b..96a78e2 100644 --- a/TournamentAPI/Matches/MatchCorrectionService.cs +++ b/TournamentAPI/Matches/MatchCorrectionService.cs @@ -1,12 +1,17 @@ using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; using TournamentAPI.Data.Models; +using TournamentAPI.Metrics; +using TournamentAPI.Tracing; namespace TournamentAPI.Matches; -public static class MatchCorrectionService +public class MatchCorrectionService( + ILogger logger, + MatchCascadePositionCalculator cascadePositionCalculator, + MatchMetrics matchMetrics) { - public static async Task ApplyCorrectionAsync( + public async Task ApplyCorrectionAsync( ApplicationDbContext context, Match match, MatchStatus previousStatus, @@ -19,6 +24,10 @@ public static async Task ApplyCorrectionAsync( Guid correlationId, CancellationToken token) { + using var activity = TournamentActivitySource.Instance.StartActivity("MatchCorrectionService.ApplyCorrection"); + activity?.SetTag("match.id", match.Id); + activity?.SetTag("correlation.id", correlationId.ToString()); + context.MatchCorrectionAudits.Add(BuildAuditRow( match, previousStatus, previousWinnerId, previousPlayer1Id, previousPlayer2Id, previousPlayer1Score, previousPlayer2Score, @@ -27,10 +36,20 @@ public static async Task ApplyCorrectionAsync( if (previousWinnerId == match.WinnerId) return match; + logger.LogInformation( + "Match {MatchId} result corrected: winner {PreviousWinnerId} -> {NewWinnerId}, score {PreviousPlayer1Score}-{PreviousPlayer2Score} -> {NewPlayer1Score}-{NewPlayer2Score}", + match.Id, + previousWinnerId, + match.WinnerId, + previousPlayer1Score, + previousPlayer2Score, + match.Player1Score, + match.Player2Score); + return await PropagateAsync(context, match, previousWinnerId, performedByUserId, correlationId, token); } - public static async Task RecordIdempotentDuplicateAsync( + public async Task RecordIdempotentDuplicateAsync( ApplicationDbContext context, Match currentCommittedState, int performedByUserId, @@ -46,10 +65,14 @@ public static async Task RecordIdempotentDuplicateAsync( performedByUserId, notes: "Idempotent duplicate: retried request matched already-committed state.")); + logger.LogInformation( + "Idempotent duplicate correction detected for match {MatchId}: retried request matched already-committed state", + currentCommittedState.Id); + await context.SaveChangesAsync(token); } - private static async Task PropagateAsync( + private async Task PropagateAsync( ApplicationDbContext context, Match sourceMatch, int? sourceMatchPreviousWinnerId, @@ -62,6 +85,10 @@ private static async Task PropagateAsync( while (true) { + using var stepActivity = TournamentActivitySource.Instance.StartActivity("MatchCorrectionService.Propagate.Step"); + stepActivity?.SetTag("match.id", upstreamMatch.Id); + stepActivity?.SetTag("round", upstreamMatch.Round); + var currentRoundMatchIds = await context.Matches .Where(m => m.BracketId == upstreamMatch.BracketId && m.Round == upstreamMatch.Round) .OrderBy(m => m.Id) @@ -75,12 +102,14 @@ private static async Task PropagateAsync( var nextRoundMatchIds = nextRoundMatches.Select(m => m.Id).ToList(); - var downstreamMatchId = MatchCascadePositionCalculator.GetDownstreamMatchId( + var downstreamMatchId = cascadePositionCalculator.GetDownstreamMatchId( currentRoundMatchIds, nextRoundMatchIds, upstreamMatch.Id); if (downstreamMatchId is null) return upstreamMatch; + stepActivity?.SetTag("downstream_match.id", downstreamMatchId); + var downstream = nextRoundMatches.Single(m => m.Id == downstreamMatchId); var previousStatus = downstream.Status; @@ -125,6 +154,7 @@ private static async Task PropagateAsync( } downstream.Status = MatchStatus.NeedsReplay; + matchMetrics.MatchNeedsReplay(); context.MatchCorrectionAudits.Add(BuildAuditRow( downstream, diff --git a/TournamentAPI/Matches/MatchMutations.cs b/TournamentAPI/Matches/MatchMutations.cs index 0ef1a74..4ebca4c 100644 --- a/TournamentAPI/Matches/MatchMutations.cs +++ b/TournamentAPI/Matches/MatchMutations.cs @@ -6,7 +6,9 @@ using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; +using TournamentAPI.Metrics; using TournamentAPI.Tournaments; +using TournamentAPI.Tracing; namespace TournamentAPI.Matches; @@ -22,6 +24,9 @@ public static partial class MatchMutations ClaimsPrincipal userClaims, IResolverContext resolverContext, ApplicationDbContext context, + MatchCorrectionService matchCorrectionService, + BracketCompletionService bracketCompletionService, + MatchMetrics matchMetrics, CancellationToken token) { var userId = userClaims.GetUserId(); @@ -34,6 +39,8 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateMatchExists(match, matchId))) return null; + using var _ = resolverContext.PushEntityContext("Match", matchId); + var tournament = match!.Bracket.Tournament; if (resolverContext.TryReportError(TournamentValidations.ValidateIsOwner(tournament.OwnerId, userId, tournament.Id))) @@ -73,7 +80,7 @@ public static partial class MatchMutations if (isReplay) { - frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( + frontierMatch = await matchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, @@ -87,10 +94,12 @@ public static partial class MatchMutations token); } - await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await bracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); await context.SaveChangesAsync(token); + matchMetrics.MatchPlayed(); + return true; } catch (DbUpdateException) @@ -110,8 +119,17 @@ public static partial class MatchMutations ClaimsPrincipal userClaims, IResolverContext resolverContext, ApplicationDbContext context, + ILoggerFactory loggerFactory, + MatchCorrectionService matchCorrectionService, + BracketCompletionService bracketCompletionService, + MatchMetrics matchMetrics, CancellationToken token) { + using var activity = TournamentActivitySource.Instance.StartActivity("Match.CorrectMatchResult"); + activity?.SetTag("match.id", matchId); + + var logger = loggerFactory.CreateLogger(typeof(MatchMutations).FullName!); + var userId = userClaims.GetUserId(); var match = await context.Matches @@ -122,6 +140,8 @@ public static partial class MatchMutations if (resolverContext.TryReportError(MatchValidations.ValidateMatchExists(match, matchId))) return null; + using var _ = resolverContext.PushEntityContext("Match", matchId); + var tournament = match!.Bracket.Tournament; if (resolverContext.TryReportError(TournamentValidations.ValidateIsOwner(tournament.OwnerId, userId, tournament.Id))) @@ -167,7 +187,7 @@ public static partial class MatchMutations try { - var frontierMatch = await MatchCorrectionService.ApplyCorrectionAsync( + var frontierMatch = await matchCorrectionService.ApplyCorrectionAsync( context, match, previousStatus, @@ -180,10 +200,12 @@ public static partial class MatchMutations Guid.NewGuid(), token); - await BracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); + await bracketCompletionService.SyncChampionAsync(context, tournament, match.BracketId, frontierMatch.Round, token); await context.SaveChangesAsync(token); + matchMetrics.MatchResultCorrected(); + return true; } catch (DbUpdateConcurrencyException) @@ -199,10 +221,11 @@ public static partial class MatchMutations && currentState.Player1Score == player1Score && currentState.Player2Score == player2Score) { - await MatchCorrectionService.RecordIdempotentDuplicateAsync(context, currentState, userId, token); + await matchCorrectionService.RecordIdempotentDuplicateAsync(context, currentState, userId, token); return true; } + logger.LogWarning("Match {MatchId} correction rejected due to a version conflict", matchId); resolverContext.ReportError(MatchErrors.MatchVersionConflict(matchId)); return null; } diff --git a/TournamentAPI/Matches/MatchService.cs b/TournamentAPI/Matches/MatchService.cs index 254fbc9..e6d4940 100644 --- a/TournamentAPI/Matches/MatchService.cs +++ b/TournamentAPI/Matches/MatchService.cs @@ -1,5 +1,6 @@ using GreenDonut.Data; using HotChocolate.Types.Pagination; +using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; @@ -10,7 +11,7 @@ namespace TournamentAPI.Matches; public class MatchService( IMatchBatchingContext batchingContext, IUserWonMatchIdsBatchingContext wonMatchIdsBatchingContext, - ApplicationDbContext context) + IDbContextFactory contextFactory) { public async Task> GetMatchesByBracketAsync( int bracketId, @@ -33,6 +34,8 @@ public async Task> GetWonMatchesByUserAsync( { var ids = await wonMatchIdsBatchingContext.WonMatchIdsByWinnerId.LoadAsync(userId, cancellationToken) ?? []; + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var page = await context.Matches .Where(m => ids.Contains(m.Id)) .With(query, s => s.DefaultMatchOrder()) diff --git a/TournamentAPI/Metrics/BracketMetrics.cs b/TournamentAPI/Metrics/BracketMetrics.cs new file mode 100644 index 0000000..2c4b5a0 --- /dev/null +++ b/TournamentAPI/Metrics/BracketMetrics.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.Metrics; + +namespace TournamentAPI.Metrics; + +public class BracketMetrics +{ + private readonly Counter _bracketGenerationTotal; + + public BracketMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricConstants.BracketMeterName); + _bracketGenerationTotal = meter.CreateCounter("tournamentapi.brackets.bracket_generation_total", description: "Number of bracket generation attempts"); + } + + public void GenerationSucceeded() => _bracketGenerationTotal.Add(1, new KeyValuePair("result", "success")); + + public void GenerationFailed() => _bracketGenerationTotal.Add(1, new KeyValuePair("result", "failure")); +} diff --git a/TournamentAPI/Metrics/GraphQLMetrics.cs b/TournamentAPI/Metrics/GraphQLMetrics.cs new file mode 100644 index 0000000..34f38db --- /dev/null +++ b/TournamentAPI/Metrics/GraphQLMetrics.cs @@ -0,0 +1,31 @@ +using System.Diagnostics.Metrics; + +namespace TournamentAPI.Metrics; + +public class GraphQLMetrics +{ + private readonly Histogram _requestDuration; + private readonly Counter _errorsTotal; + + public GraphQLMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricConstants.GraphQLMeterName); + _requestDuration = meter.CreateHistogram( + "graphql.server.request.duration", + unit: "s", + description: "Duration of GraphQL requests"); + _errorsTotal = meter.CreateCounter( + "graphql.server.errors_total", + description: "Number of GraphQL errors returned to clients"); + } + + public void RecordRequestDuration(double durationSeconds, string operationType, string operationName) => + _requestDuration.Record(durationSeconds, + new KeyValuePair("graphql.operation.type", operationType), + new KeyValuePair("graphql.operation.name", operationName)); + + public void RecordError(string operationName, string errorCode) => + _errorsTotal.Add(1, + new KeyValuePair("graphql.operation.name", operationName), + new KeyValuePair("graphql.error_code", errorCode)); +} diff --git a/TournamentAPI/Metrics/MatchMetrics.cs b/TournamentAPI/Metrics/MatchMetrics.cs new file mode 100644 index 0000000..2f7f47d --- /dev/null +++ b/TournamentAPI/Metrics/MatchMetrics.cs @@ -0,0 +1,24 @@ +using System.Diagnostics.Metrics; + +namespace TournamentAPI.Metrics; + +public class MatchMetrics +{ + private readonly Counter _matchesPlayedTotal; + private readonly Counter _matchesNeedingReplayTotal; + private readonly Counter _matchResultCorrectionsTotal; + + public MatchMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricConstants.MatchMeterName); + _matchesPlayedTotal = meter.CreateCounter("tournamentapi.matches.matches_played_total", description: "Number of matches played"); + _matchesNeedingReplayTotal = meter.CreateCounter("tournamentapi.matches.matches_needing_replay_total", description: "Number of matches placed into NeedsReplay status by a cascading correction"); + _matchResultCorrectionsTotal = meter.CreateCounter("tournamentapi.matches.match_result_corrections_total", description: "Number of accepted match result corrections"); + } + + public void MatchPlayed() => _matchesPlayedTotal.Add(1); + + public void MatchNeedsReplay() => _matchesNeedingReplayTotal.Add(1); + + public void MatchResultCorrected() => _matchResultCorrectionsTotal.Add(1); +} diff --git a/TournamentAPI/Metrics/MetricConstants.cs b/TournamentAPI/Metrics/MetricConstants.cs index 27e0537..40944ca 100644 --- a/TournamentAPI/Metrics/MetricConstants.cs +++ b/TournamentAPI/Metrics/MetricConstants.cs @@ -3,4 +3,9 @@ namespace TournamentAPI.Metrics; public static class MetricConstants { public const string TournamentMeterName = "TournamentAPI.Tournaments"; + public const string BracketMeterName = "TournamentAPI.Brackets"; + public const string MatchMeterName = "TournamentAPI.Matches"; + public const string ParticipantMeterName = "TournamentAPI.Participants"; + public const string UserMeterName = "TournamentAPI.Users"; + public const string GraphQLMeterName = "TournamentAPI.GraphQL"; } diff --git a/TournamentAPI/Metrics/ParticipantMetrics.cs b/TournamentAPI/Metrics/ParticipantMetrics.cs new file mode 100644 index 0000000..8092d36 --- /dev/null +++ b/TournamentAPI/Metrics/ParticipantMetrics.cs @@ -0,0 +1,20 @@ +using System.Diagnostics.Metrics; + +namespace TournamentAPI.Metrics; + +public class ParticipantMetrics +{ + private readonly Counter _tournamentJoinAttemptsTotal; + private readonly Counter _slotContentionTotal; + + public ParticipantMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricConstants.ParticipantMeterName); + _tournamentJoinAttemptsTotal = meter.CreateCounter("tournamentapi.participants.tournament_join_attempts_total", description: "Number of self-service tournament join attempts"); + _slotContentionTotal = meter.CreateCounter("tournamentapi.participants.slot_contention_total", description: "Number of participant-slot unique-constraint races"); + } + + public void JoinAttempt(string result) => _tournamentJoinAttemptsTotal.Add(1, new KeyValuePair("result", result)); + + public void SlotContention() => _slotContentionTotal.Add(1); +} diff --git a/TournamentAPI/Metrics/TournamentMetrics.cs b/TournamentAPI/Metrics/TournamentMetrics.cs index d158c7f..214ce4e 100644 --- a/TournamentAPI/Metrics/TournamentMetrics.cs +++ b/TournamentAPI/Metrics/TournamentMetrics.cs @@ -6,12 +6,16 @@ public class TournamentMetrics { private readonly Counter _tournamentsCreated; private readonly UpDownCounter _activeTournaments; + private readonly Counter _tournamentsClosedTotal; + private readonly Counter _tournamentsDeletedTotal; public TournamentMetrics(IMeterFactory meterFactory) { var meter = meterFactory.Create(MetricConstants.TournamentMeterName); _tournamentsCreated = meter.CreateCounter("tournamentapi.tournaments.tournaments_created", description: "Number of tournaments created"); _activeTournaments = meter.CreateUpDownCounter("tournamentapi.tournaments.active_tournaments", description: "Number of currently active tournaments"); + _tournamentsClosedTotal = meter.CreateCounter("tournamentapi.tournaments.tournaments_closed_total", description: "Number of tournaments closed, tagged by reason"); + _tournamentsDeletedTotal = meter.CreateCounter("tournamentapi.tournaments.tournaments_deleted_total", description: "Number of tournaments deleted"); } public void IncrementTournamentsCreated() => _tournamentsCreated.Add(1); @@ -19,4 +23,9 @@ public TournamentMetrics(IMeterFactory meterFactory) public void TournamentOpened() => _activeTournaments.Add(1); public void TournamentClosed() => _activeTournaments.Add(-1); + + public void IncrementTournamentsClosed(string reason, int count = 1) => + _tournamentsClosedTotal.Add(count, new KeyValuePair("reason", reason)); + + public void IncrementTournamentsDeleted() => _tournamentsDeletedTotal.Add(1); } diff --git a/TournamentAPI/Metrics/UserMetrics.cs b/TournamentAPI/Metrics/UserMetrics.cs new file mode 100644 index 0000000..939bf6b --- /dev/null +++ b/TournamentAPI/Metrics/UserMetrics.cs @@ -0,0 +1,26 @@ +using System.Diagnostics.Metrics; + +namespace TournamentAPI.Metrics; + +public class UserMetrics +{ + private readonly Counter _loginAttemptsTotal; + private readonly Counter _userRegistrationsTotal; + private readonly Counter _refreshTokenFailuresTotal; + + public UserMetrics(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricConstants.UserMeterName); + _loginAttemptsTotal = meter.CreateCounter("tournamentapi.users.login_attempts_total", description: "Number of login attempts"); + _userRegistrationsTotal = meter.CreateCounter("tournamentapi.users.user_registrations_total", description: "Number of successful user registrations"); + _refreshTokenFailuresTotal = meter.CreateCounter("tournamentapi.users.refresh_token_failures_total", description: "Number of refresh token failures"); + } + + public void LoginSucceeded() => _loginAttemptsTotal.Add(1, new KeyValuePair("result", "success")); + + public void LoginFailed() => _loginAttemptsTotal.Add(1, new KeyValuePair("result", "failure")); + + public void UserRegistered() => _userRegistrationsTotal.Add(1); + + public void RefreshTokenFailed(string reason) => _refreshTokenFailuresTotal.Add(1, new KeyValuePair("reason", reason)); +} diff --git a/TournamentAPI/Participants/ParticipantMutations.cs b/TournamentAPI/Participants/ParticipantMutations.cs index 546e1d7..d710875 100644 --- a/TournamentAPI/Participants/ParticipantMutations.cs +++ b/TournamentAPI/Participants/ParticipantMutations.cs @@ -6,6 +6,7 @@ using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; +using TournamentAPI.Metrics; using TournamentAPI.Tournaments; using TournamentAPI.Users; @@ -22,8 +23,11 @@ public static partial class ParticipantMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, + ParticipantMetrics participantMetrics, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Tournament", input.TournamentId); + var userId = userClaims.GetUserId(); var tournament = await context.Tournaments @@ -69,6 +73,7 @@ public static partial class ParticipantMutations } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation(TournamentParticipant.SlotNumberUniqueIndexName)) { + participantMetrics.SlotContention(); resolverContext.ReportError(TournamentErrors.TournamentFull(input.TournamentId, tournament.MaxParticipants)); return null; } diff --git a/TournamentAPI/Program.cs b/TournamentAPI/Program.cs index 35470bd..f44fd4a 100644 --- a/TournamentAPI/Program.cs +++ b/TournamentAPI/Program.cs @@ -28,6 +28,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -46,6 +50,8 @@ builder.Services.AddSerilog((_, loggerConfiguration) => loggerConfiguration + .ReadFrom.Configuration(builder.Configuration) + .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.OpenTelemetry(opt => { diff --git a/TournamentAPI/TournamentAPI.csproj b/TournamentAPI/TournamentAPI.csproj index b411697..e81b93a 100644 --- a/TournamentAPI/TournamentAPI.csproj +++ b/TournamentAPI/TournamentAPI.csproj @@ -34,6 +34,7 @@ + diff --git a/TournamentAPI/Tournaments/TournamentAutoCloseJob.cs b/TournamentAPI/Tournaments/TournamentAutoCloseJob.cs index 1d5c99b..1255e49 100644 --- a/TournamentAPI/Tournaments/TournamentAutoCloseJob.cs +++ b/TournamentAPI/Tournaments/TournamentAutoCloseJob.cs @@ -3,6 +3,7 @@ using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Metrics; +using TournamentAPI.Tracing; namespace TournamentAPI.Tournaments; @@ -26,6 +27,8 @@ public TournamentAutoCloseJob( public async Task RunAsync(CancellationToken cancellationToken) { + using var activity = TournamentActivitySource.Instance.StartActivity("TournamentAutoCloseJob.Run"); + await using var context = await _contextFactory.CreateDbContextAsync(cancellationToken); var now = DateTime.UtcNow; @@ -35,12 +38,16 @@ public async Task RunAsync(CancellationToken cancellationToken) setters => setters.SetProperty(t => t.Status, TournamentStatus.Closed), cancellationToken); + activity?.SetTag("tournament.auto_close.count", closedCount); + if (closedCount == 0) return; for (var i = 0; i < closedCount; i++) _tournamentMetrics.TournamentClosed(); + _tournamentMetrics.IncrementTournamentsClosed("auto", closedCount); + _logger.LogInformation("Auto-closed {Count} tournament(s) past StartDate.", closedCount); } } diff --git a/TournamentAPI/Tournaments/TournamentMutations.cs b/TournamentAPI/Tournaments/TournamentMutations.cs index 21503b2..a6826d3 100644 --- a/TournamentAPI/Tournaments/TournamentMutations.cs +++ b/TournamentAPI/Tournaments/TournamentMutations.cs @@ -19,8 +19,11 @@ public static partial class TournamentMutations ClaimsPrincipal userClaims, ApplicationDbContext context, IResolverContext resolverContext, + ParticipantMetrics participantMetrics, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Tournament", tournamentId); + var userId = userClaims.GetUserId(); var tournament = await context.Tournaments @@ -34,13 +37,22 @@ public static partial class TournamentMutations return null; if (resolverContext.TryReportError(TournamentValidations.ValidateTournamentIsNotClosed(tournament, DateTime.UtcNow))) + { + participantMetrics.JoinAttempt("closed"); return null; + } if (resolverContext.TryReportError(TournamentValidations.ValidateTournamentNotFull(tournament))) + { + participantMetrics.JoinAttempt("full"); return null; + } if (resolverContext.TryReportError(TournamentValidations.ValidateUserNotAlreadyParticipant(tournament, userId))) + { + participantMetrics.JoinAttempt("already_joined"); return null; + } var participant = new TournamentParticipant { @@ -53,16 +65,20 @@ public static partial class TournamentMutations try { await context.SaveChangesAsync(token); + participantMetrics.JoinAttempt("success"); return true; } catch (DbUpdateException ex) when (ex.IsUniqueConstraintViolation(TournamentParticipant.SlotNumberUniqueIndexName)) { + participantMetrics.SlotContention(); + participantMetrics.JoinAttempt("full"); resolverContext.ReportError( TournamentErrors.TournamentFull(tournamentId, tournament.MaxParticipants)); return null; } catch (DbUpdateException) { + participantMetrics.JoinAttempt("already_joined"); resolverContext.ReportError( TournamentErrors.UserAlreadyParticipant(userId, tournamentId)); return null; @@ -106,6 +122,8 @@ public static partial class TournamentMutations context.Tournaments.Add(tournament); await context.SaveChangesAsync(token); + using var _ = resolverContext.PushEntityContext("Tournament", tournament.Id); + tournamentMetrics.IncrementTournamentsCreated(); if (tournament.Status == TournamentStatus.Open) tournamentMetrics.TournamentOpened(); @@ -124,6 +142,8 @@ public static partial class TournamentMutations TournamentMetrics tournamentMetrics, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Tournament", input.TournamentId); + var userId = userClaims.GetUserId(); var tournament = await context.Tournaments @@ -190,7 +210,10 @@ public static partial class TournamentMutations if (previousStatus != TournamentStatus.Open && tournament.Status == TournamentStatus.Open) tournamentMetrics.TournamentOpened(); else if (previousStatus == TournamentStatus.Open && tournament.Status != TournamentStatus.Open) + { tournamentMetrics.TournamentClosed(); + tournamentMetrics.IncrementTournamentsClosed("manual"); + } } await context.SaveChangesAsync(token); @@ -207,6 +230,8 @@ public static partial class TournamentMutations TournamentMetrics tournamentMetrics, CancellationToken token) { + using var _ = resolverContext.PushEntityContext("Tournament", tournamentId); + var userId = userClaims.GetUserId(); var tournament = await context.Tournaments @@ -233,6 +258,8 @@ public static partial class TournamentMutations if (wasOpen) tournamentMetrics.TournamentClosed(); + tournamentMetrics.IncrementTournamentsDeleted(); + return true; } } diff --git a/TournamentAPI/Tracing/TournamentActivitySource.cs b/TournamentAPI/Tracing/TournamentActivitySource.cs new file mode 100644 index 0000000..ed40c0b --- /dev/null +++ b/TournamentAPI/Tracing/TournamentActivitySource.cs @@ -0,0 +1,10 @@ +using System.Diagnostics; + +namespace TournamentAPI.Tracing; + +public static class TournamentActivitySource +{ + public const string Name = "TournamentAPI"; + + public static readonly ActivitySource Instance = new(Name); +} diff --git a/TournamentAPI/Users/UserMutations.cs b/TournamentAPI/Users/UserMutations.cs index 91436b7..7a5c18c 100644 --- a/TournamentAPI/Users/UserMutations.cs +++ b/TournamentAPI/Users/UserMutations.cs @@ -6,6 +6,7 @@ using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; +using TournamentAPI.Metrics; using TournamentAPI.Services; namespace TournamentAPI.Users; @@ -23,6 +24,8 @@ public static partial class UserMutations { var userId = userClaims.GetUserId(); + using var _ = resolverContext.PushEntityContext("User", userId); + var user = await context.Users.FirstOrDefaultAsync(u => u.Id == userId, token); if (resolverContext.TryReportError(UserValidations.ValidateUserExists(user, userId))) @@ -38,7 +41,8 @@ public static partial class UserMutations public static async Task RegisterUser( RegisterUserInput input, UserManager userManager, - IResolverContext resolverContext) + IResolverContext resolverContext, + UserMetrics userMetrics) { var user = new ApplicationUser { @@ -53,6 +57,9 @@ public static partial class UserMutations return null; } + using var _ = resolverContext.PushEntityContext("User", user.Id); + userMetrics.UserRegistered(); + return true; } @@ -84,6 +91,8 @@ JwtService jwtService return null; } + using var _ = resolverContext.PushEntityContext("User", existingToken.UserId); + existingToken.Revoked = DateTime.UtcNow; try @@ -113,23 +122,37 @@ JwtService jwtService ApplicationDbContext context, IHttpContextAccessor httpContextAccessor, IResolverContext resolverContext, - JwtService jwtService) + JwtService jwtService, + ILoggerFactory loggerFactory, + UserMetrics userMetrics) { + var logger = loggerFactory.CreateLogger(typeof(UserMutations).FullName!); + var user = await userManager.FindByEmailAsync(input.Email); if (resolverContext.TryReportError(UserValidations.ValidateCredentials(user))) + { + userMetrics.LoginFailed(); + logger.LogWarning("Login failed: no account found for the given email"); return null; + } + + using var _ = resolverContext.PushEntityContext("User", user!.Id); var canSignIn = await signInManager.CheckPasswordSignInAsync(user!, input.Password, true); if (canSignIn.IsLockedOut) { + userMetrics.LoginFailed(); + logger.LogWarning("Login failed: account {UserId} is locked out", user.Id); resolverContext.ReportError(UserErrors.AccountLockedOut); return null; } if (!canSignIn.Succeeded) { + userMetrics.LoginFailed(); + logger.LogWarning("Login failed: invalid credentials for account {UserId}", user.Id); resolverContext.ReportError(UserErrors.InvalidCredentials()); return null; } @@ -154,6 +177,8 @@ JwtService jwtService context.RefreshTokens.Add(refreshToken); await context.SaveChangesAsync(); + userMetrics.LoginSucceeded(); + httpContextAccessor.HttpContext.Response.AppendRefreshTokenCookie(refreshTokenResult.Raw, refreshToken.Expires); return accessToken; @@ -163,11 +188,16 @@ JwtService jwtService JwtService jwtService, ApplicationDbContext context, IResolverContext resolverContext, - IHttpContextAccessor httpContextAccessor + IHttpContextAccessor httpContextAccessor, + ILoggerFactory loggerFactory, + UserMetrics userMetrics ) { + var logger = loggerFactory.CreateLogger(typeof(UserMutations).FullName!); + if (httpContextAccessor.HttpContext == null) { + userMetrics.RefreshTokenFailed("missing_cookie"); resolverContext.ReportError(UserErrors.UnableToSetRefreshTokenCookie()); return null; } @@ -180,19 +210,27 @@ IHttpContextAccessor httpContextAccessor if (existingToken is null) { + userMetrics.RefreshTokenFailed("not_found"); resolverContext.ReportError(UserErrors.RefreshTokenInvalid()); return null; } + using var _ = resolverContext.PushEntityContext("User", existingToken.UserId); + if (!existingToken.IsActive) { if (existingToken.Revoked is not null) { await RevokeAllActiveTokensAsync(context, existingToken.UserId); + userMetrics.RefreshTokenFailed("reused"); + logger.LogWarning( + "Refresh token theft detected for user {UserId}: a revoked token was reused, all active tokens have been revoked", + existingToken.UserId); resolverContext.ReportError(UserErrors.RefreshTokenReused()); return null; } + userMetrics.RefreshTokenFailed("expired"); resolverContext.ReportError(UserErrors.RefreshTokenExpired()); return null; } @@ -200,6 +238,7 @@ IHttpContextAccessor httpContextAccessor var user = await context.Users.FirstOrDefaultAsync(u => u.Id == existingToken.UserId); if (user is null) { + userMetrics.RefreshTokenFailed("user_not_found"); resolverContext.ReportError(UserErrors.UserNotFound(existingToken.UserId)); return null; } @@ -224,6 +263,7 @@ IHttpContextAccessor httpContextAccessor } catch (DbUpdateConcurrencyException) { + userMetrics.RefreshTokenFailed("conflict"); resolverContext.ReportError(UserErrors.RefreshTokenConflict()); return null; } diff --git a/TournamentAPI/Users/UserTournamentsService.cs b/TournamentAPI/Users/UserTournamentsService.cs index e1fe733..b91ac3c 100644 --- a/TournamentAPI/Users/UserTournamentsService.cs +++ b/TournamentAPI/Users/UserTournamentsService.cs @@ -1,5 +1,6 @@ using GreenDonut.Data; using HotChocolate.Types.Pagination; +using Microsoft.EntityFrameworkCore; using TournamentAPI.Data; using TournamentAPI.Data.Models; using TournamentAPI.Extensions; @@ -9,7 +10,7 @@ namespace TournamentAPI.Users; public class UserTournamentsService( IUserPlayedTournamentIdsBatchingContext playedTournamentIdsBatchingContext, IUserWonTournamentIdsBatchingContext wonTournamentIdsBatchingContext, - ApplicationDbContext context) + IDbContextFactory contextFactory) { public async Task> GetPlayedTournamentsAsync( int userId, @@ -39,6 +40,8 @@ private async Task> GetPageAsync( { tournamentIds ??= []; + await using var context = await contextFactory.CreateDbContextAsync(cancellationToken); + var page = await context.Tournaments .Where(t => tournamentIds.Contains(t.Id)) .With(query, s => s.DefaultTournamentOrder()) diff --git a/TournamentAPI/appsettings.Development.json b/TournamentAPI/appsettings.Development.json index 6d56b2f..0e1cb0d 100644 --- a/TournamentAPI/appsettings.Development.json +++ b/TournamentAPI/appsettings.Development.json @@ -1,10 +1,4 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, "ApiKey": { "Value": "pk_test_3K7s9x2mPq8vN4Lr6Hf2bT5wX1yZ8cD3eF7gH9jK" }, diff --git a/TournamentAPI/appsettings.json b/TournamentAPI/appsettings.json index 7044597..f55d550 100644 --- a/TournamentAPI/appsettings.json +++ b/TournamentAPI/appsettings.json @@ -1,8 +1,11 @@ { - "Logging": { - "LogLevel": { + "Serilog": { + "MinimumLevel": { "Default": "Information", - "Microsoft.AspNetCore": "Warning" + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } } }, "ApiKey": { diff --git a/TournamentAPI/packages.lock.json b/TournamentAPI/packages.lock.json index ce36535..63ee3f0 100644 --- a/TournamentAPI/packages.lock.json +++ b/TournamentAPI/packages.lock.json @@ -233,6 +233,17 @@ "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" } }, + "OpenTelemetry.Instrumentation.EntityFrameworkCore": { + "type": "Direct", + "requested": "[1.18.0-beta.1, )", + "resolved": "1.18.0-beta.1", + "contentHash": "n15cNXxkSl9G6KD41wZYmujjERYOBiIgn2VeesTFhLtHZ+QQQE3baLrCk7UyzidEw/uGObRcW3SKhqpyMpGe1w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "8.0.0", + "Microsoft.Extensions.Options": "8.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.18.0, 2.0.0)" + } + }, "OpenTelemetry.Instrumentation.Http": { "type": "Direct", "requested": "[1.15.1, )", @@ -1412,19 +1423,19 @@ }, "OpenTelemetry.Api": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==", + "resolved": "1.18.0", + "contentHash": "Dhzm5oughIY9EWckt2J1yXcEbXpki5WCL2q7prJoYxzH6C7jdkfGtPMTkUVZZ4AhjJKzQnlC+kLSIlmIVwC4Vw==", "dependencies": { "System.Diagnostics.DiagnosticSource": "10.0.0" } }, "OpenTelemetry.Api.ProviderBuilderExtensions": { "type": "Transitive", - "resolved": "1.15.3", - "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "resolved": "1.18.0", + "contentHash": "io6A6PJFp3vIObRGf6oPjdHsdglkinXt/0SpTDJHrVnsLxUtOzwh/QwTsnhBJ43wJ6QcGMkyNzsSSY8XEB1Q5Q==", "dependencies": { "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", - "OpenTelemetry.Api": "1.15.3" + "OpenTelemetry.Api": "1.18.0" } }, "Serilog.Extensions.Hosting": {