├── test └── BookStoreMetrics.UnitTest │ ├── GlobalUsings.cs │ ├── BookStoreMetrics.UnitTest.csproj │ └── BookStoreMetricsTests.cs ├── scripts ├── prometheus │ ├── Dockerfile │ └── prometheus.yml ├── sql │ ├── entrypoint.sh │ ├── import-data.sh │ ├── Dockerfile │ └── setup.sql ├── grafana │ ├── prometheus-dashboards.yml │ ├── Dockerfile │ ├── prometheus-connector.yml │ └── dashboards │ │ ├── aspnet-core-endpoints-dashboard.json │ │ └── bookstore-dashboard.json └── otel-collector │ └── otel-collector-config.yaml ├── docs ├── grafana-dashboards.png ├── app-otel-metrics-diagram.png ├── bookstore-custom-metrics.png ├── bookstore-database-diagram.png ├── aspnet-core-metrics-dashboard.png ├── aspnet-core-books-endpoint-dashboard.png ├── aspnet-core-orders-endpoint-dashboard.png └── runtime-perf-counters-and-process-dashboard.png ├── src ├── BookStore.Domain │ ├── Models │ │ ├── Entity.cs │ │ ├── Category.cs │ │ ├── Inventory.cs │ │ ├── Book.cs │ │ └── Order.cs │ ├── BookStore.Domain.csproj │ ├── Interfaces │ │ ├── ICategoryRepository.cs │ │ ├── IOrderRepository.cs │ │ ├── IInventoryRepository.cs │ │ ├── IOrderService.cs │ │ ├── IBookRepository.cs │ │ ├── IInventoryService.cs │ │ ├── ICategoryService.cs │ │ ├── IBookService.cs │ │ └── IRepository.cs │ └── Services │ │ ├── InventoryService.cs │ │ ├── CategoryService.cs │ │ ├── BookService.cs │ │ └── OrderService.cs ├── BookStore.WebApi │ ├── appsettings.Development.json │ ├── Dtos │ │ ├── Category │ │ │ ├── CategoryResultDto.cs │ │ │ ├── CategoryAddDto.cs │ │ │ └── CategoryEditDto.cs │ │ ├── Inventory │ │ │ ├── InventoryResultDto.cs │ │ │ ├── InventoryEditDto.cs │ │ │ └── InventoryAddDto.cs │ │ ├── Book │ │ │ ├── BookResultDto.cs │ │ │ ├── BookAddDto.cs │ │ │ └── BookEditDto.cs │ │ └── Order │ │ │ ├── OrderResultDto.cs │ │ │ └── OrderAddDto.cs │ ├── appsettings.json │ ├── Properties │ │ └── launchSettings.json │ ├── Middleware │ │ ├── SimulatedLatencyExtensions.cs │ │ └── SimulatedLatencyMiddleware.cs │ ├── Dockerfile │ ├── BookStore.WebApi.csproj │ ├── Program.cs │ ├── Configuration │ │ └── AutoMapperConfig.cs │ └── Controllers │ │ ├── OrdersController.cs │ │ ├── InventoriesController.cs │ │ ├── CategoriesController.cs │ │ └── BooksController.cs └── BookStore.Infrastructure │ ├── Repositories │ ├── InventoryRepository.cs │ ├── CategoryRepository.cs │ ├── OrderRepository.cs │ ├── Repository.cs │ └── BookRepository.cs │ ├── BookStore.Infrastructure.csproj │ ├── Configuration │ └── InfrastructureDependencies.cs │ ├── Metrics │ └── BookStoreMetrics.cs │ └── Context │ └── BookStoreDbContext.cs ├── .dockerignore ├── docker-compose.yml ├── BookStore.sln ├── .gitignore ├── README.md └── seed-data.sh /test/BookStoreMetrics.UnitTest/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using Xunit; -------------------------------------------------------------------------------- /scripts/prometheus/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM prom/prometheus:v2.48.0 2 | 3 | ADD prometheus.yml /etc/prometheus/ -------------------------------------------------------------------------------- /docs/grafana-dashboards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/grafana-dashboards.png -------------------------------------------------------------------------------- /docs/app-otel-metrics-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/app-otel-metrics-diagram.png -------------------------------------------------------------------------------- /docs/bookstore-custom-metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/bookstore-custom-metrics.png -------------------------------------------------------------------------------- /docs/bookstore-database-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/bookstore-database-diagram.png -------------------------------------------------------------------------------- /docs/aspnet-core-metrics-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/aspnet-core-metrics-dashboard.png -------------------------------------------------------------------------------- /docs/aspnet-core-books-endpoint-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/aspnet-core-books-endpoint-dashboard.png -------------------------------------------------------------------------------- /docs/aspnet-core-orders-endpoint-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/aspnet-core-orders-endpoint-dashboard.png -------------------------------------------------------------------------------- /docs/runtime-perf-counters-and-process-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/HEAD/docs/runtime-perf-counters-and-process-dashboard.png -------------------------------------------------------------------------------- /src/BookStore.Domain/Models/Entity.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.Domain.Models 2 | { 3 | public abstract class Entity 4 | { 5 | public int Id { get; set; } 6 | } 7 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/BookStore.Domain.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /scripts/sql/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #start SQL Server, start the script to create the DB and import the data, start the app 2 | #/opt/mssql/bin/sqlserver & /usr/src/app/import-data.sh 3 | /usr/src/app/import-data.sh & /opt/mssql/bin/sqlservr -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/ICategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using BookStore.Domain.Models; 2 | 3 | namespace BookStore.Domain.Interfaces 4 | { 5 | public interface ICategoryRepository : IRepository 6 | { 7 | 8 | } 9 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Category/CategoryResultDto.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Dtos.Category 2 | { 3 | public class CategoryResultDto 4 | { 5 | public int Id { get; set; } 6 | 7 | public string Name { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Inventory/InventoryResultDto.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Dtos.Inventory 2 | { 3 | public class InventoryResultDto 4 | { 5 | public int BookId { get; set; } 6 | 7 | public int Amount { get; set; } 8 | } 9 | } -------------------------------------------------------------------------------- /scripts/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | global: 2 | scrape_interval: 15s 3 | 4 | scrape_configs: 5 | - job_name: 'otel-collector' 6 | scrape_interval: 5s 7 | static_configs: 8 | - targets: ['otel-collector:8889'] 9 | - targets: ['otel-collector:8888'] -------------------------------------------------------------------------------- /src/BookStore.Domain/Models/Category.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace BookStore.Domain.Models 4 | { 5 | public class Category : Entity 6 | { 7 | public string Name { get; set; } 8 | 9 | public IEnumerable Books { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /scripts/sql/import-data.sh: -------------------------------------------------------------------------------- 1 | 2 | # Wait to be sure that SQL Server came up 3 | sleep 20s 4 | 5 | # Run the setup script to create the DB and the schema in the DB 6 | # Note: make sure that your password matches what is in the Dockerfile 7 | /opt/mssql-tools/bin/sqlcmd -S localhost -U SA -P P@ssw0rd? -d master -i setup.sql -------------------------------------------------------------------------------- /scripts/grafana/prometheus-dashboards.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'prometheus-net' 5 | orgId: 1 6 | type: file 7 | disableDeletion: true 8 | updateIntervalSeconds: 10 9 | allowUiUpdates: true 10 | options: 11 | path: /var/lib/grafana/dashboards 12 | foldersFromFilesStructure: true -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IOrderRepository.cs: -------------------------------------------------------------------------------- 1 | using BookStore.Domain.Models; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace BookStore.Domain.Interfaces 6 | { 7 | public interface IOrderRepository: IRepository 8 | { 9 | Task> GetOrdersByBookId(int bookId); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IInventoryRepository.cs: -------------------------------------------------------------------------------- 1 | using BookStore.Domain.Models; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace BookStore.Domain.Interfaces 6 | { 7 | public interface IInventoryRepository : IRepository 8 | { 9 | Task> SearchInventoryForBook(string bookName); 10 | } 11 | } -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /scripts/otel-collector/otel-collector-config.yaml: -------------------------------------------------------------------------------- 1 | receivers: 2 | otlp: 3 | protocols: 4 | grpc: 5 | 6 | exporters: 7 | prometheus: 8 | endpoint: "0.0.0.0:8889" 9 | 10 | processors: 11 | batch: 12 | 13 | extensions: 14 | health_check: 15 | 16 | service: 17 | extensions: [health_check] 18 | pipelines: 19 | metrics: 20 | receivers: [otlp] 21 | processors: [batch] 22 | exporters: [prometheus] -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Category/CategoryAddDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Category 4 | { 5 | public class CategoryAddDto 6 | { 7 | [Required(ErrorMessage = "The field {0} is required")] 8 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 9 | public string Name { get; set; } 10 | } 11 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IOrderService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using BookStore.Domain.Models; 3 | using System.Collections.Generic; 4 | using System.Threading.Tasks; 5 | 6 | namespace BookStore.Domain.Interfaces 7 | { 8 | public interface IOrderService 9 | { 10 | Task> GetAll(); 11 | Task GetById(int id); 12 | Task Add(Order order); 13 | Task Remove(Order order); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "ConnectionStrings": { 10 | "DbConnection": "Server=(localdb)\\mssqllocaldb;Database=BookStore;Trusted_Connection=True;MultipleActiveResultSets=true" 11 | }, 12 | "Otlp": { 13 | "Endpoint": "localhost:4317" 14 | }, 15 | "BookStoreMeterName": "BookStore" 16 | } 17 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Inventory/InventoryEditDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Inventory 4 | { 5 | public class InventoryEditDto 6 | { 7 | [Key] 8 | public int BookId { get; set; } 9 | 10 | [Required(ErrorMessage = "The field {0} is required")] 11 | [Range(0, int.MaxValue, ErrorMessage = "Please enter a value bigger than {1}")] 12 | public int Amount { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "BookStore.WebApi": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": true, 8 | "launchUrl": "swagger", 9 | "applicationUrl": "https://localhost:7187;http://localhost:5187", 10 | "environmentVariables": { 11 | "ASPNETCORE_ENVIRONMENT": "Development" 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IBookRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using BookStore.Domain.Models; 4 | 5 | namespace BookStore.Domain.Interfaces 6 | { 7 | public interface IBookRepository : IRepository 8 | { 9 | new Task> GetAll(); 10 | new Task GetById(int id); 11 | Task> GetBooksByCategory(int categoryId); 12 | Task> SearchBookWithCategory(string searchedValue); 13 | } 14 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Inventory/InventoryAddDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Inventory 4 | { 5 | public class InventoryAddDto 6 | { 7 | [Required(ErrorMessage = "The field {0} is required")] 8 | public int BookId { get; set; } 9 | 10 | [Required(ErrorMessage = "The field {0} is required")] 11 | [Range(0, int.MaxValue, ErrorMessage = "Please enter a value bigger than {1}")] 12 | public int Amount { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /scripts/grafana/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM grafana/grafana:10.2.2 2 | COPY ./prometheus-connector.yml /etc/grafana/provisioning/datasources/ 3 | COPY ./prometheus-dashboards.yml /etc/grafana/provisioning/dashboards/ 4 | COPY ./dashboards/bookstore-dashboard.json /var/lib/grafana/dashboards/ 5 | COPY ./dashboards/dotnet-performance-counters-dashboard.json /var/lib/grafana/dashboards/ 6 | COPY ./dashboards/aspnet-core-metrics-dashboard.json /var/lib/grafana/dashboards/ 7 | COPY ./dashboards/aspnet-core-endpoints-dashboard.json /var/lib/grafana/dashboards/ 8 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Category/CategoryEditDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Category 4 | { 5 | public class CategoryEditDto 6 | { 7 | [Required(ErrorMessage = "The field {0} is required")] 8 | public int Id { get; set; } 9 | 10 | [Required(ErrorMessage = "The field {0} is required")] 11 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 12 | public string Name { get; set; } 13 | } 14 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Book/BookResultDto.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Dtos.Book 2 | { 3 | public class BookResultDto 4 | { 5 | public int Id { get; set; } 6 | 7 | public int CategoryId { get; set; } 8 | 9 | public string CategoryName { get; set; } 10 | 11 | public string Name { get; set; } 12 | 13 | public string Author { get; set; } 14 | 15 | public string Description { get; set; } 16 | 17 | public double Value { get; set; } 18 | 19 | public DateTime PublishDate { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Middleware/SimulatedLatencyExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Middleware 2 | { 3 | public static class SimulatedLatencyExtensions 4 | { 5 | public static IApplicationBuilder UseSimulatedLatency( 6 | this IApplicationBuilder app, 7 | TimeSpan min, 8 | TimeSpan max 9 | ) 10 | { 11 | return app.UseMiddleware( 12 | typeof(SimulatedLatencyMiddleware), 13 | min, 14 | max 15 | ); 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/BookStore.Domain/Models/Inventory.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.Domain.Models 2 | { 3 | public class Inventory: Entity 4 | { 5 | public int Amount { get; set; } 6 | public virtual Book Book { get; set; } 7 | 8 | public bool HasInventoryAvailable() 9 | { 10 | return Amount > 0; 11 | } 12 | 13 | public void DecreaseInventory() 14 | { 15 | Amount -= 1; 16 | } 17 | 18 | public void IncreaseInventory() 19 | { 20 | Amount += 1; 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Order/OrderResultDto.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Dtos.Order 2 | { 3 | public class OrderResultDto 4 | { 5 | public int Id { get; set; } 6 | 7 | public string CustomerName { get; set; } 8 | 9 | public string Address { get; set; } 10 | 11 | public string Telephone { get; set; } 12 | 13 | public string City { get; set; } 14 | 15 | public string Status { get; set; } 16 | 17 | public string TotalAmount { get; set; } 18 | 19 | public IEnumerable Books { get; set; } 20 | } 21 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IInventoryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Models; 5 | 6 | namespace BookStore.Domain.Interfaces 7 | { 8 | public interface IInventoryService 9 | { 10 | Task GetById(int id); 11 | Task Add(Inventory inventory); 12 | Task Update(Inventory inventory); 13 | Task Remove(Inventory inventory); 14 | Task> SearchInventoryForBook(string searchedValue); 15 | } 16 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/ICategoryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Models; 5 | 6 | namespace BookStore.Domain.Interfaces 7 | { 8 | public interface ICategoryService 9 | { 10 | Task> GetAll(); 11 | Task GetById(int id); 12 | Task Add(Category category); 13 | Task Update(Category category); 14 | Task Remove(Category category); 15 | Task> Search(string categoryName); 16 | } 17 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IBookService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Models; 5 | 6 | namespace BookStore.Domain.Interfaces 7 | { 8 | public interface IBookService 9 | { 10 | Task> GetAll(); 11 | Task GetById(int id); 12 | Task Add(Book book); 13 | Task Update(Book book); 14 | Task Remove(Book book); 15 | Task> GetBooksByCategory(int categoryId); 16 | Task> Search(string bookName); 17 | Task> SearchBookWithCategory(string searchedValue); 18 | } 19 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Interfaces/IRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq.Expressions; 4 | using System.Threading.Tasks; 5 | using BookStore.Domain.Models; 6 | 7 | namespace BookStore.Domain.Interfaces 8 | { 9 | public interface IRepository : IDisposable where TEntity : Entity 10 | { 11 | Task Add(TEntity entity); 12 | Task> GetAll(); 13 | Task GetById(int id); 14 | Task Update(TEntity entity); 15 | Task UpdateRange(IEnumerable entities); 16 | Task Remove(TEntity entity); 17 | Task> Search(Expression> predicate); 18 | Task SaveChanges(); 19 | } 20 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Repositories/InventoryRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using BookStore.Domain.Interfaces; 6 | using BookStore.Domain.Models; 7 | using BookStore.Infrastructure.Context; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace BookStore.Infrastructure.Repositories 11 | { 12 | public class InventoryRepository(BookStoreDbContext db) : Repository(db), IInventoryRepository 13 | { 14 | public async Task> SearchInventoryForBook(string bookName) 15 | { 16 | return await Db.Inventories.AsNoTracking() 17 | .Include(b => b.Book) 18 | .Where(b => b.Book.Name.Contains(bookName)) 19 | .ToListAsync(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/BookStore.Domain/Models/Book.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace BookStore.Domain.Models 5 | { 6 | public class Book : Entity 7 | { 8 | 9 | public string Name { get; set; } 10 | public string Author { get; set; } 11 | public string Description { get; set; } 12 | public double Value { get; set; } 13 | public DateTime PublishDate { get; set; } 14 | public int CategoryId { get; set; } 15 | 16 | public Category Category { get; set; } 17 | public Inventory Inventory { get; set; } 18 | public List Orders { get; set; } 19 | 20 | public bool HasPositivePrice() 21 | { 22 | return Value > 0; 23 | } 24 | 25 | public bool HasCorrectPublishDate() 26 | { 27 | return PublishDate < DateTime.Today; 28 | } 29 | 30 | 31 | } 32 | } -------------------------------------------------------------------------------- /scripts/grafana/prometheus-connector.yml: -------------------------------------------------------------------------------- 1 | # config file version 2 | apiVersion: 1 3 | 4 | # list of datasources that should be deleted from the database 5 | deleteDatasources: 6 | - name: Prometheus 7 | orgId: 1 8 | 9 | # list of datasources to insert/update depending 10 | # what's available in the database 11 | datasources: 12 | # name of the datasource. Required 13 | - name: Prometheus 14 | # datasource type. Required 15 | type: prometheus 16 | # access mode. proxy or direct (Server or Browser in the UI). Required 17 | access: proxy 18 | # org id. will default to orgId 1 if not specified 19 | orgId: 1 20 | # custom UID which can be used to reference this datasource in other parts of the configuration, if not specified will be generated automatically 21 | # uid: my_unique_uid 22 | # url 23 | url: http://prometheus:9090 -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Book/BookAddDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Book 4 | { 5 | public class BookAddDto 6 | { 7 | [Required(ErrorMessage = "The field {0} is required")] 8 | public int CategoryId { get; set; } 9 | 10 | [Required(ErrorMessage = "The field {0} is required")] 11 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 12 | public string Name { get; set; } 13 | 14 | [Required(ErrorMessage = "The field {0} is required")] 15 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 16 | public string Author { get; set; } 17 | 18 | public string Description { get; set; } 19 | 20 | public double Value { get; set; } 21 | 22 | public DateTime PublishDate { get; set; } 23 | } 24 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Book/BookEditDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Book 4 | { 5 | public class BookEditDto 6 | { 7 | [Key] 8 | public int Id { get; set; } 9 | 10 | [Required(ErrorMessage = "The field {0} is required")] 11 | public int CategoryId { get; set; } 12 | 13 | [Required(ErrorMessage = "The field {0} is required")] 14 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 15 | public string Name { get; set; } 16 | 17 | [Required(ErrorMessage = "The field {0} is required")] 18 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 19 | public string Author { get; set; } 20 | 21 | public string Description { get; set; } 22 | 23 | public double Value { get; set; } 24 | 25 | public DateTime PublishDate { get; set; } 26 | } 27 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0-jammy AS build-env 2 | WORKDIR /app 3 | 4 | # Copy everything 5 | COPY . ./ 6 | 7 | # Restore packages 8 | RUN dotnet restore -s "https://api.nuget.org/v3/index.json" \ 9 | --runtime linux-x64 10 | 11 | # Build project 12 | RUN dotnet build "./src/BookStore.WebApi/BookStore.WebApi.csproj" \ 13 | -c Release \ 14 | --runtime linux-x64 \ 15 | --self-contained true \ 16 | --no-restore 17 | 18 | # Publish app 19 | RUN dotnet publish "./src/BookStore.WebApi/BookStore.WebApi.csproj" \ 20 | -c Release \ 21 | -o /app/publish \ 22 | --no-restore \ 23 | --no-build \ 24 | --self-contained true \ 25 | --runtime linux-x64 26 | 27 | # Build runtime image 28 | FROM mcr.microsoft.com/dotnet/runtime-deps:8.0.0-jammy-chiseled-extra 29 | 30 | # Copy artifact 31 | WORKDIR /app 32 | COPY --from=build-env /app/publish . 33 | 34 | # Starts on port 8080 35 | ENV ASPNETCORE_URLS=http://+:8080 36 | 37 | 38 | 39 | # Set Entrypoint 40 | ENTRYPOINT ["./BookStore.WebApi"] 41 | -------------------------------------------------------------------------------- /scripts/sql/Dockerfile: -------------------------------------------------------------------------------- 1 | # We choose exact tag (not 'latest'), to be sure that new version wont break creating image 2 | FROM mcr.microsoft.com/mssql/server:2017-CU17-ubuntu 3 | 4 | # Create app directory 5 | RUN mkdir -p /usr/src/app 6 | WORKDIR /usr/src/app 7 | 8 | # Copy initialization scripts 9 | COPY . /usr/src/app 10 | 11 | # Grant permissions for the run-initialization script to be executable 12 | RUN chmod +x /usr/src/app/import-data.sh 13 | 14 | # Set environment variables, not to have to write them with docker run command 15 | # Note: make sure that your password matches what is in the run-initialization script 16 | ENV SA_PASSWORD P@ssw0rd? 17 | ENV ACCEPT_EULA Y 18 | ENV MSSQL_PID Express 19 | 20 | # Expose port 1433 in case accesing from other container 21 | EXPOSE 1433 22 | 23 | # Run Microsoft SQl Server and initialization script (at the same time) 24 | # Note: If you want to start MsSQL only (without initialization script) you can comment bellow line out, CMD entry from base image will be taken 25 | CMD /bin/bash ./entrypoint.sh -------------------------------------------------------------------------------- /src/BookStore.WebApi/Dtos/Order/OrderAddDto.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace BookStore.WebApi.Dtos.Order 4 | { 5 | public class OrderAddDto 6 | { 7 | [Required(ErrorMessage = "The field {0} is required")] 8 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 9 | public string CustomerName { get; set; } 10 | 11 | [Required(ErrorMessage = "The field {0} is required")] 12 | [StringLength(150, ErrorMessage = "The field {0} must be between {2} and {1} characters", MinimumLength = 2)] 13 | public string Address { get; set; } 14 | 15 | [Required(ErrorMessage = "The field {0} is required")] 16 | public string Telephone { get; set; } 17 | 18 | [Required(ErrorMessage = "The field {0} is required")] 19 | public string City { get; set; } 20 | 21 | [Required(ErrorMessage = "The field {0} is required")] 22 | public IEnumerable Books { get; set; } 23 | } 24 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Models/Order.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace BookStore.Domain.Models 5 | { 6 | public class Order : Entity 7 | { 8 | public string CustomerName { get; set; } 9 | public string Address { get; set; } 10 | public string Telephone { get; set; } 11 | public string City { get; set; } 12 | public double TotalAmount { get; set; } 13 | public string Status { get; set; } 14 | public List Books { get; set; } 15 | 16 | public bool IsAlreadyCancelled() 17 | { 18 | return Status.Contains("CANCELLED", StringComparison.InvariantCultureIgnoreCase); 19 | } 20 | 21 | public void SetCancelledStatus() 22 | { 23 | Status = "CANCELLED"; 24 | } 25 | 26 | public void SetNewOrderStatus() 27 | { 28 | Status = "NEW_ORDER"; 29 | } 30 | 31 | public void SetTotalAmount(double amount) 32 | { 33 | TotalAmount = amount; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Repositories/CategoryRepository.cs: -------------------------------------------------------------------------------- 1 | using BookStore.Domain.Interfaces; 2 | using BookStore.Domain.Models; 3 | using BookStore.Infrastructure.Context; 4 | using BookStore.Infrastructure.Metrics; 5 | using System.Threading.Tasks; 6 | 7 | namespace BookStore.Infrastructure.Repositories 8 | { 9 | public class CategoryRepository(BookStoreDbContext context, 10 | BookStoreMetrics meters) : Repository(context), ICategoryRepository 11 | { 12 | public override async Task Add(Category entity) 13 | { 14 | await base.Add(entity); 15 | meters.AddCategory(); 16 | meters.IncreaseTotalCategories(); 17 | } 18 | 19 | public override async Task Update(Category entity) 20 | { 21 | await base.Update(entity); 22 | meters.UpdateCategory(); 23 | } 24 | 25 | public override async Task Remove(Category entity) 26 | { 27 | await base.Remove(entity); 28 | meters.DeleteCategory(); 29 | meters.DecreaseTotalCategories(); 30 | } 31 | 32 | 33 | } 34 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/BookStore.Infrastructure.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | 7 | 8 | 9 | 10 | all 11 | runtime; build; native; contentfiles; analyzers; buildtransitive 12 | 13 | 14 | 15 | 16 | all 17 | runtime; build; native; contentfiles; analyzers; buildtransitive 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Middleware/SimulatedLatencyMiddleware.cs: -------------------------------------------------------------------------------- 1 | namespace BookStore.WebApi.Middleware 2 | { 3 | public class SimulatedLatencyMiddleware 4 | { 5 | private readonly RequestDelegate _next; 6 | private readonly int _minDelayInMs; 7 | private readonly int _maxDelayInMs; 8 | private readonly ThreadLocal _random; 9 | 10 | public SimulatedLatencyMiddleware( 11 | RequestDelegate next, 12 | TimeSpan min, 13 | TimeSpan max 14 | ) 15 | { 16 | _next = next; 17 | _minDelayInMs = (int)min.TotalMilliseconds; 18 | _maxDelayInMs = (int)max.TotalMilliseconds; 19 | _random = new ThreadLocal(() => new Random()); 20 | } 21 | 22 | public async Task Invoke(HttpContext context) 23 | { 24 | if (_random.Value != null) 25 | { 26 | var delayInMs = _random.Value.Next( 27 | _minDelayInMs, 28 | _maxDelayInMs 29 | ); 30 | 31 | await Task.Delay(delayInMs); 32 | } 33 | 34 | await _next(context); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/BookStore.WebApi.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | all 13 | runtime; build; native; contentfiles; analyzers; buildtransitive 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /test/BookStoreMetrics.UnitTest/BookStoreMetrics.UnitTest.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | runtime; build; native; contentfiles; analyzers; buildtransitive 19 | all 20 | 21 | 22 | runtime; build; native; contentfiles; analyzers; buildtransitive 23 | all 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | networks: 4 | metrics: 5 | name: bookstore-network 6 | 7 | services: 8 | mssql: 9 | build: 10 | context: ./scripts/sql 11 | ports: 12 | - "1433:1433" 13 | environment: 14 | SA_PASSWORD: "P@ssw0rd?" 15 | ACCEPT_EULA: "Y" 16 | networks: 17 | - metrics 18 | 19 | prometheus: 20 | build: 21 | context: ./scripts/prometheus 22 | depends_on: 23 | - app 24 | ports: 25 | - 9090:9090 26 | networks: 27 | - metrics 28 | 29 | grafana: 30 | build: 31 | context: ./scripts/grafana 32 | depends_on: 33 | - prometheus 34 | ports: 35 | - 3000:3000 36 | networks: 37 | - metrics 38 | 39 | otel-collector: 40 | image: otel/opentelemetry-collector:0.89.0 41 | command: ["--config=/etc/otel-collector-config.yaml"] 42 | volumes: 43 | - ./scripts/otel-collector/otel-collector-config.yaml:/etc/otel-collector-config.yaml 44 | ports: 45 | - "8888:8888" 46 | - "8889:8889" 47 | - "13133:13133" 48 | - "4317:4317" 49 | networks: 50 | - metrics 51 | 52 | app: 53 | build: 54 | context: ./ 55 | dockerfile: ./src/BookStore.WebApi/Dockerfile 56 | depends_on: 57 | - mssql 58 | - otel-collector 59 | ports: 60 | - 5001:8080 61 | environment: 62 | ConnectionStrings__DbConnection: Server=mssql;Database=BookStore;User Id=SA;Password=P@ssw0rd?;Encrypt=False 63 | Otlp__Endpoint: http://otel-collector:4317 64 | networks: 65 | - metrics -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Configuration/InfrastructureDependencies.cs: -------------------------------------------------------------------------------- 1 | using BookStore.Domain.Interfaces; 2 | using BookStore.Domain.Services; 3 | using BookStore.Infrastructure.Context; 4 | using BookStore.Infrastructure.Metrics; 5 | using BookStore.Infrastructure.Repositories; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.Configuration; 8 | 9 | namespace Microsoft.Extensions.DependencyInjection 10 | { 11 | public static class InfrastructureDependencies 12 | { 13 | public static IServiceCollection RegisterInfrastureDependencies(this IServiceCollection services, IConfiguration configuration) 14 | { 15 | 16 | services.AddDbContext(options => 17 | { 18 | options.UseSqlServer(configuration.GetConnectionString("DbConnection")); 19 | }); 20 | 21 | services.AddScoped(); 22 | 23 | services.AddScoped(); 24 | services.AddScoped(); 25 | services.AddScoped(); 26 | services.AddScoped(); 27 | 28 | services.AddScoped(); 29 | services.AddScoped(); 30 | services.AddScoped(); 31 | services.AddScoped(); 32 | 33 | services.AddSingleton(); 34 | 35 | return services; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /scripts/sql/setup.sql: -------------------------------------------------------------------------------- 1 | CREATE DATABASE BookStore 2 | GO 3 | 4 | USE BookStore 5 | GO 6 | 7 | CREATE TABLE [Categories] ( 8 | [Id] int NOT NULL IDENTITY, 9 | [Name] varchar(150) NOT NULL, 10 | CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]) 11 | ); 12 | GO 13 | 14 | CREATE TABLE [Books] ( 15 | [Id] int NOT NULL IDENTITY, 16 | [Name] varchar(150) NOT NULL, 17 | [Author] varchar(150) NOT NULL, 18 | [Description] varchar(350) NULL, 19 | [Value] float NOT NULL, 20 | [PublishDate] datetime2 NOT NULL, 21 | [CategoryId] int NOT NULL, 22 | CONSTRAINT [PK_Books] PRIMARY KEY ([Id]), 23 | CONSTRAINT [FK_Books_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE NO ACTION 24 | ); 25 | GO 26 | 27 | CREATE INDEX [IX_Books_CategoryId] ON [Books] ([CategoryId]); 28 | GO 29 | 30 | 31 | CREATE TABLE [Inventory] ( 32 | [BookId] int NOT NULL, 33 | [Amount] int NOT NULL, 34 | CONSTRAINT [FK_Inventory_Books] FOREIGN KEY ([BookId]) REFERENCES [Books] ([Id]) ON DELETE NO ACTION 35 | ); 36 | GO 37 | 38 | 39 | CREATE TABLE [Orders] ( 40 | [Id] int NOT NULL IDENTITY, 41 | [CustomerName] varchar(350) NOT NULL, 42 | [Address] varchar(350) NOT NULL, 43 | [Telephone] varchar(350) NOT NULL, 44 | [City] varchar(350) NOT NULL, 45 | [TotalAmount] float NOT NULL, 46 | [Status] varchar(150) NOT NULL, 47 | CONSTRAINT [PK_Orders] PRIMARY KEY ([Id]), 48 | ); 49 | GO 50 | 51 | CREATE TABLE [Books_Orders] ( 52 | [BookId] int NOT NULL, 53 | [OrderId] int NOT NULL, 54 | CONSTRAINT [PK_Books_Orders] PRIMARY KEY (BookId, OrderId), 55 | CONSTRAINT [FK_Books] FOREIGN KEY ([BookId]) REFERENCES [Books] ([Id]) ON DELETE NO ACTION, 56 | CONSTRAINT [FK_Orders] FOREIGN KEY ([OrderId]) REFERENCES [Orders] ([Id]) ON DELETE NO ACTION 57 | ); 58 | GO -------------------------------------------------------------------------------- /src/BookStore.WebApi/Program.cs: -------------------------------------------------------------------------------- 1 | using BookStore.WebApi.Middleware; 2 | using Microsoft.OpenApi.Models; 3 | using OpenTelemetry.Metrics; 4 | using OpenTelemetry.Resources; 5 | 6 | var builder = WebApplication.CreateBuilder(args); 7 | 8 | builder.Services.AddControllers(); 9 | builder.Services.AddEndpointsApiExplorer(); 10 | 11 | builder.Services.AddSwaggerGen(opts => 12 | { 13 | opts.SwaggerDoc("v1", new OpenApiInfo() 14 | { 15 | Title = "BookStore API", 16 | Version = "v1" 17 | }); 18 | }); 19 | 20 | builder.Services.AddAutoMapper(typeof(Program)); 21 | builder.Services.RegisterInfrastureDependencies(builder.Configuration); 22 | 23 | builder.Services.AddOpenTelemetry().WithMetrics(opts => opts 24 | .SetResourceBuilder(ResourceBuilder.CreateDefault().AddService("BookStore.WebApi")) 25 | .AddMeter(builder.Configuration.GetValue("BookStoreMeterName")) 26 | .AddAspNetCoreInstrumentation() 27 | .AddProcessInstrumentation() 28 | .AddRuntimeInstrumentation() 29 | .AddView( 30 | instrumentName: "orders-price", 31 | new ExplicitBucketHistogramConfiguration { Boundaries = [15, 30, 45, 60, 75] }) 32 | .AddView( 33 | instrumentName: "orders-number-of-books", 34 | new ExplicitBucketHistogramConfiguration { Boundaries = [1, 2, 5] }) 35 | .AddOtlpExporter(options => 36 | { 37 | options.Endpoint = new Uri(builder.Configuration["Otlp:Endpoint"] 38 | ?? throw new InvalidOperationException()); 39 | })); 40 | 41 | var app = builder.Build(); 42 | 43 | // Add simulated latency to improve http requests avg. time dashboard 44 | app.UseSimulatedLatency( 45 | min: TimeSpan.FromMilliseconds(500), 46 | max: TimeSpan.FromMilliseconds(1000) 47 | ); 48 | app.UseSwagger(); 49 | app.UseSwaggerUI(); 50 | app.UseAuthorization(); 51 | app.MapControllers(); 52 | app.Run(); 53 | -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Repositories/OrderRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | using BookStore.Infrastructure.Context; 7 | using System.Threading.Tasks; 8 | using Microsoft.EntityFrameworkCore; 9 | using BookStore.Infrastructure.Metrics; 10 | 11 | namespace BookStore.Infrastructure.Repositories 12 | { 13 | public class OrderRepository(BookStoreDbContext context, BookStoreMetrics meters) : Repository(context), 14 | IOrderRepository 15 | { 16 | public override async Task GetById(int id) 17 | { 18 | return await Db.Orders 19 | .Include(b => b.Books) 20 | .FirstOrDefaultAsync(x => x.Id == id); 21 | } 22 | 23 | public override async Task> GetAll() 24 | { 25 | return await Db.Orders 26 | .Include(b => b.Books) 27 | .ToListAsync(); 28 | } 29 | 30 | public override async Task Add(Order entity) 31 | { 32 | DbSet.Add(entity); 33 | await base.SaveChanges(); 34 | 35 | meters.RecordOrderTotalPrice(entity.TotalAmount); 36 | meters.RecordNumberOfBooks(entity.Books.Count); 37 | meters.IncreaseTotalOrders(entity.City); 38 | } 39 | 40 | public override async Task Update(Order entity) 41 | { 42 | await base.Update(entity); 43 | 44 | meters.IncreaseOrdersCanceled(); 45 | } 46 | 47 | public async Task> GetOrdersByBookId(int bookId) 48 | { 49 | return await Db.Orders.AsNoTracking() 50 | .Include(b => b.Books) 51 | .Where(x => x.Books.Any(y => y.Id == bookId)) 52 | .ToListAsync(); 53 | 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Services/InventoryService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | 7 | namespace BookStore.Domain.Services 8 | { 9 | public class InventoryService(IInventoryRepository inventoryRepository, 10 | IBookRepository bookRepository, 11 | IOrderRepository orderRepository) 12 | : IInventoryService 13 | { 14 | public async Task GetById(int id) 15 | { 16 | return await inventoryRepository.GetById(id); 17 | } 18 | 19 | public async Task Add(Inventory inventory) 20 | { 21 | if (inventoryRepository.Search(b => b.Id == inventory.Id).Result.Any()) 22 | return null; 23 | 24 | if (await bookRepository.GetById(inventory.Id) is null) 25 | return null; 26 | 27 | await inventoryRepository.Add(inventory); 28 | return inventory; 29 | } 30 | 31 | public async Task Update(Inventory inventory) 32 | { 33 | if (inventoryRepository.Search(b => b.Id != inventory.Id).Result.Any()) 34 | return null; 35 | 36 | await inventoryRepository.Update(inventory); 37 | return inventory; 38 | } 39 | 40 | public async Task Remove(Inventory inventory) 41 | { 42 | var orders = await orderRepository.GetOrdersByBookId(inventory.Id); 43 | 44 | if (orders.Any(x => !x.IsAlreadyCancelled())) 45 | return false; 46 | 47 | await inventoryRepository.Remove(inventory); 48 | return true; 49 | } 50 | 51 | 52 | public async Task> SearchInventoryForBook(string bookName) 53 | { 54 | return await inventoryRepository.SearchInventoryForBook(bookName); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/BookStore.Domain/Services/CategoryService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | 7 | namespace BookStore.Domain.Services 8 | { 9 | public class CategoryService(ICategoryRepository categoryRepository, IBookRepository bookRepository) 10 | : ICategoryService 11 | { 12 | public async Task> GetAll() 13 | { 14 | return await categoryRepository.GetAll(); 15 | } 16 | 17 | public async Task GetById(int id) 18 | { 19 | return await categoryRepository.GetById(id); 20 | } 21 | 22 | public async Task Add(Category category) 23 | { 24 | if (categoryRepository.Search(c => c.Name == category.Name).Result.Any()) 25 | return null; 26 | 27 | await categoryRepository.Add(category); 28 | return category; 29 | } 30 | 31 | public async Task Update(Category category) 32 | { 33 | if (categoryRepository.Search(c => c.Name == category.Name && c.Id != category.Id).Result.Any()) 34 | return null; 35 | 36 | if (!categoryRepository.Search(c => c.Id == category.Id).Result.Any()) 37 | return null; 38 | 39 | await categoryRepository.Update(category); 40 | return category; 41 | } 42 | 43 | public async Task Remove(Category category) 44 | { 45 | var books = await bookRepository.GetBooksByCategory(category.Id); 46 | if (books.Any()) return false; 47 | 48 | await categoryRepository.Remove(category); 49 | return true; 50 | } 51 | 52 | public async Task> Search(string categoryName) 53 | { 54 | return await categoryRepository.Search(c => c.Name.Contains(categoryName)); 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Repositories/Repository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Linq.Expressions; 5 | using System.Threading.Tasks; 6 | using BookStore.Domain.Interfaces; 7 | using BookStore.Domain.Models; 8 | using BookStore.Infrastructure.Context; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace BookStore.Infrastructure.Repositories 12 | { 13 | public abstract class Repository(BookStoreDbContext db) : IRepository 14 | where TEntity : Entity 15 | { 16 | protected readonly BookStoreDbContext Db = db; 17 | 18 | protected readonly DbSet DbSet = db.Set(); 19 | 20 | public virtual async Task Add(TEntity entity) 21 | { 22 | DbSet.Add(entity); 23 | await SaveChanges(); 24 | } 25 | 26 | public virtual async Task> GetAll() 27 | { 28 | return await DbSet.ToListAsync(); 29 | } 30 | 31 | public virtual async Task GetById(int id) 32 | { 33 | return await DbSet.FindAsync(id); 34 | } 35 | 36 | public virtual async Task Update(TEntity entity) 37 | { 38 | DbSet.Update(entity); 39 | await SaveChanges(); 40 | } 41 | 42 | public virtual async Task UpdateRange(IEnumerable entities) 43 | { 44 | DbSet.UpdateRange(entities); 45 | await SaveChanges(); 46 | } 47 | 48 | public virtual async Task Remove(TEntity entity) 49 | { 50 | DbSet.Remove(entity); 51 | await SaveChanges(); 52 | } 53 | 54 | public async Task> Search(Expression> predicate) 55 | { 56 | return await DbSet.AsNoTracking().Where(predicate).ToListAsync(); 57 | } 58 | 59 | public async Task SaveChanges() 60 | { 61 | return await Db.SaveChangesAsync(); 62 | } 63 | 64 | public void Dispose() 65 | { 66 | Db?.Dispose(); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /src/BookStore.WebApi/Configuration/AutoMapperConfig.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BookStore.Domain.Models; 3 | using BookStore.WebApi.Dtos.Book; 4 | using BookStore.WebApi.Dtos.Category; 5 | using BookStore.WebApi.Dtos.Inventory; 6 | using BookStore.WebApi.Dtos.Order; 7 | using Microsoft.AspNetCore.Identity; 8 | using System.Numerics; 9 | 10 | namespace BookStore.WebApi.Configuration 11 | { 12 | public class AutoMapperConfig : Profile 13 | { 14 | public AutoMapperConfig() 15 | { 16 | CreateMap().ReverseMap(); 17 | CreateMap().ReverseMap(); 18 | CreateMap().ReverseMap(); 19 | 20 | CreateMap().ReverseMap(); 21 | CreateMap().ReverseMap(); 22 | CreateMap().ReverseMap(); 23 | 24 | CreateMap() 25 | .ForMember(x => x.Id, opt => opt.MapFrom(m => m.BookId)) 26 | .ForMember(x => x.Amount, opt => opt.MapFrom(m => m.Amount)) 27 | .ForMember(x => x.Book, opt => opt.Ignore()) 28 | .ReverseMap(); 29 | CreateMap() 30 | .ForMember(x => x.Id, opt => opt.MapFrom(m => m.BookId)) 31 | .ForMember(x => x.Amount, opt => opt.MapFrom(m => m.Amount)) 32 | .ForMember(x => x.Book, opt => opt.Ignore()) 33 | .ReverseMap(); 34 | CreateMap() 35 | .ForMember(x => x.Id, opt => opt.MapFrom(m => m.BookId)) 36 | .ForMember(x => x.Book, opt => opt.Ignore()) 37 | .ForMember(x => x.Amount, opt => opt.MapFrom(m => m.Amount)) 38 | .ReverseMap(); 39 | 40 | CreateMap() 41 | .ForMember(x => x.Id, opt => opt.MapFrom(src => src)); 42 | CreateMap() 43 | .ForMember(x => x.Books, opt => opt.MapFrom(m => m.Books)); 44 | 45 | CreateMap() 46 | .ForMember(x => x.Books, opt => opt.MapFrom(m => m.Books.Select(x => x.Id).ToList())); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Controllers/OrdersController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BookStore.Domain.Interfaces; 3 | using BookStore.Domain.Models; 4 | using BookStore.WebApi.Dtos.Order; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace BookStore.WebApi.Controllers 8 | { 9 | [Route("api/[controller]")] 10 | public class OrdersController(IMapper mapper, 11 | IOrderService orderService) : ControllerBase 12 | { 13 | [HttpGet] 14 | [ProducesResponseType(StatusCodes.Status200OK)] 15 | public async Task GetAll() 16 | { 17 | var orders = await orderService.GetAll(); 18 | 19 | return Ok(mapper.Map>(orders)); 20 | } 21 | 22 | [HttpGet("{id:int}")] 23 | [ProducesResponseType(StatusCodes.Status200OK)] 24 | [ProducesResponseType(StatusCodes.Status404NotFound)] 25 | public async Task GetById(int id) 26 | { 27 | var order = await orderService.GetById(id); 28 | 29 | if (order is null) 30 | return NotFound(); 31 | 32 | return Ok(mapper.Map(order)); 33 | } 34 | 35 | [HttpPost] 36 | [ProducesResponseType(StatusCodes.Status200OK)] 37 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 38 | public async Task Add([FromBody]OrderAddDto orderDto) 39 | { 40 | if (!ModelState.IsValid) return BadRequest(); 41 | 42 | var order = mapper.Map(orderDto); 43 | var result = await orderService.Add(order); 44 | 45 | if (result == null) return BadRequest(); 46 | 47 | return Ok(mapper.Map(result)); 48 | } 49 | 50 | 51 | [HttpDelete("{id:int}")] 52 | [ProducesResponseType(StatusCodes.Status200OK)] 53 | [ProducesResponseType(StatusCodes.Status404NotFound)] 54 | public async Task Remove(int id) 55 | { 56 | var order = await orderService.GetById(id); 57 | if (order == null) return NotFound(); 58 | 59 | var result = await orderService.Remove(order); 60 | if (result == null) return BadRequest(); 61 | 62 | return Ok(mapper.Map(result)); 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Repositories/BookRepository.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | using BookStore.Infrastructure.Context; 7 | using BookStore.Infrastructure.Metrics; 8 | using Microsoft.EntityFrameworkCore; 9 | 10 | namespace BookStore.Infrastructure.Repositories 11 | { 12 | public class BookRepository(BookStoreDbContext context, 13 | BookStoreMetrics meters) : Repository(context), IBookRepository 14 | { 15 | public override async Task> GetAll() 16 | { 17 | return await Db.Books.Include(b => b.Category) 18 | .OrderBy(b => b.Name) 19 | .ToListAsync(); 20 | } 21 | 22 | public override async Task GetById(int id) 23 | { 24 | return await Db.Books.Include(b => b.Category) 25 | .Where(b => b.Id == id) 26 | .FirstOrDefaultAsync(); 27 | } 28 | 29 | public async Task> GetBooksByCategory(int categoryId) 30 | { 31 | return await Search(b => b.CategoryId == categoryId); 32 | } 33 | 34 | public async Task> SearchBookWithCategory(string searchedValue) 35 | { 36 | return await Db.Books.AsNoTracking() 37 | .Include(b => b.Category) 38 | .Where(b => b.Name.Contains(searchedValue) || 39 | b.Author.Contains(searchedValue) || 40 | b.Description.Contains(searchedValue) || 41 | b.Category.Name.Contains(searchedValue)) 42 | .ToListAsync(); 43 | } 44 | 45 | public override async Task Add(Book entity) 46 | { 47 | await base.Add(entity); 48 | 49 | meters.AddBook(); 50 | meters.IncreaseTotalBooks(); 51 | } 52 | 53 | public override async Task Update(Book entity) 54 | { 55 | await base.Update(entity); 56 | 57 | meters.UpdateBook(); 58 | } 59 | 60 | public override async Task Remove(Book entity) 61 | { 62 | await base.Remove(entity); 63 | 64 | meters.DeleteBook(); 65 | meters.DecreaseTotalBooks(); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Services/BookService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | 7 | namespace BookStore.Domain.Services 8 | { 9 | public class BookService(IBookRepository bookRepository, ICategoryRepository categoryRepository) 10 | : IBookService 11 | { 12 | public async Task> GetAll() 13 | { 14 | return await bookRepository.GetAll(); 15 | } 16 | 17 | public async Task GetById(int id) 18 | { 19 | return await bookRepository.GetById(id); 20 | } 21 | 22 | public async Task Add(Book book) 23 | { 24 | if (bookRepository.Search(b => b.Name == book.Name).Result.Any()) 25 | return null; 26 | 27 | var category = await categoryRepository.GetById(book.CategoryId); 28 | if (category is null) 29 | return null; 30 | 31 | if (!book.HasCorrectPublishDate()) 32 | return null; 33 | 34 | if (!book.HasPositivePrice()) 35 | return null; 36 | 37 | await bookRepository.Add(book); 38 | return book; 39 | } 40 | 41 | public async Task Update(Book book) 42 | { 43 | if (bookRepository.Search(b => b.Name == book.Name && b.Id != book.Id).Result.Any()) 44 | return null; 45 | 46 | if (!book.HasCorrectPublishDate()) 47 | return null; 48 | 49 | if (!book.HasPositivePrice()) 50 | return null; 51 | 52 | await bookRepository.Update(book); 53 | return book; 54 | } 55 | 56 | public async Task Remove(Book book) 57 | { 58 | await bookRepository.Remove(book); 59 | return true; 60 | } 61 | 62 | public async Task> GetBooksByCategory(int categoryId) 63 | { 64 | return await bookRepository.GetBooksByCategory(categoryId); 65 | } 66 | 67 | public async Task> Search(string bookName) 68 | { 69 | return await bookRepository.Search(c => c.Name.Contains(bookName)); 70 | } 71 | 72 | public async Task> SearchBookWithCategory(string searchedValue) 73 | { 74 | return await bookRepository.SearchBookWithCategory(searchedValue); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /src/BookStore.Domain/Services/OrderService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using BookStore.Domain.Interfaces; 5 | using BookStore.Domain.Models; 6 | 7 | namespace BookStore.Domain.Services 8 | { 9 | public class OrderService(IOrderRepository orderRepository, 10 | IBookRepository bookRepository, 11 | IInventoryRepository inventoryRepository) 12 | : IOrderService 13 | { 14 | public async Task> GetAll() 15 | { 16 | return await orderRepository.GetAll(); 17 | } 18 | 19 | public async Task GetById(int id) 20 | { 21 | return await orderRepository.GetById(id); 22 | } 23 | 24 | public async Task Add(Order order) 25 | { 26 | double sum = 0; 27 | List inventoryList = []; 28 | 29 | for (var i = 0; i < order.Books.Count; i++) 30 | { 31 | var orderingBook = await bookRepository.GetById(order.Books[i].Id); 32 | if (orderingBook is null) 33 | return null; 34 | 35 | if (!orderingBook.HasPositivePrice()) 36 | return null; 37 | 38 | if (!orderingBook.HasCorrectPublishDate()) 39 | return null; 40 | 41 | var inventory = await inventoryRepository.GetById(order.Books[i].Id); 42 | if (inventory is null || !inventory.HasInventoryAvailable()) 43 | return null; 44 | 45 | order.Books[i] = orderingBook; 46 | sum += orderingBook.Value; 47 | inventoryList.Add(inventory); 48 | } 49 | 50 | foreach (var inventoryItem in inventoryList) 51 | { 52 | inventoryItem.DecreaseInventory(); 53 | await inventoryRepository.Update(inventoryItem); 54 | } 55 | 56 | order.SetTotalAmount(sum); 57 | order.SetNewOrderStatus(); 58 | await orderRepository.Add(order); 59 | 60 | return order; 61 | } 62 | 63 | public async Task Remove(Order order) 64 | { 65 | if (order.IsAlreadyCancelled()) 66 | return null; 67 | 68 | order.SetCancelledStatus(); 69 | await orderRepository.Update(order); 70 | 71 | foreach (var book in order.Books) 72 | { 73 | var inventory = await inventoryRepository.GetById(book.Id); 74 | inventory.IncreaseInventory(); 75 | await inventoryRepository.Update(inventory); 76 | } 77 | 78 | return order; 79 | 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Controllers/InventoriesController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BookStore.Domain.Interfaces; 3 | using BookStore.Domain.Models; 4 | using BookStore.WebApi.Dtos.Inventory; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace BookStore.WebApi.Controllers 8 | { 9 | [Route("api/[controller]")] 10 | public class InventoriesController(IMapper mapper, 11 | IInventoryService inventoryService) 12 | : ControllerBase 13 | { 14 | [HttpGet("{bookId:int}")] 15 | [ProducesResponseType(StatusCodes.Status200OK)] 16 | [ProducesResponseType(StatusCodes.Status404NotFound)] 17 | public async Task GetById(int bookId) 18 | { 19 | var inventory = await inventoryService.GetById(bookId); 20 | 21 | if (inventory == null) return NotFound(); 22 | 23 | return Ok(mapper.Map(inventory)); 24 | } 25 | 26 | 27 | [HttpGet] 28 | [Route("get-inventory-by-book-name/{bookName}")] 29 | [ProducesResponseType(StatusCodes.Status200OK)] 30 | [ProducesResponseType(StatusCodes.Status404NotFound)] 31 | public async Task>> SearchInventoryForBook(string bookName) 32 | { 33 | var inventory = mapper.Map>(await inventoryService.SearchInventoryForBook(bookName)); 34 | 35 | if (inventory.Count == 0) return NotFound("None inventory was founded"); 36 | 37 | return Ok(mapper.Map>(inventory)); 38 | } 39 | 40 | [HttpPost] 41 | [ProducesResponseType(StatusCodes.Status200OK)] 42 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 43 | public async Task Add([FromBody]InventoryAddDto inventoryDto) 44 | { 45 | if (!ModelState.IsValid) return BadRequest(); 46 | 47 | var inventory = mapper.Map(inventoryDto); 48 | var inventoryResult = await inventoryService.Add(inventory); 49 | 50 | if (inventoryResult == null) return BadRequest(); 51 | 52 | return Ok(mapper.Map(inventoryResult)); 53 | } 54 | 55 | [HttpPut] 56 | [ProducesResponseType(StatusCodes.Status200OK)] 57 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 58 | public async Task Update([FromBody]InventoryEditDto inventoryDto) 59 | { 60 | if (!ModelState.IsValid) return BadRequest(); 61 | 62 | await inventoryService.Update(mapper.Map(inventoryDto)); 63 | 64 | return Ok(inventoryDto); 65 | } 66 | 67 | [HttpDelete("{bookId:int}")] 68 | [ProducesResponseType(StatusCodes.Status200OK)] 69 | [ProducesResponseType(StatusCodes.Status404NotFound)] 70 | public async Task Remove(int bookId) 71 | { 72 | var inventory = await inventoryService.GetById(bookId); 73 | if (inventory == null) return NotFound(); 74 | 75 | var result = await inventoryService.Remove(inventory); 76 | if (!result) return BadRequest(); 77 | 78 | return Ok(); 79 | } 80 | } 81 | } -------------------------------------------------------------------------------- /BookStore.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.3.32804.467 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BookStore.Domain", "src\BookStore.Domain\BookStore.Domain.csproj", "{84A309D2-F63D-4EF0-8746-C2F648652A00}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BookStore.Infrastructure", "src\BookStore.Infrastructure\BookStore.Infrastructure.csproj", "{9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BookStore.WebApi", "src\BookStore.WebApi\BookStore.WebApi.csproj", "{05647CBB-2581-40D4-837F-98C3180CA2C4}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BookStoreMetrics.UnitTest", "test\BookStoreMetrics.UnitTest\BookStoreMetrics.UnitTest.csproj", "{FFB7D84D-DE85-439C-99F9-9A4F903D758A}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BFA3675C-768A-42C2-9AEB-5989D5D16B7A}" 15 | EndProject 16 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{4748F07C-D7C8-45CF-9990-25FF77DD75F4}" 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {84A309D2-F63D-4EF0-8746-C2F648652A00}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {84A309D2-F63D-4EF0-8746-C2F648652A00}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {84A309D2-F63D-4EF0-8746-C2F648652A00}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {84A309D2-F63D-4EF0-8746-C2F648652A00}.Release|Any CPU.Build.0 = Release|Any CPU 28 | {9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 29 | {9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65}.Debug|Any CPU.Build.0 = Debug|Any CPU 30 | {9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {05647CBB-2581-40D4-837F-98C3180CA2C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {05647CBB-2581-40D4-837F-98C3180CA2C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {05647CBB-2581-40D4-837F-98C3180CA2C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {05647CBB-2581-40D4-837F-98C3180CA2C4}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {FFB7D84D-DE85-439C-99F9-9A4F903D758A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {FFB7D84D-DE85-439C-99F9-9A4F903D758A}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {FFB7D84D-DE85-439C-99F9-9A4F903D758A}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {FFB7D84D-DE85-439C-99F9-9A4F903D758A}.Release|Any CPU.Build.0 = Release|Any CPU 40 | EndGlobalSection 41 | GlobalSection(SolutionProperties) = preSolution 42 | HideSolutionNode = FALSE 43 | EndGlobalSection 44 | GlobalSection(NestedProjects) = preSolution 45 | {84A309D2-F63D-4EF0-8746-C2F648652A00} = {BFA3675C-768A-42C2-9AEB-5989D5D16B7A} 46 | {9B3CAEB7-EBE1-45D5-8D42-D81ECF14CC65} = {BFA3675C-768A-42C2-9AEB-5989D5D16B7A} 47 | {05647CBB-2581-40D4-837F-98C3180CA2C4} = {BFA3675C-768A-42C2-9AEB-5989D5D16B7A} 48 | {FFB7D84D-DE85-439C-99F9-9A4F903D758A} = {4748F07C-D7C8-45CF-9990-25FF77DD75F4} 49 | EndGlobalSection 50 | GlobalSection(ExtensibilityGlobals) = postSolution 51 | SolutionGuid = {A2BADA72-AE12-438E-B570-E651573BC3B2} 52 | EndGlobalSection 53 | EndGlobal 54 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Controllers/CategoriesController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BookStore.Domain.Interfaces; 3 | using BookStore.Domain.Models; 4 | using BookStore.WebApi.Dtos.Category; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace BookStore.WebApi.Controllers 8 | { 9 | [Route("api/[controller]")] 10 | public class CategoriesController(IMapper mapper, 11 | ICategoryService categoryService) : ControllerBase 12 | { 13 | [HttpGet] 14 | [ProducesResponseType(StatusCodes.Status200OK)] 15 | public async Task GetAll() 16 | { 17 | var categories = await categoryService.GetAll(); 18 | 19 | return Ok(mapper.Map>(categories)); 20 | } 21 | 22 | [HttpGet("{id:int}")] 23 | [ProducesResponseType(StatusCodes.Status200OK)] 24 | [ProducesResponseType(StatusCodes.Status404NotFound)] 25 | public async Task GetById(int id) 26 | { 27 | var category = await categoryService.GetById(id); 28 | 29 | if (category == null) return NotFound(); 30 | 31 | return Ok(mapper.Map(category)); 32 | } 33 | 34 | [HttpPost] 35 | [ProducesResponseType(StatusCodes.Status200OK)] 36 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 37 | public async Task Add([FromBody]CategoryAddDto categoryDto) 38 | { 39 | if (!ModelState.IsValid) return BadRequest(); 40 | 41 | var category = mapper.Map(categoryDto); 42 | var categoryResult = await categoryService.Add(category); 43 | 44 | if (categoryResult == null) return BadRequest(); 45 | 46 | return Ok(mapper.Map(categoryResult)); 47 | } 48 | 49 | [HttpPut] 50 | [ProducesResponseType(StatusCodes.Status200OK)] 51 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 52 | public async Task Update([FromBody]CategoryEditDto categoryDto) 53 | { 54 | if (!ModelState.IsValid) return BadRequest(); 55 | 56 | var categoryResult = await categoryService.Update(mapper.Map(categoryDto)); 57 | if (categoryResult == null) return BadRequest(); 58 | 59 | return Ok(categoryDto); 60 | } 61 | 62 | [HttpDelete("{id:int}")] 63 | [ProducesResponseType(StatusCodes.Status200OK)] 64 | [ProducesResponseType(StatusCodes.Status404NotFound)] 65 | public async Task Remove(int id) 66 | { 67 | var category = await categoryService.GetById(id); 68 | if (category == null) return NotFound(); 69 | 70 | var result = await categoryService.Remove(category); 71 | 72 | if (!result) return BadRequest(); 73 | 74 | return Ok(); 75 | } 76 | 77 | [HttpGet] 78 | [Route("search/{category}")] 79 | [ProducesResponseType(StatusCodes.Status200OK)] 80 | [ProducesResponseType(StatusCodes.Status404NotFound)] 81 | public async Task>> Search(string category) 82 | { 83 | var categories = mapper.Map>(await categoryService.Search(category)); 84 | 85 | if (categories == null || categories.Count == 0) 86 | return NotFound("None category was founded"); 87 | 88 | return Ok(categories); 89 | } 90 | } 91 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Metrics/BookStoreMetrics.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics.Metrics; 4 | using Microsoft.Extensions.Configuration; 5 | 6 | namespace BookStore.Infrastructure.Metrics 7 | { 8 | public class BookStoreMetrics 9 | { 10 | //Books meters 11 | private Counter BooksAddedCounter { get; } 12 | private Counter BooksDeletedCounter { get; } 13 | private Counter BooksUpdatedCounter { get; } 14 | private UpDownCounter TotalBooksUpDownCounter { get; } 15 | 16 | //Categories meters 17 | private Counter CategoriesAddedCounter { get; } 18 | private Counter CategoriesDeletedCounter { get; } 19 | private Counter CategoriesUpdatedCounter { get; } 20 | private ObservableGauge TotalCategoriesGauge { get; } 21 | private int _totalCategories = 0; 22 | 23 | //Order meters 24 | private Histogram OrdersPriceHistogram { get; } 25 | private Histogram NumberOfBooksPerOrderHistogram { get; } 26 | private ObservableCounter OrdersCanceledCounter { get; } 27 | private int _ordersCanceled = 0; 28 | private Counter TotalOrdersCounter { get; } 29 | 30 | 31 | 32 | public BookStoreMetrics(IMeterFactory meterFactory, IConfiguration configuration) 33 | { 34 | var meter = meterFactory.Create(configuration["BookStoreMeterName"] ?? 35 | throw new NullReferenceException("BookStore meter missing a name")); 36 | 37 | BooksAddedCounter = meter.CreateCounter("books-added", "Book"); 38 | BooksDeletedCounter = meter.CreateCounter("books-deleted", "Book"); 39 | BooksUpdatedCounter = meter.CreateCounter("books-updated", "Book"); 40 | TotalBooksUpDownCounter = meter.CreateUpDownCounter("total-books", "Book"); 41 | 42 | CategoriesAddedCounter = meter.CreateCounter("categories-added", "Category"); 43 | CategoriesDeletedCounter = meter.CreateCounter("categories-deleted", "Category"); 44 | CategoriesUpdatedCounter = meter.CreateCounter("categories-updated", "Category"); 45 | TotalCategoriesGauge = meter.CreateObservableGauge("total-categories", () => _totalCategories); 46 | 47 | OrdersPriceHistogram = meter.CreateHistogram("orders-price", "Euros", "Price distribution of book orders"); 48 | NumberOfBooksPerOrderHistogram = meter.CreateHistogram("orders-number-of-books", "Books", "Number of books per order"); 49 | OrdersCanceledCounter = meter.CreateObservableCounter("orders-canceled", () => _ordersCanceled); 50 | TotalOrdersCounter = meter.CreateCounter("total-orders", "Orders"); 51 | } 52 | 53 | 54 | //Books meters 55 | public void AddBook() => BooksAddedCounter.Add(1); 56 | public void DeleteBook() => BooksDeletedCounter.Add(1); 57 | public void UpdateBook() => BooksUpdatedCounter.Add(1); 58 | public void IncreaseTotalBooks() => TotalBooksUpDownCounter.Add(1); 59 | public void DecreaseTotalBooks() => TotalBooksUpDownCounter.Add(-1); 60 | 61 | //Categories meters 62 | public void AddCategory() => CategoriesAddedCounter.Add(1); 63 | public void DeleteCategory() => CategoriesDeletedCounter.Add(1); 64 | public void UpdateCategory() => CategoriesUpdatedCounter.Add(1); 65 | public void IncreaseTotalCategories() => _totalCategories++; 66 | public void DecreaseTotalCategories() => _totalCategories--; 67 | 68 | //Orders meters 69 | public void RecordOrderTotalPrice(double price) => OrdersPriceHistogram.Record(price); 70 | public void RecordNumberOfBooks(int amount) => NumberOfBooksPerOrderHistogram.Record(amount); 71 | public void IncreaseOrdersCanceled() => _ordersCanceled++; 72 | public void IncreaseTotalOrders(string city) => TotalOrdersCounter.Add(1, KeyValuePair.Create("City", city)); 73 | 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/BookStore.WebApi/Controllers/BooksController.cs: -------------------------------------------------------------------------------- 1 | using AutoMapper; 2 | using BookStore.Domain.Interfaces; 3 | using BookStore.Domain.Models; 4 | using BookStore.WebApi.Dtos.Book; 5 | using Microsoft.AspNetCore.Mvc; 6 | 7 | namespace BookStore.WebApi.Controllers 8 | { 9 | [Route("api/[controller]")] 10 | public class BooksController(IMapper mapper, 11 | IBookService bookService) : ControllerBase 12 | { 13 | [HttpGet] 14 | [ProducesResponseType(StatusCodes.Status200OK)] 15 | public async Task GetAll() 16 | { 17 | var books = await bookService.GetAll(); 18 | return Ok(mapper.Map>(books)); 19 | } 20 | 21 | [HttpGet("{id:int}")] 22 | [ProducesResponseType(StatusCodes.Status200OK)] 23 | [ProducesResponseType(StatusCodes.Status404NotFound)] 24 | public async Task GetById(int id) 25 | { 26 | var book = await bookService.GetById(id); 27 | 28 | if (book == null) return NotFound(); 29 | 30 | return Ok(mapper.Map(book)); 31 | } 32 | 33 | [HttpGet] 34 | [Route("get-books-by-category/{categoryId:int}")] 35 | [ProducesResponseType(StatusCodes.Status200OK)] 36 | [ProducesResponseType(StatusCodes.Status404NotFound)] 37 | public async Task GetBooksByCategory(int categoryId) 38 | { 39 | var books = await bookService.GetBooksByCategory(categoryId); 40 | 41 | if (!books.Any()) return NotFound(); 42 | 43 | return Ok(mapper.Map>(books)); 44 | } 45 | 46 | [HttpPost] 47 | [ProducesResponseType(StatusCodes.Status200OK)] 48 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 49 | public async Task Add([FromBody]BookAddDto bookDto) 50 | { 51 | if (!ModelState.IsValid) return BadRequest(); 52 | 53 | var book = mapper.Map(bookDto); 54 | var bookResult = await bookService.Add(book); 55 | 56 | if (bookResult == null) return BadRequest(); 57 | 58 | return Ok(mapper.Map(bookResult)); 59 | } 60 | 61 | [HttpPut] 62 | [ProducesResponseType(StatusCodes.Status200OK)] 63 | [ProducesResponseType(StatusCodes.Status400BadRequest)] 64 | public async Task Update([FromBody]BookEditDto bookDto) 65 | { 66 | if (!ModelState.IsValid) return BadRequest(); 67 | 68 | var bookResult = await bookService.Update(mapper.Map(bookDto)); 69 | if (bookResult == null) return BadRequest(); 70 | 71 | return Ok(bookDto); 72 | } 73 | 74 | [HttpDelete("{id:int}")] 75 | [ProducesResponseType(StatusCodes.Status200OK)] 76 | [ProducesResponseType(StatusCodes.Status404NotFound)] 77 | public async Task Remove(int id) 78 | { 79 | var book = await bookService.GetById(id); 80 | if (book == null) return NotFound(); 81 | 82 | await bookService.Remove(book); 83 | 84 | return Ok(); 85 | } 86 | 87 | [HttpGet] 88 | [Route("search/{bookName}")] 89 | [ProducesResponseType(StatusCodes.Status200OK)] 90 | [ProducesResponseType(StatusCodes.Status404NotFound)] 91 | public async Task>> Search(string bookName) 92 | { 93 | var books = mapper.Map>(await bookService.Search(bookName)); 94 | 95 | if (books == null || books.Count == 0) return NotFound("None book was founded"); 96 | 97 | return Ok(books); 98 | } 99 | 100 | [HttpGet] 101 | [Route("search-book-with-category/{searchedValue}")] 102 | [ProducesResponseType(StatusCodes.Status200OK)] 103 | [ProducesResponseType(StatusCodes.Status404NotFound)] 104 | public async Task>> SearchBookWithCategory(string searchedValue) 105 | { 106 | var books = mapper.Map>(await bookService.SearchBookWithCategory(searchedValue)); 107 | 108 | if (books.Count == 0) return NotFound("None book was founded"); 109 | 110 | return Ok(mapper.Map>(books)); 111 | } 112 | } 113 | } -------------------------------------------------------------------------------- /src/BookStore.Infrastructure/Context/BookStoreDbContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using BookStore.Domain.Models; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace BookStore.Infrastructure.Context 7 | { 8 | public class BookStoreDbContext : DbContext 9 | { 10 | public BookStoreDbContext(DbContextOptions options) : base(options) { } 11 | public DbSet Categories { get; set; } 12 | public DbSet Books { get; set; } 13 | public virtual DbSet Inventories { get; set; } 14 | public virtual DbSet Orders { get; set; } 15 | 16 | protected override void OnModelCreating(ModelBuilder modelBuilder) 17 | { 18 | foreach (var property in modelBuilder.Model.GetEntityTypes() 19 | .SelectMany(e => e.GetProperties() 20 | .Where(p => p.ClrType == typeof(string)))) 21 | property.SetColumnType("varchar(150)"); 22 | 23 | foreach (var relationship in modelBuilder.Model.GetEntityTypes() 24 | .SelectMany(e => e.GetForeignKeys())) relationship.DeleteBehavior = DeleteBehavior.ClientSetNull; 25 | 26 | modelBuilder.Entity(entity => 27 | { 28 | entity.HasIndex(e => e.CategoryId, "IX_Books_CategoryId"); 29 | 30 | entity.Property(e => e.Author) 31 | .IsRequired() 32 | .HasMaxLength(150) 33 | .IsUnicode(false); 34 | 35 | entity.Property(e => e.Description) 36 | .HasMaxLength(350) 37 | .IsUnicode(false); 38 | 39 | entity.Property(e => e.Name) 40 | .IsRequired() 41 | .HasMaxLength(150) 42 | .IsUnicode(false); 43 | 44 | entity.HasOne(d => d.Category) 45 | .WithMany(p => p.Books) 46 | .HasForeignKey(d => d.CategoryId) 47 | .OnDelete(DeleteBehavior.ClientSetNull); 48 | 49 | entity.HasMany(d => d.Orders) 50 | .WithMany(p => p.Books) 51 | .UsingEntity>( 52 | "BooksOrder", 53 | l => l.HasOne().WithMany().HasForeignKey("OrderId").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Orders"), 54 | r => r.HasOne().WithMany().HasForeignKey("BookId").OnDelete(DeleteBehavior.ClientSetNull).HasConstraintName("FK_Books"), 55 | j => 56 | { 57 | j.HasKey("BookId", "OrderId"); 58 | 59 | j.ToTable("Books_Orders"); 60 | }); 61 | }); 62 | 63 | modelBuilder.Entity(entity => 64 | { 65 | entity.Property(e => e.Name) 66 | .IsRequired() 67 | .HasMaxLength(150) 68 | .IsUnicode(false); 69 | }); 70 | 71 | modelBuilder.Entity(entity => 72 | { 73 | entity.ToTable("Inventory"); 74 | 75 | entity.Property(e => e.Id) 76 | .IsRequired() 77 | .HasColumnName("BookId"); 78 | 79 | entity.HasOne(d => d.Book) 80 | .WithOne(p => p.Inventory) 81 | .HasForeignKey(d => d.Id) 82 | .OnDelete(DeleteBehavior.ClientSetNull) 83 | .HasConstraintName("FK_Inventory_Books"); 84 | }); 85 | 86 | modelBuilder.Entity(entity => 87 | { 88 | entity.Property(e => e.Address) 89 | .IsRequired() 90 | .HasMaxLength(350) 91 | .IsUnicode(false); 92 | 93 | entity.Property(e => e.City) 94 | .IsRequired() 95 | .HasMaxLength(350) 96 | .IsUnicode(false); 97 | 98 | entity.Property(e => e.CustomerName) 99 | .IsRequired() 100 | .HasMaxLength(350) 101 | .IsUnicode(false); 102 | 103 | entity.Property(e => e.Status) 104 | .IsRequired() 105 | .HasMaxLength(150) 106 | .IsUnicode(false); 107 | 108 | entity.Property(e => e.Telephone) 109 | .IsRequired() 110 | .HasMaxLength(350) 111 | .IsUnicode(false); 112 | 113 | }); 114 | 115 | base.OnModelCreating(modelBuilder); 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /test/BookStoreMetrics.UnitTest/BookStoreMetricsTests.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics.Metrics; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Diagnostics.Metrics.Testing; 5 | 6 | namespace BookStoreMetrics.UnitTest 7 | { 8 | public class BookStoreMetricsTests 9 | { 10 | private static IServiceProvider CreateServiceProvider() 11 | { 12 | var serviceCollection = new ServiceCollection(); 13 | var config = CreateIConfiguration(); 14 | serviceCollection.AddMetrics(); 15 | serviceCollection.AddSingleton(config); 16 | serviceCollection.AddSingleton(); 17 | return serviceCollection.BuildServiceProvider(); 18 | } 19 | 20 | private static IConfiguration CreateIConfiguration() 21 | { 22 | var inMemorySettings = new Dictionary { 23 | {"BookStoreMeterName", "BookStore"} 24 | }; 25 | 26 | return new ConfigurationBuilder() 27 | .AddInMemoryCollection(inMemorySettings!) 28 | .Build(); 29 | } 30 | 31 | [Fact] 32 | public void GivenTheTotalNumberOfBooksOnTheStore_WhenWeRecordThemOnAHistogram_ThenTheValueGetsRecordedSuccessfully() 33 | { 34 | //Arrange 35 | var services = CreateServiceProvider(); 36 | var metrics = services.GetRequiredService(); 37 | var meterFactory = services.GetRequiredService(); 38 | var collector = new MetricCollector(meterFactory, "BookStore", "orders-number-of-books"); 39 | 40 | // Act 41 | metrics.RecordNumberOfBooks(35); 42 | 43 | // Assert 44 | var measurements = collector.GetMeasurementSnapshot(); 45 | Assert.Equal(35, measurements[0].Value); 46 | } 47 | 48 | [Fact] 49 | public void GivenASetOfBooks_WhenWeIncreaseAndDecreaseTheInventory_ThenTheTotalAmountOfBooksIsRecordedSuccessfully() 50 | { 51 | //Arrange 52 | var services = CreateServiceProvider(); 53 | var metrics = services.GetRequiredService(); 54 | var meterFactory = services.GetRequiredService(); 55 | var collector = new MetricCollector(meterFactory, "BookStore", "total-books"); 56 | 57 | // Act 58 | metrics.IncreaseTotalBooks(); 59 | metrics.IncreaseTotalBooks(); 60 | metrics.DecreaseTotalBooks(); 61 | metrics.IncreaseTotalBooks(); 62 | metrics.IncreaseTotalBooks(); 63 | metrics.DecreaseTotalBooks(); 64 | metrics.DecreaseTotalBooks(); 65 | metrics.IncreaseTotalBooks(); 66 | 67 | // Assert 68 | var measurements = collector.GetMeasurementSnapshot(); 69 | Assert.Equal(2, measurements.EvaluateAsCounter()); 70 | } 71 | 72 | [Fact] 73 | public void GivenSomeNewBookOrders_WhenWeIncreaseTheTotalOrdersCounter_ThenTheCountryGetsStoredAsATag() 74 | { 75 | //Arrange 76 | var services = CreateServiceProvider(); 77 | var metrics = services.GetRequiredService(); 78 | var meterFactory = services.GetRequiredService(); 79 | var collector = new MetricCollector(meterFactory, "BookStore", "total-orders"); 80 | 81 | // Act 82 | metrics.IncreaseTotalOrders("Barcelona"); 83 | metrics.IncreaseTotalOrders("Paris"); 84 | 85 | // Assert 86 | var measurements = collector.GetMeasurementSnapshot(); 87 | 88 | Assert.True(measurements.ContainsTags("City").Any()); 89 | Assert.Equal(2, measurements.EvaluateAsCounter()); 90 | } 91 | 92 | [Fact] 93 | public void GivenSomeNewBookCategories_WhenWeIncreaseAndDecreaseTheObservableGauge_ThenTheLastMeasurementOnTheCollectorIsCorrect() 94 | { 95 | //Arrange 96 | var services = CreateServiceProvider(); 97 | var metrics = services.GetRequiredService(); 98 | var meterFactory = services.GetRequiredService(); 99 | var collector = new MetricCollector(meterFactory, "BookStore", "total-categories"); 100 | 101 | // Act 102 | metrics.IncreaseTotalCategories(); 103 | metrics.DecreaseTotalCategories(); 104 | metrics.IncreaseTotalCategories(); 105 | metrics.IncreaseTotalCategories(); 106 | 107 | // Assert 108 | collector.RecordObservableInstruments(); 109 | Assert.Equal(2, collector.LastMeasurement?.Value); 110 | } 111 | } 112 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | [Ll]ogs/ 33 | 34 | # Visual Studio 2015/2017 cache/options directory 35 | .vs/ 36 | # Uncomment if you have tasks that create the project's static files in wwwroot 37 | #wwwroot/ 38 | 39 | # Visual Studio 2017 auto generated files 40 | Generated\ Files/ 41 | 42 | # MSTest test Results 43 | [Tt]est[Rr]esult*/ 44 | [Bb]uild[Ll]og.* 45 | 46 | # NUnit 47 | *.VisualState.xml 48 | TestResult.xml 49 | nunit-*.xml 50 | 51 | # Build Results of an ATL Project 52 | [Dd]ebugPS/ 53 | [Rr]eleasePS/ 54 | dlldata.c 55 | 56 | # Benchmark Results 57 | BenchmarkDotNet.Artifacts/ 58 | 59 | # .NET Core 60 | project.lock.json 61 | project.fragment.lock.json 62 | artifacts/ 63 | 64 | # StyleCop 65 | StyleCopReport.xml 66 | 67 | # Files built by Visual Studio 68 | *_i.c 69 | *_p.c 70 | *_h.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.iobj 75 | *.pch 76 | *.pdb 77 | *.ipdb 78 | *.pgc 79 | *.pgd 80 | *.rsp 81 | *.sbr 82 | *.tlb 83 | *.tli 84 | *.tlh 85 | *.tmp 86 | *.tmp_proj 87 | *_wpftmp.csproj 88 | *.log 89 | *.vspscc 90 | *.vssscc 91 | .builds 92 | *.pidb 93 | *.svclog 94 | *.scc 95 | 96 | # Chutzpah Test files 97 | _Chutzpah* 98 | 99 | # Visual C++ cache files 100 | ipch/ 101 | *.aps 102 | *.ncb 103 | *.opendb 104 | *.opensdf 105 | *.sdf 106 | *.cachefile 107 | *.VC.db 108 | *.VC.VC.opendb 109 | 110 | # Visual Studio profiler 111 | *.psess 112 | *.vsp 113 | *.vspx 114 | *.sap 115 | 116 | # Visual Studio Trace Files 117 | *.e2e 118 | 119 | # TFS 2012 Local Workspace 120 | $tf/ 121 | 122 | # Guidance Automation Toolkit 123 | *.gpState 124 | 125 | # ReSharper is a .NET coding add-in 126 | _ReSharper*/ 127 | *.[Rr]e[Ss]harper 128 | *.DotSettings.user 129 | 130 | # TeamCity is a build add-in 131 | _TeamCity* 132 | 133 | # DotCover is a Code Coverage Tool 134 | *.dotCover 135 | 136 | # AxoCover is a Code Coverage Tool 137 | .axoCover/* 138 | !.axoCover/settings.json 139 | 140 | # Visual Studio code coverage results 141 | *.coverage 142 | *.coveragexml 143 | 144 | # NCrunch 145 | _NCrunch_* 146 | .*crunch*.local.xml 147 | nCrunchTemp_* 148 | 149 | # MightyMoose 150 | *.mm.* 151 | AutoTest.Net/ 152 | 153 | # Web workbench (sass) 154 | .sass-cache/ 155 | 156 | # Installshield output folder 157 | [Ee]xpress/ 158 | 159 | # DocProject is a documentation generator add-in 160 | DocProject/buildhelp/ 161 | DocProject/Help/*.HxT 162 | DocProject/Help/*.HxC 163 | DocProject/Help/*.hhc 164 | DocProject/Help/*.hhk 165 | DocProject/Help/*.hhp 166 | DocProject/Help/Html2 167 | DocProject/Help/html 168 | 169 | # Click-Once directory 170 | publish/ 171 | 172 | # Publish Web Output 173 | *.[Pp]ublish.xml 174 | *.azurePubxml 175 | # Note: Comment the next line if you want to checkin your web deploy settings, 176 | # but database connection strings (with potential passwords) will be unencrypted 177 | *.pubxml 178 | *.publishproj 179 | 180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 181 | # checkin your Azure Web App publish settings, but sensitive information contained 182 | # in these scripts will be unencrypted 183 | PublishScripts/ 184 | 185 | # NuGet Packages 186 | *.nupkg 187 | # NuGet Symbol Packages 188 | *.snupkg 189 | # The packages folder can be ignored because of Package Restore 190 | **/[Pp]ackages/* 191 | # except build/, which is used as an MSBuild target. 192 | !**/[Pp]ackages/build/ 193 | # Uncomment if necessary however generally it will be regenerated when needed 194 | #!**/[Pp]ackages/repositories.config 195 | # NuGet v3's project.json files produces more ignorable files 196 | *.nuget.props 197 | *.nuget.targets 198 | 199 | # Microsoft Azure Build Output 200 | csx/ 201 | *.build.csdef 202 | 203 | # Microsoft Azure Emulator 204 | ecf/ 205 | rcf/ 206 | 207 | # Windows Store app package directories and files 208 | AppPackages/ 209 | BundleArtifacts/ 210 | Package.StoreAssociation.xml 211 | _pkginfo.txt 212 | *.appx 213 | *.appxbundle 214 | *.appxupload 215 | 216 | # Visual Studio cache files 217 | # files ending in .cache can be ignored 218 | *.[Cc]ache 219 | # but keep track of directories ending in .cache 220 | !?*.[Cc]ache/ 221 | 222 | # Others 223 | ClientBin/ 224 | ~$* 225 | *~ 226 | *.dbmdl 227 | *.dbproj.schemaview 228 | *.jfm 229 | *.pfx 230 | *.publishsettings 231 | orleans.codegen.cs 232 | 233 | # Including strong name files can present a security risk 234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 235 | #*.snk 236 | 237 | # Since there are multiple workflows, uncomment next line to ignore bower_components 238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 239 | #bower_components/ 240 | 241 | # RIA/Silverlight projects 242 | Generated_Code/ 243 | 244 | # Backup & report files from converting an old project file 245 | # to a newer Visual Studio version. Backup files are not needed, 246 | # because we have git ;-) 247 | _UpgradeReport_Files/ 248 | Backup*/ 249 | UpgradeLog*.XML 250 | UpgradeLog*.htm 251 | ServiceFabricBackup/ 252 | *.rptproj.bak 253 | 254 | # SQL Server files 255 | *.mdf 256 | *.ldf 257 | *.ndf 258 | 259 | # Business Intelligence projects 260 | *.rdl.data 261 | *.bim.layout 262 | *.bim_*.settings 263 | *.rptproj.rsuser 264 | *- [Bb]ackup.rdl 265 | *- [Bb]ackup ([0-9]).rdl 266 | *- [Bb]ackup ([0-9][0-9]).rdl 267 | 268 | # Microsoft Fakes 269 | FakesAssemblies/ 270 | 271 | # GhostDoc plugin setting file 272 | *.GhostDoc.xml 273 | 274 | # Node.js Tools for Visual Studio 275 | .ntvs_analysis.dat 276 | node_modules/ 277 | 278 | # Visual Studio 6 build log 279 | *.plg 280 | 281 | # Visual Studio 6 workspace options file 282 | *.opt 283 | 284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 285 | *.vbw 286 | 287 | # Visual Studio LightSwitch build output 288 | **/*.HTMLClient/GeneratedArtifacts 289 | **/*.DesktopClient/GeneratedArtifacts 290 | **/*.DesktopClient/ModelManifest.xml 291 | **/*.Server/GeneratedArtifacts 292 | **/*.Server/ModelManifest.xml 293 | _Pvt_Extensions 294 | 295 | # Paket dependency manager 296 | .paket/paket.exe 297 | paket-files/ 298 | 299 | # FAKE - F# Make 300 | .fake/ 301 | 302 | # CodeRush personal settings 303 | .cr/personal 304 | 305 | # Python Tools for Visual Studio (PTVS) 306 | __pycache__/ 307 | *.pyc 308 | 309 | # Cake - Uncomment if you are using it 310 | # tools/** 311 | # !tools/packages.config 312 | 313 | # Tabs Studio 314 | *.tss 315 | 316 | # Telerik's JustMock configuration file 317 | *.jmconfig 318 | 319 | # BizTalk build output 320 | *.btp.cs 321 | *.btm.cs 322 | *.odx.cs 323 | *.xsd.cs 324 | 325 | # OpenCover UI analysis results 326 | OpenCover/ 327 | 328 | # Azure Stream Analytics local run output 329 | ASALocalRun/ 330 | 331 | # MSBuild Binary and Structured Log 332 | *.binlog 333 | 334 | # NVidia Nsight GPU debugger configuration file 335 | *.nvuser 336 | 337 | # MFractors (Xamarin productivity tool) working folder 338 | .mfractor/ 339 | 340 | # Local History for Visual Studio 341 | .localhistory/ 342 | 343 | # BeatPulse healthcheck temp database 344 | healthchecksdb 345 | 346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 347 | MigrationBackup/ 348 | 349 | # Ionide (cross platform F# VS Code tools) working folder 350 | .ionide/ 351 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # **Introduction** 2 | 3 | This repository contains a practical example about how to use OpenTelemetry to add custom metrics to a .NET app, and how to visualize those metrics using Prometheus and Grafana. 4 | 5 | # **Content** 6 | 7 | The repository contains the following applications. 8 | 9 | ![application-diagram](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/app-otel-metrics-diagram.png) 10 | 11 | - The BookStore WebAPI uses the OpenTelemetry OTLP exporter package (``OpenTelemetry.Exporter.OpenTelemetryProtocol``) to send the metric data to the OpenTelemetry Collector. 12 | - The Prometheus server obtains the metric data from the OpenTelemetry Collector. 13 | - The Grafana server comes preconfigured with a few dashboards to visualize the OpenTelemetry metrics emitted by the BookStore WebApi. 14 | 15 | # **Application** 16 | 17 | The application is a BookStore API built using **.NET 8**. It allows us to do the following actions: 18 | 19 | - Get, add, update and delete book categories. 20 | - Get, add, update and delete books. 21 | - Get, add, update and delete inventory. 22 | - Get, add and delete orders. 23 | 24 | For a better understanding, here's how the database diagram looks like: 25 | 26 | ![bookstore-db-diagram](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/bookstore-database-diagram.png) 27 | 28 | # **OpenTelemetry Metrics** 29 | 30 | The application generates the following metrics: 31 | 32 | ## **BookStore custom metrics** 33 | 34 | Those are the business metrics instrumented directly on the application using the Metrics API incorporated into the .NET runtime itself. 35 | 36 | The metrics generated are the following ones: 37 | - ``BooksAddedCounter``: It counts how many books are added to the store. 38 | - ``BooksDeletedCounter``: It counts how many books are deleted from the store. 39 | - ``BooksUpdatedCounter``: It counts how many books are updated. 40 | - ``TotalBooksUpDownCounter``: Total number of books that the store has at any given time. 41 | - ``CategoriesAddedCounter``: It counts how many book categories are added to the store. 42 | - ``CategoriesDeletedCounter``: It counts how many book categories are deleted from the store. 43 | - ``CategoriesUpdatedCounter``: It counts how many book categories are updated. 44 | - ``TotalCategoriesGauge``: Total number of book categories that the store has at any given time. 45 | - ``OrdersPriceHistogram``: Shows the price distribution of the orders. 46 | - ``NumberOfBooksPerOrderHistogram``: Shows the number of books distribution per order. 47 | - ``OrdersCanceledCounter``: It counts how many orders has been cancelled. 48 | - ``TotalOrdersCounter``: Total number of orders that the store has at any given time. 49 | 50 | ### **How to unit test the BookingStore Custom metrics** 51 | 52 | It is possible to test any custom metrics you create using the ``Microsoft.Extensions.Diagnostics.Testing`` NuGet package and the ``MetricCollector`` implementation. 53 | 54 | The ``MetricCollector`` class makes it easy to record the measurements from specific instruments and assert the values were correct. 55 | 56 | In the ``/test`` folder, there is a Unit test project with a few examples that test that the measurements are correct. 57 | 58 | ## **.NET built-in metrics** 59 | 60 | These metrics are generated by the ``System.Diagnostics.Metrics`` API and they're natively built-in on .NET framework starting from .NET 8. 61 | 62 | Here's the full list of the Meters and Instruments built-in the .NET framework. 63 | - https://learn.microsoft.com/en-us/dotnet/core/diagnostics/built-in-metrics-aspnetcore 64 | 65 | To start emitting these metrics on your application, there are two options available: 66 | 67 | - Install and configure the ``OpenTelemetry.Instrumentation.AspNetCore`` NuGet package. 68 | 69 | To start using the ``OpenTelemetry.Instrumentation.AspNetCore`` package you only need to add the ``AddAspNetCoreInstrumentation()`` extension method when setting up the .NET OpenTelemetry component. Here's an example: 70 | 71 | ```csharp 72 | builder.Services.AddOpenTelemetry().WithMetrics(opts => opts 73 | .AddAspNetCoreInstrumentation() 74 | ); 75 | ``` 76 | 77 | This instrumentation library automatically enables all built-in metrics by default. The advantage of using this method is that the ``AddAspNetCoreInstrumentation()`` extension simplifies the process of enabling all built-in metrics via a single line of code. 78 | 79 | - Manually register the built-in Meters using the ``AddMeter`` extension method. 80 | 81 | ```csharp 82 | builder.Services.AddOpenTelemetry().WithMetrics(opts => opts 83 | .AddMeter("Microsoft.AspNetCore.Hosting") 84 | .AddMeter("Microsoft.AspNetCore.Server.Kestrel") 85 | .AddMeter("Microsoft.AspNetCore.Http.Connections") 86 | .AddMeter("Microsoft.AspNetCore.Routing") 87 | .AddMeter("Microsoft.AspNetCore.Diagnostics") 88 | .AddMeter("Microsoft.AspNetCore.RateLimiting") 89 | ); 90 | ``` 91 | The advantage of using this method is that it allows us to exert more granular control over which of the built-in metrics we want to emit. Additionally, employing the ``AddMeter()`` for metric activation eliminates the necessity to depend on the ``OpenTelemetry.Instrumentation.AspNetCore`` instrumentation library. 92 | 93 | ## **System.Runtime performance metrics** 94 | 95 | Those metrics are generated by the ``OpenTelemetry.Instrumentation.Runtime`` NuGet package. This is an instrumentation library, which instruments .NET Runtime and collect runtime performance metrics. 96 | 97 | To start using the ``OpenTelemetry.Instrumentation.Runtime`` package you only need to add the ``AddRuntimeInstrumentation()`` extension method when setting up the .NET OpenTelemetry component. Here's an example: 98 | 99 | ```csharp 100 | builder.Services.AddOpenTelemetry().WithMetrics(opts => opts 101 | .AddRuntimeInstrumentation() 102 | ); 103 | ``` 104 | 105 | The ``OpenTelemetry.Instrumentation.Runtime`` package collects metrics about the following ``System.Runtime`` counters: 106 | 107 | - ``process.runtime.dotnet.gc.collections.count``: Number of garbage collections that have occurred since process start. 108 | - ``process.runtime.dotnet.gc.objects.size``: Count of bytes currently in use by objects in the GC heap that haven't been collected yet. Fragmentation and other GC committed memory pools are excluded. 109 | - ``process.runtime.dotnet.gc.allocations.size``: Count of bytes allocated on the managed GC heap since the process start 110 | - ``process.runtime.dotnet.gc.committed_memory.size``: The amount of committed virtual memory for the managed GC heap, as observed during the latest garbage collection. 111 | - ``process.runtime.dotnet.gc.heap.size``: The heap size (including fragmentation), as observed during the latest garbage collection. 112 | - ``process.runtime.dotnet.gc.heap.fragmentation.size``: The heap fragmentation, as observed during the latest garbage collection. 113 | - ``process.runtime.dotnet.jit.il_compiled.size``: Count of bytes of intermediate language that have been compiled since the process start. 114 | - ``process.runtime.dotnet.jit.methods_compiled.count``: The number of times the JIT compiler compiled a method since the process start. 115 | - ``process.runtime.dotnet.jit.compilation_time``: The amount of time the JIT compiler has spent compiling methods since the process start. 116 | - ``process.runtime.dotnet.monitor.lock_contention.count``: The number of times there was contention when trying to acquire a monitor lock since the process start. 117 | - ``process.runtime.dotnet.thread_pool.threads.count``: The number of thread pool threads that currently exist. 118 | - ``process.runtime.dotnet.thread_pool.completed_items.count``: The number of work items that have been processed by the thread pool since the process start. 119 | - ``process.runtime.dotnet.thread_pool.queue.length``: The number of work items that are currently queued to be processed by the thread pool. 120 | - ``process.runtime.dotnet.timer.count``: The number of timer instances that are currently active. 121 | - ``process.runtime.dotnet.assemblies.count``: The number of .NET assemblies that are currently loaded. 122 | - ``process.runtime.dotnet.exceptions.count``: Count of exceptions that have been thrown in managed code, since the observation started. 123 | 124 | Some of the GC related metrics will be unavailable until at least one garbage collection has occurred. 125 | 126 | ## **Process metrics** 127 | 128 | Those metrics are generated by the ``OpenTelemetry.Instrumentation.Process`` NuGet package. This is an Instrumentation Library, which instruments .NET and collects telemetry about process behavior. 129 | 130 | To start using the ``OpenTelemetry.Instrumentation.Process`` package you only need to add the ``AddProcessInstrumentation()`` extension method when setting up the .NET OpenTelemetry component. Here's an example: 131 | 132 | ```csharp 133 | builder.Services.AddOpenTelemetry().WithMetrics(opts => opts 134 | .AddProcessInstrumentation() 135 | ); 136 | ``` 137 | 138 | The ``OpenTelemetry.Instrumentation.Process`` package the following metrics of the running process: 139 | - ``process.memory.usage``: The amount of physical memory allocated for this process. 140 | - ``process.memory.virtual``: The amount of committed virtual memory for this process. One way to think of this is all the address space this process can read from without triggering an access violation; this includes memory backed solely by RAM, by a swapfile/pagefile and by other mapped files on disk. 141 | - ``process.cpu.time``: Total CPU seconds broken down by states. 142 | - ``process.cpu.count``: The number of processors (CPU cores) available to the current process. 143 | - ``process.threads``: Process threads count. 144 | 145 | 146 | # **OpenTelemetry .NET Client** 147 | 148 | The app uses the following package versions: 149 | 150 | ```xml 151 | 152 | 153 | 154 | 155 | 156 | ``` 157 | 158 | # **External Dependencies** 159 | 160 | - OpenTelemetry Collector 161 | - Prometheus 162 | - MSSQL Server 163 | - Grafana 164 | 165 | # **How to run the app** 166 | 167 | The repository contains a ``docker-compose`` that starts up the BookStore app and also the external dependencies. 168 | The external dependencies (OpenTelemetry Collector, Prometheus, MSSQL Server and Grafana) are already preconfigured so you don't need to do any extra setup. 169 | 170 | - The OpenTelemetry Collector is already configured to export the metrics to Prometheus. 171 | - The MSSQL Server comes with the BookStore database schema configured. 172 | - The Prometheus is already configured to receive the metric data from the OpenTelemetry Collector. 173 | - The Grafana has the Prometheus connector already setup, it also contains 3 custom dashboards to visualize the OpenTelemetry metrics emitted by the BookStore app. 174 | 175 | Just run ``docker-compose up`` and your good to go! 176 | 177 | ## **How to test the app** 178 | 179 | > _It requires to have **cURL** installed on your local machine._ 180 | 181 | This repository contains a ``seed-data.sh`` Shell script that will invoke some endpoints of the BookStore API via cURL. 182 | 183 | The ``seed-data.sh`` script runs the following operations: 184 | - Adds 8 book categories. 185 | - Updates 3 book categories. 186 | - Deletes 2 book categories. 187 | - Adds 17 books into the store. 188 | - Updates 4 existing books. 189 | - Deletes 2 existing books. 190 | - Adds inventory for every book on the store. 191 | - Creates 10 orders. 192 | - Cancels 3 existing orders. 193 | 194 | The purpose behind this script is to generate a decent amount of metrics that later can be visualized in Grafana. 195 | 196 | # **Output** 197 | 198 | If you open Grafana, you're going to see those 3 dashboards. 199 | 200 | ![grafana-dashboards](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/grafana-dashboards.png) 201 | 202 | If you open those dashboards after running the ``seed-data.sh`` script from the previous section, you're going to see something like this. 203 | 204 | ## **BookStore Custom Metrics dashboard** 205 | 206 | ![bookstore-dashboard](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/bookstore-custom-metrics.png) 207 | 208 | ## **.NET Performance Counters & Process Metrics dashboard** 209 | 210 | ![runtime-perf-counters-and-process-dashboard](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/runtime-perf-counters-and-process-dashboard.png) 211 | 212 | ## **.NET built-in metrics dashboard** 213 | 214 | This pair of dashboards were not built by me; they were built by the .NET team themselves. I simply downloaded them and included them in this repository so that when you run ``docker-compose up``, they are already included in the example, and you don't need to import them. 215 | 216 | If you want to play around with them on your own, the link to download them is as follows: [link](https://github.com/dotnet/aspire/tree/main/src/Grafana) 217 | 218 | ### **.NET /api/orders endpoint metrics** 219 | 220 | ![aspnet-core-orders-endpoint-dashboard](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/aspnet-core-orders-endpoint-dashboard.png) 221 | 222 | ### **.NET /api/books endpoint metrics** 223 | 224 | ![aspnet-core-books-endpoint-dashboard](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/aspnet-core-books-endpoint-dashboard.png) 225 | 226 | ### **.NET general metrics dashboard** 227 | 228 | ![aspnet-core-metrics-dashboard](https://raw.githubusercontent.com/karlospn/opentelemetry-metrics-demo/main/docs/aspnet-core-metrics-dashboard.png) 229 | 230 | # **Changelog** 231 | 232 | ### **04/14/2024** 233 | - Update ``OpenTelemetry`` packages to remove known security vulnerabilities. 234 | - Remove the ``AutoMapper.Extensions.Microsoft.DependencyInjection`` package because it has been deprecated. And instead install ``AutoMapper 13.0.0``. 235 | - Update ``EntityFramework`` packages to the latest stable version. 236 | 237 | ### **11/29/2023** 238 | - Update application to .NET 8. 239 | - The application now uses an Ubuntu Chiseled base image instead of a Debian one. 240 | - Rename the ``OtelMetrics`` class to ``BookStoreMetrics``. 241 | - The ``BookStoreMetrics`` class uses the new ``IMeterFactory`` interface to create the ``Meter``. 242 | - Move the ``Meter`` name from being a hardcoded string to configuration. 243 | - Add some new C# 12 features like primary constructors and collection expressions. 244 | - Update OpenTelemetry packages to the latest version. 245 | - Create a Unit Test project to demonstrate how can we test the ``Instruments`` using the ``Microsoft.Extensions.Diagnostics.Testing`` library and the ``MetricCollector``. 246 | - Update Grafana, Prometheus and OTEL Collector images used on the docker-compose to the most recent versions. 247 | - Fix a few broken panels on the Grafana dashboards due to the upgrade. 248 | - Deleted the custom dashboard that uses ASP.NET core metrics and replaced it with the two new ones built by the .NET team itself. They can be found in the Grafana Store. Here's the [link](https://github.com/dotnet/aspire/tree/main/src/Grafana) 249 | 250 | ### **04/09/2023** 251 | - Update application to .NET 7. 252 | - Add a new middleware into the app that simulates latency. 253 | - Update OpenTelemetry packages to the latest version. 254 | - Fix breaking changes on the app due to the OpenTelemetry packages version upgrade. 255 | - Fix a few broken panels on the Grafana dashboard due to the OpenTelemetry packages version upgrade. 256 | - Install and configure ``OpenTelemetry.Instrumentation.Process`` package to import CPU and memory metrics. 257 | - Replace the ``TotalBooksGauge`` metric with the ``TotalBooksUpDownCounter`` metric. 258 | - Update Grafana, Prometheus and OTEL Collector images used on the docker-compose to the newest versions. 259 | 260 | 261 | -------------------------------------------------------------------------------- /seed-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | ######################### 4 | ##### Set API URI ####### 5 | ######################### 6 | api_uri=http://localhost:5001 7 | 8 | ######################### 9 | ##### Add Categories #### 10 | ######################### 11 | 12 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Educational"}' 13 | echo 14 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Drama"}' 15 | echo 16 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Fantasy"}' 17 | echo 18 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Mistery"}' 19 | echo 20 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "SciFi"}' 21 | echo 22 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Western"}' 23 | echo 24 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Contemporary"}' 25 | echo 26 | curl -k -X 'POST' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"name": "Dystopian"}' 27 | echo 28 | sleep 5 29 | 30 | ######################### 31 | ### Update Categories ### 32 | ######################### 33 | 34 | curl -k -X 'PUT' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"id": 2, "name": "History"}' 35 | echo 36 | curl -k -X 'PUT' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"id": 4, "name": "Travel"}' 37 | echo 38 | curl -k -X 'PUT' "${api_uri}/api/Categories" -H 'accept: */*' -H 'Content-Type: application/json' -d '{"id": 6, "name": "Memoir"}' 39 | echo 40 | sleep 5 41 | 42 | ######################### 43 | ### Delete Categories ### 44 | ######################### 45 | 46 | curl -k -X 'DELETE' "${api_uri}/api/Categories/6" -H 'accept: */*' 47 | echo 48 | curl -k -X 'DELETE' "${api_uri}/api/Categories/3" -H 'accept: */*' 49 | echo 50 | sleep 5 51 | 52 | ######################### 53 | ###### Add Books ####### 54 | ######################### 55 | 56 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 57 | -d '{ 58 | "categoryId": 1, 59 | "name": "Parallel Programming and Concurrency with C# 10 and .NET 6", 60 | "author": "Alvin Ashcraft", 61 | "description": "Leverage the latest parallel and concurrency features in .NET 6 when building your next application and explore the benefits and challenges of asynchrony, parallelism, and concurrency in .NET via practical examples.", 62 | "value": 37.99, 63 | "publishDate": "2022-08-30T00:00:00.000Z" 64 | }' 65 | echo 66 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 67 | -d '{ 68 | "categoryId": 1, 69 | "name": "Mapping Data Flows in Azure Data Factory", 70 | "author": "Mark Kromer", 71 | "description": "Build scalable ETL data pipelines in the cloud using Azure Data Factory Mapping Data Flows.", 72 | "value": 49.99, 73 | "publishDate": "2022-08-26T00:00:00.000Z" 74 | }' 75 | echo 76 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 77 | -d '{ 78 | "categoryId": 1, 79 | "name": "Programming C# 10", 80 | "author": "Ian Griffiths", 81 | "description": "C# is undeniably one of the most versatile programming languages available to engineers today. With this comprehensive guide, you will learn just how powerful the combination of C# and .NET can be.", 82 | "value": 56.61, 83 | "publishDate": "2022-09-13T00:00:00.000Z" 84 | }' 85 | echo 86 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 87 | -d '{ 88 | "categoryId": 1, 89 | "name": "Design Patterns in .NET 6", 90 | "author": "Dmitri Nesteruk", 91 | "description": "Implement design patterns in .NET 6 using the latest versions of the C# and F# languages.", 92 | "value": 56.60, 93 | "publishDate": "2022-08-30T00:00:00.000Z" 94 | }' 95 | echo 96 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 97 | -d '{ 98 | "categoryId": 1, 99 | "name": "Practical Database Auditing for Microsoft SQL Server and Azure SQL", 100 | "author": "Josephine Bush", 101 | "description": "Know how to track changes and key events in your SQL Server databases in support of application troubleshooting, regulatory compliance, and governance.", 102 | "value": 49.99, 103 | "publishDate": "2022-09-20T00:00:00.000Z" 104 | }' 105 | echo 106 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 107 | -d '{ 108 | "categoryId": 1, 109 | "name": "Software Architecture with C# 10 and .NET 6", 110 | "author": "Gabriel Baptista", 111 | "description": "Design scalable and high-performance enterprise applications using the latest features of C# 10 and .NET 6.", 112 | "value": 49.39, 113 | "publishDate": "2022-03-15T00:00:00.000Z" 114 | }' 115 | echo 116 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 117 | -d '{ 118 | "categoryId": 1, 119 | "name": "Patterns of Enterprise Application Architecture", 120 | "author": "Martin Fowler", 121 | "description": "Developers of enterprise applications (e.g reservation systems, supply chain programs, financial systems, etc.) face a unique set of challenges, different than those faced by their desktop system and embedded system peers.", 122 | "value": 60.99, 123 | "publishDate": "2002-11-05T00:00:00.000Z" 124 | }' 125 | echo 126 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 127 | -d '{ 128 | "categoryId": 2, 129 | "name": "History of the World Map by Map", 130 | "author": "DK", 131 | "description": "Explore the history of the world in unprecedented detail with this ultimate guide to history throughout the ages.", 132 | "value": 35.40, 133 | "publishDate": "2018-10-23T00:00:00.000Z" 134 | }' 135 | echo 136 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 137 | -d '{ 138 | "categoryId": 2, 139 | "name": "Pagan Christianity: Exploring the Roots of Our Church Practices", 140 | "author": "Frank Viola", 141 | "description": "Have you ever wondered why we Christians do what we do for church every Sunday morning? Why do we dress up for church?.", 142 | "value": 15.39, 143 | "publishDate": "2012-02-01T00:00:00.000Z" 144 | }' 145 | echo 146 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 147 | -d '{ 148 | "categoryId": 2, 149 | "name": "National Geographic Atlas of the National Parks", 150 | "author": "Jon Waterman", 151 | "description": "The first book of its kind, this stunning atlas showcases America park system from coast to coast, richly illustrated with an inspiring and informative collection of maps, graphics, and photographs.", 152 | "value": 44.49, 153 | "publishDate": "2019-11-19T00:00:00.000Z" 154 | }' 155 | echo 156 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 157 | -d '{ 158 | "categoryId": 4, 159 | "name": "National Geographic Road Atlas 2022", 160 | "author": "National Geographic", 161 | "description": "National Geographic Road Atlas: Adventure Edition, is the ideal companion for the next time you hit the road.", 162 | "value": 22.46, 163 | "publishDate": "2019-01-10T00:00:00.000Z" 164 | }' 165 | echo 166 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 167 | -d '{ 168 | "categoryId": 4, 169 | "name": "Rick Steves Paris", 170 | "author": "Rick Steves", 171 | "description": "Now more than ever, you can count on Rick Steves to tell you what you really need to know when traveling through Paris.", 172 | "value": 19.79, 173 | "publishDate": "2022-09-20T00:00:00.000Z" 174 | }' 175 | echo 176 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 177 | -d '{ 178 | "categoryId": 4, 179 | "name": "Rick Steves London", 180 | "author": "Rick Steves", 181 | "description": "Now more than ever, you can count on Rick Steves to tell you what you really need to know when traveling through London.", 182 | "value": 19.79, 183 | "publishDate": "2022-09-06T00:00:00.000Z" 184 | }' 185 | echo 186 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 187 | -d '{ 188 | "categoryId": 7, 189 | "name": "West with Giraffes: A Novel", 190 | "author": "Linda Rutledge", 191 | "description": "An emotional, rousing novel inspired by the incredible true story of two giraffes who made headlines and won the hearts of Depression-era America.", 192 | "value": 10.99, 193 | "publishDate": "2021-02-01T00:00:00.000Z" 194 | }' 195 | echo 196 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 197 | -d '{ 198 | "categoryId": 8, 199 | "name": "The Big Dark Sky", 200 | "author": "Dean Koontz", 201 | "description": "A group of strangers bound by terrifying synchronicity becomes humankinds hope of survival in an exhilarating, twist-filled novel by Dean Koontz.", 202 | "value": 12.99, 203 | "publishDate": "2022-07-19T00:00:00.000Z" 204 | }' 205 | echo 206 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 207 | -d '{ 208 | "categoryId": 8, 209 | "name": "Ready Player Two: A Nove", 210 | "author": "Ernes Cline", 211 | "description": "The thrilling sequel to the beloved worldwide best seller Ready Player One, the near-future adventure that inspired the blockbuster Steven Spielberg film.", 212 | "value": 13.99, 213 | "publishDate": "2020-11-24T00:00:00.000Z" 214 | }' 215 | echo 216 | curl -k -X 'POST' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 217 | -d '{ 218 | "categoryId": 8, 219 | "name": "Morning Star: Book III of the Red Rising Trilogy", 220 | "author": "Pierce Brown", 221 | "description": "Darrow would have lived in peace, but his enemies brought him war. The Gold overlords demanded his obedience, hanged his wife, and enslaved his people. But Darrow is determined to fight back.", 222 | "value": 14.55, 223 | "publishDate": "2016-02-16T00:00:00.000Z" 224 | }' 225 | echo 226 | sleep 5 227 | 228 | ######################### 229 | #### Update Books ###### 230 | ######################### 231 | 232 | curl -k -X 'PUT' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 233 | -d '{ 234 | "id": 15, 235 | "categoryId": 8, 236 | "name": "The Big Dark Sky", 237 | "author": "Dean Koontz", 238 | "description": "A group of strangers bound by terrifying synchronicity becomes humankinds hope of survival in an exhilarating, twist-filled novel by Dean Koontz.", 239 | "value": 15.99, 240 | "publishDate": "2022-07-19T00:00:00.000Z" 241 | }' 242 | echo 243 | curl -k -X 'PUT' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 244 | -d '{ 245 | "id": 17 246 | "categoryId": 8, 247 | "name": "Morning Star: Book III of the Red Rising Trilogy", 248 | "author": "Pierce Brown", 249 | "description": "Darrow would have lived in peace, but his enemies brought him war. The Gold overlords demanded his obedience, hanged his wife, and enslaved his people. But Darrow is determined to fight back.", 250 | "value": 10.55, 251 | "publishDate": "2016-02-16T00:00:00.000Z" 252 | }' 253 | echo 254 | curl -k -X 'PUT' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 255 | -d '{ 256 | "id": 17 257 | "categoryId": 8, 258 | "name": "Morning Star: Book III of the Red Rising Trilogy", 259 | "author": "Pierce Brown", 260 | "description": "Darrow would have lived in peace, but his enemies brought him war. The Gold overlords demanded his obedience, hanged his wife, and enslaved his people. But Darrow is determined to fight back.", 261 | "value": 9.49, 262 | "publishDate": "2016-02-16T00:00:00.000Z" 263 | }' 264 | echo 265 | curl -k -X 'PUT' "${api_uri}/api/Books" -H 'accept: */*' -H 'Content-Type: application/json' \ 266 | -d '{ 267 | "id": 16 268 | "categoryId": 8, 269 | "name": "Ready Player Two: A Nove", 270 | "author": "Ernes Cline", 271 | "description": "The thrilling sequel to the beloved worldwide best seller Ready Player One, the near-future adventure that inspired the blockbuster Steven Spielberg film.", 272 | "value": 9.99, 273 | "publishDate": "2020-11-24T00:00:00.000Z" 274 | }' 275 | echo 276 | sleep 5 277 | 278 | ######################### 279 | #### Delete Books ###### 280 | ######################### 281 | 282 | curl -k -X 'DELETE' "${api_uri}/api/Books/16" -H 'accept: */*' 283 | echo 284 | curl -k -X 'DELETE' "${api_uri}/api/Books/17" -H 'accept: */*' 285 | echo 286 | sleep 5 287 | 288 | ######################### 289 | #### Add Inventory ###### 290 | ######################### 291 | 292 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 293 | -d '{ 294 | "bookId": 1, 295 | "amount": 50 296 | }' 297 | echo 298 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 299 | -d '{ 300 | "bookId": 2, 301 | "amount": 25 302 | }' 303 | echo 304 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 305 | -d '{ 306 | "bookId": 3, 307 | "amount": 30 308 | }' 309 | echo 310 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 311 | -d '{ 312 | "bookId": 4, 313 | "amount": 20 314 | }' 315 | echo 316 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 317 | -d '{ 318 | "bookId": 5, 319 | "amount": 35 320 | }' 321 | echo 322 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 323 | -d '{ 324 | "bookId": 6, 325 | "amount": 50 326 | }' 327 | echo 328 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 329 | -d '{ 330 | "bookId": 7, 331 | "amount": 20 332 | }' 333 | echo 334 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 335 | -d '{ 336 | "bookId": 8, 337 | "amount": 50 338 | }' 339 | echo 340 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 341 | -d '{ 342 | "bookId": 9, 343 | "amount": 25 344 | }' 345 | echo 346 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 347 | -d '{ 348 | "bookId": 10, 349 | "amount": 55 350 | }' 351 | echo 352 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 353 | -d '{ 354 | "bookId": 11, 355 | "amount": 60 356 | }' 357 | echo 358 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 359 | -d '{ 360 | "bookId": 12, 361 | "amount": 50 362 | }' 363 | echo 364 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 365 | -d '{ 366 | "bookId": 13, 367 | "amount": 60 368 | }' 369 | echo 370 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 371 | -d '{ 372 | "bookId": 14, 373 | "amount": 25 374 | }' 375 | echo 376 | curl -k -X 'POST' "${api_uri}/api/Inventories" -H 'accept: */*' -H 'Content-Type: application/json' \ 377 | -d '{ 378 | "bookId": 15, 379 | "amount": 75 380 | }' 381 | echo 382 | sleep 5 383 | 384 | ######################### 385 | # Delete fake Inventory # 386 | ######################### 387 | 388 | curl -k -X 'DELETE' "${api_uri}/api/Inventories/33" -H 'accept: */*' 389 | echo 390 | sleep 5 391 | 392 | ######################### 393 | ###### Add Orders ###### 394 | ######################### 395 | 396 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 397 | -d '{ 398 | "customerName": "John Doe", 399 | "address": "7 PENN NEW YORK NY 10001-0011", 400 | "telephone": "607-261-0843", 401 | "city": "New York", 402 | "books": [ 403 | 8,10 404 | ] 405 | }' 406 | echo 407 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 408 | -d '{ 409 | "customerName": "Mary Jones", 410 | "address": "208 W 30TH NEW YORK NY 10001-1017", 411 | "telephone": "607-261-0843", 412 | "city": "New York", 413 | "books": [ 414 | 1,2,6 415 | ] 416 | }' 417 | echo 418 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 419 | -d '{ 420 | "customerName": "Joe Munson", 421 | "address": "601 W 26TH NEW YORK NY 10001-1115", 422 | "telephone": "315-277-6032", 423 | "city": "New York", 424 | "books": [ 425 | 7 426 | ] 427 | }' 428 | echo 429 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 430 | -d '{ 431 | "customerName": "Jeff Goals", 432 | "address": "651 W HANCOCK DETROIT MI 48201-1147", 433 | "telephone": "313-201-8703", 434 | "city": "Detroit", 435 | "books": [ 436 | 3,11 437 | ] 438 | }' 439 | echo 440 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 441 | -d '{ 442 | "customerName": "Dan Mire", 443 | "address": "702 W CANFIELD DETROIT MI 48201-1135", 444 | "telephone": "313-938-3526", 445 | "city": "Detroit", 446 | "books": [ 447 | 7 448 | ] 449 | }' 450 | echo 451 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 452 | -d '{ 453 | "customerName": "James Roberts", 454 | "address": "800 POLO CLUB AUSTIN TX 78737-2614", 455 | "telephone": "512-217-1539", 456 | "city": "Austin", 457 | "books": [ 458 | 3,4,6,10,12 459 | ] 460 | }' 461 | echo 462 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 463 | -d '{ 464 | "customerName": "John Jones", 465 | "address": "300 WINECUP AUSTIN TX 78737-4562", 466 | "telephone": "512-555-0122", 467 | "city": "Austin", 468 | "books": [ 469 | 8 470 | ] 471 | }' 472 | echo 473 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 474 | -d '{ 475 | "customerName": "Audrey Fills", 476 | "address": "400 WHIRLAWAY AUSTIN TX 78737-2631", 477 | "telephone": "512-555-0139", 478 | "city": "Austin", 479 | "books": [ 480 | 1,14 481 | ] 482 | }' 483 | echo 484 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 485 | -d '{ 486 | "customerName": "Julia Boris", 487 | "address": "900 COURTLAND ATLANTA TX 75551-1531", 488 | "telephone": "404-555-0177", 489 | "city": "Atlanta", 490 | "books": [ 491 | 10 492 | ] 493 | }' 494 | echo 495 | curl -k -X 'POST' "${api_uri}/api/Orders" -H 'accept: */*' -H 'Content-Type: application/json' \ 496 | -d '{ 497 | "customerName": "Lori Loomis", 498 | "address": "1500 WESTLAKE SEATTLE WA 98109-3036", 499 | "telephone": "202-555-0158", 500 | "city": "Seattle", 501 | "books": [ 502 | 11,12,13 503 | ] 504 | }' 505 | echo 506 | sleep 5 507 | 508 | ######################### 509 | ##### Cancel Orders ##### 510 | ######################### 511 | 512 | curl -k -X 'DELETE' "${api_uri}/api/Orders/3" -H 'accept: */*' 513 | echo 514 | curl -k -X 'DELETE' "${api_uri}/api/Orders/4" -H 'accept: */*' 515 | echo 516 | curl -k -X 'DELETE' "${api_uri}/api/Orders/9" -H 'accept: */*' 517 | echo 518 | -------------------------------------------------------------------------------- /scripts/grafana/dashboards/aspnet-core-endpoints-dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "description": "ASP.NET Core endpoint metrics from OpenTelemetry", 25 | "editable": true, 26 | "fiscalYearStartMonth": 0, 27 | "gnetId": 19925, 28 | "graphTooltip": 0, 29 | "id": 4, 30 | "links": [ 31 | { 32 | "asDropdown": false, 33 | "icon": "dashboard", 34 | "includeVars": false, 35 | "keepTime": true, 36 | "tags": [], 37 | "targetBlank": false, 38 | "title": " ASP.NET Core", 39 | "tooltip": "", 40 | "type": "link", 41 | "url": "/d/KdDACDp4z/asp-net-core-metrics" 42 | } 43 | ], 44 | "liveNow": false, 45 | "panels": [ 46 | { 47 | "datasource": { 48 | "type": "prometheus", 49 | "uid": "PBFA97CFB590B2093" 50 | }, 51 | "fieldConfig": { 52 | "defaults": { 53 | "color": { 54 | "fixedColor": "dark-green", 55 | "mode": "continuous-GrYlRd", 56 | "seriesBy": "max" 57 | }, 58 | "custom": { 59 | "axisBorderShow": false, 60 | "axisCenteredZero": false, 61 | "axisColorMode": "text", 62 | "axisLabel": "", 63 | "axisPlacement": "auto", 64 | "axisSoftMin": 0, 65 | "barAlignment": 0, 66 | "drawStyle": "line", 67 | "fillOpacity": 50, 68 | "gradientMode": "opacity", 69 | "hideFrom": { 70 | "legend": false, 71 | "tooltip": false, 72 | "viz": false 73 | }, 74 | "insertNulls": false, 75 | "lineInterpolation": "smooth", 76 | "lineWidth": 1, 77 | "pointSize": 5, 78 | "scaleDistribution": { 79 | "type": "linear" 80 | }, 81 | "showPoints": "never", 82 | "spanNulls": false, 83 | "stacking": { 84 | "group": "A", 85 | "mode": "none" 86 | }, 87 | "thresholdsStyle": { 88 | "mode": "off" 89 | } 90 | }, 91 | "mappings": [ 92 | { 93 | "options": { 94 | "match": "null+nan", 95 | "result": { 96 | "index": 0, 97 | "text": "0 ms" 98 | } 99 | }, 100 | "type": "special" 101 | } 102 | ], 103 | "thresholds": { 104 | "mode": "absolute", 105 | "steps": [ 106 | { 107 | "color": "green", 108 | "value": null 109 | } 110 | ] 111 | }, 112 | "unit": "s" 113 | }, 114 | "overrides": [ 115 | { 116 | "__systemRef": "hideSeriesFrom", 117 | "matcher": { 118 | "id": "byNames", 119 | "options": { 120 | "mode": "exclude", 121 | "names": [ 122 | "p50" 123 | ], 124 | "prefix": "All except:", 125 | "readOnly": true 126 | } 127 | }, 128 | "properties": [ 129 | { 130 | "id": "custom.hideFrom", 131 | "value": { 132 | "legend": false, 133 | "tooltip": false, 134 | "viz": false 135 | } 136 | } 137 | ] 138 | } 139 | ] 140 | }, 141 | "gridPos": { 142 | "h": 9, 143 | "w": 12, 144 | "x": 0, 145 | "y": 0 146 | }, 147 | "id": 40, 148 | "options": { 149 | "legend": { 150 | "calcs": [ 151 | "lastNotNull", 152 | "min", 153 | "max" 154 | ], 155 | "displayMode": "table", 156 | "placement": "right", 157 | "showLegend": true 158 | }, 159 | "tooltip": { 160 | "mode": "multi", 161 | "sort": "none" 162 | } 163 | }, 164 | "targets": [ 165 | { 166 | "datasource": { 167 | "type": "prometheus", 168 | "uid": "PBFA97CFB590B2093" 169 | }, 170 | "editorMode": "code", 171 | "expr": "histogram_quantile(0.50, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 172 | "legendFormat": "p50", 173 | "range": true, 174 | "refId": "p50" 175 | }, 176 | { 177 | "datasource": { 178 | "type": "prometheus", 179 | "uid": "PBFA97CFB590B2093" 180 | }, 181 | "editorMode": "code", 182 | "expr": "histogram_quantile(0.75, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 183 | "hide": false, 184 | "legendFormat": "p75", 185 | "range": true, 186 | "refId": "p75" 187 | }, 188 | { 189 | "datasource": { 190 | "type": "prometheus", 191 | "uid": "PBFA97CFB590B2093" 192 | }, 193 | "editorMode": "code", 194 | "expr": "histogram_quantile(0.90, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 195 | "hide": false, 196 | "legendFormat": "p90", 197 | "range": true, 198 | "refId": "p90" 199 | }, 200 | { 201 | "datasource": { 202 | "type": "prometheus", 203 | "uid": "PBFA97CFB590B2093" 204 | }, 205 | "editorMode": "code", 206 | "expr": "histogram_quantile(0.95, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 207 | "hide": false, 208 | "legendFormat": "p95", 209 | "range": true, 210 | "refId": "p95" 211 | }, 212 | { 213 | "datasource": { 214 | "type": "prometheus", 215 | "uid": "PBFA97CFB590B2093" 216 | }, 217 | "editorMode": "code", 218 | "expr": "histogram_quantile(0.98, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 219 | "hide": false, 220 | "legendFormat": "p98", 221 | "range": true, 222 | "refId": "p98" 223 | }, 224 | { 225 | "datasource": { 226 | "type": "prometheus", 227 | "uid": "PBFA97CFB590B2093" 228 | }, 229 | "editorMode": "code", 230 | "expr": "histogram_quantile(0.99, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 231 | "hide": false, 232 | "legendFormat": "p99", 233 | "range": true, 234 | "refId": "p99" 235 | }, 236 | { 237 | "datasource": { 238 | "type": "prometheus", 239 | "uid": "PBFA97CFB590B2093" 240 | }, 241 | "editorMode": "code", 242 | "expr": "histogram_quantile(0.999, sum(rate(http_server_request_duration_seconds_bucket{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m])) by (le))", 243 | "hide": false, 244 | "legendFormat": "p99.9", 245 | "range": true, 246 | "refId": "p99.9" 247 | } 248 | ], 249 | "title": "Requests Duration - $method $route", 250 | "type": "timeseries" 251 | }, 252 | { 253 | "datasource": { 254 | "type": "prometheus", 255 | "uid": "PBFA97CFB590B2093" 256 | }, 257 | "description": "", 258 | "fieldConfig": { 259 | "defaults": { 260 | "color": { 261 | "mode": "palette-classic", 262 | "seriesBy": "max" 263 | }, 264 | "custom": { 265 | "axisBorderShow": false, 266 | "axisCenteredZero": false, 267 | "axisColorMode": "text", 268 | "axisLabel": "", 269 | "axisPlacement": "auto", 270 | "barAlignment": 0, 271 | "drawStyle": "line", 272 | "fillOpacity": 50, 273 | "gradientMode": "opacity", 274 | "hideFrom": { 275 | "legend": false, 276 | "tooltip": false, 277 | "viz": false 278 | }, 279 | "insertNulls": false, 280 | "lineInterpolation": "smooth", 281 | "lineWidth": 1, 282 | "pointSize": 5, 283 | "scaleDistribution": { 284 | "type": "linear" 285 | }, 286 | "showPoints": "never", 287 | "spanNulls": false, 288 | "stacking": { 289 | "group": "A", 290 | "mode": "none" 291 | }, 292 | "thresholdsStyle": { 293 | "mode": "off" 294 | } 295 | }, 296 | "mappings": [ 297 | { 298 | "options": { 299 | "match": "null+nan", 300 | "result": { 301 | "index": 0, 302 | "text": "0%" 303 | } 304 | }, 305 | "type": "special" 306 | } 307 | ], 308 | "max": 1, 309 | "thresholds": { 310 | "mode": "absolute", 311 | "steps": [ 312 | { 313 | "color": "green", 314 | "value": null 315 | } 316 | ] 317 | }, 318 | "unit": "percentunit" 319 | }, 320 | "overrides": [ 321 | { 322 | "matcher": { 323 | "id": "byName", 324 | "options": "All" 325 | }, 326 | "properties": [ 327 | { 328 | "id": "color", 329 | "value": { 330 | "fixedColor": "dark-orange", 331 | "mode": "fixed" 332 | } 333 | } 334 | ] 335 | }, 336 | { 337 | "matcher": { 338 | "id": "byName", 339 | "options": "4XX" 340 | }, 341 | "properties": [ 342 | { 343 | "id": "color", 344 | "value": { 345 | "fixedColor": "yellow", 346 | "mode": "fixed" 347 | } 348 | } 349 | ] 350 | }, 351 | { 352 | "matcher": { 353 | "id": "byName", 354 | "options": "5XX" 355 | }, 356 | "properties": [ 357 | { 358 | "id": "color", 359 | "value": { 360 | "fixedColor": "dark-red", 361 | "mode": "fixed" 362 | } 363 | } 364 | ] 365 | } 366 | ] 367 | }, 368 | "gridPos": { 369 | "h": 9, 370 | "w": 12, 371 | "x": 12, 372 | "y": 0 373 | }, 374 | "id": 46, 375 | "options": { 376 | "legend": { 377 | "calcs": [ 378 | "lastNotNull", 379 | "min", 380 | "max" 381 | ], 382 | "displayMode": "table", 383 | "placement": "right", 384 | "showLegend": true 385 | }, 386 | "tooltip": { 387 | "mode": "multi", 388 | "sort": "none" 389 | } 390 | }, 391 | "targets": [ 392 | { 393 | "datasource": { 394 | "type": "prometheus", 395 | "uid": "PBFA97CFB590B2093" 396 | }, 397 | "editorMode": "code", 398 | "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..|5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", 399 | "legendFormat": "All", 400 | "range": true, 401 | "refId": "All" 402 | }, 403 | { 404 | "datasource": { 405 | "type": "prometheus", 406 | "uid": "PBFA97CFB590B2093" 407 | }, 408 | "editorMode": "code", 409 | "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"4..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", 410 | "hide": false, 411 | "legendFormat": "4XX", 412 | "range": true, 413 | "refId": "4XX" 414 | }, 415 | { 416 | "datasource": { 417 | "type": "prometheus", 418 | "uid": "PBFA97CFB590B2093" 419 | }, 420 | "editorMode": "code", 421 | "expr": "sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\", http_response_status_code=~\"5..\"}[5m]) or vector(0)) / sum(rate(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[5m]))", 422 | "hide": false, 423 | "legendFormat": "5XX", 424 | "range": true, 425 | "refId": "5XX" 426 | } 427 | ], 428 | "title": "Errors Rate - $method $route", 429 | "type": "timeseries" 430 | }, 431 | { 432 | "datasource": { 433 | "type": "prometheus", 434 | "uid": "PBFA97CFB590B2093" 435 | }, 436 | "description": "", 437 | "fieldConfig": { 438 | "defaults": { 439 | "color": { 440 | "mode": "thresholds" 441 | }, 442 | "custom": { 443 | "align": "auto", 444 | "cellOptions": { 445 | "type": "auto" 446 | }, 447 | "inspect": false 448 | }, 449 | "mappings": [], 450 | "thresholds": { 451 | "mode": "absolute", 452 | "steps": [ 453 | { 454 | "color": "green", 455 | "value": null 456 | }, 457 | { 458 | "color": "red", 459 | "value": 80 460 | } 461 | ] 462 | } 463 | }, 464 | "overrides": [ 465 | { 466 | "matcher": { 467 | "id": "byName", 468 | "options": "Requests" 469 | }, 470 | "properties": [ 471 | { 472 | "id": "custom.width", 473 | "value": 300 474 | }, 475 | { 476 | "id": "custom.cellOptions", 477 | "value": { 478 | "mode": "gradient", 479 | "type": "gauge" 480 | } 481 | }, 482 | { 483 | "id": "color", 484 | "value": { 485 | "mode": "continuous-YlRd" 486 | } 487 | } 488 | ] 489 | }, 490 | { 491 | "matcher": { 492 | "id": "byName", 493 | "options": "Route" 494 | }, 495 | "properties": [ 496 | { 497 | "id": "links", 498 | "value": [ 499 | { 500 | "title": "", 501 | "url": "/d/NagEsjE4z/asp-net-core-endpoint-details?var-route=${__data.fields.Route}&var-method=${__data.fields.Method}&${__url_time_range}" 502 | } 503 | ] 504 | } 505 | ] 506 | } 507 | ] 508 | }, 509 | "gridPos": { 510 | "h": 8, 511 | "w": 12, 512 | "x": 0, 513 | "y": 9 514 | }, 515 | "hideTimeOverride": false, 516 | "id": 44, 517 | "options": { 518 | "cellHeight": "sm", 519 | "footer": { 520 | "countRows": false, 521 | "fields": "", 522 | "reducer": [ 523 | "sum" 524 | ], 525 | "show": false 526 | }, 527 | "showHeader": true, 528 | "sortBy": [ 529 | { 530 | "desc": true, 531 | "displayName": "Value" 532 | } 533 | ] 534 | }, 535 | "pluginVersion": "10.2.2", 536 | "targets": [ 537 | { 538 | "datasource": { 539 | "type": "prometheus", 540 | "uid": "PBFA97CFB590B2093" 541 | }, 542 | "editorMode": "code", 543 | "exemplar": false, 544 | "expr": "sum by (error_type) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\", error_type!=\"\"}[$__rate_interval])\r\n)", 545 | "format": "table", 546 | "instant": true, 547 | "interval": "", 548 | "legendFormat": "{{route}}", 549 | "range": false, 550 | "refId": "A" 551 | } 552 | ], 553 | "title": "Unhandled Exceptions", 554 | "transformations": [ 555 | { 556 | "id": "organize", 557 | "options": { 558 | "excludeByName": { 559 | "Time": true, 560 | "method": false 561 | }, 562 | "indexByName": { 563 | "Time": 0, 564 | "Value": 2, 565 | "error_type": 1 566 | }, 567 | "renameByName": { 568 | "Value": "Requests", 569 | "error_type": "Exception", 570 | "http_request_method": "Method", 571 | "http_route": "Route" 572 | } 573 | } 574 | } 575 | ], 576 | "type": "table" 577 | }, 578 | { 579 | "datasource": { 580 | "type": "prometheus", 581 | "uid": "PBFA97CFB590B2093" 582 | }, 583 | "fieldConfig": { 584 | "defaults": { 585 | "color": { 586 | "fixedColor": "blue", 587 | "mode": "fixed" 588 | }, 589 | "mappings": [], 590 | "thresholds": { 591 | "mode": "absolute", 592 | "steps": [ 593 | { 594 | "color": "green", 595 | "value": null 596 | }, 597 | { 598 | "color": "red", 599 | "value": 80 600 | } 601 | ] 602 | } 603 | }, 604 | "overrides": [] 605 | }, 606 | "gridPos": { 607 | "h": 4, 608 | "w": 12, 609 | "x": 12, 610 | "y": 9 611 | }, 612 | "id": 42, 613 | "options": { 614 | "colorMode": "background", 615 | "graphMode": "area", 616 | "justifyMode": "auto", 617 | "orientation": "auto", 618 | "reduceOptions": { 619 | "calcs": [ 620 | "max" 621 | ], 622 | "fields": "", 623 | "values": false 624 | }, 625 | "textMode": "value_and_name", 626 | "wideLayout": true 627 | }, 628 | "pluginVersion": "10.2.2", 629 | "targets": [ 630 | { 631 | "datasource": { 632 | "type": "prometheus", 633 | "uid": "PBFA97CFB590B2093" 634 | }, 635 | "editorMode": "code", 636 | "expr": "sum by (http_response_status_code) (\r\n max_over_time(http_server_request_duration_seconds_count{http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", 637 | "legendFormat": "Status {{http_response_status_code}}", 638 | "range": true, 639 | "refId": "A" 640 | } 641 | ], 642 | "title": "Requests HTTP Status Code", 643 | "type": "stat" 644 | }, 645 | { 646 | "datasource": { 647 | "type": "prometheus", 648 | "uid": "PBFA97CFB590B2093" 649 | }, 650 | "description": "", 651 | "fieldConfig": { 652 | "defaults": { 653 | "color": { 654 | "fixedColor": "green", 655 | "mode": "fixed" 656 | }, 657 | "mappings": [], 658 | "thresholds": { 659 | "mode": "absolute", 660 | "steps": [ 661 | { 662 | "color": "green", 663 | "value": null 664 | }, 665 | { 666 | "color": "red", 667 | "value": 80 668 | } 669 | ] 670 | } 671 | }, 672 | "overrides": [] 673 | }, 674 | "gridPos": { 675 | "h": 4, 676 | "w": 6, 677 | "x": 12, 678 | "y": 13 679 | }, 680 | "id": 48, 681 | "options": { 682 | "colorMode": "background", 683 | "graphMode": "area", 684 | "justifyMode": "auto", 685 | "orientation": "auto", 686 | "reduceOptions": { 687 | "calcs": [ 688 | "max" 689 | ], 690 | "fields": "", 691 | "values": false 692 | }, 693 | "textMode": "value_and_name", 694 | "wideLayout": true 695 | }, 696 | "pluginVersion": "10.2.2", 697 | "targets": [ 698 | { 699 | "datasource": { 700 | "type": "prometheus", 701 | "uid": "PBFA97CFB590B2093" 702 | }, 703 | "editorMode": "code", 704 | "expr": "sum by (url_scheme) (\r\n max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval])\r\n )", 705 | "legendFormat": "{{scheme}}", 706 | "range": true, 707 | "refId": "A" 708 | } 709 | ], 710 | "title": "Requests Secured", 711 | "type": "stat" 712 | }, 713 | { 714 | "datasource": { 715 | "type": "prometheus", 716 | "uid": "PBFA97CFB590B2093" 717 | }, 718 | "description": "", 719 | "fieldConfig": { 720 | "defaults": { 721 | "color": { 722 | "fixedColor": "purple", 723 | "mode": "fixed" 724 | }, 725 | "mappings": [], 726 | "thresholds": { 727 | "mode": "absolute", 728 | "steps": [ 729 | { 730 | "color": "green", 731 | "value": null 732 | }, 733 | { 734 | "color": "red", 735 | "value": 80 736 | } 737 | ] 738 | } 739 | }, 740 | "overrides": [] 741 | }, 742 | "gridPos": { 743 | "h": 4, 744 | "w": 6, 745 | "x": 18, 746 | "y": 13 747 | }, 748 | "id": 50, 749 | "options": { 750 | "colorMode": "background", 751 | "graphMode": "area", 752 | "justifyMode": "auto", 753 | "orientation": "auto", 754 | "reduceOptions": { 755 | "calcs": [ 756 | "max" 757 | ], 758 | "fields": "", 759 | "values": false 760 | }, 761 | "textMode": "value_and_name", 762 | "wideLayout": true 763 | }, 764 | "pluginVersion": "10.2.2", 765 | "targets": [ 766 | { 767 | "datasource": { 768 | "type": "prometheus", 769 | "uid": "PBFA97CFB590B2093" 770 | }, 771 | "editorMode": "code", 772 | "expr": "sum by (method_route) (\r\n label_replace(max_over_time(http_server_request_duration_seconds_count{job=\"$job\", instance=\"$instance\", http_route=\"$route\", http_request_method=\"$method\"}[$__rate_interval]), \"method_route\", \"http/$1\", \"network_protocol_version\", \"(.*)\")\r\n )", 773 | "legendFormat": "{{protocol}}", 774 | "range": true, 775 | "refId": "A" 776 | } 777 | ], 778 | "title": "Requests HTTP Protocol", 779 | "type": "stat" 780 | } 781 | ], 782 | "refresh": "", 783 | "revision": 1, 784 | "schemaVersion": 38, 785 | "tags": [ 786 | "dotnet", 787 | "prometheus", 788 | "aspnetcore" 789 | ], 790 | "templating": { 791 | "list": [ 792 | { 793 | "current": { 794 | "isNone": true, 795 | "selected": false, 796 | "text": "None", 797 | "value": "" 798 | }, 799 | "datasource": { 800 | "type": "prometheus", 801 | "uid": "PBFA97CFB590B2093" 802 | }, 803 | "definition": "label_values(http_server_active_requests,job)", 804 | "hide": 0, 805 | "includeAll": false, 806 | "label": "Job", 807 | "multi": false, 808 | "name": "job", 809 | "options": [], 810 | "query": { 811 | "query": "label_values(http_server_active_requests,job)", 812 | "refId": "PrometheusVariableQueryEditor-VariableQuery" 813 | }, 814 | "refresh": 1, 815 | "regex": "", 816 | "skipUrlSync": false, 817 | "sort": 1, 818 | "type": "query" 819 | }, 820 | { 821 | "current": { 822 | "isNone": true, 823 | "selected": false, 824 | "text": "None", 825 | "value": "" 826 | }, 827 | "datasource": { 828 | "type": "prometheus", 829 | "uid": "PBFA97CFB590B2093" 830 | }, 831 | "definition": "label_values(http_server_active_requests{job=~\"$job\"},instance)", 832 | "hide": 0, 833 | "includeAll": false, 834 | "label": "Instance", 835 | "multi": false, 836 | "name": "instance", 837 | "options": [], 838 | "query": { 839 | "query": "label_values(http_server_active_requests{job=~\"$job\"},instance)", 840 | "refId": "PrometheusVariableQueryEditor-VariableQuery" 841 | }, 842 | "refresh": 1, 843 | "regex": "", 844 | "skipUrlSync": false, 845 | "sort": 1, 846 | "type": "query" 847 | }, 848 | { 849 | "current": { 850 | "isNone": true, 851 | "selected": false, 852 | "text": "None", 853 | "value": "" 854 | }, 855 | "datasource": { 856 | "type": "prometheus", 857 | "uid": "PBFA97CFB590B2093" 858 | }, 859 | "definition": "label_values(http_server_request_duration_seconds_count,http_route)", 860 | "description": "Route", 861 | "hide": 0, 862 | "includeAll": false, 863 | "label": "Route", 864 | "multi": false, 865 | "name": "route", 866 | "options": [], 867 | "query": { 868 | "query": "label_values(http_server_request_duration_seconds_count,http_route)", 869 | "refId": "PrometheusVariableQueryEditor-VariableQuery" 870 | }, 871 | "refresh": 1, 872 | "regex": "", 873 | "skipUrlSync": false, 874 | "sort": 1, 875 | "type": "query" 876 | }, 877 | { 878 | "current": { 879 | "isNone": true, 880 | "selected": false, 881 | "text": "None", 882 | "value": "" 883 | }, 884 | "datasource": { 885 | "type": "prometheus", 886 | "uid": "PBFA97CFB590B2093" 887 | }, 888 | "definition": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", 889 | "hide": 0, 890 | "includeAll": false, 891 | "label": "Method", 892 | "multi": false, 893 | "name": "method", 894 | "options": [], 895 | "query": { 896 | "query": "label_values(http_server_request_duration_seconds_count{http_route=~\"$route\"},http_request_method)", 897 | "refId": "PrometheusVariableQueryEditor-VariableQuery" 898 | }, 899 | "refresh": 1, 900 | "regex": "", 901 | "skipUrlSync": false, 902 | "sort": 1, 903 | "type": "query" 904 | } 905 | ] 906 | }, 907 | "time": { 908 | "from": "now-5m", 909 | "to": "now" 910 | }, 911 | "timepicker": { 912 | "refresh_intervals": [ 913 | "1s", 914 | "5s", 915 | "10s", 916 | "30s", 917 | "1m", 918 | "5m", 919 | "15m", 920 | "30m", 921 | "1h", 922 | "2h", 923 | "1d" 924 | ] 925 | }, 926 | "timezone": "", 927 | "title": "ASP.NET Core Endpoint", 928 | "uid": "NagEsjE4z", 929 | "version": 1, 930 | "weekStart": "" 931 | } -------------------------------------------------------------------------------- /scripts/grafana/dashboards/bookstore-dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": { 7 | "type": "grafana", 8 | "uid": "-- Grafana --" 9 | }, 10 | "enable": true, 11 | "hide": true, 12 | "iconColor": "rgba(0, 211, 255, 1)", 13 | "name": "Annotations & Alerts", 14 | "target": { 15 | "limit": 100, 16 | "matchAny": false, 17 | "tags": [], 18 | "type": "dashboard" 19 | }, 20 | "type": "dashboard" 21 | } 22 | ] 23 | }, 24 | "editable": true, 25 | "fiscalYearStartMonth": 0, 26 | "graphTooltip": 0, 27 | "id": 2, 28 | "links": [], 29 | "liveNow": false, 30 | "panels": [ 31 | { 32 | "collapsed": true, 33 | "gridPos": { 34 | "h": 1, 35 | "w": 24, 36 | "x": 0, 37 | "y": 0 38 | }, 39 | "id": 18, 40 | "panels": [ 41 | { 42 | "datasource": { 43 | "type": "prometheus", 44 | "uid": "PBFA97CFB590B2093" 45 | }, 46 | "fieldConfig": { 47 | "defaults": { 48 | "color": { 49 | "mode": "thresholds" 50 | }, 51 | "mappings": [], 52 | "thresholds": { 53 | "mode": "absolute", 54 | "steps": [ 55 | { 56 | "color": "green", 57 | "value": null 58 | } 59 | ] 60 | } 61 | }, 62 | "overrides": [] 63 | }, 64 | "gridPos": { 65 | "h": 7, 66 | "w": 5, 67 | "x": 0, 68 | "y": 1 69 | }, 70 | "id": 24, 71 | "options": { 72 | "colorMode": "value", 73 | "graphMode": "area", 74 | "justifyMode": "auto", 75 | "orientation": "auto", 76 | "reduceOptions": { 77 | "calcs": [ 78 | "lastNotNull" 79 | ], 80 | "fields": "", 81 | "values": false 82 | }, 83 | "textMode": "auto", 84 | "wideLayout": true 85 | }, 86 | "pluginVersion": "10.2.2", 87 | "targets": [ 88 | { 89 | "datasource": { 90 | "type": "prometheus", 91 | "uid": "PBFA97CFB590B2093" 92 | }, 93 | "disableTextWrap": false, 94 | "editorMode": "builder", 95 | "expr": "sum(orders_Orders_total)", 96 | "fullMetaSearch": false, 97 | "includeNullMetadata": true, 98 | "legendFormat": "__auto", 99 | "range": true, 100 | "refId": "A", 101 | "useBackend": false 102 | } 103 | ], 104 | "title": "Total Orders Placed", 105 | "type": "stat" 106 | }, 107 | { 108 | "datasource": { 109 | "type": "prometheus", 110 | "uid": "PBFA97CFB590B2093" 111 | }, 112 | "fieldConfig": { 113 | "defaults": { 114 | "color": { 115 | "mode": "palette-classic" 116 | }, 117 | "custom": { 118 | "hideFrom": { 119 | "legend": false, 120 | "tooltip": false, 121 | "viz": false 122 | } 123 | }, 124 | "mappings": [] 125 | }, 126 | "overrides": [] 127 | }, 128 | "gridPos": { 129 | "h": 7, 130 | "w": 6, 131 | "x": 5, 132 | "y": 1 133 | }, 134 | "id": 26, 135 | "options": { 136 | "legend": { 137 | "displayMode": "list", 138 | "placement": "bottom", 139 | "showLegend": true 140 | }, 141 | "pieType": "pie", 142 | "reduceOptions": { 143 | "calcs": [ 144 | "lastNotNull" 145 | ], 146 | "fields": "", 147 | "values": false 148 | }, 149 | "tooltip": { 150 | "mode": "single", 151 | "sort": "none" 152 | } 153 | }, 154 | "targets": [ 155 | { 156 | "datasource": { 157 | "type": "prometheus", 158 | "uid": "PBFA97CFB590B2093" 159 | }, 160 | "disableTextWrap": false, 161 | "editorMode": "builder", 162 | "expr": "orders_Orders_total", 163 | "fullMetaSearch": false, 164 | "includeNullMetadata": true, 165 | "legendFormat": "{{City}}", 166 | "range": true, 167 | "refId": "A", 168 | "useBackend": false 169 | } 170 | ], 171 | "title": "Total Orders Placed by City", 172 | "type": "piechart" 173 | }, 174 | { 175 | "datasource": { 176 | "type": "prometheus", 177 | "uid": "PBFA97CFB590B2093" 178 | }, 179 | "fieldConfig": { 180 | "defaults": { 181 | "color": { 182 | "mode": "thresholds" 183 | }, 184 | "mappings": [], 185 | "thresholds": { 186 | "mode": "absolute", 187 | "steps": [ 188 | { 189 | "color": "green", 190 | "value": null 191 | }, 192 | { 193 | "color": "red", 194 | "value": 1 195 | } 196 | ] 197 | } 198 | }, 199 | "overrides": [] 200 | }, 201 | "gridPos": { 202 | "h": 7, 203 | "w": 4, 204 | "x": 11, 205 | "y": 1 206 | }, 207 | "id": 28, 208 | "options": { 209 | "colorMode": "value", 210 | "graphMode": "area", 211 | "justifyMode": "auto", 212 | "orientation": "auto", 213 | "reduceOptions": { 214 | "calcs": [ 215 | "lastNotNull" 216 | ], 217 | "fields": "", 218 | "values": false 219 | }, 220 | "textMode": "auto", 221 | "wideLayout": true 222 | }, 223 | "pluginVersion": "10.2.2", 224 | "targets": [ 225 | { 226 | "datasource": { 227 | "type": "prometheus", 228 | "uid": "PBFA97CFB590B2093" 229 | }, 230 | "disableTextWrap": false, 231 | "editorMode": "builder", 232 | "expr": "orders_canceled_total", 233 | "fullMetaSearch": false, 234 | "includeNullMetadata": true, 235 | "legendFormat": "__auto", 236 | "range": true, 237 | "refId": "A", 238 | "useBackend": false 239 | } 240 | ], 241 | "title": "Orders Canceled", 242 | "type": "stat" 243 | }, 244 | { 245 | "datasource": { 246 | "type": "prometheus", 247 | "uid": "PBFA97CFB590B2093" 248 | }, 249 | "fieldConfig": { 250 | "defaults": { 251 | "color": { 252 | "mode": "thresholds" 253 | }, 254 | "mappings": [], 255 | "thresholds": { 256 | "mode": "absolute", 257 | "steps": [ 258 | { 259 | "color": "green", 260 | "value": null 261 | } 262 | ] 263 | } 264 | }, 265 | "overrides": [] 266 | }, 267 | "gridPos": { 268 | "h": 7, 269 | "w": 5, 270 | "x": 15, 271 | "y": 1 272 | }, 273 | "id": 34, 274 | "options": { 275 | "colorMode": "value", 276 | "graphMode": "area", 277 | "justifyMode": "auto", 278 | "orientation": "auto", 279 | "reduceOptions": { 280 | "calcs": [ 281 | "lastNotNull" 282 | ], 283 | "fields": "", 284 | "values": false 285 | }, 286 | "textMode": "auto", 287 | "wideLayout": true 288 | }, 289 | "pluginVersion": "10.2.2", 290 | "targets": [ 291 | { 292 | "datasource": { 293 | "type": "prometheus", 294 | "uid": "PBFA97CFB590B2093" 295 | }, 296 | "editorMode": "code", 297 | "expr": "orders_price_Euros_sum / orders_price_Euros_count", 298 | "legendFormat": "__auto", 299 | "range": true, 300 | "refId": "A" 301 | } 302 | ], 303 | "title": "Avg. Order Price", 304 | "type": "stat" 305 | }, 306 | { 307 | "datasource": { 308 | "type": "prometheus", 309 | "uid": "PBFA97CFB590B2093" 310 | }, 311 | "fieldConfig": { 312 | "defaults": { 313 | "color": { 314 | "mode": "thresholds" 315 | }, 316 | "mappings": [], 317 | "thresholds": { 318 | "mode": "absolute", 319 | "steps": [ 320 | { 321 | "color": "green", 322 | "value": null 323 | } 324 | ] 325 | } 326 | }, 327 | "overrides": [] 328 | }, 329 | "gridPos": { 330 | "h": 7, 331 | "w": 4, 332 | "x": 20, 333 | "y": 1 334 | }, 335 | "id": 36, 336 | "options": { 337 | "colorMode": "value", 338 | "graphMode": "area", 339 | "justifyMode": "auto", 340 | "orientation": "auto", 341 | "reduceOptions": { 342 | "calcs": [ 343 | "lastNotNull" 344 | ], 345 | "fields": "", 346 | "values": false 347 | }, 348 | "textMode": "auto", 349 | "wideLayout": true 350 | }, 351 | "pluginVersion": "10.2.2", 352 | "targets": [ 353 | { 354 | "datasource": { 355 | "type": "prometheus", 356 | "uid": "PBFA97CFB590B2093" 357 | }, 358 | "editorMode": "code", 359 | "expr": "orders_number_of_books_Books_sum / orders_number_of_books_Books_count", 360 | "legendFormat": "__auto", 361 | "range": true, 362 | "refId": "A" 363 | } 364 | ], 365 | "title": "Avg. Number of Books per Order", 366 | "type": "stat" 367 | }, 368 | { 369 | "datasource": { 370 | "type": "prometheus", 371 | "uid": "PBFA97CFB590B2093" 372 | }, 373 | "fieldConfig": { 374 | "defaults": { 375 | "color": { 376 | "mode": "thresholds" 377 | }, 378 | "mappings": [], 379 | "thresholds": { 380 | "mode": "absolute", 381 | "steps": [ 382 | { 383 | "color": "green", 384 | "value": null 385 | }, 386 | { 387 | "color": "red", 388 | "value": 80 389 | } 390 | ] 391 | } 392 | }, 393 | "overrides": [] 394 | }, 395 | "gridPos": { 396 | "h": 8, 397 | "w": 12, 398 | "x": 0, 399 | "y": 8 400 | }, 401 | "id": 30, 402 | "options": { 403 | "displayMode": "gradient", 404 | "minVizHeight": 10, 405 | "minVizWidth": 0, 406 | "namePlacement": "auto", 407 | "orientation": "auto", 408 | "reduceOptions": { 409 | "calcs": [ 410 | "lastNotNull" 411 | ], 412 | "fields": "", 413 | "values": false 414 | }, 415 | "showUnfilled": true, 416 | "valueMode": "color" 417 | }, 418 | "pluginVersion": "10.2.2", 419 | "targets": [ 420 | { 421 | "datasource": { 422 | "type": "prometheus", 423 | "uid": "PBFA97CFB590B2093" 424 | }, 425 | "editorMode": "code", 426 | "expr": "orders_number_of_books_Books_bucket", 427 | "format": "heatmap", 428 | "legendFormat": "{{le}}", 429 | "range": true, 430 | "refId": "A" 431 | } 432 | ], 433 | "title": "Number of books per order", 434 | "type": "bargauge" 435 | }, 436 | { 437 | "datasource": { 438 | "type": "prometheus", 439 | "uid": "PBFA97CFB590B2093" 440 | }, 441 | "fieldConfig": { 442 | "defaults": { 443 | "color": { 444 | "mode": "thresholds" 445 | }, 446 | "mappings": [], 447 | "thresholds": { 448 | "mode": "absolute", 449 | "steps": [ 450 | { 451 | "color": "green", 452 | "value": null 453 | }, 454 | { 455 | "color": "red", 456 | "value": 80 457 | } 458 | ] 459 | } 460 | }, 461 | "overrides": [] 462 | }, 463 | "gridPos": { 464 | "h": 8, 465 | "w": 12, 466 | "x": 12, 467 | "y": 8 468 | }, 469 | "id": 32, 470 | "options": { 471 | "displayMode": "gradient", 472 | "minVizHeight": 10, 473 | "minVizWidth": 0, 474 | "namePlacement": "auto", 475 | "orientation": "auto", 476 | "reduceOptions": { 477 | "calcs": [ 478 | "lastNotNull" 479 | ], 480 | "fields": "", 481 | "values": false 482 | }, 483 | "showUnfilled": true, 484 | "valueMode": "color" 485 | }, 486 | "pluginVersion": "10.2.2", 487 | "targets": [ 488 | { 489 | "datasource": { 490 | "type": "prometheus", 491 | "uid": "PBFA97CFB590B2093" 492 | }, 493 | "editorMode": "code", 494 | "expr": "orders_price_Euros_bucket", 495 | "format": "heatmap", 496 | "legendFormat": "{{le}}", 497 | "range": true, 498 | "refId": "A" 499 | } 500 | ], 501 | "title": "Price Distribution per Order", 502 | "type": "bargauge" 503 | } 504 | ], 505 | "title": "Orders", 506 | "type": "row" 507 | }, 508 | { 509 | "collapsed": true, 510 | "gridPos": { 511 | "h": 1, 512 | "w": 24, 513 | "x": 0, 514 | "y": 1 515 | }, 516 | "id": 22, 517 | "panels": [ 518 | { 519 | "datasource": { 520 | "type": "prometheus", 521 | "uid": "PBFA97CFB590B2093" 522 | }, 523 | "fieldConfig": { 524 | "defaults": { 525 | "color": { 526 | "mode": "thresholds" 527 | }, 528 | "mappings": [], 529 | "thresholds": { 530 | "mode": "absolute", 531 | "steps": [ 532 | { 533 | "color": "green", 534 | "value": null 535 | } 536 | ] 537 | } 538 | }, 539 | "overrides": [] 540 | }, 541 | "gridPos": { 542 | "h": 8, 543 | "w": 5, 544 | "x": 0, 545 | "y": 2 546 | }, 547 | "id": 10, 548 | "options": { 549 | "colorMode": "value", 550 | "graphMode": "area", 551 | "justifyMode": "auto", 552 | "orientation": "auto", 553 | "reduceOptions": { 554 | "calcs": [ 555 | "lastNotNull" 556 | ], 557 | "fields": "", 558 | "values": false 559 | }, 560 | "textMode": "auto", 561 | "wideLayout": true 562 | }, 563 | "pluginVersion": "10.2.2", 564 | "targets": [ 565 | { 566 | "datasource": { 567 | "type": "prometheus", 568 | "uid": "PBFA97CFB590B2093" 569 | }, 570 | "disableTextWrap": false, 571 | "editorMode": "builder", 572 | "expr": "total_books_Book", 573 | "fullMetaSearch": false, 574 | "includeNullMetadata": true, 575 | "legendFormat": "__auto", 576 | "range": true, 577 | "refId": "A", 578 | "useBackend": false 579 | } 580 | ], 581 | "title": "Total Number of Books Available in the Store", 582 | "type": "stat" 583 | }, 584 | { 585 | "datasource": { 586 | "type": "prometheus", 587 | "uid": "PBFA97CFB590B2093" 588 | }, 589 | "fieldConfig": { 590 | "defaults": { 591 | "color": { 592 | "mode": "thresholds" 593 | }, 594 | "mappings": [], 595 | "thresholds": { 596 | "mode": "absolute", 597 | "steps": [ 598 | { 599 | "color": "green", 600 | "value": null 601 | } 602 | ] 603 | } 604 | }, 605 | "overrides": [] 606 | }, 607 | "gridPos": { 608 | "h": 8, 609 | "w": 4, 610 | "x": 5, 611 | "y": 2 612 | }, 613 | "id": 12, 614 | "options": { 615 | "colorMode": "value", 616 | "graphMode": "area", 617 | "justifyMode": "auto", 618 | "orientation": "auto", 619 | "reduceOptions": { 620 | "calcs": [ 621 | "lastNotNull" 622 | ], 623 | "fields": "", 624 | "values": false 625 | }, 626 | "textMode": "auto", 627 | "wideLayout": true 628 | }, 629 | "pluginVersion": "10.2.2", 630 | "targets": [ 631 | { 632 | "datasource": { 633 | "type": "prometheus", 634 | "uid": "PBFA97CFB590B2093" 635 | }, 636 | "editorMode": "code", 637 | "expr": "books_added_Book_total", 638 | "legendFormat": "__auto", 639 | "range": true, 640 | "refId": "A" 641 | } 642 | ], 643 | "title": "Books Added", 644 | "type": "stat" 645 | }, 646 | { 647 | "datasource": { 648 | "type": "prometheus", 649 | "uid": "PBFA97CFB590B2093" 650 | }, 651 | "fieldConfig": { 652 | "defaults": { 653 | "color": { 654 | "mode": "thresholds" 655 | }, 656 | "mappings": [], 657 | "thresholds": { 658 | "mode": "absolute", 659 | "steps": [ 660 | { 661 | "color": "green", 662 | "value": null 663 | } 664 | ] 665 | } 666 | }, 667 | "overrides": [] 668 | }, 669 | "gridPos": { 670 | "h": 8, 671 | "w": 4, 672 | "x": 9, 673 | "y": 2 674 | }, 675 | "id": 14, 676 | "options": { 677 | "colorMode": "value", 678 | "graphMode": "area", 679 | "justifyMode": "auto", 680 | "orientation": "auto", 681 | "reduceOptions": { 682 | "calcs": [ 683 | "lastNotNull" 684 | ], 685 | "fields": "", 686 | "values": false 687 | }, 688 | "textMode": "auto", 689 | "wideLayout": true 690 | }, 691 | "pluginVersion": "10.2.2", 692 | "targets": [ 693 | { 694 | "datasource": { 695 | "type": "prometheus", 696 | "uid": "PBFA97CFB590B2093" 697 | }, 698 | "editorMode": "code", 699 | "expr": "books_deleted_Book_total", 700 | "legendFormat": "__auto", 701 | "range": true, 702 | "refId": "A" 703 | } 704 | ], 705 | "title": "Books Deleted", 706 | "type": "stat" 707 | }, 708 | { 709 | "datasource": { 710 | "type": "prometheus", 711 | "uid": "PBFA97CFB590B2093" 712 | }, 713 | "fieldConfig": { 714 | "defaults": { 715 | "color": { 716 | "mode": "thresholds" 717 | }, 718 | "mappings": [], 719 | "thresholds": { 720 | "mode": "absolute", 721 | "steps": [ 722 | { 723 | "color": "green", 724 | "value": null 725 | } 726 | ] 727 | } 728 | }, 729 | "overrides": [] 730 | }, 731 | "gridPos": { 732 | "h": 8, 733 | "w": 4, 734 | "x": 13, 735 | "y": 2 736 | }, 737 | "id": 16, 738 | "options": { 739 | "colorMode": "value", 740 | "graphMode": "area", 741 | "justifyMode": "auto", 742 | "orientation": "auto", 743 | "reduceOptions": { 744 | "calcs": [ 745 | "lastNotNull" 746 | ], 747 | "fields": "", 748 | "values": false 749 | }, 750 | "textMode": "auto", 751 | "wideLayout": true 752 | }, 753 | "pluginVersion": "10.2.2", 754 | "targets": [ 755 | { 756 | "datasource": { 757 | "type": "prometheus", 758 | "uid": "PBFA97CFB590B2093" 759 | }, 760 | "editorMode": "code", 761 | "expr": "books_updated_Book_total", 762 | "legendFormat": "__auto", 763 | "range": true, 764 | "refId": "A" 765 | } 766 | ], 767 | "title": "Books Updated", 768 | "type": "stat" 769 | } 770 | ], 771 | "title": "Books", 772 | "type": "row" 773 | }, 774 | { 775 | "collapsed": true, 776 | "gridPos": { 777 | "h": 1, 778 | "w": 24, 779 | "x": 0, 780 | "y": 2 781 | }, 782 | "id": 20, 783 | "panels": [ 784 | { 785 | "datasource": { 786 | "type": "prometheus", 787 | "uid": "PBFA97CFB590B2093" 788 | }, 789 | "fieldConfig": { 790 | "defaults": { 791 | "color": { 792 | "mode": "thresholds" 793 | }, 794 | "mappings": [], 795 | "thresholds": { 796 | "mode": "absolute", 797 | "steps": [ 798 | { 799 | "color": "green", 800 | "value": null 801 | } 802 | ] 803 | } 804 | }, 805 | "overrides": [] 806 | }, 807 | "gridPos": { 808 | "h": 8, 809 | "w": 5, 810 | "x": 0, 811 | "y": 3 812 | }, 813 | "id": 8, 814 | "options": { 815 | "colorMode": "value", 816 | "graphMode": "area", 817 | "justifyMode": "auto", 818 | "orientation": "auto", 819 | "reduceOptions": { 820 | "calcs": [ 821 | "lastNotNull" 822 | ], 823 | "fields": "", 824 | "values": false 825 | }, 826 | "textMode": "auto", 827 | "wideLayout": true 828 | }, 829 | "pluginVersion": "10.2.2", 830 | "targets": [ 831 | { 832 | "datasource": { 833 | "type": "prometheus", 834 | "uid": "PBFA97CFB590B2093" 835 | }, 836 | "editorMode": "builder", 837 | "expr": "total_categories", 838 | "legendFormat": "__auto", 839 | "range": true, 840 | "refId": "A" 841 | } 842 | ], 843 | "title": "Total Number of Categories Available in the Store", 844 | "type": "stat" 845 | }, 846 | { 847 | "datasource": { 848 | "type": "prometheus", 849 | "uid": "PBFA97CFB590B2093" 850 | }, 851 | "fieldConfig": { 852 | "defaults": { 853 | "color": { 854 | "mode": "thresholds" 855 | }, 856 | "mappings": [], 857 | "thresholds": { 858 | "mode": "absolute", 859 | "steps": [ 860 | { 861 | "color": "green", 862 | "value": null 863 | } 864 | ] 865 | } 866 | }, 867 | "overrides": [] 868 | }, 869 | "gridPos": { 870 | "h": 8, 871 | "w": 4, 872 | "x": 5, 873 | "y": 3 874 | }, 875 | "id": 2, 876 | "options": { 877 | "colorMode": "value", 878 | "graphMode": "area", 879 | "justifyMode": "auto", 880 | "orientation": "auto", 881 | "reduceOptions": { 882 | "calcs": [ 883 | "lastNotNull" 884 | ], 885 | "fields": "", 886 | "values": false 887 | }, 888 | "textMode": "auto", 889 | "wideLayout": true 890 | }, 891 | "pluginVersion": "10.2.2", 892 | "targets": [ 893 | { 894 | "datasource": { 895 | "type": "prometheus", 896 | "uid": "PBFA97CFB590B2093" 897 | }, 898 | "editorMode": "code", 899 | "expr": "categories_added_Category_total", 900 | "legendFormat": "__auto", 901 | "range": true, 902 | "refId": "A" 903 | } 904 | ], 905 | "title": "Categories Added", 906 | "type": "stat" 907 | }, 908 | { 909 | "datasource": { 910 | "type": "prometheus", 911 | "uid": "PBFA97CFB590B2093" 912 | }, 913 | "fieldConfig": { 914 | "defaults": { 915 | "color": { 916 | "mode": "thresholds" 917 | }, 918 | "mappings": [], 919 | "thresholds": { 920 | "mode": "absolute", 921 | "steps": [ 922 | { 923 | "color": "green", 924 | "value": null 925 | } 926 | ] 927 | } 928 | }, 929 | "overrides": [] 930 | }, 931 | "gridPos": { 932 | "h": 8, 933 | "w": 4, 934 | "x": 9, 935 | "y": 3 936 | }, 937 | "id": 4, 938 | "options": { 939 | "colorMode": "value", 940 | "graphMode": "area", 941 | "justifyMode": "auto", 942 | "orientation": "auto", 943 | "reduceOptions": { 944 | "calcs": [ 945 | "lastNotNull" 946 | ], 947 | "fields": "", 948 | "values": false 949 | }, 950 | "textMode": "auto", 951 | "wideLayout": true 952 | }, 953 | "pluginVersion": "10.2.2", 954 | "targets": [ 955 | { 956 | "datasource": { 957 | "type": "prometheus", 958 | "uid": "PBFA97CFB590B2093" 959 | }, 960 | "editorMode": "code", 961 | "expr": "categories_updated_Category_total", 962 | "legendFormat": "__auto", 963 | "range": true, 964 | "refId": "A" 965 | } 966 | ], 967 | "title": "Categories Updated", 968 | "type": "stat" 969 | }, 970 | { 971 | "datasource": { 972 | "type": "prometheus", 973 | "uid": "PBFA97CFB590B2093" 974 | }, 975 | "fieldConfig": { 976 | "defaults": { 977 | "color": { 978 | "mode": "thresholds" 979 | }, 980 | "mappings": [], 981 | "thresholds": { 982 | "mode": "absolute", 983 | "steps": [ 984 | { 985 | "color": "green", 986 | "value": null 987 | } 988 | ] 989 | } 990 | }, 991 | "overrides": [] 992 | }, 993 | "gridPos": { 994 | "h": 8, 995 | "w": 4, 996 | "x": 13, 997 | "y": 3 998 | }, 999 | "id": 6, 1000 | "options": { 1001 | "colorMode": "value", 1002 | "graphMode": "area", 1003 | "justifyMode": "auto", 1004 | "orientation": "auto", 1005 | "reduceOptions": { 1006 | "calcs": [ 1007 | "lastNotNull" 1008 | ], 1009 | "fields": "", 1010 | "values": false 1011 | }, 1012 | "textMode": "auto", 1013 | "wideLayout": true 1014 | }, 1015 | "pluginVersion": "10.2.2", 1016 | "targets": [ 1017 | { 1018 | "datasource": { 1019 | "type": "prometheus", 1020 | "uid": "PBFA97CFB590B2093" 1021 | }, 1022 | "editorMode": "code", 1023 | "expr": "categories_deleted_Category_total", 1024 | "legendFormat": "__auto", 1025 | "range": true, 1026 | "refId": "A" 1027 | } 1028 | ], 1029 | "title": "Categories Deleted", 1030 | "type": "stat" 1031 | } 1032 | ], 1033 | "title": "Categories", 1034 | "type": "row" 1035 | } 1036 | ], 1037 | "refresh": "10s", 1038 | "schemaVersion": 38, 1039 | "tags": [], 1040 | "templating": { 1041 | "list": [] 1042 | }, 1043 | "time": { 1044 | "from": "now-6h", 1045 | "to": "now" 1046 | }, 1047 | "timepicker": {}, 1048 | "timezone": "", 1049 | "title": "BookStore dashboard", 1050 | "uid": "tJ5Z5XGVz", 1051 | "version": 2, 1052 | "weekStart": "" 1053 | } --------------------------------------------------------------------------------