feat: add preprod observability foundation

This commit is contained in:
2026-05-08 15:45:31 -04:00
parent 1ca6ab7117
commit 8bcff96821
35 changed files with 1627 additions and 56 deletions

View File

@@ -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>();

View File

@@ -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()

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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."));
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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),

View File

@@ -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(

View File

@@ -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

View File

@@ -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, []);

View File

@@ -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(

View File

@@ -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>>()

View File

@@ -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>