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:
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user