From f37ca11fe193839ac659b1553611daaf41443ed0 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 12 May 2026 21:38:20 +0200 Subject: [PATCH 1/6] build: add OpenTelemetry OTLP exporter package Added OpenTelemetry.Exporter.OpenTelemetryProtocol (v1.15.3) to enable exporting telemetry data using the OpenTelemetry Protocol (OTLP). Updated Directory.Packages.props, TournamentAPI.csproj, and packages.lock.json to reflect this addition. This allows integration with observability backends such as Jaeger, Prometheus, or OpenTelemetry Collector. --- Directory.Packages.props | 1 + TournamentAPI.Benchmarks/packages.lock.json | 10 ++++++++++ TournamentAPI.IntegrationTests/packages.lock.json | 10 ++++++++++ TournamentAPI.LoadTests/packages.lock.json | 10 ++++++++++ TournamentAPI.UnitTests/packages.lock.json | 10 ++++++++++ TournamentAPI/TournamentAPI.csproj | 3 ++- TournamentAPI/packages.lock.json | 9 +++++++++ 7 files changed, 52 insertions(+), 1 deletion(-) 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/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/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/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, )", From 186b0a2103852d6961207e147f621dccb0f460d8 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 12 May 2026 21:38:56 +0200 Subject: [PATCH 2/6] build: add Prometheus and Grafana services Added Prometheus and Grafana services to docker-compose.yml for monitoring and visualization. Configured Prometheus with OTLP receiver and persistent storage. Exposed ports 5431 (Prometheus) and 3000 (Grafana). Defined named volumes for data persistence. --- docker-compose.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 docker-compose.yml 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 From e63b9428c1811caa21be08adc8e0a3ed77d7d111 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 12 May 2026 21:39:45 +0200 Subject: [PATCH 3/6] refactor: use UpDownCounter for active tournaments metric Refactor TournamentMetrics to use an UpDownCounter for tracking active tournaments instead of an observable gauge. Remove database polling and update the metric in real-time by calling TournamentOpened() and TournamentClosed() when tournaments are created, updated, or deleted. Inject TournamentMetrics into mutation methods to support this change. This improves accuracy and performance of the active tournaments metric. --- TournamentAPI/Metrics/TournamentMetrics.cs | 32 ++++--------------- .../Tournaments/TournamentMutations.cs | 19 ++++++++++- 2 files changed, 25 insertions(+), 26 deletions(-) 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/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; } } From 9f6046647e78ec76a3b5aead4f2218bcde246e39 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 12 May 2026 21:40:42 +0200 Subject: [PATCH 4/6] refactor: switch to OTLP metrics exporter and cleanup metrics Removed TournamentMetrics as a hosted service, keeping only its singleton registration. Replaced the console metrics exporter with an OTLP exporter configured for HTTP Protobuf, sending metrics to http://localhost:5431/api/v1/otlp/v1/metrics. --- .../Configuration/Extensions/MetricsExtensions.cs | 2 -- .../Configuration/Extensions/TelemetryExtensions.cs | 7 +++++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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; From b5dd21e1b3cd49687b69b7e72e450122f6012d29 Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Tue, 12 May 2026 21:41:32 +0200 Subject: [PATCH 5/6] feat: configure Prometheus scrape configs and intervals Added global scrape_interval of 15s in prometheus.yml. Configured a new 'prometheus' job with HTTP scheme, 10s scrape interval, 5s scrape timeout, and static target 'localhost:9090'. --- prometheus/prometheus.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 prometheus/prometheus.yml 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 From e1fc66c8058d4225f7e368eb8c5c76a1ad417b6d Mon Sep 17 00:00:00 2001 From: Dejmenek Date: Wed, 13 May 2026 16:28:43 +0200 Subject: [PATCH 6/6] docs: add API running instructions to README Added a "Running the API" section to the README with detailed steps for starting Prometheus and Grafana via Docker Compose, running the .NET 9 API, and connecting Grafana to Prometheus for metrics visualization. Includes prerequisites and service URLs for easier setup. --- README.md | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) 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