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