diff --git a/.env b/.env index 41db96e..7c6130e 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ TEST_PROJ_NAME=MyProject TEMPL_DOTNET_NAME=aspnext -NUGET_FILE=Aspnext.Template.8.1.0.nupkg +NUGET_FILE=Aspnext.Template.8.2.0.nupkg NUGET_API_KEY= \ No newline at end of file diff --git a/README.md b/README.md index 5c5101c..af7fff1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ [![Dotnet foundation](https://img.shields.io/badge/-.NET%20Template-blueviolet)](https://dotnetfoundation.org/) -![Version](https://img.shields.io/badge/version-8.1.0-blue) -[![Nuget package](https://img.shields.io/badge/Nuget%20-Package-red)](https://www.nuget.org/packages/Aspnext.Template/8.1.0) +![Version](https://img.shields.io/badge/version-8.2.0-blue) +[![Nuget package](https://img.shields.io/badge/Nuget%20-Package-red)](https://www.nuget.org/packages/Aspnext.Template/8.2.0) # Asset 1@2x AspNext @@ -17,7 +17,7 @@ If you don't want to use it, you can start with __main__ template like this: 1. Install template: ```sh -dotnet new install Aspnext.Template::8.1.0 +dotnet new install Aspnext.Template::8.2.0 ``` 2. Create template with dotnet new: diff --git a/src/template/Aspnext.Template.csproj b/src/template/Aspnext.Template.csproj index 1a59e56..a5cd4e6 100644 --- a/src/template/Aspnext.Template.csproj +++ b/src/template/Aspnext.Template.csproj @@ -2,7 +2,7 @@ Template - 8.1.0 + 8.2.0 Aspnext.Template ASP.NET 8 awesome SPA template Ilya Klimenko (MadL1me) diff --git a/src/template/Template/.template.config/dotnetcli.host.json b/src/template/Template/.template.config/dotnetcli.host.json index 2be1af0..2a37627 100644 --- a/src/template/Template/.template.config/dotnetcli.host.json +++ b/src/template/Template/.template.config/dotnetcli.host.json @@ -1,10 +1,6 @@ { "$schema": "http://json.schemastore.org/dotnetcli.host", "symbolInfo": { - "AddSwaggerSupport": { - "longName": "swagger", - "shortName": "sw" - }, "AddDatabase": { "longName": "database", "shortName": "db" @@ -13,6 +9,10 @@ "longName": "examples", "shortName": "e" }, + "AddObservability": { + "longName": "observability", + "shortName": "o" + }, "AddZitadelAuth": { "longName": "auth" } diff --git a/src/template/Template/.template.config/template.json b/src/template/Template/.template.config/template.json index a5b8352..499d1cb 100644 --- a/src/template/Template/.template.config/template.json +++ b/src/template/Template/.template.config/template.json @@ -42,6 +42,12 @@ "datatype": "bool", "defaultValue": "true" }, + "AddObservability": { + "type": "parameter", + "description": "Adds OpenTelemetry tracing and metrics support with docker compose", + "datatype": "bool", + "defaultValue": "true" + }, "UsePostgreSql": { "type": "computed", "value": "(AddDatabase == \"pgsql\")" @@ -67,6 +73,17 @@ "infra/docker-compose-zitadel.yaml", "src/Infrastructure/AspnextTemplate.Infrastructure.Zitadel/**" ] + }, + { + "condition": "(!AddObservability)", + "exclude": [ + "infra/docker-compose-grafana.yaml", + "infra/docker-compose-jaeger.yaml", + "infra/docker-compose-prometheus.yaml", + "infra/prometheus.yaml", + "src/Infrastructure/AspnextTemplate.Infrastructure.Observability", + "src/AspnextTemplate.Domain/Observability/**" + ] } ] } diff --git a/src/template/Template/AspnextTemplate.sln b/src/template/Template/AspnextTemplate.sln index 2c5e6e6..1c607ac 100644 --- a/src/template/Template/AspnextTemplate.sln +++ b/src/template/Template/AspnextTemplate.sln @@ -11,6 +11,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".config", ".config", "{DF2D infra\docker-compose-postgres.yaml = infra\docker-compose-postgres.yaml infra\docker-compose-app.yaml = infra\docker-compose-app.yaml infra\docker-compose-zitadel.yaml = infra\docker-compose-zitadel.yaml + infra\docker-compose-prometheus.yaml = infra\docker-compose-prometheus.yaml + infra\docker-compose-jaeger.yaml = infra\docker-compose-jaeger.yaml + infra\prometheus.yml = infra\prometheus.yml + infra\docker-compose-grafana.yaml = infra\docker-compose-grafana.yaml EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspnextTemplate", "src\AspnextTemplate.Api\AspnextTemplate.Api.csproj", "{94618EED-2AEA-44A5-AC03-9A3B9AC0DBC4}" @@ -25,6 +29,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastru EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspnextTemplate.Infrastructure.Zitadel", "src\Infrastructure\AspnextTemplate.Infrastructure.Zitadel\AspnextTemplate.Infrastructure.Zitadel.csproj", "{A142CFBD-9B0E-4B10-8A16-7404886A3DC3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspnextTemplate.Domain", "src\AspnextTemplate.Domain\AspnextTemplate.Domain.csproj", "{61833C02-5273-4A5D-A7CC-1DD695D35475}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AspnextTemplate.Infrastructure.Observability", "src\Infrastructure\AspnextTemplate.Infrastructure.Observability\AspnextTemplate.Infrastructure.Observability.csproj", "{2480637A-E46B-4338-995C-18574CD4118A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -42,10 +50,20 @@ Global {A142CFBD-9B0E-4B10-8A16-7404886A3DC3}.Debug|Any CPU.Build.0 = Debug|Any CPU {A142CFBD-9B0E-4B10-8A16-7404886A3DC3}.Release|Any CPU.ActiveCfg = Release|Any CPU {A142CFBD-9B0E-4B10-8A16-7404886A3DC3}.Release|Any CPU.Build.0 = Release|Any CPU + {61833C02-5273-4A5D-A7CC-1DD695D35475}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {61833C02-5273-4A5D-A7CC-1DD695D35475}.Debug|Any CPU.Build.0 = Debug|Any CPU + {61833C02-5273-4A5D-A7CC-1DD695D35475}.Release|Any CPU.ActiveCfg = Release|Any CPU + {61833C02-5273-4A5D-A7CC-1DD695D35475}.Release|Any CPU.Build.0 = Release|Any CPU + {2480637A-E46B-4338-995C-18574CD4118A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2480637A-E46B-4338-995C-18574CD4118A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2480637A-E46B-4338-995C-18574CD4118A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2480637A-E46B-4338-995C-18574CD4118A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {94618EED-2AEA-44A5-AC03-9A3B9AC0DBC4} = {D3BD39A8-2835-4B2A-8E74-A3E59B387D36} {3E0E9C11-D112-41DF-8029-57F996829E87} = {D3BD39A8-2835-4B2A-8E74-A3E59B387D36} {A142CFBD-9B0E-4B10-8A16-7404886A3DC3} = {3E0E9C11-D112-41DF-8029-57F996829E87} + {61833C02-5273-4A5D-A7CC-1DD695D35475} = {D3BD39A8-2835-4B2A-8E74-A3E59B387D36} + {2480637A-E46B-4338-995C-18574CD4118A} = {3E0E9C11-D112-41DF-8029-57F996829E87} EndGlobalSection EndGlobal diff --git a/src/template/Template/Makefile b/src/template/Template/Makefile index 0160be2..2381edf 100644 --- a/src/template/Template/Makefile +++ b/src/template/Template/Makefile @@ -5,7 +5,8 @@ DB_TASK = run_db HOME := $(shell echo $$HOME) PASSWORD := YOUR_PASSWORD # Replace with actual password -.PHONY: init init_frontend init_backend init_db init_certs run_backend $(DB_TASK) run_frontend run_auth run +.PHONY: init init_frontend init_backend init_db init_certs run_backend $(DB_TASK) run_frontend run_auth run \ + run_tracing run_metrics run_grafana init: init_frontend init_backend init_certs init_db @@ -13,12 +14,6 @@ init_backend: @echo "Initialize the back-end project" cd AspnextTemplate && dotnet restore && docker compose build -//#if (UsePostgreSql) -init_db: - @echo "Initialize the database" - cd infra && docker compose --file docker-compose-postgres.yaml up -d -//#endif - init_certs: @echo "Generate self-signed certificates" dotnet dev-certs https -ep $(HOME)/.aspnet/https/AspnextTemplate.pfx -p $(PASSWORD) @@ -32,13 +27,24 @@ run_backend: $(DB_TASK) //#if (UsePostgreSql) run_db: @echo "Run the database" - cd infra && docker compose --file docker-compose-postgres.yaml up -d + docker compose --file infra/docker-compose-postgres.yaml up -d //#endif //#if (AddZitadelAuth) run_auth: @echo "Run Zitadel identity provider" - cd infra && docker compose --file docker-compose-zitadel.yaml up -d + docker compose --file infra/docker-compose-zitadel.yaml up -d +//#endif + +//#if (AddObservability) +run_tracing: + docker compose --file infra/docker-compose-jaeger.yaml up -d + +run_metrics: + docker compose --file infra/docker-compose-prometheus.yaml up -d + +run_grafana: + docker compose --file infra/docker-compose-grafana.yaml up -d //#endif run: run_backend diff --git a/src/template/Template/infra/docker-compose-grafana.yaml b/src/template/Template/infra/docker-compose-grafana.yaml new file mode 100644 index 0000000..ac1432c --- /dev/null +++ b/src/template/Template/infra/docker-compose-grafana.yaml @@ -0,0 +1,21 @@ +version: '3.7' + +services: + grafana: + image: grafana/grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + restart: always + ports: + - 3000:3000 + volumes: + - grafana-data:/var/lib/grafana + networks: + - localconnect + +volumes: + grafana-data: {} + +networks: + localconnect: + diff --git a/src/template/Template/infra/docker-compose-jaeger.yaml b/src/template/Template/infra/docker-compose-jaeger.yaml new file mode 100644 index 0000000..e6c80bb --- /dev/null +++ b/src/template/Template/infra/docker-compose-jaeger.yaml @@ -0,0 +1,44 @@ +version: '3.7' + +# To run a specific version of Jaeger, use environment variable, e.g.: +# JAEGER_VERSION=1.52 docker compose up + +services: + jaeger: + image: jaegertracing/all-in-one:${JAEGER_VERSION:-latest} + ports: + - "16686:16686" + - "4318:4318" + - 5775:5775/udp + - 6831:6831/udp + - 6832:6832/udp + - 5778:5778 + - 16686:16686 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 4317:4317 + - 4318:4318 + - 9411:9411 + environment: + - LOG_LEVEL=debug + networks: + - jaeger-example + hotrod: + image: jaegertracing/example-hotrod:${JAEGER_VERSION:-latest} + # To run the latest trunk build, find the tag at Docker Hub and use the line below + # https://hub.docker.com/r/jaegertracing/example-hotrod-snapshot/tags + #image: jaegertracing/example-hotrod-snapshot:0ab8f2fcb12ff0d10830c1ee3bb52b745522db6c + ports: + - "8085:8085" + - "8083:8083" + command: ["all"] + environment: + - OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 + networks: + - jaeger-example + depends_on: + - jaeger + +networks: + jaeger-example: diff --git a/src/template/Template/infra/docker-compose-prometheus.yaml b/src/template/Template/infra/docker-compose-prometheus.yaml new file mode 100644 index 0000000..ea137f6 --- /dev/null +++ b/src/template/Template/infra/docker-compose-prometheus.yaml @@ -0,0 +1,16 @@ +version: '3.7' + +services: + prometheus: + image: prom/prometheus + restart: always + ports: + - 9090:9090 + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + networks: + - localconnect + +networks: + localconnect: + diff --git a/src/template/Template/infra/prometheus.yml b/src/template/Template/infra/prometheus.yml new file mode 100644 index 0000000..7f2406e --- /dev/null +++ b/src/template/Template/infra/prometheus.yml @@ -0,0 +1,15 @@ +global: + scrape_interval: 10s # By default, scrape targets every 5 seconds. + + # Attach these labels to any time series or alerts when communicating with + # external systems (federation, remote storage, Alertmanager). + # external_labels: + # monitor: 'nats-openrmf-server' + +# A scrape configuration containing exactly one endpoint to scrape: +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'localhost-read-prometheus' + # metrics_path defaults to '/metrics' + static_configs: + - targets: ['host.docker.internal:5214'] diff --git a/src/template/Template/src/AspnextTemplate.Api/AspnextTemplate.Api.csproj b/src/template/Template/src/AspnextTemplate.Api/AspnextTemplate.Api.csproj index 4b68b3e..a0a9e84 100644 --- a/src/template/Template/src/AspnextTemplate.Api/AspnextTemplate.Api.csproj +++ b/src/template/Template/src/AspnextTemplate.Api/AspnextTemplate.Api.csproj @@ -1,5 +1,5 @@ - + net8.0 enable @@ -19,8 +19,13 @@ + - - + + + + + + diff --git a/src/template/Template/src/AspnextTemplate.Api/Controllers/ExampleController.cs b/src/template/Template/src/AspnextTemplate.Api/Controllers/ExampleController.cs index d189bd8..3c58121 100644 --- a/src/template/Template/src/AspnextTemplate.Api/Controllers/ExampleController.cs +++ b/src/template/Template/src/AspnextTemplate.Api/Controllers/ExampleController.cs @@ -6,6 +6,10 @@ using System.Security.Claims; #endif using Microsoft.AspNetCore.Mvc; +#if (AddObservability) +using AspnextTemplate.Domain.Observability; +using OpenTelemetry.Trace; +#endif namespace AspnextTemplate.Api.Controllers; @@ -23,10 +27,21 @@ public class ExampleController : ControllerBase private static readonly string[] Names = { "John", "Albert", "Mark " }; private readonly ILogger _logger; +#if (AddObservability) + private readonly Tracer _tracer; + private readonly IMetricsProvider _metricsProvider; +#endif - public ExampleController(ILogger logger) + // In this controller we're using service provider just for convinience and + // ease of template development + // In real controllers, inject interfaces directly. + public ExampleController(IServiceProvider serviceProvider) { - _logger = logger; + _logger = serviceProvider.GetService>() ?? throw new ArgumentNullException(); +#if (AddObservability) + _tracer = serviceProvider.GetService() ?? throw new ArgumentNullException(); + _metricsProvider = serviceProvider.GetService() ?? throw new ArgumentNullException(); +#endif } // Example of synchronous Http.Get request pipeline with users/{userId} route @@ -55,16 +70,21 @@ public async Task UpdateUser([FromBody] ExampleUserDto userDto) / // custom status code returns with data return StatusCode(StatusCodes.Status200OK, new { Value1 = true, Value2 = "Yay!"}); } + #if (AddZitadelAuth) + #region Zitadel + // Endpoint without auth [HttpPost("non-authorize")] public object NonAuthorize() => Result(); + // Goes to Introspect endpoint of Zitadel to validate token [HttpPost("introspect/valid")] [Authorize(AuthenticationSchemes = AuthConstants.Schema)] public object IntrospectValidToken() => Result(); - // Authorization by custom PolicyNam + // Authorization by custom PolicyName + // For this example, Role should be exact name to be passed [HttpPost("introspect/requires-role")] [Authorize(Policy = AuthConstants.SomePolicyName)] public object IntrospectRequiresRole() => Result(); @@ -81,5 +101,32 @@ public async Task UpdateUser([FromBody] ExampleUserDto userDto) / IsInUserRole = User.IsInRole("User"), InChargeRole = User.IsInRole("charge"), }; + + #endregion +#endif + +#if (AddObservability) + // Showcase of tracing and metrics in action + [HttpPost("observe")] + public object ObserveAndTrace() + { + using var span = _tracer.StartActiveSpan("Observe-Parent"); + + using (var child1 = _tracer.StartActiveSpan("child1")) + { + child1.SetAttribute("test", "some value"); + _logger.LogInformation("child1 trace information"); + } + + using (var child2 = _tracer.StartActiveSpan("child2")) + { + child2.SetAttribute("test", "another value"); + _logger.LogInformation("child2 trace information"); + } + + _metricsProvider.IncTestMetric(12345); + + return Ok(); + } #endif } diff --git a/src/template/Template/src/AspnextTemplate.Api/Program.cs b/src/template/Template/src/AspnextTemplate.Api/Program.cs index a0d94e6..2451eb7 100644 --- a/src/template/Template/src/AspnextTemplate.Api/Program.cs +++ b/src/template/Template/src/AspnextTemplate.Api/Program.cs @@ -1,4 +1,5 @@ using AspnextTemplate.Api.Extensions; +using AspnextTemplate.Infrastructure.Observability; #if (AddZitadelAuth) using AspnextTemplate.Infrastructure.Zitadel; #endif @@ -19,8 +20,16 @@ // Add custom zitadel authentication builder.Services.AddZitadelAuthentication(builder.Configuration); #endif +#if (AddObservability) +// Add custom observability +builder.Services.AddObservability(new ObservabilityOptions( + "your_env", + "AspnextTemplate", + "AspnextTemplate")); +#endif #if (UsePostgreSql) + builder.Services.AddDbContext( options => options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); #endif diff --git a/src/template/Template/src/AspnextTemplate.Domain/AspnextTemplate.Domain.csproj b/src/template/Template/src/AspnextTemplate.Domain/AspnextTemplate.Domain.csproj new file mode 100644 index 0000000..3a63532 --- /dev/null +++ b/src/template/Template/src/AspnextTemplate.Domain/AspnextTemplate.Domain.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/src/template/Template/src/AspnextTemplate.Domain/Observability/IMetricsProvider.cs b/src/template/Template/src/AspnextTemplate.Domain/Observability/IMetricsProvider.cs new file mode 100644 index 0000000..eaae449 --- /dev/null +++ b/src/template/Template/src/AspnextTemplate.Domain/Observability/IMetricsProvider.cs @@ -0,0 +1,6 @@ +namespace AspnextTemplate.Domain.Observability; + +public interface IMetricsProvider +{ + void IncTestMetric(long metricLabel); +} diff --git a/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/AspnextTemplate.Infrastructure.Observability.csproj b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/AspnextTemplate.Infrastructure.Observability.csproj new file mode 100644 index 0000000..452caaf --- /dev/null +++ b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/AspnextTemplate.Infrastructure.Observability.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + diff --git a/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/MetricsProvider.cs b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/MetricsProvider.cs new file mode 100644 index 0000000..7a5e376 --- /dev/null +++ b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/MetricsProvider.cs @@ -0,0 +1,24 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using AspnextTemplate.Domain.Observability; + +namespace AspnextTemplate.Infrastructure.Observability; + +public class MetricsProvider : IMetricsProvider +{ + private readonly Counter _testMetricCounter; + + public MetricsProvider(IMeterFactory meterFactory) + { + var meter = meterFactory.Create(MetricsConstants.AppMeterName); + _testMetricCounter = meter.CreateCounter("AspnextTemplate_test_counter"); + } + + public void IncTestMetric(long metricLabel) + { + _testMetricCounter.Add(1, new TagList + { + new("test_label", metricLabel), + }); + } +} diff --git a/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/ServiceCollectionExtensions.cs b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a89cd58 --- /dev/null +++ b/src/template/Template/src/Infrastructure/AspnextTemplate.Infrastructure.Observability/ServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +using System.Reflection; +using AspnextTemplate.Domain.Observability; +using Microsoft.Extensions.DependencyInjection; +using OpenTelemetry; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; + +namespace AspnextTemplate.Infrastructure.Observability; + +public static class MetricsConstants +{ + public const string AppMeterName = "AspnextTemplate.Meter"; +} + +public record ObservabilityOptions( + string Environment, + string ServiceNamespace, + string ServiceName, + string OtlpEndpoint = "http://localhost:4317"); + +public static class ServiceCollectionExtensions +{ + public static OpenTelemetryBuilder AddObservability(this IServiceCollection services, ObservabilityOptions options) + { + var serviceVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString(); + + services.AddSingleton(); + services.AddSingleton(TracerProvider.Default.GetTracer(options.ServiceName)); + + return services.AddOpenTelemetry() + .ConfigureResource(resource => resource.AddService( + serviceName: options.ServiceName, + serviceNamespace: options.ServiceNamespace, + serviceVersion: serviceVersion, + serviceInstanceId: Environment.MachineName + ).AddAttributes(new Dictionary + { + { "deployment.environment", options.Environment } + })) + .WithTracing(tracing => tracing + .AddSource(options.ServiceName) + .SetResourceBuilder( + ResourceBuilder.CreateDefault() + .AddService(serviceName: options.ServiceName, serviceVersion: serviceVersion)) + .AddAspNetCoreInstrumentation() + .AddOtlpExporter(opts => + { + opts.Endpoint = new Uri(options.OtlpEndpoint); + })) + .WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation() + .AddRuntimeInstrumentation() + .AddMeter(MetricsConstants.AppMeterName) + .AddPrometheusExporter() + ); + } +}