M1: the interaction ledger
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
This commit is contained in:
1 parent
125a49a1a0
commit
15cd034b29
16 files changed
+735
-1
No files matched your search
@@ -0,0 +1,105 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Statistics;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Statistics
|
||||
{
|
||||
public class InteractionsTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_local_kind_is_kept_only_on_public_and_unlisted_traffic()
|
||||
{
|
||||
Assert.Equal("person", Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "person" }).LocalKind);
|
||||
Assert.Equal("group", Interactions.Sanitize(new InteractionEvent { Audience = "unlisted", LocalKind = "group" }).LocalKind);
|
||||
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "private", LocalKind = "group" }).LocalKind);
|
||||
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "none", LocalKind = "person" }).LocalKind);
|
||||
Assert.Null(Interactions.Sanitize(new InteractionEvent { LocalKind = "person" }).LocalKind);
|
||||
Assert.Null(Interactions.Sanitize(new InteractionEvent { Audience = "public", LocalKind = "circle" }).LocalKind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Junk_becomes_other_and_hosts_are_normalised()
|
||||
{
|
||||
var e = Interactions.Sanitize(new InteractionEvent
|
||||
{
|
||||
Host = "Mastodon.Social.",
|
||||
Channel = "sideways",
|
||||
Activity = "https://www.w3.org/ns/activitystreams#Create",
|
||||
Object = "Note",
|
||||
Outcome = "Dropped!",
|
||||
Reason = "not-addressed",
|
||||
Audience = "everyone",
|
||||
Inbox = "front-door",
|
||||
Signature = "cavage:rsa-sha256",
|
||||
Features = new() { "fep-044f", "Bad Feature", "fep-044f" }
|
||||
});
|
||||
|
||||
Assert.Equal("mastodon.social", e.Host);
|
||||
Assert.Equal("other", e.Channel);
|
||||
Assert.Equal("other", e.Activity);
|
||||
Assert.Equal("Note", e.Object);
|
||||
Assert.Equal("other", e.Outcome);
|
||||
Assert.Equal("not-addressed", e.Reason);
|
||||
Assert.Null(e.Audience);
|
||||
Assert.Null(e.Inbox);
|
||||
Assert.Equal("cavage:rsa-sha256", e.Signature);
|
||||
Assert.Equal(new[] { "fep-044f", "other" }, e.Features);
|
||||
Assert.Equal("-", Interactions.Host("evil host/with path"));
|
||||
Assert.Equal("-", Interactions.Host(default));
|
||||
Assert.Equal("social.example", Interactions.HostOf("https://Social.Example:8443/users/x"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Latencies_and_waits_fall_into_buckets()
|
||||
{
|
||||
Assert.Equal("le50ms", Interactions.Latency(0));
|
||||
Assert.Equal("le250ms", Interactions.Latency(101));
|
||||
Assert.Equal("inf", Interactions.Latency(31_000));
|
||||
Assert.Equal("le1s", Interactions.Wait(900));
|
||||
Assert.Equal("le60s", Interactions.Wait(59_000));
|
||||
Assert.Equal("inf", Interactions.Wait(100_000_000));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_actor_hash_is_stable_for_a_salt_and_unlinkable_across_salts()
|
||||
{
|
||||
var monday = new byte[32];
|
||||
var tuesday = Enumerable.Repeat((byte)1, 32).ToArray();
|
||||
const string actor = "https://social.example/users/alice";
|
||||
|
||||
var first = InteractionSalts.Hash(monday, actor);
|
||||
|
||||
Assert.Equal(first, InteractionSalts.Hash(monday, actor));
|
||||
Assert.NotEqual(first, InteractionSalts.Hash(tuesday, actor));
|
||||
Assert.NotEqual(first, InteractionSalts.Hash(monday, actor + "2"));
|
||||
Assert.Equal(16, first.Length);
|
||||
Assert.DoesNotContain("alice", first);
|
||||
Assert.Null(InteractionSalts.Hash(default, actor));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_full_ledger_drops_and_counts_but_never_blocks_or_throws()
|
||||
{
|
||||
var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.Instance);
|
||||
|
||||
for (var i = 0; i < 10_005; i++)
|
||||
ledger.Record(new InteractionEvent { Channel = Interactions.In });
|
||||
ledger.Record(default);
|
||||
|
||||
Assert.Equal(5, ledger.Dropped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_disabled_ledger_records_nothing()
|
||||
{
|
||||
var ledger = new InteractionLedger(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions { Enabled = false }), NullLogger<InteractionLedger>.Instance);
|
||||
|
||||
for (var i = 0; i < 10_005; i++)
|
||||
ledger.Record(new InteractionEvent { Channel = Interactions.In });
|
||||
|
||||
Assert.Equal(0, ledger.Dropped);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Jobs;
|
||||
using PrivaPub.Models.Statistics;
|
||||
using PrivaPub.Tests.Support;
|
||||
|
||||
namespace PrivaPub.Tests.Statistics
|
||||
{
|
||||
[Trait("Category", "Integration")]
|
||||
public sealed class LedgerStoreTests : IAsyncLifetime
|
||||
{
|
||||
readonly string _known = $"known{Guid.NewGuid():N}.example";
|
||||
readonly string _stranger = $"stranger{Guid.NewGuid():N}.example";
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
|
||||
await DB.Default.SaveAsync(new RemoteInstance { Host = _known });
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
static InteractionLedger Ledger() =>
|
||||
new(new InteractionSalts(), new StaticOptions<StatisticsOptions>(new StatisticsOptions()), NullLogger<InteractionLedger>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Events_are_stored_with_a_hash_and_never_the_actor()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var ledger = Ledger();
|
||||
var actor = $"https://{_known}/users/someone{Guid.NewGuid():N}";
|
||||
|
||||
ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.In, Activity = "Create", Object = "Note", Outcome = Interactions.Accepted }, actor);
|
||||
ledger.Record(new InteractionEvent { Host = _stranger, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "no-signature" }, hostClaimed: true);
|
||||
ledger.Record(new InteractionEvent { Host = _known, Channel = Interactions.Receive, Status = 401, Outcome = Interactions.Refused, Reason = "signature-invalid" }, hostClaimed: true);
|
||||
await ledger.Flush(token);
|
||||
|
||||
var stored = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == _known).ExecuteAsync(token);
|
||||
Assert.Equal(2, stored.Count);
|
||||
var accepted = Assert.Single(stored, e => e.Channel == Interactions.In);
|
||||
Assert.NotNull(accepted.ActorHash);
|
||||
Assert.Equal("signature-invalid", Assert.Single(stored, e => e.Channel == Interactions.Receive).Reason);
|
||||
Assert.False(await DB.Default.Find<InteractionEvent>().Match(e => e.Host == _stranger).ExecuteAnyAsync(token));
|
||||
var raw = await DB.Default.Database().GetCollection<BsonDocument>(nameof(InteractionEvent)).Find(new BsonDocument("_id", ObjectId.Parse(accepted.ID))).FirstAsync(token);
|
||||
Assert.DoesNotContain("someone", raw.ToJson());
|
||||
Assert.DoesNotContain("/users/", raw.ToJson());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Two_processes_agree_on_a_days_salt_and_no_salt_is_made_for_the_past()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
|
||||
var one = await new InteractionSalts().For(DateTime.UtcNow, token);
|
||||
var two = await new InteractionSalts().For(DateTime.UtcNow, token);
|
||||
|
||||
Assert.Equal(Convert.ToBase64String(one), Convert.ToBase64String(two));
|
||||
Assert.Null(await new InteractionSalts().For(new DateTime(2001, 1, 1, 0, 0, 0, DateTimeKind.Utc), token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Counters_add_up_per_day()
|
||||
{
|
||||
var token = TestContext.Current.CancellationToken;
|
||||
var ledger = Ledger();
|
||||
|
||||
ledger.Count(_known, "media:hit", 1000);
|
||||
ledger.Count(_known, "media:hit", 500);
|
||||
ledger.Count(_known, "media.miss");
|
||||
ledger.CountServer(ServerSections.Client, $"test{_known}:GET:2xx", 120);
|
||||
ledger.CountServer(ServerSections.Served, $"test{_known}:200:signed");
|
||||
await ledger.Flush(token);
|
||||
|
||||
var day = await DB.Default.Find<InstanceDay>().Match(d => d.Host == _known && d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token);
|
||||
Assert.Equal(2, day.Reads["media:hit"]);
|
||||
Assert.Equal(1500, day.Reads["media:hit:bytes"]);
|
||||
Assert.Equal(1, day.Reads["media_miss"]);
|
||||
var server = await DB.Default.Find<ServerDay>().Match(d => d.Day == DateTime.UtcNow.Date).ExecuteFirstAsync(token);
|
||||
Assert.Equal(1, server.Client[$"test{_known.Replace('.', '_')}:GET:2xx"]);
|
||||
Assert.Equal(1, server.Served[$"test{_known.Replace('.', '_')}:200:signed"]);
|
||||
Assert.True(server.ClientLatency["le250ms"] >= 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using PrivaPub.Infrastructure.Statistics;
|
||||
using PrivaPub.Models.Statistics;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace PrivaPub.Tests.Support
|
||||
{
|
||||
public sealed class MemoryLedger : IInteractionLedger
|
||||
{
|
||||
public ConcurrentQueue<(InteractionEvent Event, string ActorUri, bool HostClaimed)> Events { get; } = new();
|
||||
public ConcurrentDictionary<(string Host, string Key), long> Counts { get; } = new();
|
||||
public ConcurrentDictionary<(string Section, string Key), long> ServerCounts { get; } = new();
|
||||
|
||||
public long Dropped => 0;
|
||||
|
||||
public void Record(InteractionEvent interaction, string actorUri = default, bool hostClaimed = false) =>
|
||||
Events.Enqueue((Interactions.Sanitize(interaction), actorUri, hostClaimed));
|
||||
|
||||
public void Count(string host, string key, long bytes = 0) =>
|
||||
Counts.AddOrUpdate((Interactions.Host(host), key), 1, (_, count) => count + 1);
|
||||
|
||||
public void CountServer(string section, string key, int? latencyMs = default) =>
|
||||
ServerCounts.AddOrUpdate((section, key), 1, (_, count) => count + 1);
|
||||
|
||||
public IReadOnlyList<InteractionEvent> Of(string channel) => Events.Select(e => e.Event).Where(e => e.Channel == channel).ToList();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user