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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions src/Ravelin.Domain/Diagnostics/ErrorFingerprint.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Security.Cryptography;
using System.Text;

namespace Ravelin.Domain.Diagnostics;

/// <summary>
/// Computes a stable identity for an exception so the same fault always groups to one record,
/// however often it fires. Identity = SHA-256 of the exception type plus the top few stack
/// frames with volatile detail (file paths, line numbers) stripped — so an unrelated edit that
/// shifts line numbers doesn't fork the group, but two genuinely different faults stay distinct.
/// Pure: no I/O, fully unit-testable.
/// </summary>
public static class ErrorFingerprint
{
private const int FramesUsed = 5;

/// <summary>The normalized top frames used for grouping — also stored as the human-readable
/// repro excerpt. "at Ns.Type.Method(args) in /path/File.cs:line 42" becomes
/// "at Ns.Type.Method(args)".</summary>
public static string NormalizeFrames(string? stackTrace)
{
if (string.IsNullOrWhiteSpace(stackTrace))
{
return string.Empty;
}

var frames = stackTrace
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(line => line.StartsWith("at ", StringComparison.Ordinal))
.Take(FramesUsed)
.Select(StripVolatile);

return string.Join('\n', frames);
}

/// <summary>The stable fingerprint (SHA-256 hex) for (exception type, normalized frames).</summary>
public static string Compute(string exceptionType, string? stackTrace)
{
var basis = exceptionType + "\n" + NormalizeFrames(stackTrace);
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(basis)));
}

private static string StripVolatile(string frame)
{
// Drop the " in <file>:line <n>" suffix that varies with edits/builds.
var inIndex = frame.IndexOf(" in ", StringComparison.Ordinal);
return inIndex >= 0 ? frame[..inIndex] : frame;
}
}
46 changes: 46 additions & 0 deletions src/Ravelin.Domain/Diagnostics/SecretScrubber.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Text.RegularExpressions;

namespace Ravelin.Domain.Diagnostics;

/// <summary>
/// Removes secret-shaped substrings (Ravelin API keys, bearer tokens, JWTs, long high-entropy
/// runs) from text before it is persisted as a captured error or sent to an external tracker.
/// Capturing a bug requires capturing the inputs that triggered it — so the capture sink must
/// uphold the same "never store/log secrets" rule as the rest of the app. Conservative by
/// design: when a value looks like a credential, it is redacted.
/// </summary>
public static class SecretScrubber
{
private const string Redacted = "[redacted]";

// Ravelin API keys: the "rvln_" prefix followed by base64url.
private static readonly Regex ApiKey = new(@"rvln_[A-Za-z0-9_\-]{8,}", RegexOptions.Compiled);

// "Authorization: Bearer <token>" — keep the scheme word, drop the token.
private static readonly Regex Bearer =
new(@"Bearer\s+[A-Za-z0-9_\-\.=]+", RegexOptions.Compiled | RegexOptions.IgnoreCase);

// JWT: three base64url segments separated by dots.
private static readonly Regex Jwt =
new(@"\b[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\b", RegexOptions.Compiled);

// Generic long high-entropy token (base64/connection-string secret/key material).
private static readonly Regex LongToken =
new(@"\b[A-Za-z0-9+/]{32,}={0,2}\b", RegexOptions.Compiled);

/// <summary>Returns the input with credential-shaped substrings replaced by a redaction
/// marker. Null/empty passes through unchanged.</summary>
public static string? Scrub(string? input)
{
if (string.IsNullOrEmpty(input))
{
return input;
}

var s = ApiKey.Replace(input, Redacted);
s = Bearer.Replace(s, $"Bearer {Redacted}");
s = Jwt.Replace(s, Redacted);
s = LongToken.Replace(s, Redacted);
return s;
}
}
49 changes: 49 additions & 0 deletions src/Ravelin.Domain/Entities/AppError.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
namespace Ravelin.Domain.Entities;

using Ravelin.Domain.Enums;

/// <summary>
/// A deduplicated, captured application error — an unhandled exception caught at the request
/// boundary — grouped by <see cref="Fingerprint"/> so the same fault recorded a thousand times
/// is one row with an occurrence count. This is the unit a tracked issue (and eventually an
/// auto-fix attempt) is created from. All captured free text is scrubbed of secret-shaped
/// values before it is stored (capturing a bug must never leak a credential).
/// </summary>
public class AppError
{
public Guid Id { get; set; } = Guid.NewGuid();

/// <summary>Stable identity = hash(exception type + normalized top stack frames). Unique, so
/// the same fault always maps to this one row regardless of how often it fires.</summary>
public required string Fingerprint { get; set; }

public required string ExceptionType { get; set; }

/// <summary>Exception message, scrubbed of secret-shaped values.</summary>
public string? Message { get; set; }

/// <summary>Normalized top stack frames (no file paths or line numbers) — the repro context
/// an issue/agent works from. Scrubbed.</summary>
public string? StackExcerpt { get; set; }

/// <summary>Where it surfaced. Path only — never the query string, body, or headers.</summary>
public string? RequestMethod { get; set; }
public string? RequestPath { get; set; }

/// <summary>Correlation id of the most recent occurrence, to cross-reference request logs.</summary>
public string? LastCorrelationId { get; set; }

public AppErrorStatus Status { get; set; } = AppErrorStatus.Open;

/// <summary>How many times this fault has been captured.</summary>
public int Occurrences { get; set; } = 1;

public DateTimeOffset FirstSeenAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;

// --- Issue-tracker link (the seam for capture -> Linear; set once synced) ---
/// <summary>Identifier of the tracked issue (e.g. Linear "RAV-123"), once synced.</summary>
public string? IssueIdentifier { get; set; }
public string? IssueUrl { get; set; }
public DateTimeOffset? IssueSyncedAt { get; set; }
}
10 changes: 10 additions & 0 deletions src/Ravelin.Domain/Enums/AppErrorStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Ravelin.Domain.Enums;

/// <summary>Lifecycle of a captured application error (an unhandled exception), distinct from a
/// security <see cref="Entities.Finding"/>. A recurrence reopens a resolved error.</summary>
public enum AppErrorStatus
{
Open = 0,
Resolved = 1,
Muted = 2,
}
33 changes: 33 additions & 0 deletions src/Ravelin.Infrastructure/Configurations/AppErrorConfiguration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Ravelin.Domain.Entities;

namespace Ravelin.Infrastructure.Configurations;

public class AppErrorConfiguration : IEntityTypeConfiguration<AppError>
{
public void Configure(EntityTypeBuilder<AppError> builder)
{
builder.HasKey(e => e.Id);

// Dedup identity: one row per fault (SHA-256 hex is 64 chars).
builder.Property(e => e.Fingerprint).IsRequired().HasMaxLength(64);
builder.HasIndex(e => e.Fingerprint).IsUnique();

builder.Property(e => e.ExceptionType).IsRequired().HasMaxLength(256);
builder.Property(e => e.Message).HasMaxLength(2000);
builder.Property(e => e.StackExcerpt).HasMaxLength(4000);
builder.Property(e => e.RequestMethod).HasMaxLength(16);
builder.Property(e => e.RequestPath).HasMaxLength(512);
builder.Property(e => e.LastCorrelationId).HasMaxLength(64);

// Enum as string, matching the Finding/FindingAlert mapping convention.
builder.Property(e => e.Status).HasConversion<string>().HasMaxLength(16);

builder.Property(e => e.IssueIdentifier).HasMaxLength(64);
builder.Property(e => e.IssueUrl).HasMaxLength(512);

// Triage/dashboard lookups: open errors, most-recent first.
builder.HasIndex(e => new { e.Status, e.LastSeenAt });
}
}
5 changes: 5 additions & 0 deletions src/Ravelin.Infrastructure/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ public static IServiceCollection AddRavelinInfrastructure(
services.AddSingleton<NotificationService>();
services.AddSingleton<SlaReEvaluator>();

// Error capture: dedup + persist unhandled exceptions. The issue tracker is a no-op until
// a real (config-gated) one is registered in the capture→Linear delivery stage.
services.AddSingleton<IIssueTracker, NullIssueTracker>();
services.AddSingleton<AppErrorService>();

return services;
}
}
Loading
Loading