const std = @import("std"); const fs = std.fs; const json = std.json; const Atomic = std.atomic.Value; const Thread = std.Thread; const Mutex = Thread.Mutex; const STATS_PATH = "/data/stats.json"; pub const Platform = enum { soundcloud, bandcamp, spotify, plyr, apple }; // top posters tracking pub const MAX_POSTERS = 100; pub const PosterEntry = struct { did: [64]u8 = undefined, // DIDs are typically 32 chars but allow headroom did_len: u8 = 0, count: u32 = 0, pub fn getDid(self: *const PosterEntry) []const u8 { return self.did[0..self.did_len]; } pub fn setDid(self: *PosterEntry, did: []const u8) void { const len = @min(did.len, 64); @memcpy(self.did[0..len], did[0..len]); self.did_len = @intCast(len); } }; pub const TopPosters = struct { entries: [MAX_POSTERS]PosterEntry = [_]PosterEntry{.{}} ** MAX_POSTERS, count: usize = 0, mutex: Mutex = .{}, pub fn record(self: *TopPosters, did: []const u8) void { self.mutex.lock(); defer self.mutex.unlock(); // look for existing entry for (self.entries[0..self.count]) |*entry| { if (std.mem.eql(u8, entry.getDid(), did)) { entry.count += 1; return; } } // add new entry if space if (self.count < MAX_POSTERS) { self.entries[self.count].setDid(did); self.entries[self.count].count = 1; self.count += 1; } } pub fn getTop(self: *TopPosters, comptime N: usize) [N]PosterEntry { self.mutex.lock(); defer self.mutex.unlock(); // copy and sort var sorted: [MAX_POSTERS]PosterEntry = self.entries; const slice = sorted[0..self.count]; std.mem.sort(PosterEntry, slice, {}, struct { fn cmp(_: void, a: PosterEntry, b: PosterEntry) bool { return a.count > b.count; } }.cmp); var result: [N]PosterEntry = [_]PosterEntry{.{}} ** N; const copy_len = @min(N, self.count); @memcpy(result[0..copy_len], slice[0..copy_len]); return result; } pub fn toJson(self: *TopPosters, alloc: std.mem.Allocator) ![]const u8 { self.mutex.lock(); defer self.mutex.unlock(); var buf = std.ArrayList(u8).init(alloc); const w = buf.writer(); try w.writeAll("{"); var first = true; for (self.entries[0..self.count]) |entry| { if (entry.count == 0) continue; if (!first) try w.writeAll(","); first = false; try w.print("\"{s}\":{d}", .{ entry.getDid(), entry.count }); } try w.writeAll("}"); return buf.toOwnedSlice(); } pub fn loadFromJson(self: *TopPosters, data: []const u8) void { self.mutex.lock(); defer self.mutex.unlock(); const parsed = json.parseFromSlice(json.Value, std.heap.page_allocator, data, .{}) catch return; defer parsed.deinit(); if (parsed.value != .object) return; const obj = parsed.value.object; self.count = 0; var iter = obj.iterator(); while (iter.next()) |kv| { if (self.count >= MAX_POSTERS) break; if (kv.value_ptr.* != .integer) continue; self.entries[self.count].setDid(kv.key_ptr.*); self.entries[self.count].count = @intCast(@max(0, kv.value_ptr.integer)); self.count += 1; } } }; pub const Stats = struct { started_at: i64, prior_uptime: u64 = 0, messages: Atomic(u64), matches: Atomic(u64), last_event_time_us: Atomic(i64), last_event_received_at: Atomic(i64), last_match_time: Atomic(i64), connected_at: Atomic(i64), last_post_time_ms: Atomic(i64), // actual post creation time from TID // platform counters soundcloud: Atomic(u64), bandcamp: Atomic(u64), spotify: Atomic(u64), plyr: Atomic(u64), apple: Atomic(u64), // match type counters quote_matches: Atomic(u64), // posts quoting music posts multi_platform: Atomic(u64), // posts with 2+ platforms // for lag trend tracking prev_lag_ms: Atomic(i64), // top posters top_posters: TopPosters = .{}, posters_since: Atomic(i64) = Atomic(i64).init(0), // when tracking started pub fn init() Stats { var self = Stats{ .started_at = std.time.timestamp(), .messages = Atomic(u64).init(0), .matches = Atomic(u64).init(0), .last_event_time_us = Atomic(i64).init(0), .last_event_received_at = Atomic(i64).init(0), .last_match_time = Atomic(i64).init(0), .connected_at = Atomic(i64).init(0), .last_post_time_ms = Atomic(i64).init(0), .soundcloud = Atomic(u64).init(0), .bandcamp = Atomic(u64).init(0), .spotify = Atomic(u64).init(0), .plyr = Atomic(u64).init(0), .apple = Atomic(u64).init(0), .quote_matches = Atomic(u64).init(0), .multi_platform = Atomic(u64).init(0), .prev_lag_ms = Atomic(i64).init(0), }; self.load(); return self; } fn load(self: *Stats) void { const file = fs.openFileAbsolute(STATS_PATH, .{}) catch return; defer file.close(); var buf: [16384]u8 = undefined; const len = file.readAll(&buf) catch return; if (len == 0) return; const parsed = json.parseFromSlice(json.Value, std.heap.page_allocator, buf[0..len], .{}) catch return; defer parsed.deinit(); const root = parsed.value.object; if (root.get("messages")) |v| if (v == .integer) { self.messages.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("cumulative_uptime")) |v| if (v == .integer) { self.prior_uptime = @intCast(@max(0, v.integer)); }; if (root.get("matches")) |v| if (v == .integer) { self.matches.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("soundcloud")) |v| if (v == .integer) { self.soundcloud.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("bandcamp")) |v| if (v == .integer) { self.bandcamp.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("spotify")) |v| if (v == .integer) { self.spotify.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("plyr")) |v| if (v == .integer) { self.plyr.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("apple")) |v| if (v == .integer) { self.apple.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("quote_matches")) |v| if (v == .integer) { self.quote_matches.store(@intCast(@max(0, v.integer)), .monotonic); }; if (root.get("multi_platform")) |v| if (v == .integer) { self.multi_platform.store(@intCast(@max(0, v.integer)), .monotonic); }; // load top posters if (root.get("top_posters")) |v| if (v == .object) { var iter = v.object.iterator(); while (iter.next()) |kv| { if (self.top_posters.count >= MAX_POSTERS) break; if (kv.value_ptr.* != .integer) continue; self.top_posters.entries[self.top_posters.count].setDid(kv.key_ptr.*); self.top_posters.entries[self.top_posters.count].count = @intCast(@max(0, kv.value_ptr.integer)); self.top_posters.count += 1; } }; if (root.get("posters_since")) |v| if (v == .integer) { self.posters_since.store(v.integer, .monotonic); }; std.debug.print("loaded stats from {s}\n", .{STATS_PATH}); } pub fn save(self: *Stats) void { const file = fs.createFileAbsolute(STATS_PATH, .{}) catch return; defer file.close(); const now = std.time.timestamp(); const session_uptime: u64 = @intCast(@max(0, now - self.started_at)); const total_uptime = self.prior_uptime + session_uptime; // build top_posters json var posters_buf: [8192]u8 = undefined; var posters_len: usize = 0; { self.top_posters.mutex.lock(); defer self.top_posters.mutex.unlock(); posters_buf[0] = '{'; posters_len = 1; var first = true; for (self.top_posters.entries[0..self.top_posters.count]) |entry| { if (entry.count == 0) continue; const prefix: []const u8 = if (first) "\"" else ",\""; first = false; const written = std.fmt.bufPrint(posters_buf[posters_len..], "{s}{s}\":{d}", .{ prefix, entry.getDid(), entry.count, }) catch break; posters_len += written.len; } posters_buf[posters_len] = '}'; posters_len += 1; } var buf: [16384]u8 = undefined; const data = std.fmt.bufPrint(&buf, \\{{"messages":{},"matches":{},"cumulative_uptime":{},"soundcloud":{},"bandcamp":{},"spotify":{},"plyr":{},"apple":{},"quote_matches":{},"multi_platform":{},"posters_since":{},"top_posters":{s}}} , .{ self.messages.load(.monotonic), self.matches.load(.monotonic), total_uptime, self.soundcloud.load(.monotonic), self.bandcamp.load(.monotonic), self.spotify.load(.monotonic), self.plyr.load(.monotonic), self.apple.load(.monotonic), self.quote_matches.load(.monotonic), self.multi_platform.load(.monotonic), self.posters_since.load(.monotonic), posters_buf[0..posters_len], }) catch return; file.writeAll(data) catch return; } pub fn totalUptime(self: *Stats) i64 { const now = std.time.timestamp(); const session: i64 = now - self.started_at; return @as(i64, @intCast(self.prior_uptime)) + session; } pub fn recordMessage(self: *Stats) void { _ = self.messages.fetchAdd(1, .monotonic); } pub fn getMessages(self: *const Stats) u64 { return self.messages.load(.monotonic); } pub fn recordEvent(self: *Stats, time_us: i64, post_time_ms: i64) void { _ = self.messages.fetchAdd(1, .monotonic); self.last_event_time_us.store(time_us, .monotonic); self.last_event_received_at.store(std.time.milliTimestamp(), .monotonic); // track post creation time (from TID) for actual lag if (post_time_ms > 0) { self.last_post_time_ms.store(post_time_ms, .monotonic); } } pub fn getPostLagMs(self: *Stats) i64 { const post_time = self.last_post_time_ms.load(.monotonic); if (post_time == 0) return 0; const now_ms = std.time.milliTimestamp(); return now_ms - post_time; } pub fn recordMatch(self: *Stats) void { _ = self.matches.fetchAdd(1, .monotonic); self.last_match_time.store(std.time.milliTimestamp(), .monotonic); } pub fn recordPlatform(self: *Stats, platform: Platform) void { switch (platform) { .soundcloud => _ = self.soundcloud.fetchAdd(1, .monotonic), .bandcamp => _ = self.bandcamp.fetchAdd(1, .monotonic), .spotify => _ = self.spotify.fetchAdd(1, .monotonic), .plyr => _ = self.plyr.fetchAdd(1, .monotonic), .apple => _ = self.apple.fetchAdd(1, .monotonic), } } pub fn getPlatformCounts(self: *const Stats) struct { soundcloud: u64, bandcamp: u64, spotify: u64, plyr: u64, apple: u64 } { return .{ .soundcloud = self.soundcloud.load(.monotonic), .bandcamp = self.bandcamp.load(.monotonic), .spotify = self.spotify.load(.monotonic), .plyr = self.plyr.load(.monotonic), .apple = self.apple.load(.monotonic), }; } pub fn recordQuoteMatch(self: *Stats) void { _ = self.quote_matches.fetchAdd(1, .monotonic); } pub fn recordMultiPlatform(self: *Stats) void { _ = self.multi_platform.fetchAdd(1, .monotonic); } pub fn recordPoster(self: *Stats, did: []const u8) void { // set tracking start time on first poster if (self.posters_since.load(.monotonic) == 0) { self.posters_since.store(std.time.timestamp(), .monotonic); } self.top_posters.record(did); } pub fn getPostersSince(self: *Stats) i64 { return self.posters_since.load(.monotonic); } pub fn getTopPosters(self: *Stats, comptime N: usize) [N]PosterEntry { return self.top_posters.getTop(N); } pub fn getUniquePosterCount(self: *Stats) usize { self.top_posters.mutex.lock(); defer self.top_posters.mutex.unlock(); return self.top_posters.count; } /// returns concentration of top N posters as percentage of tracked poster posts pub fn getTopConcentration(self: *Stats, comptime N: usize) f64 { self.top_posters.mutex.lock(); defer self.top_posters.mutex.unlock(); // sum all tracked poster posts var total_tracked: u64 = 0; for (self.top_posters.entries[0..self.top_posters.count]) |entry| { total_tracked += entry.count; } if (total_tracked == 0) return 0; // sum top N var sorted: [MAX_POSTERS]PosterEntry = self.top_posters.entries; const slice = sorted[0..self.top_posters.count]; std.mem.sort(PosterEntry, slice, {}, struct { fn cmp(_: void, a: PosterEntry, b: PosterEntry) bool { return a.count > b.count; } }.cmp); var top_sum: u64 = 0; const limit = @min(N, self.top_posters.count); for (slice[0..limit]) |entry| { top_sum += entry.count; } return @as(f64, @floatFromInt(top_sum)) / @as(f64, @floatFromInt(total_tracked)) * 100.0; } pub fn getQuoteMatches(self: *const Stats) u64 { return self.quote_matches.load(.monotonic); } pub fn getMultiPlatform(self: *const Stats) u64 { return self.multi_platform.load(.monotonic); } pub fn getMatchRate(self: *Stats) f64 { const uptime_sec = self.totalUptime(); if (uptime_sec <= 0) return 0; const uptime_hours: f64 = @as(f64, @floatFromInt(uptime_sec)) / 3600.0; const matches: f64 = @floatFromInt(self.matches.load(.monotonic)); return matches / uptime_hours; } pub fn recordConnected(self: *Stats) void { self.connected_at.store(std.time.milliTimestamp(), .monotonic); } pub fn getLagMs(self: *Stats) i64 { const event_time_us = self.last_event_time_us.load(.monotonic); if (event_time_us == 0) return 0; const event_time_ms = @divTrunc(event_time_us, 1000); const now_ms = std.time.milliTimestamp(); return now_ms - event_time_ms; } pub fn getMatches(self: *const Stats) u64 { return self.matches.load(.monotonic); } pub fn getLastEventReceivedAt(self: *const Stats) i64 { return self.last_event_received_at.load(.monotonic); } pub fn getLastMatchTime(self: *const Stats) i64 { return self.last_match_time.load(.monotonic); } pub fn getConnectedAt(self: *const Stats) i64 { return self.connected_at.load(.monotonic); } pub fn getStatus(self: *Stats) []const u8 { const lag = self.getPostLagMs(); if (lag > 60000) return "catching_up"; return "live"; } }; var global_stats: Stats = undefined; var initialized: bool = false; pub fn init() void { global_stats = Stats.init(); initialized = true; _ = Thread.spawn(.{}, saveTicker, .{}) catch {}; } pub fn get() *Stats { if (!initialized) init(); return &global_stats; } fn saveTicker() void { while (true) { Thread.sleep(60 * std.time.ns_per_s); global_stats.save(); } } // ----------------------------------------------------------------------------- // tests // ----------------------------------------------------------------------------- test "Stats.recordMessage increments counter" { var s = Stats{ .started_at = std.time.timestamp(), .messages = Atomic(u64).init(0), .matches = Atomic(u64).init(0), .last_event_time_us = Atomic(i64).init(0), .last_event_received_at = Atomic(i64).init(0), .last_match_time = Atomic(i64).init(0), .connected_at = Atomic(i64).init(0), .last_post_time_ms = Atomic(i64).init(0), .soundcloud = Atomic(u64).init(0), .bandcamp = Atomic(u64).init(0), .spotify = Atomic(u64).init(0), .plyr = Atomic(u64).init(0), .apple = Atomic(u64).init(0), .quote_matches = Atomic(u64).init(0), .multi_platform = Atomic(u64).init(0), .prev_lag_ms = Atomic(i64).init(0), }; try std.testing.expectEqual(0, s.getMessages()); s.recordMessage(); try std.testing.expectEqual(1, s.getMessages()); s.recordMessage(); try std.testing.expectEqual(2, s.getMessages()); } test "Stats.recordPlatform increments platform counters" { var s = Stats{ .started_at = std.time.timestamp(), .messages = Atomic(u64).init(0), .matches = Atomic(u64).init(0), .last_event_time_us = Atomic(i64).init(0), .last_event_received_at = Atomic(i64).init(0), .last_match_time = Atomic(i64).init(0), .connected_at = Atomic(i64).init(0), .last_post_time_ms = Atomic(i64).init(0), .soundcloud = Atomic(u64).init(0), .bandcamp = Atomic(u64).init(0), .spotify = Atomic(u64).init(0), .plyr = Atomic(u64).init(0), .apple = Atomic(u64).init(0), .quote_matches = Atomic(u64).init(0), .multi_platform = Atomic(u64).init(0), .prev_lag_ms = Atomic(i64).init(0), }; s.recordPlatform(.soundcloud); s.recordPlatform(.soundcloud); s.recordPlatform(.bandcamp); s.recordPlatform(.apple); const counts = s.getPlatformCounts(); try std.testing.expectEqual(2, counts.soundcloud); try std.testing.expectEqual(1, counts.bandcamp); try std.testing.expectEqual(0, counts.spotify); try std.testing.expectEqual(0, counts.plyr); try std.testing.expectEqual(1, counts.apple); } test "Stats.getStatus returns live or catching_up" { var s = Stats{ .started_at = std.time.timestamp(), .messages = Atomic(u64).init(0), .matches = Atomic(u64).init(0), .last_event_time_us = Atomic(i64).init(0), .last_event_received_at = Atomic(i64).init(0), .last_match_time = Atomic(i64).init(0), .connected_at = Atomic(i64).init(0), .last_post_time_ms = Atomic(i64).init(0), .soundcloud = Atomic(u64).init(0), .bandcamp = Atomic(u64).init(0), .spotify = Atomic(u64).init(0), .plyr = Atomic(u64).init(0), .apple = Atomic(u64).init(0), .quote_matches = Atomic(u64).init(0), .multi_platform = Atomic(u64).init(0), .prev_lag_ms = Atomic(i64).init(0), }; // no post time set = live try std.testing.expectEqualStrings("live", s.getStatus()); // recent post = live s.last_post_time_ms.store(std.time.milliTimestamp(), .monotonic); try std.testing.expectEqualStrings("live", s.getStatus()); // old post = catching_up s.last_post_time_ms.store(std.time.milliTimestamp() - 120000, .monotonic); try std.testing.expectEqualStrings("catching_up", s.getStatus()); }