InteractionEvent records one interaction with a remote server: its channel (recv, in, out, http, preview, crawl), activity and object type, outcome and reason, status, latency, wait, bytes, attempt, audience, local actor kind, inbox, signature, features and the object's age. IInteractionLedger.Record never blocks and never throws: events go into a bounded channel of 10k, a full channel drops and counts, and a hosted service writes batches of up to 1000 every two seconds. Privacy, as decided by the owner: - no persona, root, group or activity id, inbox URL, actor URI or sender IP is stored; - distinct accounts are counted with an HMAC keyed by a per-day salt (InteractionSalt, upserted so restarts agree, never created for a past day); - the local actor kind survives only on public and unlisted traffic; - a host claimed by an unverified sender is kept only if it is already known. Traffic caused by reading is only counted per day (InstanceDay.Reads, ServerDay). Indexes: a 90-day TTL on events, unique day rows, and a TTL safety net on salts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
102 lines
3.9 KiB
C#
102 lines
3.9 KiB
C#
using PrivaPub.Models.Statistics;
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace PrivaPub.Infrastructure.Statistics
|
|
{
|
|
public static partial class Interactions
|
|
{
|
|
public const string Receive = "recv";
|
|
public const string In = "in";
|
|
public const string Out = "out";
|
|
public const string Http = "http";
|
|
public const string Preview = "preview";
|
|
public const string Crawl = "crawl";
|
|
|
|
public const string Queued = "queued";
|
|
public const string Refused = "refused";
|
|
public const string Accepted = "accepted";
|
|
public const string Dropped = "dropped";
|
|
public const string Rejected = "rejected";
|
|
public const string Ok = "ok";
|
|
public const string Failed = "failed";
|
|
public const string Deferred = "deferred";
|
|
public const string Retry = "retry";
|
|
public const string Dead = "dead";
|
|
|
|
public const string Public = "public";
|
|
public const string Unlisted = "unlisted";
|
|
public const string Private = "private";
|
|
public const string None = "none";
|
|
|
|
public const string Unknown = "-";
|
|
public const string Other = "other";
|
|
|
|
static readonly HashSet<string> Channels = new(StringComparer.Ordinal) { Receive, In, Out, Http, Preview, Crawl };
|
|
static readonly HashSet<string> Audiences = new(StringComparer.Ordinal) { Public, Unlisted, Private, None };
|
|
static readonly HashSet<string> LocalKinds = new(StringComparer.Ordinal) { "person", "group", "application" };
|
|
static readonly HashSet<string> Inboxes = new(StringComparer.Ordinal) { "shared", "personal" };
|
|
static readonly int[] LatencyBuckets = { 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000 };
|
|
static readonly int[] WaitBuckets = { 1, 10, 60, 600, 3600, 21600, 86400 };
|
|
|
|
public static InteractionEvent Sanitize(InteractionEvent e)
|
|
{
|
|
e.Host = Host(e.Host);
|
|
e.Channel = Channels.Contains(e.Channel) ? e.Channel : Other;
|
|
e.Activity = TypeName(e.Activity);
|
|
e.Object = TypeName(e.Object);
|
|
e.Purpose = Word(e.Purpose);
|
|
e.Trigger = Word(e.Trigger);
|
|
e.Outcome = Word(e.Outcome);
|
|
e.Reason = Word(e.Reason);
|
|
e.Audience = e.Audience != default && Audiences.Contains(e.Audience) ? e.Audience : default;
|
|
e.LocalKind = e.Audience is Public or Unlisted && e.LocalKind != default && LocalKinds.Contains(e.LocalKind) ? e.LocalKind : default;
|
|
e.Inbox = e.Inbox != default && Inboxes.Contains(e.Inbox) ? e.Inbox : default;
|
|
e.Signature = e.Signature == default ? default : SignatureName().IsMatch(e.Signature) ? e.Signature : Other;
|
|
e.Features = e.Features?.Select(Word).Where(f => f != default).Distinct().Take(32).ToList();
|
|
if (e.Features?.Count == 0)
|
|
e.Features = default;
|
|
return e;
|
|
}
|
|
|
|
public static string Host(string host)
|
|
{
|
|
host = host?.Trim().TrimEnd('.').ToLowerInvariant();
|
|
return string.IsNullOrEmpty(host) || host.Length > 253 || !HostName().IsMatch(host) ? Unknown : host;
|
|
}
|
|
|
|
public static string HostOf(string uri) =>
|
|
Uri.TryCreate(uri, UriKind.Absolute, out var parsed) ? Host(parsed.Host) : Unknown;
|
|
|
|
public static string Latency(int milliseconds) => Bucket("le", milliseconds, LatencyBuckets, "ms");
|
|
|
|
public static string Wait(int milliseconds) => Bucket("le", milliseconds / 1000, WaitBuckets, "s");
|
|
|
|
static string Bucket(string prefix, int value, int[] limits, string unit)
|
|
{
|
|
foreach (var limit in limits)
|
|
if (value <= limit)
|
|
return $"{prefix}{limit}{unit}";
|
|
return "inf";
|
|
}
|
|
|
|
static string TypeName(string value) =>
|
|
value == default ? default : TypeWord().IsMatch(value) ? value : Other;
|
|
|
|
static string Word(string value) =>
|
|
value == default ? default : KebabWord().IsMatch(value) ? value : Other;
|
|
|
|
[GeneratedRegex("^[A-Za-z][A-Za-z0-9_-]{0,31}$")]
|
|
private static partial Regex TypeWord();
|
|
|
|
[GeneratedRegex("^[a-z0-9][a-z0-9-]{0,39}$")]
|
|
private static partial Regex KebabWord();
|
|
|
|
[GeneratedRegex("^[a-z0-9][a-z0-9:-]{0,39}$")]
|
|
private static partial Regex SignatureName();
|
|
|
|
[GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")]
|
|
private static partial Regex HostName();
|
|
}
|
|
}
|