Add Sentry (#19)

* add sentry

* add sentry

* better log web3proxy

* Add managing and worker on sentry

* better log web3proxy
This commit is contained in:
Oda
2025-04-22 20:49:02 +02:00
committed by GitHub
parent df5f7185c8
commit 42a4cafd8d
40 changed files with 2959 additions and 146 deletions

View File

@@ -0,0 +1,78 @@
namespace Managing.Core.Exceptions;
/// <summary>
/// Exception thrown when validation fails (maps to 400 Bad Request)
/// </summary>
public class ValidationException : Exception
{
public ValidationException(string message) : base(message)
{
}
public ValidationException(string message, Exception innerException) : base(message, innerException)
{
}
}
/// <summary>
/// Exception thrown when a resource is not found (maps to 404 Not Found)
/// </summary>
public class NotFoundException : Exception
{
public NotFoundException(string message) : base(message)
{
}
public NotFoundException(string resourceType, string identifier)
: base($"{resourceType} with identifier '{identifier}' was not found.")
{
}
}
/// <summary>
/// Exception thrown when the user does not have permission (maps to 403 Forbidden)
/// </summary>
public class ForbiddenException : Exception
{
public ForbiddenException(string message) : base(message)
{
}
public ForbiddenException() : base("You do not have permission to access this resource.")
{
}
}
/// <summary>
/// Exception thrown when there is a conflict with the current state (maps to 409 Conflict)
/// </summary>
public class ConflictException : Exception
{
public ConflictException(string message) : base(message)
{
}
}
/// <summary>
/// Exception thrown when a rate limit is exceeded (maps to 429 Too Many Requests)
/// </summary>
public class RateLimitExceededException : Exception
{
public RateLimitExceededException(string message) : base(message)
{
}
public RateLimitExceededException() : base("Rate limit exceeded. Please try again later.")
{
}
}
/// <summary>
/// Exception thrown when an external service is unavailable (maps to 503 Service Unavailable)
/// </summary>
public class ServiceUnavailableException : Exception
{
public ServiceUnavailableException(string message) : base(message)
{
}
}

View File

@@ -0,0 +1,106 @@
# Error Handling in Managing Application
This document describes the centralized error handling approach used in the Managing applications to ensure consistent error responses and logging, with Sentry integration for error monitoring.
## Architecture
The error handling architecture consists of:
1. **Global Error Handling Middleware**: Captures all unhandled exceptions and formats consistent responses
2. **Sentry Integration**: Sends detailed error information to Sentry for monitoring and analysis
3. **Custom Exception Types**: Provide appropriate HTTP status code mapping
4. **SentryErrorCapture Utility**: Provides methods for manually capturing errors with context
## Global Error Handling Middleware
The `GlobalErrorHandlingMiddleware` is registered in `Program.cs` for both the main API and Worker API:
```csharp
app.UseMiddleware(typeof(GlobalErrorHandlingMiddleware));
```
When an exception occurs, the middleware:
1. Determines the appropriate HTTP status code based on exception type
2. Logs the error with request details
3. Captures the exception in Sentry with appropriate context
4. Returns a standardized JSON error response to the client
## Sentry Integration
Sentry is integrated in three ways:
1. **Global Configuration**: Set up in `Program.cs` with environment-specific settings
2. **Error Capture**: In the global middleware and utility methods
3. **Diagnostic Endpoint**: The SentryDiagnosticsMiddleware provides a test endpoint at `/api/sentry-diagnostics`
The captured data includes:
- HTTP request details (path, method, query strings)
- Exception details (type, message, stack trace)
- Additional context from exception data
- Tags for better categorization and filtering
## Using Custom Exception Types
The shared exception types map to appropriate HTTP status codes:
| Exception Type | HTTP Status Code | Use Case |
|----------------|------------------|----------|
| ValidationException | 400 Bad Request | Input validation errors |
| NotFoundException | 404 Not Found | Resource does not exist |
| ForbiddenException | 403 Forbidden | Permission denied |
| ConflictException | 409 Conflict | Resource state conflict |
| RateLimitExceededException | 429 Too Many Requests | Rate limit exceeded |
| ServiceUnavailableException | 503 Service Unavailable | External service down |
Example:
```csharp
// Validation error
throw new ValidationException("The username must be at least 3 characters");
// Resource not found with context
throw new NotFoundException("User", userId);
// Permission denied
throw new ForbiddenException();
```
## Manual Error Reporting
For manually reporting errors to Sentry, use the `SentryErrorCapture` utility:
```csharp
// Capture an exception with context
SentryErrorCapture.CaptureException(ex, "MyService", new Dictionary<string, object> {
{ "userId", user.Id },
{ "operation", "ProcessImport" }
});
// Enrich an exception before throwing
throw SentryErrorCapture.EnrichException(new ValidationException("Invalid data"),
new Dictionary<string, object> {
{ "validationErrors", errors }
});
```
## Error Response Format
The standard error response format is:
```json
{
"statusCode": 400,
"message": "The error message",
"traceId": "sentry-event-id",
"stackTrace": "Only included in non-production environments"
}
```
## Best Practices
1. **Use custom exception types**: Throw the appropriate exception type for each error case
2. **Add context to exceptions**: Use the Data dictionary to add context that will be captured
3. **Don't duplicate Sentry reporting**: Let the global middleware handle Sentry integration
4. **Avoid sensitive data**: Never include sensitive data (passwords, tokens) in error messages or context
5. **Use the diagnostic endpoint**: Test Sentry connectivity using the `/api/sentry-diagnostics` endpoint

View File

@@ -0,0 +1,109 @@
using Sentry;
namespace Managing.Core.Exceptions;
/// <summary>
/// Utility class for capturing errors with Sentry across the application
/// </summary>
public static class SentryErrorCapture
{
/// <summary>
/// Captures an exception in Sentry with additional context
/// </summary>
/// <param name="exception">The exception to capture</param>
/// <param name="contextName">A descriptive name for where the error occurred</param>
/// <param name="extraData">Optional dictionary of additional data to include</param>
/// <returns>The Sentry event ID</returns>
public static SentryId CaptureException(Exception exception, string contextName, IDictionary<string, object> extraData = null)
{
return SentrySdk.CaptureException(exception, scope =>
{
// Add context information
scope.SetTag("context", contextName);
scope.SetTag("error_type", exception.GetType().Name);
// Add any extra data provided
if (extraData != null)
{
foreach (var kvp in extraData)
{
scope.SetExtra(kvp.Key, kvp.Value?.ToString() ?? "null");
}
}
// Add extra info from the exception's Data dictionary if available
foreach (var key in exception.Data.Keys)
{
if (key is string keyStr && exception.Data[key] != null)
{
scope.SetExtra($"exception_data_{keyStr}", exception.Data[key].ToString());
}
}
// Add a breadcrumb for context
scope.AddBreadcrumb(
message: $"Exception in {contextName}",
category: "error",
level: BreadcrumbLevel.Error
);
});
}
/// <summary>
/// Enriches an exception with additional context data before throwing
/// </summary>
/// <param name="exception">The exception to enrich</param>
/// <param name="contextData">Dictionary of context data to add</param>
/// <returns>The enriched exception for chaining</returns>
public static Exception EnrichException(Exception exception, IDictionary<string, object> contextData)
{
if (contextData != null)
{
foreach (var item in contextData)
{
exception.Data[item.Key] = item.Value;
}
}
return exception;
}
/// <summary>
/// Captures a message in Sentry with additional context
/// </summary>
/// <param name="message">The message to capture</param>
/// <param name="level">The severity level</param>
/// <param name="contextName">A descriptive name for where the message originated</param>
/// <param name="extraData">Optional dictionary of additional data to include</param>
/// <returns>The Sentry event ID</returns>
public static SentryId CaptureMessage(string message, SentryLevel level, string contextName, IDictionary<string, object> extraData = null)
{
// First capture the message with the specified level
var id = SentrySdk.CaptureMessage(message, level);
// Then add context via a scope
SentrySdk.ConfigureScope(scope =>
{
// Add context information
scope.SetTag("context", contextName);
// Add any extra data provided
if (extraData != null)
{
foreach (var kvp in extraData)
{
scope.SetExtra(kvp.Key, kvp.Value?.ToString() ?? "null");
}
}
// Add a breadcrumb for context
scope.AddBreadcrumb(
message: $"Message from {contextName}",
category: "message",
level: BreadcrumbLevel.Info
);
});
return id;
}
}

View File

@@ -6,14 +6,11 @@
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Exceptions\**" />
<EmbeddedResource Remove="Exceptions\**" />
<None Remove="Exceptions\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<PackageReference Include="Sentry" Version="5.5.1" />
<PackageReference Include="Sentry.AspNetCore" Version="5.5.1" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup>

View File

@@ -1,18 +1,23 @@
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Sentry;
using Managing.Core.Exceptions;
namespace Managing.Core.Middleawares;
public class GlobalErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
public GlobalErrorHandlingMiddleware(RequestDelegate next)
private readonly ILogger<GlobalErrorHandlingMiddleware> _logger;
public GlobalErrorHandlingMiddleware(RequestDelegate next, ILogger<GlobalErrorHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext context)
{
try
@@ -24,43 +29,162 @@ public class GlobalErrorHandlingMiddleware
await HandleExceptionAsync(context, ex);
}
}
private static Task HandleExceptionAsync(HttpContext context, Exception exception)
private Task HandleExceptionAsync(HttpContext context, Exception exception)
{
HttpStatusCode status;
var exceptionType = exception.GetType();
if (exceptionType == typeof(Exception))
string errorMessage;
// Determine the appropriate status code based on exception type
status = exception switch
{
status = HttpStatusCode.InternalServerError;
}
else if (exceptionType == typeof(NotImplementedException))
// 400 Bad Request
ArgumentException => HttpStatusCode.BadRequest,
ValidationException => HttpStatusCode.BadRequest,
FormatException => HttpStatusCode.BadRequest,
InvalidOperationException => HttpStatusCode.BadRequest,
// 401 Unauthorized
UnauthorizedAccessException => HttpStatusCode.Unauthorized,
// 403 Forbidden
ForbiddenException => HttpStatusCode.Forbidden,
// 404 Not Found
KeyNotFoundException => HttpStatusCode.NotFound,
FileNotFoundException => HttpStatusCode.NotFound,
DirectoryNotFoundException => HttpStatusCode.NotFound,
NotFoundException => HttpStatusCode.NotFound,
// 408 Request Timeout
TimeoutException => HttpStatusCode.RequestTimeout,
// 409 Conflict
ConflictException => HttpStatusCode.Conflict,
// 429 Too Many Requests
RateLimitExceededException => HttpStatusCode.TooManyRequests,
// 501 Not Implemented
NotImplementedException => HttpStatusCode.NotImplemented,
// 503 Service Unavailable
ServiceUnavailableException => HttpStatusCode.ServiceUnavailable,
// 500 Internal Server Error (default)
_ => HttpStatusCode.InternalServerError
};
// Log the error with appropriate severity based on status code
var isServerError = (int)status >= 500;
if (isServerError)
{
status = HttpStatusCode.NotImplemented;
}
else if (exceptionType == typeof(UnauthorizedAccessException))
{
status = HttpStatusCode.Unauthorized;
}
else if (exceptionType == typeof(ArgumentException))
{
status = HttpStatusCode.Unauthorized;
}
else if (exceptionType == typeof(KeyNotFoundException))
{
status = HttpStatusCode.Unauthorized;
_logger.LogError(exception, "Server Error: {StatusCode} on {Path}", (int)status, context.Request.Path);
}
else
{
status = HttpStatusCode.InternalServerError;
_logger.LogWarning(exception, "Client Error: {StatusCode} on {Path}", (int)status, context.Request.Path);
}
var message = exception.Message;
var stackTrace = exception.StackTrace;
var exceptionResult = JsonSerializer.Serialize(new { error = message, stackTrace });
// Capture exception in Sentry with request context
var sentryId = SentrySdk.CaptureException(exception, scope =>
{
// Add HTTP request information
scope.SetTag("http.method", context.Request.Method);
scope.SetTag("http.url", context.Request.Path);
// Add request details
scope.SetExtra("query_string", context.Request.QueryString.ToString());
// Add custom tags to help with filtering
scope.SetTag("error_type", exception.GetType().Name);
scope.SetTag("status_code", ((int)status).ToString());
scope.SetTag("host", context.Request.Host.ToString());
scope.SetTag("path", context.Request.Path.ToString());
// Add any correlation IDs if available
if (context.Request.Headers.TryGetValue("X-Correlation-ID", out var correlationId))
{
scope.SetTag("correlation_id", correlationId.ToString());
}
// Additional context based on exception type
if (exception is ValidationException)
{
scope.SetTag("error_category", "validation");
}
else if (exception is NotFoundException)
{
scope.SetTag("error_category", "not_found");
}
// Add additional context from exception data if available
foreach (var key in exception.Data.Keys)
{
if (key is string keyStr && exception.Data[key] != null)
{
scope.SetExtra(keyStr, exception.Data[key].ToString());
}
}
// Add breadcrumb for the request
scope.AddBreadcrumb(
message: $"Request to {context.Request.Path}",
category: "request",
level: BreadcrumbLevel.Info
);
});
// Use a more user-friendly error message in production
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Production")
{
// For 5xx errors, use a generic message
if (isServerError)
{
errorMessage = "An unexpected error occurred. Our team has been notified.";
}
else
{
// For 4xx errors, keep the original message since it's likely helpful for the user
errorMessage = exception.Message;
}
}
else
{
errorMessage = exception.Message;
}
// Create the error response
var errorResponse = new ErrorResponse
{
StatusCode = (int)status,
Message = errorMessage,
TraceId = sentryId.ToString()
};
// Only include stack trace in development environment
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") != "Production")
{
errorResponse.StackTrace = exception.StackTrace;
}
var result = JsonSerializer.Serialize(errorResponse, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
context.Response.ContentType = "application/json";
context.Response.StatusCode = (int)status;
return context.Response.WriteAsync(exceptionResult);
return context.Response.WriteAsync(result);
}
// Custom error response class
private class ErrorResponse
{
public int StatusCode { get; set; }
public string Message { get; set; }
public string TraceId { get; set; }
public string StackTrace { get; set; }
}
}

View File

@@ -0,0 +1,94 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Sentry;
namespace Managing.Core.Middleawares;
public class SentryDiagnosticsMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<SentryDiagnosticsMiddleware> _logger;
public SentryDiagnosticsMiddleware(RequestDelegate next, ILogger<SentryDiagnosticsMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context)
{
// Only activate for the /api/sentry-diagnostics endpoint
if (context.Request.Path.StartsWithSegments("/api/sentry-diagnostics"))
{
await HandleDiagnosticsRequest(context);
return;
}
await _next(context);
}
private async Task HandleDiagnosticsRequest(HttpContext context)
{
var response = new StringBuilder();
response.AppendLine("Sentry Diagnostics Report");
response.AppendLine("========================");
response.AppendLine($"Timestamp: {DateTime.Now}");
response.AppendLine();
// Check if Sentry is initialized
response.AppendLine("## Sentry SDK Status");
response.AppendLine($"Sentry Enabled: {SentrySdk.IsEnabled}");
response.AppendLine($"Application Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")}");
response.AppendLine();
// Send a test event
response.AppendLine("## Test Event");
try
{
var id = SentrySdk.CaptureMessage($"Diagnostics test from {context.Request.Host} at {DateTime.Now}",
SentryLevel.Info);
response.AppendLine($"Test Event ID: {id}");
response.AppendLine(
"Test event was sent to Sentry. Check your Sentry dashboard to confirm it was received.");
// Try to send an exception too
try
{
throw new Exception("Test exception from diagnostics middleware");
}
catch (Exception ex)
{
var exceptionId = SentrySdk.CaptureException(ex);
response.AppendLine($"Test Exception ID: {exceptionId}");
}
}
catch (Exception ex)
{
response.AppendLine($"Error sending test event: {ex.Message}");
response.AppendLine(ex.StackTrace);
}
response.AppendLine();
response.AppendLine("## Connectivity Check");
response.AppendLine("If events are not appearing in Sentry, check the following:");
response.AppendLine("1. Verify your DSN is correct in appsettings.json");
response.AppendLine("2. Ensure your network allows outbound HTTPS connections to sentry.io");
response.AppendLine("3. Check Sentry server logs for any ingestion issues");
response.AppendLine("4. Verify your Sentry project is correctly configured to receive events");
// Return the diagnostic information
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync(response.ToString());
}
}
// Extension method used to add the middleware to the HTTP request pipeline.
public static class SentryDiagnosticsMiddlewareExtensions
{
public static IApplicationBuilder UseSentryDiagnostics(this IApplicationBuilder builder)
{
return builder.UseMiddleware<SentryDiagnosticsMiddleware>();
}
}