Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions API/API.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.7" />
<PackageReference Include="MimeTypeMapOfficial" Version="1.0.17" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Npgsql.OpenTelemetry" Version="9.0.3" />
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="Serilog.Enrichers.Thread" Version="4.0.0" />
Expand All @@ -36,6 +41,7 @@
<PackageReference Include="Serilog.Sinks.AspNetCore.App.SignalR" Version="1.2.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="SharpAbp.Abp.OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="4.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.3" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="9.0.0" />
<PackageReference Include="System.IO.Abstractions" Version="22.0.15" />
Expand Down
12 changes: 12 additions & 0 deletions API/Controllers/ClientController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ public async Task<IActionResult> CreateClient(ClientDto dto)
return Ok();
}

/// <summary>
/// Create several clients at once
/// </summary>
/// <param name="dtos"></param>
/// <returns></returns>
[HttpPost("create-bulk")]
public async Task<ActionResult> CreateClientBulk(IList<ClientDto> dtos)
{
await clientService.CreateClients(dtos);
return Ok();
}

/// <summary>
/// Update an existing client
/// </summary>
Expand Down
17 changes: 8 additions & 9 deletions API/Data/Repositories/ProductRepository.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using AutoMapper;
using AutoMapper.QueryableExtensions;
Expand Down Expand Up @@ -71,31 +70,31 @@ public async Task<IList<ProductDto>> GetDtoByIds(IEnumerable<int> ids)

public async Task<IList<Product>> GetAll(bool onlyEnabled = false)
{
return await ctx.Products
.WhereIf(onlyEnabled, p => p.Enabled)
return await QueryableExtensions
.WhereIf(ctx.Products, onlyEnabled, p => p.Enabled)
.ToListAsync();
}

public async Task<IList<ProductDto>> GetAllDto(bool onlyEnabled = false)
{
return await ctx.Products
.WhereIf(onlyEnabled, p => p.Enabled)
return await QueryableExtensions
.WhereIf(ctx.Products, onlyEnabled, p => p.Enabled)
.ProjectTo<ProductDto>(mapper.ConfigurationProvider)
.ToListAsync();
}

public async Task<IList<ProductCategory>> GetAllCategories(bool onlyEnabled = false)
{
return await ctx.ProductCategories
.WhereIf(onlyEnabled, p => p.Enabled)
return await QueryableExtensions
.WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled)
.OrderBy(p => p.SortValue)
.ToListAsync();
}

public async Task<IList<ProductCategoryDto>> GetAllCategoriesDtos(bool onlyEnabled = false)
{
return await ctx.ProductCategories
.WhereIf(onlyEnabled, p => p.Enabled)
return await QueryableExtensions
.WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled)
.OrderBy(p => p.SortValue)
.ProjectTo<ProductCategoryDto>(mapper.ConfigurationProvider)
.ToListAsync();
Expand Down
3 changes: 2 additions & 1 deletion API/Extensions/ApplicationServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public static void AddApplicationServices(this IServiceCollection services, ICon

private static void AddPostgres(this IServiceCollection services, IConfiguration configuration)
{
var pgConnectionString = configuration.GetConnectionString("Postgres");
var pgConnectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING")
?? configuration.GetConnectionString("Postgres");

services.AddDbContextPool<DataContext>(options =>
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Linq.Expressions;

namespace API.Entities;
namespace API.Extensions;

public static class QueryableExtensions
{
Expand Down
30 changes: 30 additions & 0 deletions API/Helpers/Telemetry/OperationTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;

namespace API.Helpers.Telemetry;

public class OperationTracker: IDisposable
{
private readonly Stopwatch _stopwatch;
private readonly Histogram<double> _histogram;
private readonly Dictionary<string, object?> _tags;
private bool _disposed;

internal OperationTracker(Histogram<double> histogram, string operationName, Dictionary<string, object?>? tags)
{
_histogram = histogram;
_tags = tags ?? [];
_stopwatch = Stopwatch.StartNew();

_tags["operation"] = operationName;
}

public void Dispose()
{
if (_disposed) return;

_stopwatch.Stop();
_histogram.Record(_stopwatch.Elapsed.TotalMilliseconds, _tags.ToArray());
_disposed = true;
}
}
46 changes: 46 additions & 0 deletions API/Helpers/Telemetry/TelemetryHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Flurl.Util;

namespace API.Helpers.Telemetry;

public static class TelemetryHelper
{

private static readonly ActivitySource ActivitySource;
private static readonly Meter Meter;

private static readonly Histogram<double> MethodTiming;
private static readonly Counter<long> InvalidOperationCounts;

static TelemetryHelper()
{
var serviceName = BuildInfo.AppName;

ActivitySource = new ActivitySource(serviceName);
Meter = new Meter(serviceName);

MethodTiming = Meter.CreateHistogram<double>(
"method_timing_duration_ms",
"ms",
"Duration of specific method, see method tag"
);

InvalidOperationCounts = Meter.CreateCounter<long>(
"invalid_operations_counter",
"absolute",
"Amount of invalid operations performed by users");
}

public static OperationTracker TrackOperation(string operationName, Dictionary<string, object?>? tags = null)
{
return new OperationTracker(MethodTiming, operationName, tags);
}

public static void InvalidOperation(string operationName, Dictionary<string, object?>? tags = null)
{
tags ??= [];
tags.Add("operation", operationName);
InvalidOperationCounts.Add(1, tags.ToArray());
}
}
18 changes: 17 additions & 1 deletion API/Services/ClientService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace API.Services;
public interface IClientService
{
Task CreateClient(ClientDto dto);
Task CreateClients(IList<ClientDto> dtos);
Task UpdateClient(ClientDto dto);
Task DeleteClient(int id);
}
Expand All @@ -18,6 +19,22 @@ public class ClientService(IUnitOfWork unitOfWork, ILogger<ClientService> logger
{

public async Task CreateClient(ClientDto dto)
{
await CreateClientFromDto(dto);
await unitOfWork.CommitAsync();
}

public async Task CreateClients(IList<ClientDto> dtos)
{
foreach (var dto in dtos)
{
await CreateClientFromDto(dto);
}

await unitOfWork.CommitAsync();
}

private async Task CreateClientFromDto(ClientDto dto)
{
if (!string.IsNullOrWhiteSpace(dto.CompanyNumber))
{
Expand All @@ -39,7 +56,6 @@ public async Task CreateClient(ClientDto dto)
};

unitOfWork.ClientRepository.Add(client);
await unitOfWork.CommitAsync();
}

public async Task UpdateClient(ClientDto dto)
Expand Down
17 changes: 17 additions & 0 deletions API/Services/DeliveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using API.Entities.Enums;
using API.Exceptions;
using API.Extensions;
using API.Helpers.Telemetry;

namespace API.Services;

Expand All @@ -31,6 +32,11 @@ public async Task<Delivery> CreateDelivery(int userId, DeliveryDto dto)
var client = await unitOfWork.ClientRepository.GetClientById(dto.ClientId);
if (client == null)
throw new InOutException("errors.client-not-found");

using var tracker = TelemetryHelper.TrackOperation("create_client", new Dictionary<string, object?>
{
["user_id"] = userId,
});

var lines = dto.Lines.GroupBy(l => l.ProductId)
.Select(g => new DeliveryLineDto
Expand Down Expand Up @@ -95,6 +101,11 @@ public async Task<Delivery> UpdateDelivery(ClaimsPrincipal actor, DeliveryDto dt
var user = await userService.GetUser(actor);
if (delivery.UserId != user.Id && !actor.IsInRole(PolicyConstants.CreateForOthers))
throw new UnauthorizedAccessException();

using var tracker = TelemetryHelper.TrackOperation("update_client", new Dictionary<string, object?>
{
["user_id"] = user.Id,
});

delivery.Message = dto.Message;

Expand Down Expand Up @@ -205,6 +216,12 @@ public async Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, Deli
throw new InOutException("errors.invalid-next-state");

delivery.State = nextState;

if (delivery.State == DeliveryState.Cancelled)
{
// TODO: Refund stock
}

await unitOfWork.CommitAsync();
}

Expand Down
12 changes: 12 additions & 0 deletions API/Services/LocalizationService.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using API.Data;
using API.Helpers.Telemetry;
using Microsoft.Extensions.Caching.Memory;

namespace API.Services;
Expand Down Expand Up @@ -78,6 +79,11 @@ public LocalizationService(ILogger<LocalizationService> logger, IDirectoryServic

public async Task<Dictionary<string, string>?> LoadLanguage(string languageCode)
{
using var tracker = TelemetryHelper.TrackOperation("load_language", new Dictionary<string, object?>
{
["language"] = languageCode,
});

if (string.IsNullOrWhiteSpace(languageCode))
{
languageCode = DefaultLocale;
Expand Down Expand Up @@ -105,6 +111,12 @@ public LocalizationService(ILogger<LocalizationService> logger, IDirectoryServic

public async Task<string> Get(string locale, string key, params object[] args)
{
using var tracker = TelemetryHelper.TrackOperation("get_translations", new Dictionary<string, object?>
{
["locale"] = locale,
["key"] = key,
});

var cacheKey = $"{locale}_{key}";
if (!_memoryCache.TryGetValue(cacheKey, out string? translatedString))
{
Expand Down
14 changes: 10 additions & 4 deletions API/Services/StockService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Helpers;
using API.Helpers.Telemetry;

namespace API.Services;

Expand All @@ -25,12 +25,18 @@ public class StockService(ILogger<StockService> logger, IUnitOfWork unitOfWork,

public async Task<Result<IList<Stock>>> UpdateStockBulkAsync(User user, IList<UpdateStockDto> dtos)
{
if (dtos == null || !dtos.Any())
dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList();

if (!dtos.Any())
{
return Result<IList<Stock>>.Failure(await localization.Translate(user.Id, "stock-bulk-empty-list"));
}

dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList();

using var tracker = TelemetryHelper.TrackOperation("bulk_stock_update", new Dictionary<string, object?>
{
["user_id"] = user.Id,
["update_operations"] = dtos.Count,
});

return await unitOfWork.ExecuteWithRetryAsync(async () =>
{
Expand Down
35 changes: 35 additions & 0 deletions API/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
using API.Middleware;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Microsoft.OpenApi.Models;
using Npgsql;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using Serilog;
using Serilog.Events;

Expand Down Expand Up @@ -85,6 +89,16 @@ public void ConfigureServices(IServiceCollection services)
});
services.AddResponseCaching();
// TODO: Rate limitter

services.AddOpenTelemetry()
.ConfigureResource(src => src
.AddService(BuildInfo.AppName))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddNpgsqlInstrumentation()
.AddMeter(BuildInfo.AppName)
.AddPrometheusExporter());
}

public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime)
Expand Down Expand Up @@ -158,12 +172,33 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider,
ctx.Context.Response.Headers["X-Robots-Tag"] = "noindex,nofollow";
}
});



var apiKey = cfg.GetValue<string>("ApiKey");
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/metrics"))
{
if (!ctx.Request.Query.TryGetValue("api-key", out var key) ||
key != apiKey || string.IsNullOrWhiteSpace(apiKey))
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
await ctx.Response.WriteAsync("Unauthorized");
return;
}
}

await next();
});

app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
//endpoints.MapHub<MessageHub>("hubs/messages");
//endpoints.MapHub<LogHub>("hubs/logs");
endpoints.MapFallbackToController("Index", "Fallback");
endpoints.MapPrometheusScrapingEndpoint();
});

applicationLifetime.ApplicationStarted.Register(() =>
Expand Down
Loading