M10: the admin statistics API

Under /clientapi/admin/statistics, admin only (the root JWT's IsAdmin policy, like the domain
blocks; /api tokens are persona tokens):
- GET overview?days: totals per channel, inbound and outbound outcomes, the delivery
  success rate, delivery latency p50/p95 from the buckets, active and known servers,
  distinct accounts, and the ledger's written/dropped/failed counts;
- GET hosts?days&sort=volume|failures|latency|host&software&page&limit: per server
  traffic, refusals, failures, latency, accounts, software and delivery health;
- GET hosts/{host}?days: the server's description, its daily series and its last 100
  events;
- GET events?host&channel&outcome&reason&before&limit, and GET server?days for the
  ServerDay rows;
- POST rollups/{day} refolds a past day; POST hosts/{host}/describe describes a server
  again.

Days not yet rolled up, today included, are folded live from the events, so the numbers are
current. Answers that may carry locations carry the DB-IP attribution.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ELjqpznMFMNrJoJUj6K5p2
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-03 11:35:40 +02:00
1 parent cee85309b8
commit 6ef7e2891a
4 files changed
+394

No files matched your search

@@ -0,0 +1,126 @@
using MongoDB.Entities;
using PrivaPub.Domain.Statistics;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Tests.Support;
using PrivaPub.Tests.Support.Host;
using System.Net;
using System.Text.Json.Nodes;
namespace PrivaPub.Tests.Statistics
{
public class StatisticsQueriesTests
{
[Fact]
public void Percentiles_come_from_the_latency_buckets()
{
var latency = new Dictionary<string, long> { ["out:le50ms"] = 50, ["out:le250ms"] = 45, ["out:le5000ms"] = 4, ["out:inf"] = 1, ["in:le50ms"] = 9 };
Assert.Equal(50, StatisticsQueries.Percentile(latency, "out", 0.5));
Assert.Equal(250, StatisticsQueries.Percentile(latency, "out", 0.95));
Assert.Equal(5000, StatisticsQueries.Percentile(latency, "out", 0.99));
Assert.Equal(30001, StatisticsQueries.Percentile(latency, "out", 1));
Assert.Null(StatisticsQueries.Percentile(latency, "http:actor", 0.5));
}
[Fact]
public void Outcomes_add_up_per_channel()
{
var counters = new Dictionary<string, long>
{
["out:Create:Note:ok"] = 3, ["out:Create:Note:retry:503"] = 2, ["out:Like:-:ok"] = 1, ["in:Create:Note:accepted:stored"] = 4, ["recv:Create:202:queued"] = 9
};
Assert.Equal(new Dictionary<string, long> { ["ok"] = 4, ["retry"] = 2 }, StatisticsQueries.Outcomes(counters, "out"));
Assert.Equal(new Dictionary<string, long> { ["accepted"] = 4 }, StatisticsQueries.Outcomes(counters, "in"));
}
}
[Trait("Category", "Integration")]
public sealed class AdminStatisticsTests : IAsyncLifetime
{
PrivaPubHost _host;
Peer _peer;
public async ValueTask InitializeAsync()
{
Assert.SkipUnless(MongoFixture.Enabled, MongoFixture.Skip);
_host = await PrivaPubHost.Shared();
_peer = await Peer.Start();
}
public async ValueTask DisposeAsync()
{
if (_peer != default)
await _peer.DisposeAsync();
}
[Fact]
public async Task Only_the_admin_reads_the_statistics()
{
var token = TestContext.Current.CancellationToken;
var plain = await _host.SignUp();
using var anonymous = _host.Client();
using var user = _host.As(plain.Jwt);
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync("/clientapi/admin/statistics/overview", token)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await user.GetAsync("/clientapi/admin/statistics/overview", token)).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, (await user.PostAsync("/clientapi/admin/statistics/rollups/2001-01-01", default, token)).StatusCode);
}
[Fact]
public async Task The_admin_sees_a_server_its_events_and_the_overview()
{
var token = TestContext.Current.CancellationToken;
var admin = await _host.Admin();
var persona = await _host.Persona(await _host.SignUp(), "stats");
var bob = new RemoteActor(_peer, "bob");
var noteId = $"{_peer.A}/notes/{Guid.NewGuid():N}";
var to = new JsonArray($"{PrivaPubHost.Base}/peasants/{persona.UserName}");
using (var client = _host.Client())
Assert.Equal(HttpStatusCode.Accepted, (await client.SendAsync(bob.SignedPost($"/peasants/{persona.UserName}/mouth", new JsonObject
{
["id"] = noteId + "/activity", ["type"] = "Create", ["actor"] = bob.Id, ["to"] = to.DeepClone(),
["object"] = new JsonObject { ["id"] = noteId, ["type"] = "Note", ["attributedTo"] = bob.Id, ["to"] = to.DeepClone(), ["content"] = "hi" }
}), token)).StatusCode);
await _host.RunInbox(noteId + "/activity", token);
await _host.Get<InteractionLedger>().Flush(token);
using var adminClient = _host.As(admin.Jwt);
var host = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/hosts/127.0.0.1?days=1", token))!;
var overview = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/overview?days=1", token))!;
var hosts = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/hosts?days=1&sort=host", token))!;
var events = JsonNode.Parse(await adminClient.GetStringAsync("/clientapi/admin/statistics/events?host=127.0.0.1&channel=in&limit=5", token))!;
var today = host["days"]!.AsArray().Last()!;
Assert.True(today["live"]!.GetValue<bool>());
Assert.True(today["counters"]!["in:Create:Note:accepted:stored"]!.GetValue<long>() >= 1);
Assert.Contains("DB-IP", host["attribution"]!.GetValue<string>());
Assert.True(overview["channels"]!["in"]!.GetValue<long>() >= 1);
Assert.Contains(hosts["hosts"]!.AsArray(), h => h!["host"]!.GetValue<string>() == "127.0.0.1");
Assert.NotEmpty(events.AsArray());
Assert.DoesNotContain(persona.UserName, host.ToJsonString());
Assert.DoesNotContain(bob.Id, host.ToJsonString());
Assert.Equal(HttpStatusCode.NotFound, (await adminClient.GetAsync($"/clientapi/admin/statistics/hosts/never{Guid.NewGuid():N}.example", token)).StatusCode);
}
[Fact]
public async Task The_admin_can_refold_a_past_day_and_describe_a_server_again()
{
var token = TestContext.Current.CancellationToken;
var admin = await _host.Admin();
using var client = _host.As(admin.Jwt);
var describe = $"described{Guid.NewGuid():N}.example";
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync($"/clientapi/admin/statistics/rollups/{DateTime.UtcNow:yyyy-MM-dd}", default, token)).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest, (await client.PostAsync("/clientapi/admin/statistics/rollups/yesterday", default, token)).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsync("/clientapi/admin/statistics/rollups/2001-01-05", default, token)).StatusCode);
Assert.Equal(HttpStatusCode.Accepted, (await client.PostAsync($"/clientapi/admin/statistics/hosts/{describe}/describe", default, token)).StatusCode);
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.RollupDay && j.Payload == "2001-01-05" && j.DedupeKey.StartsWith("rollup|2001-01-05|rerun|")).ExecuteAnyAsync(token));
Assert.True(await DB.Default.Find<Job>().Match(j => j.Kind == JobKind.DescribeInstance && j.Payload == describe).ExecuteAnyAsync(token));
}
}
}
@@ -0,0 +1,72 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PrivaPub.ClientModels;
using PrivaPub.Domain.Statistics;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using System.Globalization;
namespace PrivaPub.Controllers.ClientToServer
{
[ApiController,
Route("clientapi/admin/statistics"),
Authorize(Policy = Policies.IsAdmin)]
public class StatisticsController : ControllerBase
{
readonly StatisticsQueries _queries;
readonly IJobQueue _queue;
public StatisticsController(StatisticsQueries queries, IJobQueue queue)
{
_queries = queries;
_queue = queue;
}
[HttpGet, Route("/clientapi/admin/statistics/overview")]
public async Task<IActionResult> Overview([FromQuery] int days = 30, CancellationToken token = default) =>
Ok(await _queries.Overview(days, token));
[HttpGet, Route("/clientapi/admin/statistics/hosts")]
public async Task<IActionResult> Hosts([FromQuery] int days = 30, [FromQuery] string sort = default, [FromQuery] string software = default,
[FromQuery] int page = 1, [FromQuery] int limit = 50, CancellationToken token = default) =>
Ok(await _queries.Hosts(days, sort, software, page, limit, token));
[HttpGet, Route("/clientapi/admin/statistics/hosts/{host}")]
public async Task<IActionResult> Host(string host, [FromQuery] int days = 90, CancellationToken token = default) =>
await _queries.Host(host, days, token) is { } found ? Ok(found) : NotFound();
[HttpGet, Route("/clientapi/admin/statistics/events")]
public async Task<IActionResult> Events([FromQuery] string host = default, [FromQuery] string channel = default, [FromQuery] string outcome = default,
[FromQuery] string reason = default, [FromQuery] DateTime? before = default, [FromQuery] int limit = 100, CancellationToken token = default) =>
Ok(await _queries.Events(host, channel, outcome, reason, before, limit, token));
[HttpGet, Route("/clientapi/admin/statistics/server")]
public async Task<IActionResult> Server([FromQuery] int days = 30, CancellationToken token = default) =>
Ok(await _queries.Server(days, token));
[HttpPost, Route("/clientapi/admin/statistics/rollups/{day}")]
public async Task<IActionResult> Rollup(string day, CancellationToken token)
{
if (!DateTime.TryParseExact(day, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|| parsed.Date >= DateTime.UtcNow.Date)
return BadRequest();
var job = RollupJob.For(parsed.Date, $"rollup|{day}|rerun|{DateTime.UtcNow.Ticks}");
job.RunAt = DateTime.UtcNow;
await _queue.EnqueueMany(new[] { job }, token);
return Accepted();
}
[HttpPost, Route("/clientapi/admin/statistics/hosts/{host}/describe")]
public async Task<IActionResult> Describe(string host, CancellationToken token)
{
host = Interactions.Host(host);
if (host == Interactions.Unknown)
return BadRequest();
await _queue.Enqueue(JobKind.DescribeInstance, host, host, $"describe|{host}|admin|{DateTime.UtcNow.Ticks}", token);
return Accepted();
}
}
}
@@ -0,0 +1,195 @@
using MongoDB.Entities;
using PrivaPub.Infrastructure.Statistics;
using PrivaPub.Models.Jobs;
using PrivaPub.Models.Statistics;
namespace PrivaPub.Domain.Statistics
{
public sealed record HostDay(DateTime Day, string Host, IReadOnlyDictionary<string, long> Counters, IReadOnlyDictionary<string, long> Latency,
IReadOnlyDictionary<string, long> Bytes, IReadOnlyDictionary<string, long> Reads, int Accounts, bool Live);
public class StatisticsQueries
{
public const string Attribution = "IP geolocation by DB-IP (https://db-ip.com), CC BY 4.0";
static readonly string[] Channels = { Interactions.Receive, Interactions.In, Interactions.Out, Interactions.Http, Interactions.Preview, Interactions.Crawl };
static readonly int[] LatencyLimits = { 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000 };
public async Task<List<HostDay>> Days(int days, string host, CancellationToken token)
{
var today = DateTime.UtcNow.Date;
var since = today.AddDays(1 - Math.Clamp(days, 1, 3660));
var rows = await DB.Default.Find<InstanceDay>()
.Match(d => d.Day >= since)
.Match(d => host == null || d.Host == host)
.ExecuteAsync(token);
var result = rows.Where(r => r.RolledUpAt != null)
.Select(r => new HostDay(r.Day, r.Host, r.Counters, r.Latency, r.Bytes, r.Reads, r.Accounts, false))
.ToList();
var rolledDays = rows.Where(r => r.RolledUpAt != null).Select(r => r.Day).ToHashSet();
for (var day = since; day <= today; day = day.AddDays(1))
{
if (rolledDays.Contains(day))
continue;
var end = day.AddDays(1);
var start = day;
var events = await DB.Default.Find<InteractionEvent>()
.Match(e => e.At >= start && e.At < end)
.Match(e => host == null || e.Host == host)
.ExecuteAsync(token);
var reads = rows.Where(r => r.Day == day).ToDictionary(r => r.Host, r => r.Reads);
foreach (var (name, fold) in Rollup.Fold(events).Hosts)
result.Add(new HostDay(day, name, fold.Counters, fold.Latency, fold.Bytes, reads.GetValueOrDefault(name) ?? new(), fold.Accounts.Count, true));
foreach (var (name, read) in reads.Where(r => result.All(h => h.Day != day || h.Host != r.Key)))
result.Add(new HostDay(day, name, new Dictionary<string, long>(), new Dictionary<string, long>(), new Dictionary<string, long>(), read, 0, true));
}
return result.OrderBy(d => d.Day).ThenBy(d => d.Host, StringComparer.Ordinal).ToList();
}
public async Task<object> Overview(int days, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var counters = Sum(hostDays.Select(d => d.Counters));
var latency = Sum(hostDays.Select(d => d.Latency));
var server = await DB.Default.Find<ServerDay>().Match(d => d.Day >= DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660))).ExecuteAsync(token);
var delivered = Outcomes(counters, Interactions.Out);
var attempts = delivered.Where(o => o.Key != Interactions.Deferred).Sum(o => o.Value);
return new
{
Days = days,
Channels = Channels.ToDictionary(c => c, c => counters.Where(k => k.Key.StartsWith(c + ":", StringComparison.Ordinal)).Sum(k => k.Value)),
Inbound = Outcomes(counters, Interactions.In),
Outbound = delivered,
DeliverySuccess = attempts == 0 ? (double?)null : Math.Round(delivered.GetValueOrDefault(Interactions.Ok) / (double)attempts, 4),
DeliveryLatencyMs = new { P50 = Percentile(latency, Interactions.Out, 0.5), P95 = Percentile(latency, Interactions.Out, 0.95) },
Hosts = new
{
Active = hostDays.Where(d => d.Host != Interactions.Unknown && d.Counters.Count > 0).Select(d => d.Host).Distinct().Count(),
Known = await DB.Default.CountAsync<RemoteInstance>(cancellation: token)
},
Accounts = server.Sum(d => d.Accounts) + hostDays.Where(d => d.Live).Sum(d => d.Accounts),
Ledger = new
{
Written = server.Sum(d => d.LedgerWritten),
Dropped = server.Sum(d => d.LedgerDropped),
Failed = server.Sum(d => d.LedgerFailed)
}
};
}
public async Task<object> Hosts(int days, string sort, string software, int page, int limit, CancellationToken token)
{
var hostDays = await Days(days, default, token);
var instances = (await DB.Default.Find<RemoteInstance>().ExecuteAsync(token)).ToDictionary(i => i.Host, StringComparer.Ordinal);
var rows = hostDays.GroupBy(d => d.Host)
.Select(g =>
{
var counters = Sum(g.Select(d => d.Counters));
var outbound = Outcomes(counters, Interactions.Out);
var instance = instances.GetValueOrDefault(g.Key);
return new
{
Host = g.Key,
instance?.Software,
Version = instance?.SoftwareVersion,
In = counters.Where(k => k.Key.StartsWith("in:", StringComparison.Ordinal)).Sum(k => k.Value),
Out = counters.Where(k => k.Key.StartsWith("out:", StringComparison.Ordinal)).Sum(k => k.Value),
Refused = counters.Where(k => k.Key.StartsWith("recv:", StringComparison.Ordinal) && !k.Key.Contains(":202:", StringComparison.Ordinal)).Sum(k => k.Value),
Failures = outbound.Where(o => o.Key is Interactions.Retry or Interactions.Dead or Interactions.Failed).Sum(o => o.Value),
LatencyP50 = Percentile(Sum(g.Select(d => d.Latency)), Interactions.Out, 0.5),
Accounts = g.Sum(d => d.Accounts),
instance?.DescribedAt,
Delivery = instance == default ? default : new { instance.ConsecutiveFailures, instance.UnavailableUntil, instance.LastSuccessAt, instance.LastFailureAt }
};
})
.Where(r => software == default || string.Equals(r.Software, software, StringComparison.OrdinalIgnoreCase));
rows = sort switch
{
"failures" => rows.OrderByDescending(r => r.Failures),
"latency" => rows.OrderByDescending(r => r.LatencyP50 ?? 0),
"host" => rows.OrderBy(r => r.Host, StringComparer.Ordinal),
_ => rows.OrderByDescending(r => r.In + r.Out)
};
var list = rows.ToList();
limit = Math.Clamp(limit, 1, 200);
return new { Total = list.Count, Page = Math.Max(page, 1), Hosts = list.Skip((Math.Max(page, 1) - 1) * limit).Take(limit), Attribution };
}
public async Task<object> Host(string host, int days, CancellationToken token)
{
host = Interactions.Host(host);
var instance = await DB.Default.Find<RemoteInstance>().Match(i => i.Host == host).ExecuteFirstAsync(token);
var hostDays = await Days(days, host, token);
var events = await DB.Default.Find<InteractionEvent>().Match(e => e.Host == host).Sort(e => e.At, Order.Descending).Limit(100).ExecuteAsync(token);
if (instance == default && hostDays.Count == 0 && events.Count == 0)
return default;
return new
{
Host = host,
Instance = instance,
Days = hostDays.Select(d => new { d.Day, d.Counters, d.Latency, d.Bytes, d.Reads, d.Accounts, d.Live }),
Events = events,
Attribution
};
}
public async Task<List<InteractionEvent>> Events(string host, string channel, string outcome, string reason, DateTime? before, int limit, CancellationToken token)
{
var hostName = host == default ? default : Interactions.Host(host);
return await DB.Default.Find<InteractionEvent>()
.Match(e => hostName == null || e.Host == hostName)
.Match(e => channel == null || e.Channel == channel)
.Match(e => outcome == null || e.Outcome == outcome)
.Match(e => reason == null || e.Reason == reason)
.Match(e => before == null || e.At < before)
.Sort(e => e.At, Order.Descending)
.Limit(Math.Clamp(limit, 1, 200))
.ExecuteAsync(token);
}
public async Task<List<ServerDay>> Server(int days, CancellationToken token)
{
var since = DateTime.UtcNow.Date.AddDays(1 - Math.Clamp(days, 1, 3660));
return await DB.Default.Find<ServerDay>().Match(d => d.Day >= since).Sort(d => d.Day, Order.Ascending).ExecuteAsync(token);
}
public static Dictionary<string, long> Outcomes(IReadOnlyDictionary<string, long> counters, string channel)
{
var outcomes = new Dictionary<string, long>();
foreach (var (key, value) in counters)
{
var parts = key.Split(':');
if (parts.Length >= 4 && parts[0] == channel)
outcomes[parts[3]] = outcomes.GetValueOrDefault(parts[3]) + value;
}
return outcomes;
}
public static int? Percentile(IReadOnlyDictionary<string, long> latency, string prefix, double share)
{
var buckets = LatencyLimits.Select(limit => (Limit: (int?)limit, Count: latency.GetValueOrDefault($"{prefix}:le{limit}ms")))
.Append((Limit: (int?)null, Count: latency.GetValueOrDefault($"{prefix}:inf")))
.ToList();
var total = buckets.Sum(b => b.Count);
if (total == 0)
return default;
long seen = 0;
foreach (var (limit, count) in buckets)
{
seen += count;
if (seen >= share * total)
return limit ?? LatencyLimits[^1] + 1;
}
return default;
}
static Dictionary<string, long> Sum(IEnumerable<IReadOnlyDictionary<string, long>> dictionaries)
{
var sum = new Dictionary<string, long>();
foreach (var dictionary in dictionaries)
foreach (var (key, value) in dictionary ?? new Dictionary<string, long>())
sum[key] = sum.GetValueOrDefault(key) + value;
return sum;
}
}
}
@@ -115,6 +115,7 @@ namespace PrivaPub.Middleware
.AddHostedService(services => services.GetRequiredService<InteractionLedger>()) .AddHostedService(services => services.GetRequiredService<InteractionLedger>())
.AddSingleton<IJobHandler, RollupJob>() .AddSingleton<IJobHandler, RollupJob>()
.AddSingleton<IGeoLocator, DbIpLocator>() .AddSingleton<IGeoLocator, DbIpLocator>()
.AddSingleton<Domain.Statistics.StatisticsQueries>()
.AddHostedService<StatisticsSchedule>(); .AddHostedService<StatisticsSchedule>();
public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration) public static IServiceCollection PrivaPubAuthServicesConfiguration(this IServiceCollection service, IConfiguration configuration)