diff --git a/Directory.Packages.props b/Directory.Packages.props
index e2e9b0b..f0caa74 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -17,6 +17,7 @@
+
diff --git a/README.md b/README.md
index 1c1b78a..55b423c 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,7 @@
- [General Info](#general-info)
- [Technologies](#technologies)
+- [Running the API](#running-the-api)
- [Detailed API Documentation](#detailed-api-documentation)
- [Authentication](#authentication)
- [Tournament Management](#tournament-management)
@@ -36,6 +37,46 @@
---
+
+## Running the API
+
+### Prerequisites
+
+- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
+- [Docker](https://www.docker.com/) (for Prometheus and Grafana)
+
+### Start Prometheus and Grafana
+
+The API exports metrics via OpenTelemetry's OTLP exporter directly to Prometheus. Before starting the API, bring up the observability stack with Docker Compose:
+
+```bash
+docker-compose up -d
+```
+
+This starts:
+
+| Service | URL |
+| --- | --- |
+| Prometheus | http://localhost:5431 |
+| Grafana | http://localhost:3000 |
+
+Prometheus is configured with `--web.enable-otlp-receiver` so it accepts OTLP pushes from the API. The scrape interval is set to 15 s globally (10 s for the Prometheus self-scrape job).
+
+### Start the API
+
+```bash
+dotnet run --project TournamentAPI
+```
+
+The API will push metrics to Prometheus automatically once running.
+
+### Grafana
+
+Open `http://localhost:3000`, log in with the default credentials (`admin` / `admin`), and add Prometheus (`http://prometheus:9090`) as a data source to build dashboards from the collected metrics.
+
+---
+
+
## Detailed API Documentation
### Authentication
diff --git a/TournamentAPI.Benchmarks/packages.lock.json b/TournamentAPI.Benchmarks/packages.lock.json
index b3314ea..612b164 100644
--- a/TournamentAPI.Benchmarks/packages.lock.json
+++ b/TournamentAPI.Benchmarks/packages.lock.json
@@ -1356,6 +1356,7 @@
"Microsoft.EntityFrameworkCore.SqlServer": "[9.0.11, )",
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[9.0.11, )",
"OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
@@ -1511,6 +1512,15 @@
"OpenTelemetry": "1.15.3"
}
},
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
"OpenTelemetry.Extensions.Hosting": {
"type": "CentralTransitive",
"requested": "[1.15.3, )",
diff --git a/TournamentAPI.IntegrationTests/packages.lock.json b/TournamentAPI.IntegrationTests/packages.lock.json
index a28f2ad..a1f0d9f 100644
--- a/TournamentAPI.IntegrationTests/packages.lock.json
+++ b/TournamentAPI.IntegrationTests/packages.lock.json
@@ -1372,6 +1372,7 @@
"Microsoft.EntityFrameworkCore.SqlServer": "[9.0.11, )",
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[9.0.11, )",
"OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
@@ -1527,6 +1528,15 @@
"OpenTelemetry": "1.15.3"
}
},
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
"OpenTelemetry.Extensions.Hosting": {
"type": "CentralTransitive",
"requested": "[1.15.3, )",
diff --git a/TournamentAPI.LoadTests/packages.lock.json b/TournamentAPI.LoadTests/packages.lock.json
index bda4ccf..a73bd3a 100644
--- a/TournamentAPI.LoadTests/packages.lock.json
+++ b/TournamentAPI.LoadTests/packages.lock.json
@@ -2499,6 +2499,7 @@
"Microsoft.EntityFrameworkCore.SqlServer": "[9.0.11, )",
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[9.0.11, )",
"OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
@@ -2654,6 +2655,15 @@
"OpenTelemetry": "1.15.3"
}
},
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
"OpenTelemetry.Extensions.Hosting": {
"type": "CentralTransitive",
"requested": "[1.15.3, )",
diff --git a/TournamentAPI.UnitTests/packages.lock.json b/TournamentAPI.UnitTests/packages.lock.json
index cdd705f..dcb41bd 100644
--- a/TournamentAPI.UnitTests/packages.lock.json
+++ b/TournamentAPI.UnitTests/packages.lock.json
@@ -1143,6 +1143,7 @@
"Microsoft.EntityFrameworkCore.SqlServer": "[9.0.11, )",
"Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore": "[9.0.11, )",
"OpenTelemetry.Exporter.Console": "[1.15.3, )",
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": "[1.15.3, )",
"OpenTelemetry.Extensions.Hosting": "[1.15.3, )",
"OpenTelemetry.Instrumentation.AspNetCore": "[1.15.2, )",
"OpenTelemetry.Instrumentation.Http": "[1.15.1, )",
@@ -1295,6 +1296,15 @@
"OpenTelemetry": "1.15.3"
}
},
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "CentralTransitive",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
"OpenTelemetry.Extensions.Hosting": {
"type": "CentralTransitive",
"requested": "[1.15.3, )",
diff --git a/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs b/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs
index 9184acb..6505bef 100644
--- a/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs
+++ b/TournamentAPI/Configuration/Extensions/MetricsExtensions.cs
@@ -1,4 +1,3 @@
-using Microsoft.Extensions.DependencyInjection;
using TournamentAPI.Metrics;
namespace TournamentAPI.Configuration.Extensions;
@@ -8,7 +7,6 @@ internal static class MetricsExtensions
internal static IServiceCollection AddApplicationMetrics(this IServiceCollection services)
{
services.AddSingleton();
- services.AddHostedService(sp => sp.GetRequiredService());
return services;
}
diff --git a/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs b/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs
index d19c93e..c09a757 100644
--- a/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs
+++ b/TournamentAPI/Configuration/Extensions/TelemetryExtensions.cs
@@ -1,4 +1,3 @@
-using Microsoft.Extensions.DependencyInjection;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
@@ -23,7 +22,11 @@ internal static IServiceCollection AddApplicationTelemetry(this IServiceCollecti
{
metrics.AddMeter(MetricConstants.TournamentMeterName);
metrics.AddAspNetCoreInstrumentation();
- metrics.AddConsoleExporter();
+ metrics.AddOtlpExporter(o =>
+ {
+ o.Endpoint = new Uri("http://localhost:5431/api/v1/otlp/v1/metrics");
+ o.Protocol = OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf;
+ });
});
return services;
diff --git a/TournamentAPI/Metrics/TournamentMetrics.cs b/TournamentAPI/Metrics/TournamentMetrics.cs
index 597ce5b..d158c7f 100644
--- a/TournamentAPI/Metrics/TournamentMetrics.cs
+++ b/TournamentAPI/Metrics/TournamentMetrics.cs
@@ -1,40 +1,22 @@
using System.Diagnostics.Metrics;
-using TournamentAPI.Data;
-using TournamentAPI.Data.Models;
namespace TournamentAPI.Metrics;
-public class TournamentMetrics : IHostedService
+public class TournamentMetrics
{
- private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly Counter _tournamentsCreated;
+ private readonly UpDownCounter _activeTournaments;
- public TournamentMetrics(IMeterFactory meterFactory, IServiceScopeFactory serviceScopeFactory)
+ public TournamentMetrics(IMeterFactory meterFactory)
{
- _serviceScopeFactory = serviceScopeFactory;
-
var meter = meterFactory.Create(MetricConstants.TournamentMeterName);
_tournamentsCreated = meter.CreateCounter("tournamentapi.tournaments.tournaments_created", description: "Number of tournaments created");
-
- _ = meter.CreateObservableGauge(
- "tournamentapi.tournaments.active_tournaments",
- () => GetActiveTournamentsCount(),
- description: "Number of currently active tournaments");
+ _activeTournaments = meter.CreateUpDownCounter("tournamentapi.tournaments.active_tournaments", description: "Number of currently active tournaments");
}
- public void IncrementTournamentsCreated()
- {
- _tournamentsCreated.Add(1);
- }
-
- private int GetActiveTournamentsCount()
- {
- using var scope = _serviceScopeFactory.CreateScope();
- var context = scope.ServiceProvider.GetRequiredService();
- return context.Tournaments.Count(t => t.Status == TournamentStatus.Open);
- }
+ public void IncrementTournamentsCreated() => _tournamentsCreated.Add(1);
- public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public void TournamentOpened() => _activeTournaments.Add(1);
- public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public void TournamentClosed() => _activeTournaments.Add(-1);
}
diff --git a/TournamentAPI/TournamentAPI.csproj b/TournamentAPI/TournamentAPI.csproj
index 3a07af5..fff4800 100644
--- a/TournamentAPI/TournamentAPI.csproj
+++ b/TournamentAPI/TournamentAPI.csproj
@@ -1,4 +1,4 @@
-
+
net9.0
@@ -24,6 +24,7 @@
+
diff --git a/TournamentAPI/Tournaments/TournamentMutations.cs b/TournamentAPI/Tournaments/TournamentMutations.cs
index 9ac907f..7a36e7f 100644
--- a/TournamentAPI/Tournaments/TournamentMutations.cs
+++ b/TournamentAPI/Tournaments/TournamentMutations.cs
@@ -83,6 +83,8 @@ public class TournamentMutations
await context.SaveChangesAsync(token);
tournamentMetrics.IncrementTournamentsCreated();
+ if (tournament.Status == TournamentStatus.Open)
+ tournamentMetrics.TournamentOpened();
return context.Tournaments.Where(t => t.Id == tournament.Id);
}
@@ -95,6 +97,7 @@ public class TournamentMutations
ClaimsPrincipal userClaims,
ApplicationDbContext context,
IResolverContext resolverContext,
+ TournamentMetrics tournamentMetrics,
CancellationToken token)
{
var userId = userClaims.GetUserId();
@@ -112,13 +115,20 @@ public class TournamentMutations
return null;
tournament.Name = input.Name;
-
if (input.StartDate != null)
tournament.StartDate = input.StartDate.Value;
if (input.Status != null)
+ {
+ var previousStatus = tournament.Status;
tournament.Status = input.Status.Value;
+ if (previousStatus != TournamentStatus.Open && tournament.Status == TournamentStatus.Open)
+ tournamentMetrics.TournamentOpened();
+ else if (previousStatus == TournamentStatus.Open && tournament.Status != TournamentStatus.Open)
+ tournamentMetrics.TournamentClosed();
+ }
+
await context.SaveChangesAsync(token);
return context.Tournaments.Where(t => t.Id == tournament.Id);
@@ -130,6 +140,7 @@ public class TournamentMutations
ClaimsPrincipal userClaims,
ApplicationDbContext context,
IResolverContext resolverContext,
+ TournamentMetrics tournamentMetrics,
CancellationToken token)
{
var userId = userClaims.GetUserId();
@@ -146,6 +157,8 @@ public class TournamentMutations
if (resolverContext.TryReportError(TournamentValidations.ValidateIsOwner(tournament!.OwnerId, userId, tournamentId)))
return null;
+ var wasOpen = tournament!.Status == TournamentStatus.Open;
+
tournament.IsDeleted = true;
if (tournament.Bracket != null)
@@ -163,6 +176,10 @@ public class TournamentMutations
}
await context.SaveChangesAsync(token);
+
+ if (wasOpen)
+ tournamentMetrics.TournamentClosed();
+
return true;
}
}
diff --git a/TournamentAPI/packages.lock.json b/TournamentAPI/packages.lock.json
index b6226fd..fa57a95 100644
--- a/TournamentAPI/packages.lock.json
+++ b/TournamentAPI/packages.lock.json
@@ -168,6 +168,15 @@
"OpenTelemetry": "1.15.3"
}
},
+ "OpenTelemetry.Exporter.OpenTelemetryProtocol": {
+ "type": "Direct",
+ "requested": "[1.15.3, )",
+ "resolved": "1.15.3",
+ "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==",
+ "dependencies": {
+ "OpenTelemetry": "1.15.3"
+ }
+ },
"OpenTelemetry.Extensions.Hosting": {
"type": "Direct",
"requested": "[1.15.3, )",
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..45f5cc0
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,24 @@
+services:
+ prometheus:
+ image: prom/prometheus:latest
+ container_name: prometheus
+ command:
+ - '--config.file=/etc/prometheus/prometheus.yml'
+ - '--web.enable-otlp-receiver'
+ ports:
+ - 5431:9090
+ volumes:
+ - ./prometheus/:/etc/prometheus/
+ - prometheus:/prometheus
+
+ grafana:
+ image: grafana/grafana:latest
+ container_name: grafana
+ ports:
+ - 3000:3000
+ volumes:
+ - grafana:/var/lib/grafana
+
+volumes:
+ prometheus:
+ grafana:
\ No newline at end of file
diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml
new file mode 100644
index 0000000..ca9e623
--- /dev/null
+++ b/prometheus/prometheus.yml
@@ -0,0 +1,10 @@
+global:
+ scrape_interval: 15s
+
+scrape_configs:
+ - job_name: 'prometheus'
+ scheme: 'http'
+ scrape_interval: 10s
+ scrape_timeout: 5s
+ static_configs:
+ - targets: ['localhost:9090']
\ No newline at end of file