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": {