A RollupDay job folds each finished day's InteractionEvents into one InstanceDay per server:
- Counters for the admin, keyed channel:activity:object:outcome:reason, plus signature
schemes, audiences, local kinds and features;
- PublicCounters, everything a public page may ever read:
- inbound and outbound activities from an allowlist, on public, unlisted or unaddressed
traffic only, with the outcome collapsed (accepted or dropped, delivered or failed);
- no reasons, no Flag or Block;
- features of public objects;
- health:ok or health:failed from reachability (a 4xx means the server answered);
- latency, wait and byte histograms, and the number of distinct accounts from that day's
hashes.
Re-running a day replaces it and keeps the live Reads counters. The day's salt is then
deleted, so its hashes can never be recomputed, and the next day is queued.
StatisticsSchedule plans today's rollup every hour and catches up any of the last seven
days that have events but no rollup. Waits round up into their bucket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
108 lines
4.1 KiB
C#
108 lines
4.1 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(Feature).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", (int)Math.Ceiling(milliseconds / 1000.0), 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;
|
|
|
|
static string Feature(string value) =>
|
|
value == default ? default : FeatureName().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:/+.-]{0,63}$")]
|
|
private static partial Regex FeatureName();
|
|
|
|
[GeneratedRegex("^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$")]
|
|
private static partial Regex HostName();
|
|
}
|
|
}
|