diff --git a/.docker/grafana-datasources.yaml b/.docker/grafana-datasources.yaml new file mode 100644 index 0000000..a23ad46 --- /dev/null +++ b/.docker/grafana-datasources.yaml @@ -0,0 +1,39 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + basicAuth: false + isDefault: false + version: 1 + editable: false + - name: Tempo + type: tempo + access: proxy + orgId: 1 + url: http://tempo:3200 + basicAuth: false + isDefault: false + version: 1 + editable: false + apiVersion: 1 + uid: tempo + - name: Loki + type: loki + access: proxy + orgId: 1 + url: http://loki:3100 + basicAuth: false + isDefault: true + version: 1 + editable: false + apiVersion: 1 + jsonData: + derivedFields: + - datasourceUid: tempo + matcherRegex: (?:"traceid"):"(\w+)" + name: TraceID + url: $${__value.raw} diff --git a/.docker/opentelemetry-collector.yml b/.docker/opentelemetry-collector.yml new file mode 100644 index 0000000..04a8378 --- /dev/null +++ b/.docker/opentelemetry-collector.yml @@ -0,0 +1,42 @@ +receivers: + otlp: + protocols: + grpc: + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" + logging: + zipkin: + endpoint: "http://zipkin:9411/api/v2/spans" + format: proto + file: + path: /etc/output/logs.json + otlp: + endpoint: tempo:4317 + tls: + insecure: true + loki: + endpoint: "http://loki:3100/loki/api/v1/push" + format: json + labels: + resource: + service.name: "service_name" + service.instance.id: "service_instance_id" + +processors: + batch: + +service: + pipelines: + logs: + receivers: [otlp] + exporters: [logging, file, loki] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [logging, prometheus] + traces: + receivers: [otlp] + processors: [batch] + exporters: [logging, zipkin, otlp] diff --git a/.docker/prometheus.yml b/.docker/prometheus.yml new file mode 100644 index 0000000..a847754 --- /dev/null +++ b/.docker/prometheus.yml @@ -0,0 +1,6 @@ +scrape_configs: + - job_name: 'otel-collector' + scrape_interval: 10s + static_configs: + - targets: ['otel-collector:8889'] + - targets: ['otel-collector:8888'] diff --git a/.docker/tempo.yaml b/.docker/tempo.yaml new file mode 100644 index 0000000..c12aa40 --- /dev/null +++ b/.docker/tempo.yaml @@ -0,0 +1,15 @@ +server: + http_listen_port: 3200 + +distributor: + receivers: + otlp: + protocols: + http: + grpc: + +storage: + trace: + backend: local + local: + path: /tmp/tempo/blocks diff --git a/.gitignore b/.gitignore index a559535..d2f65af 100644 --- a/.gitignore +++ b/.gitignore @@ -358,3 +358,6 @@ Local.md # GitLab runner files /builds/ /.gitlab/runner/local/ + +# Local build and log files +/output/* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 43aa9c6..2b68f3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -26,6 +26,7 @@ workflow: rules: - if: '$CI_PIPELINE_SOURCE == "external_pull_request_event"' - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH' + - if: '$CI_PIPELINE_SOURCE == "web"' #.dotnet_build: build: @@ -48,16 +49,20 @@ build: test: stage: test variables: - ServiceNow__RestApi__BaseUrl: http://localhost:3000/api/now - ServiceNow__RestApi__Username: dummy - ServiceNow__RestApi__Password: dummy COBERTURA_REPORT_FILEPATH: ./test/*/TestResults/*/coverage.cobertura.xml REPORTGENERATOR_OPTIONS: -targetdir:sonarqubecoverage -reporttypes:SonarQube SONAR_EXTRA_PARAMETERS: /d:sonar.cpd.exclusions=**/*Generated*.cs /d:sonar.coverageReportPaths=./sonarqubecoverage/SonarQube.xml + Application__IsSecuredByAzureAd: "false" + OpenTelemetry__Service: SampleServiceNowRestClient + OpenTelemetry__Metrics__Meter: SampleServiceNowRestClientMetrics + OpenTelemetry__Tracing__Source: SampleServiceNowRestClientTracing + ServiceNow__RestApi__BaseUrl: "http://localhost:3000/api/now" + ServiceNow__RestApi__Password: dummy + ServiceNow__RestApi__Username: dummy ServiceNow__SqlServer__DataSource: mssql - ServiceNow__SqlServer__UserId: SA - ServiceNow__SqlServer__Password: $SA_PASSWORD ServiceNow__SqlServer__InitialCatalog: TestDB + ServiceNow__SqlServer__Password: $SA_PASSWORD + ServiceNow__SqlServer__UserId: SA before_script: # updates system - apt-get update @@ -105,6 +110,7 @@ test: only: - external_pull_requests - main + - web # ref. https://docs.microsoft.com/en-us/nuget/nuget-org/publish-a-package #.dotnet_pack: diff --git a/Directory.Build.props b/Directory.Build.props index bf566de..0df9578 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -5,7 +5,7 @@ - 1.1.0 + 1.2.0 diff --git a/README.md b/README.md index 30f388f..2abb71a 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,22 @@ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=rabbids-incubator_servicenow-dotnet-client&metric=coverage)](https://sonarcloud.io/summary/new_code?id=rabbids-incubator_servicenow-dotnet-client) [![Nuget](https://img.shields.io/nuget/v/RabbidsIncubator.ServiceNowClient.Application.svg)](https://www.nuget.org/packages/RabbidsIncubator.ServiceNowClient.Application) -This is the codebase of .NET components (API & libraries) to simplify the integration with [ServiceNow](https://www.servicenow.com/), -from any system (Linux, MacOS, Windows). +.NET components (API & libraries) to simplify the integration with [ServiceNow](https://www.servicenow.com/), from any system (Linux, MacOS, Windows). -## Quick start +## Getting started -* [Getting Started](./docs/getting-started.md) page to learn about how to use the components +* [Quickstart](./docs/quickstart.md) to get an application running in 5 minutes -* [Contribute](./docs/contribute.md) page to work locally with the sources and improve them +* [Contribution](./docs/contribution.md) to run locally the solution and push code changes ## Going further -* [GitLab](./docs/gitlab.md) page to understand the automation done on this codebase +* [Authentication](./docs/authentication.md) to secure the application -* [ServiceNow](./docs/servicenow.md) page for resources on the SaaS solution +* [Automation](./docs/automation.md) to know about the Continuous Integration and Delivery pipeline + +* [Dependencies](./docs/dependencies.md) to view the dependencies (in particular the link with ServiceNow) + +* [Design](./docs/design.md) to understand the software design of the solution + +* [Observability](./docs/automation.md) to monitor and measure the application diff --git a/RabbidsIncubator.ServiceNowClient.sln b/RabbidsIncubator.ServiceNowClient.sln index 962bc97..7d3a0b3 100644 --- a/RabbidsIncubator.ServiceNowClient.sln +++ b/RabbidsIncubator.ServiceNowClient.sln @@ -32,10 +32,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Application", "src\Applicat EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{D314857E-9EE4-4947-AD1A-0949B04E28CA}" ProjectSection(SolutionItems) = preProject - docs\contribute.md = docs\contribute.md - docs\getting-started.md = docs\getting-started.md - docs\gitlab.md = docs\gitlab.md - docs\servicenow.md = docs\servicenow.md + docs\authentication.md = docs\authentication.md + docs\automation.md = docs\automation.md + docs\contribution.md = docs\contribution.md + docs\dependencies.md = docs\dependencies.md + docs\design.md = docs\design.md + docs\observability.md = docs\observability.md + docs\quickstart.md = docs\quickstart.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.InMemory", "src\Infrastructure.InMemory\Infrastructure.InMemory.csproj", "{A01619B4-8F70-417D-B4BD-1651D627770E}" @@ -62,6 +65,15 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "mocks", "mocks", "{B338D8C5 mocks\servicenow.json = mocks\servicenow.json EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docker", "docker", "{8B94AA54-5F8B-463E-9CA0-D7E75B6F60AE}" + ProjectSection(SolutionItems) = preProject + docker-compose.yml = docker-compose.yml + .docker\grafana-datasources.yaml = .docker\grafana-datasources.yaml + .docker\opentelemetry-collector.yml = .docker\opentelemetry-collector.yml + .docker\prometheus.yml = .docker\prometheus.yml + .docker\tempo.yaml = .docker\tempo.yaml + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -136,6 +148,7 @@ Global {84A01CD5-1282-4114-A519-6F4C3625CE60} = {B952F0A1-0DB3-4D9F-BD49-C3DB6389FCB4} {EA2379E0-3613-4EED-9238-92E6D48C5A62} = {03243F43-0975-4776-A5D3-9BA7141293DA} {B338D8C5-A1F8-4EAD-97D4-8E23624372BB} = {03243F43-0975-4776-A5D3-9BA7141293DA} + {8B94AA54-5F8B-463E-9CA0-D7E75B6F60AE} = {03243F43-0975-4776-A5D3-9BA7141293DA} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {CF38BCED-2352-4EDA-A4D5-1462CCD9C659} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e539291 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +version: "3.9" +services: + zipkin: + image: openzipkin/zipkin:latest + container_name: zipkin + ports: + - "9411:9411" + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: otel-collector + command: ["--config=/etc/otel-collector.yml"] + volumes: + - ./.docker/opentelemetry-collector.yml:/etc/otel-collector.yml + - ./output:/etc/output:rw + ports: + - "8888:8888" # prometheus metrics + - "8889:8889" # prometheus exporter + - "4317:4317" # oltp grpc receiver + depends_on: + - zipkin + - grafana + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./.docker/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + tempo: + image: grafana/tempo:latest + container_name: tempo + command: [ "-config.file=/etc/tempo.yaml" ] + volumes: + - ./.docker/tempo.yaml:/etc/tempo.yaml + ports: + - "3200:3200" + + loki: + image: grafana/loki:2.4.2 + container_name: loki + command: [ "-config.file=/etc/loki/local-config.yaml" ] + ports: + - "3100:3100" + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - ./.docker/grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_DISABLE_LOGIN_FORM=true + depends_on: + - prometheus + - loki + - tempo diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..6d285fa --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,26 @@ +# Authentication + +## How to secure an ASP.NET application with Azure AD + +* In [Azure Portal](https://portal.azure.com/), in "Azure Active Directory > Application registrations", +select "New registration" + * Only the same is mandatory + * Once created, the application is displayed + * Save the values of "Application (client) ID", "Directory (tenant) ID" +* Update the application + * "Manifest": manually edit the content (`accessTokenAcceptedVersion` and `allowPublicClient` are null by default) + + ```json + { + "accessTokenAcceptedVersion": 2, + "allowPublicClient": true, + } + ``` + + * "Certificates & secrets": in "Client Secrets", add a new secret and save the secret value + * "Api permissions": do "Grant admin consent for Default Directory" (Microsoft Graph > User.Read has been added by default) + * "Expose an API": set the application ID URI, "api://" is the default and correct choice + * "Expose an API": add a scope, for example "access_as_user" with "Admins and users" for the consent option + +* References: + * [Scenario: Protected web API](https://docs.microsoft.com/en-us/azure/active-directory/develop/scenario-protected-web-api-overview) diff --git a/docs/gitlab.md b/docs/automation.md similarity index 98% rename from docs/gitlab.md rename to docs/automation.md index 2ed3126..c4c3c54 100644 --- a/docs/gitlab.md +++ b/docs/automation.md @@ -1,4 +1,4 @@ -# GitLab configuration guide +# Automation [GitLab](https://gitlab.com/) is used to run the CI (Continuous Integration) pipeline, which is defined in `.gitlab-ci.yml` file. diff --git a/docs/contribute.md b/docs/contribution.md similarity index 89% rename from docs/contribute.md rename to docs/contribution.md index edc60c0..dc464f6 100644 --- a/docs/contribute.md +++ b/docs/contribution.md @@ -1,4 +1,4 @@ -# Contribution guide +# Contribution ## Requirements @@ -55,6 +55,12 @@ dotnet build "IsSwaggerEnabled": true, "IsHttpsEnforced": false }, + "AzureAd": { + "Domain": "xxx.onmicrosoft.com", + "TenantId": "xxx", + "ClientId": "xxx", + "ClientSecret": "xxx" + }, "Logging": { "LogLevel": { "RabbidsIncubator": "Debug", @@ -188,7 +194,7 @@ SA_PASSWORD='s0m3Str0ng!P@ssw0rd' # runs SQL Server in a container (can be accessed with localhost or 127.0.0.1 as Data Source) docker pull mcr.microsoft.com/mssql/server:2019-latest -docker run --rm --name mssql --hostname $MSSQL_HOST \ +docker run --name mssql --hostname $MSSQL_HOST \ -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=$SA_PASSWORD" -p 1433:1433 \ -d mcr.microsoft.com/mssql/server:2019-latest @@ -198,6 +204,29 @@ docker exec -it mssql bash # initializes database with data docker cp $PWD/scripts/mssql/db-init.sql mssql:/home/db-init.sql -docker exec mssql ls "/home" +#docker exec mssql ls "/home" docker exec mssql /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P $SA_PASSWORD -i /home/db-init.sql ``` + +### Observability + +* Start containers (Prometheus, Zipkin and OpenTelemetry collector) + +```bash +docker-compose up +``` + +* Open web UIs: + * [Zipkin](http://localhost:9411/zipkin/) + * [Prometheus](http://localhost:9090/graph) + * [Grafana](http://localhost:3000/) + +* Remove containers + +```bash +docker-compose up +``` + +* Warnings: + * If you are running the containers on WSL, when the Linux VM memory is full you will face strange behaviors. +Do not hesitate to restart regularly WSL: `wsl --shutdown`. diff --git a/docs/servicenow.md b/docs/dependencies.md similarity index 97% rename from docs/servicenow.md rename to docs/dependencies.md index 21cded7..4d6f56c 100644 --- a/docs/servicenow.md +++ b/docs/dependencies.md @@ -1,4 +1,6 @@ -# ServiceNow resources +# Dependencies resources + +### ServiceNow ## General diff --git a/docs/dotnet.md b/docs/design.md similarity index 53% rename from docs/dotnet.md rename to docs/design.md index 955f4f1..dd52190 100644 --- a/docs/dotnet.md +++ b/docs/design.md @@ -1,4 +1,6 @@ -# .NET +# Design + +This codebase uses .NET framework and C# language. All technologies are free, open source and cross-platform. ## Code generation diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..9299966 --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,27 @@ +# Observability + +This solution uses [OpenTelemetry](https://opentelemetry.io/) to send logs, traces and metrics +in a vendor-neutral, robust and standardized way. + +## OpenTelemetry + +* Blog articles + * [Monitoring a .NET application using OpenTelemetry]( +https://www.meziantou.net/monitoring-a-dotnet-application-using-opentelemetry.htm), by Gérald Barré - November 15, 2021 + * [OpenTelemetry in .NET]( +https://rafaelldi.blog/posts/open-telemetry-in-dotnet/) by Rival Abdrakhmanov - January 14, 2022 + * [Optimally Configuring Open Telemetry Tracing for ASP.NET Core]( +https://rehansaeed.com/optimally-configuring-open-telemetry-tracing-for-asp-net-core/) by Muhammad Rehan Saeed - February 3, 2022 +* Documentation + * [.NET / Collect metrics](https://docs.microsoft.com/en-us/dotnet/core/diagnostics/metrics-collection) + * [OpenTelemetry / Instrumentation / .NET](https://opentelemetry.io/docs/instrumentation/net/) +* Projects + * [ASP.NET Core Instrumentation for OpenTelemetry]( +https://github.com/open-telemetry/opentelemetry-dotnet/tree/main/src/OpenTelemetry.Instrumentation.AspNetCore) + * Current metrics: `http.server.duration` (histogram) + * [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) + * [OpenTelemetry Collector Contrib](https://github.com/open-telemetry/opentelemetry-collector-contrib) +* Limitation + * .NET gRPC: [#3023](https://github.com/open-telemetry/opentelemetry-dotnet/issues/3023) +* Issue + * OpenTelemetry .NET RC9 not working with metrics: [#3078](https://github.com/open-telemetry/opentelemetry-dotnet/issues/3078) diff --git a/docs/getting-started.md b/docs/quickstart.md similarity index 80% rename from docs/getting-started.md rename to docs/quickstart.md index dbcab1a..dd14e09 100644 --- a/docs/getting-started.md +++ b/docs/quickstart.md @@ -1,4 +1,4 @@ -# Getting Started procedure +# Quickstart ## Requirements @@ -83,6 +83,22 @@ rm src/WebApi/appsettings.json cat > src/WebApi/appsettings.json < src/WebApi/appsettings.json < src/WebApi/appsettings.Development.json < logger, IConfigurationItemRelationshipRepository configurationItemRelationshipRepository) + : base(logger) { - _logger = logger; _configurationItemRelationshipRepository = configurationItemRelationshipRepository; } @@ -23,7 +21,7 @@ public ConfigurationItemRelationshipController( public async Task> Get() { var items = await _configurationItemRelationshipRepository.FindAllAsync(); - _logger.LogDebug("Number of items found: {itemsCount}", items.Count); + ReportListCount(items.Count); return items; } } diff --git a/samples/WebApiSample/Controllers/SwitchController.cs b/samples/WebApiSample/Controllers/SwitchController.cs index f039e66..cf3cb91 100644 --- a/samples/WebApiSample/Controllers/SwitchController.cs +++ b/samples/WebApiSample/Controllers/SwitchController.cs @@ -6,15 +6,13 @@ namespace RabbidsIncubator.Samples.ServiceNowWebApiSample.Controllers { [ApiController] [Route("switches")] - public class SwitchController : ControllerBase + public class SwitchController : RabbidsIncubator.ServiceNowClient.Application.Mvc.ControllerBase { - private readonly ILogger _logger; - private readonly ISwitchRepository _switchRepository; public SwitchController(ILogger logger, ISwitchRepository switchRepository) + : base(logger) { - _logger = logger; _switchRepository = switchRepository; } @@ -22,7 +20,7 @@ public SwitchController(ILogger logger, ISwitchRepository swit public async Task> Get([FromQuery] SwitchModel model, int? startIndex, int? limit) { var items = await _switchRepository.FindAllAsync(new QueryModel(model, startIndex, limit)); - _logger.LogDebug("Number of items found: {itemsCount}", items.Count); + ReportListCount(items.Count); return items; } } diff --git a/samples/WebApiSample/Infrastructure/ConfigurationItemRelationshipRepository.cs b/samples/WebApiSample/Infrastructure/ConfigurationItemRelationshipRepository.cs index 842f054..23d5102 100644 --- a/samples/WebApiSample/Infrastructure/ConfigurationItemRelationshipRepository.cs +++ b/samples/WebApiSample/Infrastructure/ConfigurationItemRelationshipRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using RabbidsIncubator.Samples.ServiceNowWebApiSample.Domain; +using RabbidsIncubator.ServiceNowClient.Domain.Diagnostics; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.Dto; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.Repositories; @@ -12,8 +13,9 @@ public ConfigurationItemRelationshipRepository( ILogger logger, IHttpClientFactory httpClientFactory, IMapper mapper, - ServiceNowRestClientConfiguration restApiConfiguration) - : base(logger, httpClientFactory, mapper, restApiConfiguration) + ServiceNowRestClientConfiguration restApiConfiguration, + IMetricsContext metricsContext) + : base(logger, httpClientFactory, mapper, restApiConfiguration, metricsContext) { } diff --git a/samples/WebApiSample/Infrastructure/SwitchRepository.cs b/samples/WebApiSample/Infrastructure/SwitchRepository.cs index ece6a4a..2e81777 100644 --- a/samples/WebApiSample/Infrastructure/SwitchRepository.cs +++ b/samples/WebApiSample/Infrastructure/SwitchRepository.cs @@ -1,5 +1,6 @@ using AutoMapper; using RabbidsIncubator.Samples.ServiceNowWebApiSample.Domain; +using RabbidsIncubator.ServiceNowClient.Domain.Diagnostics; using RabbidsIncubator.ServiceNowClient.Domain.Models; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.Repositories; @@ -12,8 +13,9 @@ public SwitchRepository( ILogger logger, IHttpClientFactory httpClientFactory, IMapper mapper, - ServiceNowRestClientConfiguration restApiConfiguration) - : base(logger, httpClientFactory, mapper, restApiConfiguration) + ServiceNowRestClientConfiguration restApiConfiguration, + IMetricsContext metricsContext) + : base(logger, httpClientFactory, mapper, restApiConfiguration, metricsContext) { } diff --git a/samples/WebApiSample/Program.cs b/samples/WebApiSample/Program.cs index 058d64d..d2c6c04 100644 --- a/samples/WebApiSample/Program.cs +++ b/samples/WebApiSample/Program.cs @@ -1,5 +1,5 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDefaultServices(builder.Configuration, new GeneratedServiceNowRestClientMappingProfile(), new InfrastructureMappingProfile()); +builder.Services.AddDefaultServices(builder.Configuration, builder.Logging, new GeneratedServiceNowRestClientMappingProfile(), new InfrastructureMappingProfile()); builder.Services.AddWebApiSampleInfrastructureRepositories(); builder.Services.AddServiceNowRestClientGeneratedRepositories(); builder.Services.AddSqlServerClientClientGeneratedRepositories(); diff --git a/samples/WebApiSample/Properties/launchSettings.json b/samples/WebApiSample/Properties/launchSettings.json index df81a87..e1ecdaf 100644 --- a/samples/WebApiSample/Properties/launchSettings.json +++ b/samples/WebApiSample/Properties/launchSettings.json @@ -1,9 +1,5 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true - }, "profiles": { "WebApi": { "commandName": "Project", diff --git a/samples/WebApiSample/WebApiSample.csproj b/samples/WebApiSample/WebApiSample.csproj index b053e80..64af515 100644 --- a/samples/WebApiSample/WebApiSample.csproj +++ b/samples/WebApiSample/WebApiSample.csproj @@ -23,7 +23,7 @@ - + diff --git a/samples/WebApiSample/appsettings.json b/samples/WebApiSample/appsettings.json index 76c2da8..f87699f 100644 --- a/samples/WebApiSample/appsettings.json +++ b/samples/WebApiSample/appsettings.json @@ -1,8 +1,20 @@ { "AllowedHosts": "*", "Application": { - "IsSwaggerEnabled": false, - "IsHttpsEnforced": true + "IsHttpsEnforced": true, + "IsOpenTelemetryEnabled": false, + "IsSecuredByAzureAd": true, + "IsSwaggerEnabled": false + }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "CallbackPath": "/signin-oidc", + "SignedOutCallbackPath": "/signout-callback-oidc" + }, + "Cache": { + "InMemory": { + "GeneralTimeoutInSeconds": 3600 + } }, "Logging": { "LogLevel": { @@ -11,9 +23,17 @@ "RabbidsIncubator": "Information" } }, - "Cache": { - "InMemory": { - "GeneralTimeoutInSeconds": 3600 + "OpenTelemetry": { + "Service": "SampleServiceNowRestClient", + "Metrics": { + "Meter": "SampleServiceNowRestClientMetrics" + }, + "Tracing": { + "Source": "SampleServiceNowRestClientTracing" } + }, + "OpenApi": { + "Title": "ServiceNow Client Web API sample", + "Version": "v1.0" } } diff --git a/samples/WebApiSample/entities.yml b/samples/WebApiSample/entities.yml index e8c86ba..2824cba 100644 --- a/samples/WebApiSample/entities.yml +++ b/samples/WebApiSample/entities.yml @@ -5,6 +5,7 @@ targetApplication: WebApp entities: - name: Location resourceName: locations + isAuthorizationRequired: false queries: findAll: serviceNowRestApiTable: cmn_location @@ -21,6 +22,7 @@ entities: mapFrom: longitude - name: DatabaseInventory resourceName: db-inventories + isAuthorizationRequired: false queries: findAll: sqlServerDatabaseTable: Inventory diff --git a/src/Application.Generators/Application.Generators.csproj b/src/Application.Generators/Application.Generators.csproj index 9e3d182..61a74e1 100644 --- a/src/Application.Generators/Application.Generators.csproj +++ b/src/Application.Generators/Application.Generators.csproj @@ -16,9 +16,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/src/Application.Generators/ControllerGenerator.cs b/src/Application.Generators/ControllerGenerator.cs index d94130e..31cf669 100644 --- a/src/Application.Generators/ControllerGenerator.cs +++ b/src/Application.Generators/ControllerGenerator.cs @@ -26,6 +26,7 @@ private static void GenerateController(GeneratorExecutionContext context, Models var sourceBuilder = new StringBuilder($@" using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using RabbidsIncubator.ServiceNowClient.Domain.Models; @@ -33,18 +34,24 @@ private static void GenerateController(GeneratorExecutionContext context, Models using {namespaces.Root}.Domain.Repositories; namespace {namespaces.WebApi}.Controllers -{{ +{{"); + + if (entity.IsAuthorizationRequired) + { + sourceBuilder.Append(@" + [Authorize]"); + } + + sourceBuilder.Append($@" [ApiController] [Route(""{entity.ResourceName}"")] - public partial class {entityPascalName}Controller : ControllerBase + public partial class {entityPascalName}Controller : RabbidsIncubator.ServiceNowClient.Application.Mvc.ControllerBase {{ - private readonly ILogger _logger; - private readonly I{entityPascalName}Repository _{entityCamelName}Repository; public {entityPascalName}Controller(ILogger<{entityPascalName}Controller> logger, I{entityPascalName}Repository {entityCamelName}Repository) + : base(logger) {{ - _logger = logger; _{entityCamelName}Repository = {entityCamelName}Repository; }} "); @@ -56,7 +63,7 @@ public partial class {entityPascalName}Controller : ControllerBase public async Task> Get(int? startIndex, int? limit) {{ var items = await _{entityCamelName}Repository.FindAllAsync(new QueryModel<{entityPascalName}Model>(null, startIndex, limit)); - _logger.LogDebug(""Number of items found: {{itemsCount}}"", items.Count); + ReportListCount(items.Count); return items; }} }} @@ -70,7 +77,7 @@ public partial class {entityPascalName}Controller : ControllerBase public async Task> Get([FromQuery] {entityPascalName}Model model, int? startIndex, int? limit) {{ var items = await _{entityCamelName}Repository.FindAllAsync(new QueryModel<{entityPascalName}Model>(model, startIndex, limit)); - _logger.LogDebug(""Number of items found: {{itemsCount}}"", items.Count); + ReportListCount(items.Count); return items; }} }} diff --git a/src/Application.Generators/Models/EntityModel.cs b/src/Application.Generators/Models/EntityModel.cs index 654d021..ed4d675 100644 --- a/src/Application.Generators/Models/EntityModel.cs +++ b/src/Application.Generators/Models/EntityModel.cs @@ -18,6 +18,11 @@ public class EntityModel /// public string ResourceName { get; set; } + /// + /// Is an authorization required to access this resource? + /// + public bool IsAuthorizationRequired { get; set; } = true; + /// /// Queries. /// diff --git a/src/Application.Generators/RepositoryGenerator.cs b/src/Application.Generators/RepositoryGenerator.cs index 5008876..c4a4247 100644 --- a/src/Application.Generators/RepositoryGenerator.cs +++ b/src/Application.Generators/RepositoryGenerator.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -83,6 +83,7 @@ private static void GenerateServiceNowRestClientRepository(GeneratorExecutionCon using System.Threading.Tasks; using AutoMapper; using Microsoft.Extensions.Logging; +using RabbidsIncubator.ServiceNowClient.Domain.Diagnostics; using RabbidsIncubator.ServiceNowClient.Domain.Models; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient; using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.Repositories; @@ -98,8 +99,9 @@ public partial class {entityPascalName}Repository : ServiceNowRestClientReposito ILogger<{entityPascalName}Repository> logger, IHttpClientFactory httpClientFactory, IMapper mapper, - ServiceNowRestClientConfiguration restApiConfiguration) - : base(logger, httpClientFactory, mapper, restApiConfiguration) + ServiceNowRestClientConfiguration restApiConfiguration, + IMetricsContext metricsContext) + : base(logger, httpClientFactory, mapper, restApiConfiguration, metricsContext) {{ }} "); diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index efbfd5e..462eb39 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -14,7 +14,17 @@ - + + + + + + + + + + + diff --git a/src/Application/Builder/ApplicationBuilderExtensions.cs b/src/Application/Builder/ApplicationBuilderExtensions.cs new file mode 100644 index 0000000..09dd735 --- /dev/null +++ b/src/Application/Builder/ApplicationBuilderExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Builder; +using RabbidsIncubator.ServiceNowClient.Application.Middlewares; + +namespace RabbidsIncubator.ServiceNowClient.Application.Builder +{ + public static class ApplicationBuilderExtensions + { + public static IApplicationBuilder UseActivityEnrichment( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/src/Application/Builder/WebApplicationExtensions.cs b/src/Application/Builder/WebApplicationExtensions.cs index 0d27c2b..8685672 100644 --- a/src/Application/Builder/WebApplicationExtensions.cs +++ b/src/Application/Builder/WebApplicationExtensions.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; +using Microsoft.OpenApi.Models; +using RabbidsIncubator.ServiceNowClient.Application.Configuration; namespace RabbidsIncubator.ServiceNowClient.Application.Builder { @@ -16,17 +18,25 @@ public static WebApplication AddDefaultMiddlewares( this WebApplication app, ConfigurationManager configuration) { - if (bool.TryParse(configuration["Application:IsSwaggerEnabled"], out var isSwaggerEnabled) && isSwaggerEnabled) + if (bool.TryParse(configuration[ConfigurationConstants.IsSwaggerEnabledConfigKey], out var isSwaggerEnabled) && isSwaggerEnabled) { + var openApi = configuration.GetSectionValue(ConfigurationConstants.OpenApiConfigKey); app.UseSwagger(); - app.UseSwaggerUI(); + app.UseSwaggerUI(c => c.SwaggerEndpoint($"/swagger/{openApi.Version}/swagger.json", + $"{openApi.Title} {openApi.Version}")); } - if (bool.TryParse(configuration["Application:IsHttpsEnforced"], out var isHttpsEnforced) && isHttpsEnforced) + if (bool.TryParse(configuration[ConfigurationConstants.IsHttpsEnforcedConfigKey], out var isHttpsEnforced) && isHttpsEnforced) { app.UseHttpsRedirection(); } + if (bool.TryParse(configuration[ConfigurationConstants.IsSecuredByAzureAdConfigKey], out var isSecuredByAzureAd) && isSecuredByAzureAd) + { + app.UseAuthentication(); + } + + app.UseActivityEnrichment(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health"); diff --git a/src/Application/Configuration/ConfigurationExtensions.cs b/src/Application/Configuration/ConfigurationExtensions.cs new file mode 100644 index 0000000..c6ff1d0 --- /dev/null +++ b/src/Application/Configuration/ConfigurationExtensions.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Extensions.Configuration; + +namespace RabbidsIncubator.ServiceNowClient.Application.Configuration +{ + public static class ConfigurationExtensions + { + public static T TryGetSection(this IConfiguration configuration, string sectionKey) + { + var section = configuration.GetSection(sectionKey); + return section.Get(); + } + + public static T GetSectionValue(this IConfiguration configuration, string sectionKey) + { + var value = configuration.TryGetSection(sectionKey); + if (value == null) + { + throw new ArgumentException($"Invalid configuration section \"{sectionKey}\" for type \"{typeof(T)}\"", + nameof(sectionKey)); + } + + return value; + } + } +} diff --git a/src/Application/ConfigurationConstants.cs b/src/Application/ConfigurationConstants.cs new file mode 100644 index 0000000..f0674ef --- /dev/null +++ b/src/Application/ConfigurationConstants.cs @@ -0,0 +1,31 @@ +namespace RabbidsIncubator.ServiceNowClient.Application +{ + public static class ConfigurationConstants + { + public const string IsHttpsEnforcedConfigKey = "Application:IsHttpsEnforced"; + + public const string IsSecuredByAzureAdConfigKey = "Application:IsSecuredByAzureAd"; + + public const string IsSwaggerEnabledConfigKey = "Application:IsSwaggerEnabled"; + + public const string IsOpenTelemetryEnabledConfigKey = "Application:IsOpenTelemetryEnabled"; + + public const string AzureAdConfigKey = "AzureAd"; + + public const string InMemoryCacheConfigKey = "Cache:InMemory"; + + public const string OpenApiConfigKey = "OpenApi"; + + public const string OpenTelemetryMetricsMeterConfigKey = "OpenTelemetry:Metrics:Meter"; + + public const string OpenTelemetryOtlpExporterEndpointConfigKey = "OpenTelemetry:OtlpExporter:Endpoint"; + + public const string OpenTelemetryServiceConfigKey = "OpenTelemetry:Service"; + + public const string OpenTelemetryTracingSourceConfigKey = "OpenTelemetry:Tracing:Source"; + + public const string ServiceNowRestApiConfigKey = "ServiceNow:RestApi"; + + public const string ServiceNowSqlServerConfigKey = "ServiceNow:SqlServer"; + } +} diff --git a/src/Application/DependencyInjection/AutoMapperExtensions.cs b/src/Application/DependencyInjection/AutoMapperExtensions.cs deleted file mode 100644 index 2a6366c..0000000 --- a/src/Application/DependencyInjection/AutoMapperExtensions.cs +++ /dev/null @@ -1,34 +0,0 @@ -using AutoMapper; -using Microsoft.Extensions.DependencyInjection; - -namespace RabbidsIncubator.ServiceNowClient.Application.DependencyInjection -{ - public static class AutoMapperExtensions - { - /// - /// Add AutoMapper configuration in service collection. - /// - /// Service collection - /// Additional profiles - /// - public static IServiceCollection AddAutoMapperConfiguration(this IServiceCollection services, params Profile[] additionalProfiles) - { - var mappingConfig = new MapperConfiguration(x => - { - x.AddProfile(new Infrastructure.ServiceNowRestClient.MappingProfiles.ServiceNowRestClientMappingProfile()); - x.AddProfile(new Infrastructure.SqlServerClient.MappingProfiles.SqlServerClientMappingProfile()); - if (additionalProfiles != null && additionalProfiles.Length > 0) - { - x.AddProfiles(additionalProfiles); - } - x.AllowNullCollections = true; - }); - - var mapper = mappingConfig.CreateMapper(); - - mapper.ConfigurationProvider.AssertConfigurationIsValid(); - services.AddSingleton(mapper); - return services; - } - } -} diff --git a/src/Application/DependencyInjection/ServiceCollectionExtensions.cs b/src/Application/DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..b375828 --- /dev/null +++ b/src/Application/DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using AutoMapper; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Web; +using Microsoft.OpenApi.Models; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using RabbidsIncubator.ServiceNowClient.Application.Configuration; +using RabbidsIncubator.ServiceNowClient.Infrastructure.InMemory.DependencyInjection; +using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.DependencyInjection; +using RabbidsIncubator.ServiceNowClient.Infrastructure.SqlServerClient.DependencyInjection; + +namespace RabbidsIncubator.ServiceNowClient.Application.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + /// + /// Add default services in the service collection. + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddDefaultServices(this IServiceCollection services, ConfigurationManager configuration, ILoggingBuilder logging, + params Profile[] additionalProfiles) + { + services.AddAuthentication(configuration, out var isSecuredByAzureAd); + services.AddOpenTelemetry(configuration, logging); + services.AddAutoMapper(additionalProfiles); + services.AddRepositories(configuration); + services.AddControllers(); + services.AddEndpointsApiExplorer(); + services.AddSwaggerGenWithOpenApiInfo(configuration, isSecuredByAzureAd); + services.AddHealthChecks(); + return services; + } + + private static IServiceCollection AddAuthentication(this IServiceCollection services, ConfigurationManager configuration, out bool isSecuredByAzureAd) + { + if (bool.TryParse(configuration[ConfigurationConstants.IsSecuredByAzureAdConfigKey], out isSecuredByAzureAd) && isSecuredByAzureAd) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(configuration.GetSection(ConfigurationConstants.AzureAdConfigKey)) + .EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + } + + return services; + } + + private static IServiceCollection AddOpenTelemetry(this IServiceCollection services, ConfigurationManager configuration, ILoggingBuilder logging, + Action? enrichAction = default) + { + if (bool.TryParse(configuration[ConfigurationConstants.IsOpenTelemetryEnabledConfigKey], out var isOpenTelemetryEnabled) && isOpenTelemetryEnabled) + { + var openTelemetryCollectorEndpoint = configuration[ConfigurationConstants.OpenTelemetryOtlpExporterEndpointConfigKey]; + var openTelemetryService = configuration[ConfigurationConstants.OpenTelemetryServiceConfigKey]; + + var openTelemetryMetricsMeter = configuration[ConfigurationConstants.OpenTelemetryMetricsMeterConfigKey]; + if (!string.IsNullOrEmpty(openTelemetryMetricsMeter)) + { + services.AddSingleton(); + services.AddOpenTelemetryMetrics(builder => + { + builder.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(openTelemetryService)); + builder.AddAspNetCoreInstrumentation(); + builder.AddHttpClientInstrumentation(); + builder.AddMeter(openTelemetryMetricsMeter); + builder.AddOtlpExporter(options => options.Endpoint = new Uri(openTelemetryCollectorEndpoint)); + }); + } + + var openTelemetryTracingSource = configuration[ConfigurationConstants.OpenTelemetryTracingSourceConfigKey]; + if (!string.IsNullOrEmpty(openTelemetryTracingSource)) + { + services.AddOpenTelemetryTracing(builder => + { + builder.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(openTelemetryService)); + builder.AddAspNetCoreInstrumentation(options => + { + options.RecordException = true; + if (enrichAction != default) + { + options.Enrich = enrichAction; + } + }); + builder.AddHttpClientInstrumentation(); + builder.AddSqlClientInstrumentation(); + builder.AddSource(openTelemetryTracingSource); + builder.AddOtlpExporter(options => options.Endpoint = new Uri(openTelemetryCollectorEndpoint)); + }); + } + + logging.AddOpenTelemetry(builder => + { + builder.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(openTelemetryService)); + builder.IncludeFormattedMessage = true; + builder.IncludeScopes = true; + builder.ParseStateValues = true; + builder.AddOtlpExporter(options => options.Endpoint = new Uri(openTelemetryCollectorEndpoint)); + }); + } + + return services; + } + + /// + /// Add AutoMapper configuration in service collection. + /// + /// Service collection + /// Additional profiles + /// + public static IServiceCollection AddAutoMapper(this IServiceCollection services, params Profile[] additionalProfiles) + { + var mappingConfig = new MapperConfiguration(x => + { + x.AddProfile(new Infrastructure.ServiceNowRestClient.MappingProfiles.ServiceNowRestClientMappingProfile()); + x.AddProfile(new Infrastructure.SqlServerClient.MappingProfiles.SqlServerClientMappingProfile()); + if (additionalProfiles != null && additionalProfiles.Length > 0) + { + x.AddProfiles(additionalProfiles); + } + x.AllowNullCollections = true; + }); + + var mapper = mappingConfig.CreateMapper(); + + mapper.ConfigurationProvider.AssertConfigurationIsValid(); + services.AddSingleton(mapper); + return services; + } + + private static IServiceCollection AddRepositories(this IServiceCollection services, ConfigurationManager configuration) + { + services.AddInMemoryRepositories(configuration.GetSectionValue(ConfigurationConstants.InMemoryCacheConfigKey)); + + services.AddServiceNowRestClientRepositories(configuration.GetSectionValue(ConfigurationConstants.ServiceNowRestApiConfigKey)); + if (configuration.TryGetSection(ConfigurationConstants.ServiceNowSqlServerConfigKey) != null) + { + services.AddSqlServerClientRepositories(configuration.GetSectionValue(ConfigurationConstants.ServiceNowSqlServerConfigKey)); + } + + return services; + } + + private static IServiceCollection AddSwaggerGenWithOpenApiInfo(this IServiceCollection services, ConfigurationManager configuration, bool isSecured) + { + services.AddSwaggerGen(c => + { + var openApi = configuration.GetSectionValue(ConfigurationConstants.OpenApiConfigKey); + c.SwaggerDoc(openApi.Version, openApi); + if (isSecured) + { + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme.\r\n\r\nEnter your token in the text input below.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "Bearer" + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + }, + Name = "Bearer", + In = ParameterLocation.Header, + }, + new List() + } + }); + } + }); + + return services; + } + } +} diff --git a/src/Application/DependencyInjection/WebApplicationServicesExtensions.cs b/src/Application/DependencyInjection/WebApplicationServicesExtensions.cs deleted file mode 100644 index 7866445..0000000 --- a/src/Application/DependencyInjection/WebApplicationServicesExtensions.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using RabbidsIncubator.ServiceNowClient.Infrastructure.InMemory.DependencyInjection; -using RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.DependencyInjection; -using RabbidsIncubator.ServiceNowClient.Infrastructure.SqlServerClient.DependencyInjection; - -namespace RabbidsIncubator.ServiceNowClient.Application.DependencyInjection -{ - public static class WebApplicationServicesExtensions - { - public const string InMemoryCacheConfigKey = "Cache:InMemory"; - - public const string RestApiServiceNowConfigKey = "ServiceNow:RestApi"; - - public const string SqlServerServiceNowConfigKey = "ServiceNow:SqlServer"; - - /// - /// Add default services in the service collection. - /// Expected configuration elements: "Cache:InMemory", "ServiceNow:RestApi", "ServiceNow:SqlServer". - /// - /// - /// - /// - /// - public static IServiceCollection AddDefaultServices( - this IServiceCollection services, - ConfigurationManager configuration, - params AutoMapper.Profile[] additionalProfiles) - { - services.AddAutoMapperConfiguration(additionalProfiles); - services.AddInMemoryRepositories(configuration.GetSectionValue(InMemoryCacheConfigKey)); - services.AddServiceNowRestClientRepositories(configuration.GetSectionValue(RestApiServiceNowConfigKey)); - if (configuration.TryGetSection(SqlServerServiceNowConfigKey) != null) - { - services.AddSqlServerClientRepositories(configuration.GetSectionValue(SqlServerServiceNowConfigKey)); - } - services.AddControllers(); - services.AddEndpointsApiExplorer(); - services.AddSwaggerGen(); - services.AddHealthChecks(); - return services; - } - - public static T TryGetSection(this IConfiguration configuration, string sectionKey) - { - var section = configuration.GetSection(sectionKey); - return section.Get(); - } - - public static T GetSectionValue(this IConfiguration configuration, string sectionKey) - { - var value = configuration.TryGetSection(sectionKey); - if (value == null) - { - throw new ArgumentException($"Missing section \"{sectionKey}\" in configuration"); - } - - return value; - } - } -} diff --git a/src/Application/Diagnostics/MetricsContext.cs b/src/Application/Diagnostics/MetricsContext.cs new file mode 100644 index 0000000..c0ad9b9 --- /dev/null +++ b/src/Application/Diagnostics/MetricsContext.cs @@ -0,0 +1,62 @@ +using System.Collections.Generic; +using System.Diagnostics.Metrics; +using Microsoft.Extensions.Configuration; +using RabbidsIncubator.ServiceNowClient.Domain.Diagnostics; + +namespace RabbidsIncubator.ServiceNowClient.Application.Diagnostics +{ + public class MetricsContext : IMetricsContext + { + private readonly string _name; + + private readonly Dictionary _instruments = new(); + + private Meter? _meter; + + public MetricsContext(IConfiguration configuration) + { + _name = configuration[ConfigurationConstants.OpenTelemetryMetricsMeterConfigKey]; + } + + public Meter Meter + { + get + { + if (_meter == null) + { + _meter = new Meter(_name); + } + return _meter; + } + } + + public void AddToCounter(string name, T value, KeyValuePair? tag = null) + where T : struct + { + if (!_instruments.ContainsKey(name)) + { + CreateCounter(name); + } + + if (_instruments.ContainsKey(name) && _instruments[name] is Counter counter) + { + if (tag == null) + { + counter.Add(value); + } + else + { + counter.Add(value, tag.Value); + } + } + } + + private Counter CreateCounter(string name) + where T : struct + { + var counter = Meter.CreateCounter(name); + _instruments.Add(name, counter); + return counter; + } + } +} diff --git a/src/Application/Middlewares/DiagnosticsEnrichmentMiddleware.cs b/src/Application/Middlewares/DiagnosticsEnrichmentMiddleware.cs new file mode 100644 index 0000000..e063e6e --- /dev/null +++ b/src/Application/Middlewares/DiagnosticsEnrichmentMiddleware.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace RabbidsIncubator.ServiceNowClient.Application.Middlewares +{ + /// + /// Middleware that will add information to diagnostics activity. + /// + /// + /// https://docs.microsoft.com/en-us/aspnet/core/fundamentals/middleware/write + /// + public class DiagnosticsEnrichmentMiddleware + { + private readonly RequestDelegate _next; + + public DiagnosticsEnrichmentMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, Domain.Diagnostics.IMetricsContext metricsContext) + { + metricsContext.AddToCounter("http.requests", 1, KeyValuePair.Create("path", context.Request.Path.ToString())); + + Activity.Current?.AddTag("user.name", (context.User.Identity as ClaimsIdentity)?.FindFirst("name")?.Value); + + await _next(context); + } + } +} diff --git a/src/Application/Mvc/ControllerBase.cs b/src/Application/Mvc/ControllerBase.cs new file mode 100644 index 0000000..370541e --- /dev/null +++ b/src/Application/Mvc/ControllerBase.cs @@ -0,0 +1,21 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +namespace RabbidsIncubator.ServiceNowClient.Application.Mvc +{ + public abstract class ControllerBase : Microsoft.AspNetCore.Mvc.ControllerBase + { + protected ILogger Logger { get; private set; } + + protected ControllerBase(ILogger logger) + { + Logger = logger; + } + + protected void ReportListCount(int count) + { + Logger.LogDebug("Number of items found: {itemsCount}", count); + Activity.Current?.AddTag("response.list_size", count); + } + } +} diff --git a/src/ConsoleApp/ConsoleApp.csproj b/src/ConsoleApp/ConsoleApp.csproj index 012d701..85f7d58 100644 --- a/src/ConsoleApp/ConsoleApp.csproj +++ b/src/ConsoleApp/ConsoleApp.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/ConsoleApp/Program.cs b/src/ConsoleApp/Program.cs index 167c5cc..722bd67 100644 --- a/src/ConsoleApp/Program.cs +++ b/src/ConsoleApp/Program.cs @@ -74,7 +74,7 @@ private static ServiceProvider CreateServiceProvider(CommandLineOptions opts, Ap }) .AddServiceNowRestClientGeneratedRepositories() .AddServiceNowRestClientRepositories(appConfiguration.ServiceNowRestClientConfiguration) - .AddAutoMapperConfiguration(new GeneratedServiceNowRestClientMappingProfile()); + .AddAutoMapper(new GeneratedServiceNowRestClientMappingProfile()); return serviceCollection.BuildServiceProvider(); } diff --git a/src/Domain/Diagnostics/IMetricsContext.cs b/src/Domain/Diagnostics/IMetricsContext.cs new file mode 100644 index 0000000..319acc2 --- /dev/null +++ b/src/Domain/Diagnostics/IMetricsContext.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; + +namespace RabbidsIncubator.ServiceNowClient.Domain.Diagnostics +{ + public interface IMetricsContext + { + void AddToCounter(string name, T value, KeyValuePair? tag = null) where T : struct; + } +} diff --git a/src/Infrastructure.InMemory/Infrastructure.InMemory.csproj b/src/Infrastructure.InMemory/Infrastructure.InMemory.csproj index b90d338..be81851 100644 --- a/src/Infrastructure.InMemory/Infrastructure.InMemory.csproj +++ b/src/Infrastructure.InMemory/Infrastructure.InMemory.csproj @@ -14,7 +14,7 @@ - + diff --git a/src/Infrastructure.ServiceNowRestClient/Infrastructure.ServiceNowRestClient.csproj b/src/Infrastructure.ServiceNowRestClient/Infrastructure.ServiceNowRestClient.csproj index 21d36f1..53ce6a5 100644 --- a/src/Infrastructure.ServiceNowRestClient/Infrastructure.ServiceNowRestClient.csproj +++ b/src/Infrastructure.ServiceNowRestClient/Infrastructure.ServiceNowRestClient.csproj @@ -14,11 +14,11 @@ - + - - - + + + diff --git a/src/Infrastructure.ServiceNowRestClient/Repositories/ServiceNowRestClientRepositoryBase.cs b/src/Infrastructure.ServiceNowRestClient/Repositories/ServiceNowRestClientRepositoryBase.cs index 29572a7..0fe86da 100644 --- a/src/Infrastructure.ServiceNowRestClient/Repositories/ServiceNowRestClientRepositoryBase.cs +++ b/src/Infrastructure.ServiceNowRestClient/Repositories/ServiceNowRestClientRepositoryBase.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using AutoMapper; using Microsoft.Extensions.Logging; +using RabbidsIncubator.ServiceNowClient.Domain.Diagnostics; namespace RabbidsIncubator.ServiceNowClient.Infrastructure.ServiceNowRestClient.Repositories { @@ -21,17 +22,21 @@ protected ServiceNowRestClientRepositoryBase( ILogger logger, IHttpClientFactory httpClientFactory, IMapper mapper, - ServiceNowRestClientConfiguration restApiConfiguration) + ServiceNowRestClientConfiguration restApiConfiguration, + IMetricsContext metricsContext) : base(logger, httpClientFactory) { Mapper = mapper; _restApiConfiguration = restApiConfiguration; + MetricsContext = metricsContext; } protected override string HttpClientName => _restApiConfiguration.HttpClientName; protected IMapper Mapper { get; private set; } + protected IMetricsContext MetricsContext { get; private set; } + /// /// Generate URL from parameters. /// @@ -90,6 +95,7 @@ protected async Task> FindAllAsync(string tableName, Domain.Models where T : class where U : Dto.IEntityDto { + MetricsContext.AddToCounter("servicenow.restapi_requests", 1); var url = GenerateUrl(tableName, queryModel, extraParameters); var resultList = await GetAsync>(url); return Mapper.Map>(resultList.Result); diff --git a/src/Infrastructure.SqlServerClient/Infrastructure.SqlServerClient.csproj b/src/Infrastructure.SqlServerClient/Infrastructure.SqlServerClient.csproj index 7bc9caa..6d257e8 100644 --- a/src/Infrastructure.SqlServerClient/Infrastructure.SqlServerClient.csproj +++ b/src/Infrastructure.SqlServerClient/Infrastructure.SqlServerClient.csproj @@ -14,9 +14,9 @@ - + - + diff --git a/src/Infrastructure.SqlServerClient/Repositories/SqlServerClientRepositoryBase.cs b/src/Infrastructure.SqlServerClient/Repositories/SqlServerClientRepositoryBase.cs index a5245f8..7e81994 100644 --- a/src/Infrastructure.SqlServerClient/Repositories/SqlServerClientRepositoryBase.cs +++ b/src/Infrastructure.SqlServerClient/Repositories/SqlServerClientRepositoryBase.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Data.SqlClient; using System.Threading.Tasks; diff --git a/test/Application.Generators.UnitTests/Application.Generators.UnitTests.csproj b/test/Application.Generators.UnitTests/Application.Generators.UnitTests.csproj index f2c8b48..4d1aa6b 100644 --- a/test/Application.Generators.UnitTests/Application.Generators.UnitTests.csproj +++ b/test/Application.Generators.UnitTests/Application.Generators.UnitTests.csproj @@ -15,16 +15,16 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Application.Generators.UnitTests/ControllerGeneratorTest.cs b/test/Application.Generators.UnitTests/ControllerGeneratorTest.cs index 3a502c0..dd1a66e 100644 --- a/test/Application.Generators.UnitTests/ControllerGeneratorTest.cs +++ b/test/Application.Generators.UnitTests/ControllerGeneratorTest.cs @@ -1,4 +1,5 @@ using System.Collections.Immutable; +using System.IO; using System.Text; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Testing; @@ -16,6 +17,8 @@ public class ControllerGeneratorTest public async Task ControllerGeneratorGenerateCode() { var original = @" +using System.Diagnostics; +using Microsoft.Extensions.Logging; using System.Collections.Generic; using System.Threading.Tasks; using RabbidsIncubator.ServiceNowClient.Domain.Models; @@ -32,10 +35,28 @@ public interface ILocationRepository Task> FindAllAsync(QueryModel query); } } +namespace RabbidsIncubator.ServiceNowClient.Application.Mvc +{ + public abstract class ControllerBase : Microsoft.AspNetCore.Mvc.ControllerBase + { + protected ILogger Logger { get; private set; } + + protected ControllerBase(ILogger logger) + { + Logger = logger; + } + + protected void ReportListCount(int count) + { + Logger.LogDebug(""Dummy log""); + } + } +} "; var expected = @" using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using RabbidsIncubator.ServiceNowClient.Domain.Models; @@ -44,17 +65,16 @@ public interface ILocationRepository namespace RabbidsIncubator.ServiceNowClient.DummyProject.Controllers { + [Authorize] [ApiController] [Route(""locations"")] - public partial class LocationController : ControllerBase + public partial class LocationController : RabbidsIncubator.ServiceNowClient.Application.Mvc.ControllerBase { - private readonly ILogger _logger; - private readonly ILocationRepository _locationRepository; public LocationController(ILogger logger, ILocationRepository locationRepository) + : base(logger) { - _logger = logger; _locationRepository = locationRepository; } @@ -62,7 +82,7 @@ public LocationController(ILogger logger, ILocationRepositor public async Task> Get([FromQuery] LocationModel model, int? startIndex, int? limit) { var items = await _locationRepository.FindAllAsync(new QueryModel(model, startIndex, limit)); - _logger.LogDebug(""Number of items found: {itemsCount}"", items.Count); + ReportListCount(items.Count); return items; } } diff --git a/test/Application.Generators.UnitTests/ModelGeneratorTest.cs b/test/Application.Generators.UnitTests/ModelGeneratorTest.cs index 9a3b176..cfa4ef9 100644 --- a/test/Application.Generators.UnitTests/ModelGeneratorTest.cs +++ b/test/Application.Generators.UnitTests/ModelGeneratorTest.cs @@ -1,4 +1,4 @@ -using System.IO; +using System.IO; using System.Text; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Testing; diff --git a/test/Application.UnitTests/Application.UnitTests.csproj b/test/Application.UnitTests/Application.UnitTests.csproj index 34dba92..20a0ce4 100644 --- a/test/Application.UnitTests/Application.UnitTests.csproj +++ b/test/Application.UnitTests/Application.UnitTests.csproj @@ -15,14 +15,14 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/Application.UnitTests/DependencyInjection/AutoMapperExtensionsTest.cs b/test/Application.UnitTests/DependencyInjection/AutoMapperExtensionsTest.cs index c5550bf..adace68 100644 --- a/test/Application.UnitTests/DependencyInjection/AutoMapperExtensionsTest.cs +++ b/test/Application.UnitTests/DependencyInjection/AutoMapperExtensionsTest.cs @@ -9,13 +9,13 @@ namespace RabbidsIncubator.ServiceNowClient.Application.UnitTests.DependencyInje public class AutoMapperExtensionsTest { [Fact] - public void AutoMapperExtensions_AddAutoMapperConfiguration_RegisterIMapper() + public void AutoMapperExtensions_AddAutoMapper_RegisterIMapper() { // Arrange var serviceCollection = new ServiceCollection(); // Act - serviceCollection.AddAutoMapperConfiguration(); + serviceCollection.AddAutoMapper(); // Assert var serviceProvider = serviceCollection.BuildServiceProvider(); diff --git a/test/Infrastructure.ServiceNowRestClient.UnitTests/Infrastructure.ServiceNowRestClient.UnitTests.csproj b/test/Infrastructure.ServiceNowRestClient.UnitTests/Infrastructure.ServiceNowRestClient.UnitTests.csproj index db440f7..3e3d63c 100644 --- a/test/Infrastructure.ServiceNowRestClient.UnitTests/Infrastructure.ServiceNowRestClient.UnitTests.csproj +++ b/test/Infrastructure.ServiceNowRestClient.UnitTests/Infrastructure.ServiceNowRestClient.UnitTests.csproj @@ -15,13 +15,13 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/test/WebApiSample.IntegrationTests/WebApiSample.IntegrationTests.csproj b/test/WebApiSample.IntegrationTests/WebApiSample.IntegrationTests.csproj index 1f89ecd..bff383d 100644 --- a/test/WebApiSample.IntegrationTests/WebApiSample.IntegrationTests.csproj +++ b/test/WebApiSample.IntegrationTests/WebApiSample.IntegrationTests.csproj @@ -15,15 +15,15 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive