feat: add preprod observability foundation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
@@ -20,7 +21,10 @@ internal static class ApplicationRegistration
|
||||
services.AddHttpContextAccessor();
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddDbContextCheck<AppDbContext>();
|
||||
.AddCheck("self", () => Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckResult.Healthy(), tags: ["live"])
|
||||
.AddDbContextCheck<AppDbContext>("postgres", tags: ["ready"])
|
||||
.AddCheck<LocalBlobStorageHealthCheck>("local_blob_storage", tags: ["ready"])
|
||||
.AddCheck<EmailerConfigurationHealthCheck>("emailer_configuration", tags: ["ready"]);
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddScoped<AccessScopeService>();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
@@ -8,7 +9,8 @@ internal sealed class LocalBlobStorage(
|
||||
IWebHostEnvironment environment,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<LocalBlobStorageOptions> options,
|
||||
ILogger<LocalBlobStorage> logger)
|
||||
ILogger<LocalBlobStorage> logger,
|
||||
SocializeMetrics metrics)
|
||||
: IBlobStorage
|
||||
{
|
||||
private const long MaxUploadSize = 10 * 1024 * 1024;
|
||||
@@ -31,32 +33,51 @@ internal sealed class LocalBlobStorage(
|
||||
string contentType,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
stream.Position = 0;
|
||||
|
||||
if (stream.Length > MaxUploadSize)
|
||||
try
|
||||
{
|
||||
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
stream.Position = 0;
|
||||
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
if (stream.Length > MaxUploadSize)
|
||||
{
|
||||
logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize);
|
||||
throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.");
|
||||
}
|
||||
|
||||
if (!ContentTypes.IsAllowed(contentType, stream))
|
||||
{
|
||||
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
}
|
||||
|
||||
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||
|
||||
await using FileStream fileStream = File.Create(filePath);
|
||||
await stream.CopyToAsync(fileStream, ct);
|
||||
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||
|
||||
string fileUri = BuildPublicUrl(relativePath);
|
||||
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
||||
metrics.RecordBlobStorageOperation("upload", true);
|
||||
|
||||
return fileUri;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType);
|
||||
throw new InvalidOperationException("Unsupported file type.");
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("upload", false);
|
||||
throw;
|
||||
}
|
||||
|
||||
string relativePath = GetSafeRelativePath(containerName, blobName);
|
||||
string filePath = Path.Combine(GetRootPath(), relativePath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath());
|
||||
|
||||
await using FileStream fileStream = File.Create(filePath);
|
||||
await stream.CopyToAsync(fileStream, ct);
|
||||
await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct);
|
||||
|
||||
string fileUri = BuildPublicUrl(relativePath);
|
||||
LogUploadedFile(logger, blobName, containerName, contentType, fileUri, null);
|
||||
|
||||
return fileUri;
|
||||
}
|
||||
|
||||
public async Task<MemoryStream> DownloadFileAsync(
|
||||
@@ -64,19 +85,43 @@ internal sealed class LocalBlobStorage(
|
||||
string blobName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
try
|
||||
{
|
||||
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||
string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName));
|
||||
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
throw new FileNotFoundException("Blob storage: Local file was not found.", blobName);
|
||||
}
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await using FileStream fileStream = File.OpenRead(filePath);
|
||||
await fileStream.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0;
|
||||
metrics.RecordBlobStorageOperation("download", true);
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
metrics.RecordBlobStorageOperation("download", false);
|
||||
throw;
|
||||
}
|
||||
|
||||
MemoryStream memoryStream = new();
|
||||
await using FileStream fileStream = File.OpenRead(filePath);
|
||||
await fileStream.CopyToAsync(memoryStream, ct);
|
||||
memoryStream.Position = 0;
|
||||
|
||||
return memoryStream;
|
||||
}
|
||||
|
||||
internal string GetRootPath()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||
|
||||
internal class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||
internal class LoggerEmailSender(
|
||||
ILogger<IEmailSender> logger,
|
||||
SocializeMetrics metrics)
|
||||
: IEmailSender
|
||||
{
|
||||
private static readonly Action<ILogger, string, string, string, string, Exception?> LogDevelopmentEmail =
|
||||
@@ -14,6 +17,7 @@ internal class LoggerEmailSender(ILogger<IEmailSender> logger)
|
||||
public Task SendEmailAsync(string email, string subject, string message)
|
||||
{
|
||||
LogDevelopmentEmail(logger, email, subject, Environment.NewLine, message, null);
|
||||
metrics.RecordEmailDelivery("logger", true);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Emailer.Services;
|
||||
@@ -11,13 +12,16 @@ internal class ResendEmailSender : IEmailSender
|
||||
{
|
||||
private static readonly Uri EndpointUri = new("https://api.resend.com/emails");
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SocializeMetrics _metrics;
|
||||
private readonly EmailerOptions _options;
|
||||
|
||||
public ResendEmailSender(
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<EmailerOptions> options)
|
||||
IOptions<EmailerOptions> options,
|
||||
SocializeMetrics metrics)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_metrics = metrics;
|
||||
_options = options.Value;
|
||||
|
||||
string apiKey = NormalizeApiKey(_options.ApiKey);
|
||||
@@ -49,13 +53,33 @@ internal class ResendEmailSender : IEmailSender
|
||||
|
||||
string json = JsonSerializer.Serialize(payload);
|
||||
using StringContent content = new(json, Encoding.UTF8, "application/json");
|
||||
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
try
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Resend email failed: {response.StatusCode} - {body}");
|
||||
using HttpResponseMessage response = await _httpClient.PostAsync(EndpointUri, content);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
string body = await response.Content.ReadAsStringAsync();
|
||||
throw new InvalidOperationException(
|
||||
$"Resend email failed: {response.StatusCode} - {body}");
|
||||
}
|
||||
|
||||
_metrics.RecordEmailDelivery("resend", true);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
_metrics.RecordEmailDelivery("resend", false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class EmailerConfigurationHealthCheck(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<EmailerOptions> options)
|
||||
: IHealthCheck
|
||||
{
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (environment.IsDevelopment())
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Development email sender logs email instead of delivering it."));
|
||||
}
|
||||
|
||||
EmailerOptions value = options.Value;
|
||||
if (string.IsNullOrWhiteSpace(value.ApiKey) || string.IsNullOrWhiteSpace(value.FromEmail))
|
||||
{
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy("Emailer API key or from address is missing."));
|
||||
}
|
||||
|
||||
return Task.FromResult(HealthCheckResult.Healthy("Emailer configuration is present."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class LocalBlobStorageHealthCheck(
|
||||
LocalBlobStorage blobStorage,
|
||||
IOptions<LocalBlobStorageOptions> options)
|
||||
: IHealthCheck
|
||||
{
|
||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string rootPath = blobStorage.GetRootPath();
|
||||
if (string.IsNullOrWhiteSpace(options.Value.RequestPath))
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Local blob storage request path is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(rootPath);
|
||||
string probePath = Path.Combine(rootPath, ".healthcheck");
|
||||
await File.WriteAllTextAsync(
|
||||
probePath,
|
||||
DateTimeOffset.UtcNow.ToString("O", System.Globalization.CultureInfo.InvariantCulture),
|
||||
cancellationToken);
|
||||
File.Delete(probePath);
|
||||
|
||||
return HealthCheckResult.Healthy("Local blob storage is writable.");
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return HealthCheckResult.Unhealthy("Local blob storage is not writable.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Npgsql;
|
||||
using OpenTelemetry.Logs;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal static class ObservabilityRegistration
|
||||
{
|
||||
private const string DefaultServiceName = "socialize-api";
|
||||
|
||||
public static WebApplicationBuilder AddObservability(this WebApplicationBuilder builder)
|
||||
{
|
||||
string serviceName = GetConfigurationValue(builder.Configuration, "OTEL_SERVICE_NAME", DefaultServiceName);
|
||||
string serviceVersion = typeof(Program).Assembly.GetName().Version?.ToString() ?? "unknown";
|
||||
|
||||
builder.Logging.Configure(options =>
|
||||
{
|
||||
options.ActivityTrackingOptions =
|
||||
ActivityTrackingOptions.TraceId |
|
||||
ActivityTrackingOptions.SpanId |
|
||||
ActivityTrackingOptions.ParentId;
|
||||
});
|
||||
|
||||
builder.Logging.AddJsonConsole(options =>
|
||||
{
|
||||
options.IncludeScopes = true;
|
||||
options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
|
||||
options.UseUtcTimestamp = true;
|
||||
options.JsonWriterOptions = new JsonWriterOptions { Indented = false };
|
||||
});
|
||||
|
||||
bool otlpEnabled = HasOtlpEndpoint(builder.Configuration);
|
||||
if (otlpEnabled)
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(options =>
|
||||
{
|
||||
options.IncludeFormattedMessage = true;
|
||||
options.IncludeScopes = true;
|
||||
options.ParseStateValues = true;
|
||||
options.SetResourceBuilder(BuildResource(serviceName, serviceVersion));
|
||||
options.AddOtlpExporter();
|
||||
});
|
||||
}
|
||||
|
||||
builder.Services.AddSingleton<SocializeMetrics>();
|
||||
builder.Services
|
||||
.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName,
|
||||
serviceVersion: serviceVersion))
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing
|
||||
.AddSource(SocializeMetrics.ActivitySourceName)
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
{
|
||||
options.RecordException = true;
|
||||
})
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddNpgsql();
|
||||
|
||||
if (otlpEnabled)
|
||||
{
|
||||
tracing.AddOtlpExporter();
|
||||
}
|
||||
})
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics
|
||||
.AddMeter(SocializeMetrics.MeterName)
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
|
||||
if (otlpEnabled)
|
||||
{
|
||||
metrics.AddOtlpExporter();
|
||||
}
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseObservabilityLoggingScope(this IApplicationBuilder app)
|
||||
{
|
||||
return app.UseMiddleware<RequestLoggingScopeMiddleware>();
|
||||
}
|
||||
|
||||
public static IEndpointRouteBuilder MapObservabilityHealthChecks(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
endpoints.MapHealthChecks(
|
||||
"/health",
|
||||
new HealthCheckOptions { ResponseWriter = WriteHealthResponseAsync });
|
||||
endpoints.MapHealthChecks(
|
||||
"/health/live",
|
||||
new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("live", StringComparer.Ordinal),
|
||||
ResponseWriter = WriteHealthResponseAsync,
|
||||
});
|
||||
endpoints.MapHealthChecks(
|
||||
"/health/ready",
|
||||
new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("ready", StringComparer.Ordinal),
|
||||
ResponseWriter = WriteHealthResponseAsync,
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static ResourceBuilder BuildResource(string serviceName, string serviceVersion)
|
||||
{
|
||||
return ResourceBuilder.CreateDefault().AddService(
|
||||
serviceName,
|
||||
serviceVersion: serviceVersion);
|
||||
}
|
||||
|
||||
private static bool HasOtlpEndpoint(ConfigurationManager configuration)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]) ||
|
||||
!string.IsNullOrWhiteSpace(configuration["Otlp:Endpoint"]);
|
||||
}
|
||||
|
||||
private static string GetConfigurationValue(
|
||||
ConfigurationManager configuration,
|
||||
string key,
|
||||
string fallback)
|
||||
{
|
||||
string? value = configuration[key];
|
||||
return string.IsNullOrWhiteSpace(value) ? fallback : value;
|
||||
}
|
||||
|
||||
private static async Task WriteHealthResponseAsync(HttpContext context, HealthReport report)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
var response = new
|
||||
{
|
||||
status = report.Status.ToString(),
|
||||
checks = report.Entries.Select(entry => new
|
||||
{
|
||||
name = entry.Key,
|
||||
status = entry.Value.Status.ToString(),
|
||||
description = entry.Value.Description,
|
||||
duration = entry.Value.Duration.TotalMilliseconds,
|
||||
}),
|
||||
duration = report.TotalDuration.TotalMilliseconds,
|
||||
};
|
||||
|
||||
await JsonSerializer.SerializeAsync(
|
||||
context.Response.Body,
|
||||
response,
|
||||
cancellationToken: context.RequestAborted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Diagnostics;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class RequestLoggingScopeMiddleware(
|
||||
RequestDelegate next,
|
||||
ILogger<RequestLoggingScopeMiddleware> logger)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
Dictionary<string, object?> scope = new()
|
||||
{
|
||||
["trace_id"] = Activity.Current?.TraceId.ToString() ?? context.TraceIdentifier,
|
||||
["span_id"] = Activity.Current?.SpanId.ToString(),
|
||||
["http.method"] = context.Request.Method,
|
||||
["url.path"] = context.Request.Path.Value,
|
||||
};
|
||||
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
scope["user.id"] = context.User.GetUserId();
|
||||
scope["user.email"] = context.User.GetEmail();
|
||||
}
|
||||
|
||||
AddGuidIfPresent(scope, "organization.id", context, "organizationId");
|
||||
AddGuidIfPresent(scope, "workspace.id", context, "workspaceId");
|
||||
AddGuidIfPresent(scope, "client.id", context, "clientId");
|
||||
AddGuidIfPresent(scope, "campaign.id", context, "campaignId");
|
||||
AddGuidIfPresent(scope, "content_item.id", context, "contentItemId");
|
||||
|
||||
using IDisposable? _ = logger.BeginScope(scope);
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static void AddGuidIfPresent(
|
||||
Dictionary<string, object?> scope,
|
||||
string scopeKey,
|
||||
HttpContext context,
|
||||
string requestKey)
|
||||
{
|
||||
string? value = GetRouteOrQueryValue(context, requestKey);
|
||||
if (Guid.TryParse(value, out Guid id))
|
||||
{
|
||||
scope[scopeKey] = id;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetRouteOrQueryValue(HttpContext context, string key)
|
||||
{
|
||||
object? routeValue = context.Request.RouteValues[key];
|
||||
if (routeValue is not null)
|
||||
{
|
||||
return Convert.ToString(routeValue, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
return context.Request.Query.TryGetValue(key, out Microsoft.Extensions.Primitives.StringValues queryValue)
|
||||
? queryValue.ToString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
internal sealed class SocializeMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "Socialize.Api";
|
||||
public const string ActivitySourceName = "Socialize.Api";
|
||||
|
||||
private readonly Counter<long> _approvalDecisionCounter;
|
||||
private readonly Counter<long> _backgroundJobRunCounter;
|
||||
private readonly Counter<long> _blobStorageOperationCounter;
|
||||
private readonly Counter<long> _commentCreatedCounter;
|
||||
private readonly Counter<long> _contentItemCreatedCounter;
|
||||
private readonly Counter<long> _emailDeliveryCounter;
|
||||
private readonly Counter<long> _feedbackSubmittedCounter;
|
||||
private readonly Counter<long> _loginAttemptCounter;
|
||||
private readonly Counter<long> _organizationCreatedCounter;
|
||||
private readonly Counter<long> _workspaceCreatedCounter;
|
||||
private readonly Counter<long> _workspaceInviteCreatedCounter;
|
||||
|
||||
public SocializeMetrics()
|
||||
{
|
||||
Meter = new Meter(MeterName);
|
||||
ActivitySource = new ActivitySource(ActivitySourceName);
|
||||
|
||||
_loginAttemptCounter = Meter.CreateCounter<long>(
|
||||
"socialize.login.attempts",
|
||||
description: "Login attempts partitioned by outcome.");
|
||||
_organizationCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.organizations.created",
|
||||
description: "Organizations created.");
|
||||
_workspaceCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.workspaces.created",
|
||||
description: "Workspaces created.");
|
||||
_contentItemCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.content_items.created",
|
||||
description: "Content items created.");
|
||||
_commentCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.comments.created",
|
||||
description: "Comments created.");
|
||||
_approvalDecisionCounter = Meter.CreateCounter<long>(
|
||||
"socialize.approval_decisions.submitted",
|
||||
description: "Approval decisions submitted.");
|
||||
_feedbackSubmittedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.feedback.submitted",
|
||||
description: "Feedback reports submitted.");
|
||||
_workspaceInviteCreatedCounter = Meter.CreateCounter<long>(
|
||||
"socialize.workspace_invites.created",
|
||||
description: "Workspace invites created.");
|
||||
_emailDeliveryCounter = Meter.CreateCounter<long>(
|
||||
"socialize.email.delivery",
|
||||
description: "Email delivery attempts partitioned by outcome and provider.");
|
||||
_blobStorageOperationCounter = Meter.CreateCounter<long>(
|
||||
"socialize.blob_storage.operations",
|
||||
description: "Blob storage operations partitioned by operation and outcome.");
|
||||
_backgroundJobRunCounter = Meter.CreateCounter<long>(
|
||||
"socialize.background_job.runs",
|
||||
description: "Background job runs partitioned by job and outcome.");
|
||||
}
|
||||
|
||||
public Meter Meter { get; }
|
||||
|
||||
public ActivitySource ActivitySource { get; }
|
||||
|
||||
public void RecordLoginAttempt(bool succeeded, string reason)
|
||||
{
|
||||
_loginAttemptCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"),
|
||||
new KeyValuePair<string, object?>("reason", reason));
|
||||
}
|
||||
|
||||
public void RecordOrganizationCreated(Guid organizationId)
|
||||
{
|
||||
_organizationCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("organization.id", organizationId));
|
||||
}
|
||||
|
||||
public void RecordWorkspaceCreated(Guid organizationId, Guid workspaceId)
|
||||
{
|
||||
_workspaceCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("organization.id", organizationId),
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||
}
|
||||
|
||||
public void RecordContentItemCreated(Guid workspaceId)
|
||||
{
|
||||
_contentItemCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId));
|
||||
}
|
||||
|
||||
public void RecordCommentCreated(Guid workspaceId, bool hasAttachment)
|
||||
{
|
||||
_commentCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("has_attachment", hasAttachment));
|
||||
}
|
||||
|
||||
public void RecordApprovalDecisionSubmitted(Guid workspaceId, string decision)
|
||||
{
|
||||
_approvalDecisionCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("decision", decision));
|
||||
}
|
||||
|
||||
public void RecordFeedbackSubmitted(string type, Guid? workspaceId)
|
||||
{
|
||||
_feedbackSubmittedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("feedback.type", type),
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId?.ToString() ?? "none"));
|
||||
}
|
||||
|
||||
public void RecordWorkspaceInviteCreated(Guid workspaceId, string role)
|
||||
{
|
||||
_workspaceInviteCreatedCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("workspace.id", workspaceId),
|
||||
new KeyValuePair<string, object?>("role", role));
|
||||
}
|
||||
|
||||
public void RecordEmailDelivery(string provider, bool succeeded)
|
||||
{
|
||||
_emailDeliveryCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("provider", provider),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void RecordBlobStorageOperation(string operation, bool succeeded)
|
||||
{
|
||||
_blobStorageOperationCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("operation", operation),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void RecordBackgroundJobRun(string job, bool succeeded)
|
||||
{
|
||||
_backgroundJobRunCounter.Add(
|
||||
1,
|
||||
new KeyValuePair<string, object?>("job", job),
|
||||
new KeyValuePair<string, object?>("outcome", succeeded ? "success" : "failure"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Meter.Dispose();
|
||||
ActivitySource.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||
@@ -37,7 +38,8 @@ internal class SubmitApprovalDecisionHandler(
|
||||
AccessScopeService accessScopeService,
|
||||
ApprovalWorkflowRuntimeService approvalWorkflowRuntimeService,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
INotificationEventWriter notificationEventWriter,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<SubmitApprovalDecisionRequest, ApprovalRequestDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -157,6 +159,7 @@ internal class SubmitApprovalDecisionHandler(
|
||||
$$"""{"stage":"{{approval.Stage}}","status":"{{contentItem.Status}}"}"""),
|
||||
ct);
|
||||
}
|
||||
metrics.RecordApprovalDecisionSubmitted(approval.WorkspaceId, normalizedDecision);
|
||||
|
||||
List<ApprovalDecision> decisions = await dbContext.ApprovalDecisions
|
||||
.Where(candidate => candidate.ApprovalRequestId == approval.Id)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
|
||||
namespace Socialize.Api.Modules.CalendarIntegrations.Services;
|
||||
|
||||
internal sealed class CalendarImportBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
SocializeMetrics metrics,
|
||||
ILogger<CalendarImportBackgroundService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
@@ -22,6 +25,7 @@ internal sealed class CalendarImportBackgroundService(
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
CalendarImportSyncService syncService = scope.ServiceProvider.GetRequiredService<CalendarImportSyncService>();
|
||||
await syncService.RefreshDueSourcesAsync(stoppingToken);
|
||||
metrics.RecordBackgroundJobRun(nameof(CalendarImportBackgroundService), true);
|
||||
}
|
||||
catch (OperationCanceledException ex) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -30,6 +34,7 @@ internal sealed class CalendarImportBackgroundService(
|
||||
#pragma warning disable CA1031 // Background service should log and continue after unexpected sync failures.
|
||||
catch (Exception ex)
|
||||
{
|
||||
metrics.RecordBackgroundJobRun(nameof(CalendarImportBackgroundService), false);
|
||||
logger.LogError(ex, "Calendar import background sync failed.");
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
|
||||
@@ -2,6 +2,7 @@ using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||
using Socialize.Api.Modules.ContentItems.Data;
|
||||
@@ -34,7 +35,8 @@ internal class CreateCommentHandler(
|
||||
AccessScopeService accessScopeService,
|
||||
IBlobStorage blobStorage,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
INotificationEventWriter notificationEventWriter,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<CreateCommentRequest, CommentDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -156,6 +158,7 @@ internal class CreateCommentHandler(
|
||||
|
||||
dbContext.Comments.Add(comment);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordCommentCreated(comment.WorkspaceId, comment.AttachmentBlobName is not null);
|
||||
|
||||
string? authorPortraitUrl = await dbContext.Users
|
||||
.Where(candidate => candidate.Id == comment.AuthorUserId)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.ContentItems.Contracts;
|
||||
using Socialize.Api.Modules.Notifications.Contracts;
|
||||
@@ -39,7 +40,8 @@ internal class CreateContentItemHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService,
|
||||
IContentItemActivityWriter activityWriter,
|
||||
INotificationEventWriter notificationEventWriter)
|
||||
INotificationEventWriter notificationEventWriter,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<CreateContentItemRequest, ContentItemDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -123,6 +125,7 @@ internal class CreateContentItemHandler(
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
});
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordContentItemCreated(item.WorkspaceId);
|
||||
|
||||
await activityWriter.WriteAsync(
|
||||
new ContentItemActivityWriteModel(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FastEndpoints;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Feedback.Contracts;
|
||||
using Socialize.Api.Modules.Feedback.Data;
|
||||
@@ -45,7 +46,8 @@ internal class SubmitFeedbackRequestValidator
|
||||
|
||||
internal class SubmitFeedbackHandler(
|
||||
AppDbContext dbContext,
|
||||
FeedbackNotificationService notificationService)
|
||||
FeedbackNotificationService notificationService,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<SubmitFeedbackRequest, FeedbackReportDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -93,6 +95,7 @@ internal class SubmitFeedbackHandler(
|
||||
dbContext.FeedbackReports.Add(report);
|
||||
await notificationService.AddNewReportNotificationsAsync(report, ct);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordFeedbackSubmitted(report.Type.ToString(), report.WorkspaceId);
|
||||
|
||||
await SendAsync(report.ToDto(), StatusCodes.Status201Created, ct);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Data;
|
||||
using Socialize.Api.Modules.Identity.Configuration;
|
||||
@@ -21,7 +22,8 @@ internal record LoginResponse(
|
||||
internal class LoginHandler(
|
||||
UserManager userManager,
|
||||
IOptionsSnapshot<JwtOptions> jwtOptions,
|
||||
AccessTokenFactory accessTokenFactory)
|
||||
AccessTokenFactory accessTokenFactory,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<LoginRequest, LoginResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -40,6 +42,7 @@ internal class LoginHandler(
|
||||
user ??= await userManager.FindByNameAsync(request.Email);
|
||||
if (user is null)
|
||||
{
|
||||
metrics.RecordLoginAttempt(false, "unknown_user");
|
||||
await SendStringAsync(
|
||||
"Invalid email or password",
|
||||
401,
|
||||
@@ -51,6 +54,7 @@ internal class LoginHandler(
|
||||
bool isPasswordValid = await userManager.CheckPasswordAsync(user, request.Password);
|
||||
if (!isPasswordValid)
|
||||
{
|
||||
metrics.RecordLoginAttempt(false, "invalid_password");
|
||||
await SendStringAsync(
|
||||
"Invalid email or password",
|
||||
401,
|
||||
@@ -61,6 +65,7 @@ internal class LoginHandler(
|
||||
// Check if the email is confirmed
|
||||
if (!user.EmailConfirmed)
|
||||
{
|
||||
metrics.RecordLoginAttempt(false, "email_unconfirmed");
|
||||
await SendStringAsync(
|
||||
"Email not verified. Please check your email for verification instructions.",
|
||||
401,
|
||||
@@ -76,6 +81,7 @@ internal class LoginHandler(
|
||||
|
||||
// Generate JWT token
|
||||
string accessToken = await accessTokenFactory.CreateAsync(user);
|
||||
metrics.RecordLoginAttempt(true, "success");
|
||||
|
||||
await SendOkAsync(
|
||||
new LoginResponse(accessToken, user.RefreshToken),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Organizations.Data;
|
||||
using Socialize.Api.Modules.Organizations.Services;
|
||||
@@ -21,7 +22,8 @@ internal class CreateOrganizationRequestValidator
|
||||
}
|
||||
|
||||
internal class CreateOrganizationHandler(
|
||||
AppDbContext dbContext)
|
||||
AppDbContext dbContext,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<CreateOrganizationRequest, OrganizationDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -66,6 +68,7 @@ internal class CreateOrganizationHandler(
|
||||
dbContext.Organizations.Add(organization);
|
||||
dbContext.OrganizationMemberships.Add(ownerMembership);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordOrganizationCreated(organization.Id);
|
||||
|
||||
await SendAsync(
|
||||
OrganizationDto.FromOrganization(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Modules.ReleaseCommunications.Configuration;
|
||||
|
||||
namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||
@@ -6,6 +7,7 @@ namespace Socialize.Api.Modules.ReleaseCommunications.Services;
|
||||
internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IOptions<ReleaseCommunicationEmailOptions> options,
|
||||
SocializeMetrics metrics,
|
||||
ILogger<ReleaseUpdateEmailDigestBackgroundService> logger)
|
||||
: BackgroundService
|
||||
{
|
||||
@@ -42,6 +44,7 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
||||
TimeSpan.FromHours(options.Value.DigestIntervalHours),
|
||||
force: false,
|
||||
ct: stoppingToken);
|
||||
metrics.RecordBackgroundJobRun(nameof(ReleaseUpdateEmailDigestBackgroundService), true);
|
||||
if (sentCount > 0 && logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
logger.LogInformation("Sent {SentCount} release update digest emails.", sentCount);
|
||||
@@ -54,6 +57,7 @@ internal sealed class ReleaseUpdateEmailDigestBackgroundService(
|
||||
#pragma warning disable CA1031
|
||||
catch (Exception ex)
|
||||
{
|
||||
metrics.RecordBackgroundJobRun(nameof(ReleaseUpdateEmailDigestBackgroundService), false);
|
||||
logger.LogError(ex, "Release update digest service failed.");
|
||||
}
|
||||
#pragma warning restore CA1031
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
@@ -24,7 +25,8 @@ internal class CreateWorkspaceRequestValidator
|
||||
|
||||
internal class CreateWorkspaceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
AccessScopeService accessScopeService,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<CreateWorkspaceRequest, WorkspaceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -65,6 +67,7 @@ internal class CreateWorkspaceHandler(
|
||||
|
||||
dbContext.Workspaces.Add(workspace);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordWorkspaceCreated(workspace.OrganizationId, workspace.Id);
|
||||
|
||||
WorkspaceDto dto = WorkspaceDto.FromWorkspace(workspace, []);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Identity.Contracts;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
@@ -31,7 +32,8 @@ internal class CreateWorkspaceInviteRequestValidator
|
||||
|
||||
internal class CreateWorkspaceInviteHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
AccessScopeService accessScopeService,
|
||||
SocializeMetrics metrics)
|
||||
: Endpoint<CreateWorkspaceInviteRequest, WorkspaceInviteDto>
|
||||
{
|
||||
public override void Configure()
|
||||
@@ -91,6 +93,7 @@ internal class CreateWorkspaceInviteHandler(
|
||||
|
||||
dbContext.WorkspaceInvites.Add(invite);
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
metrics.RecordWorkspaceInviteCreated(invite.WorkspaceId, invite.Role);
|
||||
|
||||
await SendAsync(
|
||||
new WorkspaceInviteDto(
|
||||
|
||||
@@ -6,6 +6,7 @@ using Socialize;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||
using Socialize.Api.Infrastructure;
|
||||
using Socialize.Api.Infrastructure.Observability;
|
||||
using Socialize.Api.Infrastructure.TestData;
|
||||
using Socialize.Api.Modules.Approvals;
|
||||
using Socialize.Api.Modules.Assets;
|
||||
@@ -44,6 +45,8 @@ builder.Services.AddCors(options =>
|
||||
)
|
||||
);
|
||||
|
||||
builder.AddObservability();
|
||||
|
||||
// Add services to the container.
|
||||
builder.Services.AddWebServices();
|
||||
builder.Services.AddAuthorizationAndAuthentication(builder.Configuration);
|
||||
@@ -110,6 +113,7 @@ app.UseCors("AllowAll");
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseObservabilityLoggingScope();
|
||||
|
||||
// Initialize and seed the db.
|
||||
await app.UseAppDataAsync();
|
||||
@@ -122,7 +126,7 @@ if (!app.Environment.IsDevelopment())
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHealthChecks("/health");
|
||||
app.MapObservabilityHealthChecks();
|
||||
|
||||
LocalBlobStorageOptions localBlobStorageOptions = app.Services
|
||||
.GetRequiredService<IOptions<LocalBlobStorageOptions>>()
|
||||
|
||||
@@ -28,7 +28,13 @@
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"
|
||||
Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.OpenTelemetry" Version="10.0.2" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.3" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.15.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
Reference in New Issue
Block a user