diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index a9df445b..a70ccb6a 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -1185,7 +1185,6 @@ type TelemetryFleetTotals { servers: Int! dedicatedServers: Int! publicServers: Int! - serverCapacity: Int! matches: Int! matchesWeek: Int! matchesMonth: Int! @@ -1193,7 +1192,9 @@ type TelemetryFleetTotals { matchesImported: Int! matchesImportedMonth: Int! mapsPlayed: Int! + playersKnown: Int! playersRegistered: Int! + playersPlayed: Int! playersActive30d: Int! teams: Int! } diff --git a/src/telemetry/telemetry.service.ts b/src/telemetry/telemetry.service.ts index 6a4ada0d..6072afa8 100644 --- a/src/telemetry/telemetry.service.ts +++ b/src/telemetry/telemetry.service.ts @@ -109,6 +109,12 @@ export class TelemetryService { return (name: string) => (alias ? `${alias}.${name}` : name); } + // populate_game_servers materializes a `servers` row for every port pair in + // a node's range the moment the node is enabled, so a node with a 216 port + // range adds 108 rows nobody provisioned. Counting raw rows reports a fleet + // of thousands of servers that do not exist. + private static readonly RealServer = `(game_server_node_id IS NULL OR is_dedicated)`; + private static activeLineups(interval: string) { const recent = `${TelemetryService.nativeMatch("m")} AND m.effective_at >= now() - interval '${interval}'`; @@ -187,9 +193,7 @@ export class TelemetryService { total: counts.servers_total, enabled: counts.servers_enabled, dedicated: counts.servers_dedicated, - on_demand: counts.servers_on_demand, public: counts.servers_public, - capacity: counts.servers_capacity, }, matches: { total: counts.matches_ran, @@ -211,7 +215,9 @@ export class TelemetryService { }, }, players: { + known: counts.players_known, registered: counts.players_registered, + played: counts.players_played, active_7d: counts.players_active_7d, active_30d: counts.players_active_30d, teams: counts.teams_total, @@ -324,7 +330,6 @@ export class TelemetryService { coalesce(sum((payload->'servers'->>'total')::numeric), 0) AS servers, coalesce(sum((payload->'servers'->>'dedicated')::numeric), 0) AS "dedicatedServers", coalesce(sum((payload->'servers'->>'public')::numeric), 0) AS "publicServers", - coalesce(sum((payload->'servers'->>'capacity')::numeric), 0) AS "serverCapacity", coalesce(sum((payload->'matches'->>'total')::numeric), 0) AS matches, coalesce(sum((payload->'matches'->>'week')::numeric), 0) AS "matchesWeek", coalesce(sum((payload->'matches'->>'month')::numeric), 0) AS "matchesMonth", @@ -332,7 +337,9 @@ export class TelemetryService { coalesce(sum((payload->'matches'->'external'->>'total')::numeric), 0) AS "matchesImported", coalesce(sum((payload->'matches'->'external'->>'month')::numeric), 0) AS "matchesImportedMonth", coalesce(sum((payload->'matches'->>'maps_played')::numeric), 0) AS "mapsPlayed", + coalesce(sum((payload->'players'->>'known')::numeric), 0) AS "playersKnown", coalesce(sum((payload->'players'->>'registered')::numeric), 0) AS "playersRegistered", + coalesce(sum((payload->'players'->>'played')::numeric), 0) AS "playersPlayed", coalesce(sum((payload->'players'->>'active_30d')::numeric), 0) AS "playersActive30d", coalesce(sum((payload->'players'->>'teams')::numeric), 0) AS teams FROM public.telemetry_installs @@ -346,7 +353,6 @@ export class TelemetryService { "servers", "dedicatedServers", "publicServers", - "serverCapacity", "matches", "matchesWeek", "matchesMonth", @@ -354,7 +360,9 @@ export class TelemetryService { "matchesImported", "matchesImportedMonth", "mapsPlayed", + "playersKnown", "playersRegistered", + "playersPlayed", "playersActive30d", "teams", ]); @@ -543,9 +551,7 @@ export class TelemetryService { total: int(servers.total), enabled: int(servers.enabled), dedicated: int(servers.dedicated), - on_demand: int(servers.on_demand), public: int(servers.public), - capacity: int(servers.capacity), }, matches: { total: int(matches.total), @@ -567,7 +573,9 @@ export class TelemetryService { }, }, players: { + known: int(players.known), registered: int(players.registered), + played: int(players.played), active_7d: int(players.active_7d), active_30d: int(players.active_30d), teams: int(players.teams), @@ -780,9 +788,9 @@ export class TelemetryService { } private async getSettings(): Promise> { - const rows = await this.collectQuery>( - `SELECT name, value FROM public.settings`, - ); + const rows = await this.collectQuery< + Array<{ name: string; value: string }> + >(`SELECT name, value FROM public.settings`); return new Map(rows.map(({ name, value }) => [name, value])); } @@ -797,7 +805,9 @@ export class TelemetryService { } private async getMatchesByType(): Promise> { - const rows = await this.collectQuery>( + const rows = await this.collectQuery< + Array<{ type: string; count: string }> + >( `SELECT o.type, count(*) AS count FROM public.matches m JOIN public.match_options o ON o.id = m.match_options_id @@ -860,13 +870,13 @@ export class TelemetryService { (SELECT count(*) FROM public.game_server_nodes WHERE gpu AND gpu_streaming_enabled) AS gpu_stream_nodes, - (SELECT count(*) FROM public.servers) AS servers_total, - (SELECT count(*) FROM public.servers WHERE enabled) AS servers_enabled, + (SELECT count(*) FROM public.servers + WHERE ${TelemetryService.RealServer}) AS servers_total, + (SELECT count(*) FROM public.servers + WHERE ${TelemetryService.RealServer} AND enabled) AS servers_enabled, (SELECT count(*) FROM public.servers WHERE is_dedicated) AS servers_dedicated, (SELECT count(*) FROM public.servers - WHERE game_server_node_id IS NOT NULL) AS servers_on_demand, - (SELECT count(*) FROM public.servers WHERE type <> 'Ranked') AS servers_public, - (SELECT coalesce(sum(max_players), 0) FROM public.servers) AS servers_capacity, + WHERE ${TelemetryService.RealServer} AND type <> 'Ranked') AS servers_public, (SELECT count(*) FROM public.matches) AS matches_created, (SELECT count(*) FROM public.matches WHERE ${TelemetryService.nativeMatch()}) AS matches_ran, @@ -906,7 +916,14 @@ export class TelemetryService { (SELECT count(DISTINCT r.match_id) FROM public.team_scrim_requests r WHERE r.match_id IS NOT NULL) AS matches_scrim, - (SELECT count(*) FROM public.players) AS players_registered, + -- Connect events, lineup syncs, demo imports and sanctions all create a + -- players row, so the table is mostly steam ids that never signed in. + -- last_sign_in_at is only ever written by the Steam login callback. + (SELECT count(*) FROM public.players) AS players_known, + (SELECT count(*) FROM public.players WHERE last_sign_in_at IS NOT NULL) AS players_registered, + -- Stats land per map from live round events and from parsed demos, so + -- this is everyone who played rather than everyone who was rostered. + (SELECT count(DISTINCT steam_id) FROM public.player_match_map_stats) AS players_played, (SELECT count(*) FROM public.teams) AS teams_total, -- An OR across both lineup columns cannot use an index and forces a -- join over every lineup row. Feeding the two sides in separately lets diff --git a/src/telemetry/types/TelemetryPayload.ts b/src/telemetry/types/TelemetryPayload.ts index 7e1350f6..2546e3be 100644 --- a/src/telemetry/types/TelemetryPayload.ts +++ b/src/telemetry/types/TelemetryPayload.ts @@ -1,4 +1,4 @@ -export const TELEMETRY_SCHEMA_VERSION = 1; +export const TELEMETRY_SCHEMA_VERSION = 2; export type TelemetryFeature = { enabled: boolean | null; @@ -18,13 +18,15 @@ export type TelemetryPayload = { regions: number; gpu: number; }; + // A `servers` row is not always a server. Enabling a game server node + // pre-provisions one row per port pair in its range, so a single node adds + // ~100 rows that are slots waiting on a match rather than machines. None of + // these count them. servers: { total: number; enabled: number; dedicated: number; - on_demand: number; public: number; - capacity: number; }; // Everything outside `external` counts only matches this panel actually ran. // An imported demo is stamped with a started_at, so without the split it @@ -48,8 +50,14 @@ export type TelemetryPayload = { year: number; }; }; + // `known` is every steam id the panel holds a row for, most of which never + // belonged to a person who signed in — connect events, lineup syncs, demo + // imports and sanctions all create players. `registered` is the subset that + // has signed in at least once. players: { + known: number; registered: number; + played: number; active_7d: number; active_30d: number; teams: number; diff --git a/test/telemetry.spec.ts b/test/telemetry.spec.ts index 2a2e2028..43b1474b 100644 --- a/test/telemetry.spec.ts +++ b/test/telemetry.spec.ts @@ -56,6 +56,14 @@ describe("telemetry (SQL-driven)", () => { beforeAll(async () => { await fx.region("TelemetryRegion"); + // Enabling a node pre-provisions a `servers` row per port pair in its + // range, so this seeds five rows that are slots, not servers. + await postgres.query( + `INSERT INTO game_server_nodes + (id, public_ip, start_port_range, end_port_range, region, status, enabled, label) + VALUES ('telemetry-node', '203.0.113.1', 27015, 27025, 'TelemetryRegion', 'Online', true, 'telemetry-node')`, + ); + const ran = await fx.bareMatch(new Date().toISOString()); await postgres.query( "UPDATE matches SET started_at = now() WHERE id = $1", @@ -82,6 +90,23 @@ describe("telemetry (SQL-driven)", () => { [reimported.matchId, "5stack-1"], ); + // Only the first of these ever signed in; the other two are the rows a + // panel creates for a steam id it saw in a lineup or a demo. + const signedIn = await fx.player("signed-in"); + const ghost = await fx.player("ghost"); + await fx.player("never-seen-again"); + + await postgres.query( + "UPDATE players SET last_sign_in_at = now() WHERE steam_id = $1", + [signedIn], + ); + + await postgres.query( + `INSERT INTO player_match_map_stats (steam_id, match_map_id, match_id, kills) + VALUES ($1, $3, $4, 10), ($2, $3, $4, 4)`, + [signedIn, ghost, ran.mapId, ran.matchId], + ); + payload = await service.collect(); }, 600_000); @@ -110,9 +135,25 @@ describe("telemetry (SQL-driven)", () => { expect(payload.matches.by_source.faceit).toBe(1); }); - it("reports the servers seeded by the region fixture", () => { - expect(payload.servers.total).toBeGreaterThanOrEqual(1); - expect(payload.servers.dedicated).toBeGreaterThanOrEqual(1); + it("keeps a node's pre-provisioned port slots out of the server count", async () => { + const [rows] = await postgres.query>( + "SELECT count(*) FROM servers", + ); + + // Six rows: the region fixture's dedicated server plus the node's five + // port slots. Only the dedicated one is a server anybody runs. + expect(Number(rows.count)).toBe(6); + expect(payload.servers.total).toBe(1); + expect(payload.servers.dedicated).toBe(1); + }); + + it("counts only players who have signed in as registered", () => { + expect(payload.players.known).toBe(3); + expect(payload.players.registered).toBe(1); + }); + + it("counts players with stats on at least one map as having played", () => { + expect(payload.players.played).toBe(2); }); it("reports every feature with an enabled flag or a usage count", () => { @@ -164,7 +205,7 @@ describe("telemetry (SQL-driven)", () => { const install = "11111111-2222-3333-4444-555555555555"; const report = (over: Record = {}) => ({ - schema: 1, + schema: 2, install_id: install, installed_at: "2024-01-01T00:00:00.000Z", panel_version: "deadbeef", @@ -174,9 +215,7 @@ describe("telemetry (SQL-driven)", () => { total: 10, enabled: 9, dedicated: 3, - on_demand: 7, public: 4, - capacity: 120, }, matches: { total: 500, @@ -192,7 +231,14 @@ describe("telemetry (SQL-driven)", () => { scrim: 25, external: { total: 30, week: 1, month: 3, year: 12 }, }, - players: { registered: 300, active_7d: 40, active_30d: 90, teams: 22 }, + players: { + known: 900, + registered: 300, + played: 210, + active_7d: 40, + active_30d: 90, + teams: 22, + }, features: { events: { enabled: true, count: 4 }, news: { enabled: false, count: 0 }, @@ -304,7 +350,7 @@ describe("telemetry (SQL-driven)", () => { const installB = "bbbbbbbb-0000-4000-8000-000000000002"; const report = (installId: string, matches: number, servers: number) => ({ - schema: 1, + schema: 2, install_id: installId, installed_at: "2024-01-01T00:00:00.000Z", panel_version: "cafebabe", @@ -314,9 +360,7 @@ describe("telemetry (SQL-driven)", () => { total: servers, enabled: servers, dedicated: 1, - on_demand: servers - 1, public: 2, - capacity: servers * 10, }, matches: { total: matches, @@ -332,7 +376,14 @@ describe("telemetry (SQL-driven)", () => { scrim: 2, external: { total: matches / 10, week: 0, month: 2, year: 5 }, }, - players: { registered: 50, active_7d: 5, active_30d: 12, teams: 3 }, + players: { + known: 200, + registered: 50, + played: 35, + active_7d: 5, + active_30d: 12, + teams: 3, + }, features: { events: { enabled: installId === installA, count: 3 }, highlights: { enabled: null as boolean | null, count: 10 }, @@ -351,8 +402,10 @@ describe("telemetry (SQL-driven)", () => { expect(stats.installs.active24h).toBe(2); expect(stats.totals.matches).toBe(350); expect(stats.totals.servers).toBe(10); - expect(stats.totals.serverCapacity).toBe(100); expect(stats.totals.mapsPlayed).toBe(700); + expect(stats.totals.playersKnown).toBe(400); + expect(stats.totals.playersRegistered).toBe(100); + expect(stats.totals.playersPlayed).toBe(70); // Imported matches are summed apart from the ones the panels hosted. expect(stats.totals.matchesImported).toBe(35); expect(stats.totals.matchesImportedMonth).toBe(4);