From 044fee8facdafaf8fa47259402ba94cfde081097 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 16 Jun 2025 09:11:03 +0100 Subject: [PATCH 1/5] capture http headers for tracing --- src/lib/tracing/enable-tracing.ts | 1 + src/middleware/error-middleware.ts | 3 +-- src/middleware/logger-middleware.ts | 15 ++++++++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/lib/tracing/enable-tracing.ts b/src/lib/tracing/enable-tracing.ts index 40f42ab4..c046b453 100644 --- a/src/lib/tracing/enable-tracing.ts +++ b/src/lib/tracing/enable-tracing.ts @@ -9,6 +9,7 @@ export function enableTracing(isTest: boolean) { service: 'talo', instrumentations: { '@opentelemetry/instrumentation-http': { + // todo: advanced network capture doesn't work because this overrides the config ignoreOutgoingRequestHook: (req) => req.hostname === process.env.CLICKHOUSE_HOST } } diff --git a/src/middleware/error-middleware.ts b/src/middleware/error-middleware.ts index e64f8871..ca522572 100644 --- a/src/middleware/error-middleware.ts +++ b/src/middleware/error-middleware.ts @@ -22,8 +22,7 @@ export default async function errorMiddleware(ctx: Context, next: Next) { Sentry.withScope((scope) => { scope.addEventProcessor((event) => { - const headers = Object.entries(ctx.request.headers).reduce((acc, curr) => { - const [key, value] = curr + const headers = Object.entries(ctx.request.headers).reduce((acc, [key, value]) => { if (typeof value === 'string') { acc[key] = value } diff --git a/src/middleware/logger-middleware.ts b/src/middleware/logger-middleware.ts index 7d69f144..0ea57ba0 100644 --- a/src/middleware/logger-middleware.ts +++ b/src/middleware/logger-middleware.ts @@ -1,12 +1,24 @@ import { setTraceAttributes } from '@hyperdx/node-opentelemetry' +import { IncomingHttpHeaders, OutgoingHttpHeaders } from 'http' import { Context, Next } from 'koa' +// todo: unneeded when advanced network capture works +function buildHeaders(prefix: 'req' | 'res', headers: IncomingHttpHeaders | OutgoingHttpHeaders) { + return Object.entries(headers).reduce((acc, [key, value]) => { + return { + ...acc, + [`http.headers.${prefix}.${key.toLowerCase()}`]: value + } + }, {}) +} + export default async function loggerMiddleware(ctx: Context, next: Next) { const startTime = Date.now() setTraceAttributes({ 'http.method': ctx.method, - 'http.route': ctx.path + 'http.route': ctx.path, + ...buildHeaders('req', ctx.request.headers) }) console.info(`--> ${ctx.method} ${ctx.path}`) @@ -20,6 +32,7 @@ export default async function loggerMiddleware(ctx: Context, next: Next) { 'http.route': ctx.path, 'http.status': status, 'http.time_taken_ms': timeMs, + ...buildHeaders('res', ctx.response.headers), 'http.response_size': ctx.response.length, 'clay.matched_route': ctx.state.matchedRoute, 'clay.matched_key': ctx.state.matchedServiceKey, From 3c7cd7cdc3bf36dd7f45f4200ba39cb2a7650454 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 16 Jun 2025 19:14:17 +0100 Subject: [PATCH 2/5] improve tracing setup --- src/index.ts | 3 +-- src/lib/tracing/enable-tracing.ts | 6 +----- src/lib/tracing/sentry-instrument.ts | 14 ++++++++------ 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 859a727a..7ef6bc21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import 'dotenv/config' import './lib/tracing/sentry-instrument' +import './lib/tracing/enable-tracing' import Koa from 'koa' import loggerMiddleware from './middleware/logger-middleware' import bodyParser from 'koa-bodyparser' @@ -15,10 +16,8 @@ import requestContextMiddleware from './middleware/request-context-middleware' import helmetMiddleware from './middleware/helmet-middleware' import { createServer } from 'http' import Socket from './socket' -import { enableTracing } from './lib/tracing/enable-tracing' const isTest = process.env.NODE_ENV === 'test' -enableTracing(isTest) export default async function init(): Promise { const app = new Koa() diff --git a/src/lib/tracing/enable-tracing.ts b/src/lib/tracing/enable-tracing.ts index 40f42ab4..abd1ffe8 100644 --- a/src/lib/tracing/enable-tracing.ts +++ b/src/lib/tracing/enable-tracing.ts @@ -1,10 +1,6 @@ import { init as initHyperDX } from '@hyperdx/node-opentelemetry' -export function enableTracing(isTest: boolean) { - if (isTest || typeof process.env.HYPERDX_API_KEY !== 'string') { - return - } - +if (process.env.NODE_ENV !== 'test' && typeof process.env.HYPERDX_API_KEY === 'string') { initHyperDX({ service: 'talo', instrumentations: { diff --git a/src/lib/tracing/sentry-instrument.ts b/src/lib/tracing/sentry-instrument.ts index ad7bded7..8d9cf372 100644 --- a/src/lib/tracing/sentry-instrument.ts +++ b/src/lib/tracing/sentry-instrument.ts @@ -1,8 +1,10 @@ import { init as initSentry } from '@sentry/node' -initSentry({ - dsn: process.env.SENTRY_DSN, - environment: process.env.SENTRY_ENV, - maxValueLength: 4096, - skipOpenTelemetrySetup: true -}) +if (process.env.NODE_ENV !== 'test') { + initSentry({ + dsn: process.env.SENTRY_DSN, + environment: process.env.SENTRY_ENV, + maxValueLength: 4096, + skipOpenTelemetrySetup: true + }) +} From bc2a978950204e50f43fcf2be6e51f311179070b Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Mon, 16 Jun 2025 19:15:24 +0100 Subject: [PATCH 3/5] add continuity date to game stat snapshots --- src/services/api/game-stat-api.service.ts | 9 +++-- tests/services/_api/game-stat-api/put.test.ts | 33 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/services/api/game-stat-api.service.ts b/src/services/api/game-stat-api.service.ts index 445a7952..b7f495ee 100644 --- a/src/services/api/game-stat-api.service.ts +++ b/src/services/api/game-stat-api.service.ts @@ -195,10 +195,12 @@ export default class GameStatAPIService extends APIService { req.ctx.throw(400, `Stat would go above the maxValue of ${stat.maxValue}`) } + const continuityDate: Date = req.ctx.state.continuityDate + if (!playerStat) { playerStat = new PlayerGameStat(alias.player, req.ctx.state.stat) - if (req.ctx.state.continuityDate) { - playerStat.createdAt = req.ctx.state.continuityDate + if (continuityDate) { + playerStat.createdAt = continuityDate } em.persist(playerStat) @@ -210,6 +212,9 @@ export default class GameStatAPIService extends APIService { const snapshot = new PlayerGameStatSnapshot() snapshot.construct(alias, playerStat) snapshot.change = change + if (continuityDate) { + snapshot.createdAt = continuityDate + } await clickhouse.insert({ table: 'player_game_stat_snapshots', diff --git a/tests/services/_api/game-stat-api/put.test.ts b/tests/services/_api/game-stat-api/put.test.ts index da4f9164..7e796734 100644 --- a/tests/services/_api/game-stat-api/put.test.ts +++ b/tests/services/_api/game-stat-api/put.test.ts @@ -273,4 +273,37 @@ describe('Game stats API service - put', () => { expect(snapshots[0].value).toBe(50) expect(snapshots[0].global_value).toBe(50) }) + + it('should create a player game stat snapshot with the continuity date', async () => { + const [apiKey, token] = await createAPIKeyAndToken([APIKeyScope.WRITE_GAME_STATS, APIKeyScope.WRITE_CONTINUITY_REQUESTS]) + const stat = await createStat(apiKey.game, { maxValue: 999, maxChange: 99, defaultValue: 0, global: true, globalValue: 0 }) + const player = await new PlayerFactory([apiKey.game]).one() + await em.persistAndFlush(player) + + const continuityDate = subHours(new Date(), 1) + + await request(app) + .put(`/v1/game-stats/${stat.internalName}`) + .send({ change: 50 }) + .auth(token, { type: 'bearer' }) + .set('x-talo-alias', String(player.aliases[0].id)) + .set('x-talo-continuity-timestamp', String(continuityDate.getTime())) + .expect(200) + + let snapshots: ClickHousePlayerGameStatSnapshot[] = [] + await vi.waitUntil(async () => { + snapshots = await clickhouse.query({ + query: `SELECT * FROM player_game_stat_snapshots WHERE game_stat_id = ${stat.id} AND player_alias_id = ${player.aliases[0].id}`, + format: 'JSONEachRow' + }).then((res) => res.json()) + return snapshots.length === 1 + }) + + expect(snapshots[0].change).toBe(50) + expect(snapshots[0].value).toBe(50) + expect(snapshots[0].global_value).toBe(50) + + const date = new Date(snapshots[0].created_at) + expect(date.toISOString()).toBe(continuityDate.toISOString()) + }) }) From b7b71e7e88986bbd7f76f7d6cb55ee570b1e53a3 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Tue, 17 Jun 2025 08:58:52 +0100 Subject: [PATCH 4/5] add game stat metrics to index endpoint --- src/entities/game-stat.ts | 143 ++++++++++++++++++++++ src/services/api/game-stat-api.service.ts | 136 +------------------- src/services/game-stat.service.ts | 7 ++ tests/services/game-stat/index.test.ts | 139 +++++++++++++++++++++ 4 files changed, 295 insertions(+), 130 deletions(-) diff --git a/src/entities/game-stat.ts b/src/entities/game-stat.ts index 6f8f67c3..e00504b6 100644 --- a/src/entities/game-stat.ts +++ b/src/entities/game-stat.ts @@ -2,6 +2,25 @@ import { EntityManager, Entity, ManyToOne, PrimaryKey, Property, Collection, One import { Request, Required, ValidationCondition } from 'koa-clay' import Game from './game' import PlayerGameStat from './player-game-stat' +import { ClickHouseClient } from '@clickhouse/client' +import Player from './player' +import { formatDateForClickHouse } from '../lib/clickhouse/formatDateTime' +import { endOfDay } from 'date-fns' + +type GlobalValueMetrics = { + minValue: number + maxValue: number + medianValue: number + averageValue: number + averageChange: number +} + +type PlayerValueMetrics = { + minValue: number + maxValue: number + medianValue: number + averageValue: number +} @Entity() export default class GameStat { @@ -93,6 +112,8 @@ export default class GameStat { @Property({ onUpdate: () => new Date() }) updatedAt: Date = new Date() + metrics?: { globalCount: number, globalValue: GlobalValueMetrics, playerValue: PlayerValueMetrics } + constructor(game: Game) { this.game = game } @@ -109,6 +130,127 @@ export default class GameStat { .reduce((acc, curr) => acc += curr.value, 0) } + async buildMetricsWhereConditions(startDate?: string, endDate?: string, player?: Player): Promise { + let whereConditions = `WHERE game_stat_id = ${this.id}` + + if (startDate) { + whereConditions += ` AND created_at >= '${formatDateForClickHouse(new Date(startDate))}'` + } + if (endDate) { + // when using YYYY-MM-DD, use the end of the day + const end = endDate.length === 10 ? endOfDay(new Date(endDate)) : new Date(endDate) + whereConditions += ` AND created_at <= '${formatDateForClickHouse(end)}'` + } + if (player) { + await player.aliases.loadItems() + const aliasIds = player.aliases.getIdentifiers() + whereConditions += ` AND player_alias_id IN (${aliasIds.join(', ')})` + } + + return whereConditions + } + + async loadMetrics(clickhouse: ClickHouseClient, metricsStartDate?: string, metricsEndDate?: string): Promise { + const whereConditions = await this.buildMetricsWhereConditions(metricsStartDate, metricsEndDate) + + const [globalCount, globalValue] = await this.getGlobalValueMetrics(clickhouse, whereConditions) + const playerValue = await this.getPlayerValueMetrics(clickhouse, whereConditions) + + this.metrics = { + globalCount, + globalValue, + playerValue + } + } + + async getGlobalValueMetrics( + clickhouse: ClickHouseClient, + whereConditions: string + ): Promise<[number, GlobalValueMetrics]> { + const query = ` + SELECT + count() as rawCount, + min(global_value) as minValue, + max(global_value) as maxValue, + median(global_value) as medianValue, + avg(global_value) as averageValue, + avg(change) as averageChange + FROM player_game_stat_snapshots + ${whereConditions} + ` + + const res = await clickhouse.query({ + query: query, + format: 'JSONEachRow' + }).then((res) => res.json<{ + rawCount: string | number + minValue: number + maxValue: number + medianValue: number | null + averageValue: number | null + averageChange: number | null + }>()) + + const { + rawCount, + minValue, + maxValue, + medianValue, + averageValue, + averageChange + } = res[0] + + return [ + Number(rawCount), + { + minValue: minValue || this.defaultValue, + maxValue: maxValue || this.defaultValue, + medianValue: medianValue ?? this.defaultValue, + averageValue: averageValue ?? this.defaultValue, + averageChange: averageChange ?? 0 + } + ] + } + + async getPlayerValueMetrics( + clickhouse: ClickHouseClient, + whereConditions: string + ): Promise { + const query = ` + SELECT + min(value) as minValue, + max(value) as maxValue, + median(value) as medianValue, + avg(value) as averageValue + FROM player_game_stat_snapshots + ${whereConditions} + ` + + const res = await clickhouse.query({ + query: query, + format: 'JSONEachRow' + }).then((res) => res.json<{ + minValue: number + maxValue: number + medianValue: number | null + averageValue: number | null + }>()) + + const { + minValue, + maxValue, + medianValue, + averageValue + } = res[0] + + return { + minValue: minValue || this.defaultValue, + maxValue: maxValue || this.defaultValue, + medianValue: medianValue ?? this.defaultValue, + averageValue: averageValue ?? this.defaultValue + } + } + toJSON() { return { id: this.id, @@ -116,6 +258,7 @@ export default class GameStat { name: this.name, global: this.global, globalValue: this.hydratedGlobalValue ?? this.globalValue, + metrics: this.metrics, defaultValue: this.defaultValue, maxChange: this.maxChange, minValue: this.minValue, diff --git a/src/services/api/game-stat-api.service.ts b/src/services/api/game-stat-api.service.ts index b7f495ee..e43b8166 100644 --- a/src/services/api/game-stat-api.service.ts +++ b/src/services/api/game-stat-api.service.ts @@ -1,5 +1,5 @@ import { EntityManager } from '@mikro-orm/mysql' -import { differenceInSeconds, endOfDay } from 'date-fns' +import { differenceInSeconds } from 'date-fns' import { HasPermission, Request, Response, Route, Validate } from 'koa-clay' import GameStatAPIDocs from '../../docs/game-stat-api.docs' import GameStat from '../../entities/game-stat' @@ -11,115 +11,9 @@ import { ClickHouseClient } from '@clickhouse/client' import PlayerGameStatSnapshot, { ClickHousePlayerGameStatSnapshot } from '../../entities/player-game-stat-snapshot' import Player from '../../entities/player' import { buildDateValidationSchema } from '../../lib/dates/dateValidationSchema' -import { formatDateForClickHouse } from '../../lib/clickhouse/formatDateTime' import PlayerAlias from '../../entities/player-alias' import { TraceService } from '../../lib/tracing/trace-service' -type GlobalValueMetrics = { - minValue: number - maxValue: number - medianValue: number - averageValue: number - averageChange: number -} - -type PlayerValueMetrics = { - minValue: number - maxValue: number - medianValue: number - averageValue: number -} - -async function getGlobalValueMetrics( - clickhouse: ClickHouseClient, - stat: GameStat, - whereConditions: string -): Promise<[number, GlobalValueMetrics]> { - const query = ` - SELECT - count() as rawCount, - min(global_value) as minValue, - max(global_value) as maxValue, - median(global_value) as medianValue, - avg(global_value) as averageValue, - avg(change) as averageChange - FROM player_game_stat_snapshots - ${whereConditions} - ` - - const res = await clickhouse.query({ - query: query, - format: 'JSONEachRow' - }).then((res) => res.json<{ - rawCount: string | number - minValue: number - maxValue: number - medianValue: number | null - averageValue: number | null - averageChange: number | null - }>()) - - const { - rawCount, - minValue, - maxValue, - medianValue, - averageValue, - averageChange - } = res[0] - - return [ - Number(rawCount), - { - minValue: minValue || stat.defaultValue, - maxValue: maxValue || stat.defaultValue, - medianValue: medianValue ?? stat.defaultValue, - averageValue: averageValue ?? stat.defaultValue, - averageChange: averageChange ?? 0 - } - ] -} - -async function getPlayerValueMetrics( - clickhouse: ClickHouseClient, - stat: GameStat, - whereConditions: string -): Promise { - const query = ` - SELECT - min(value) as minValue, - max(value) as maxValue, - median(value) as medianValue, - avg(value) as averageValue - FROM player_game_stat_snapshots - ${whereConditions} - ` - - const res = await clickhouse.query({ - query: query, - format: 'JSONEachRow' - }).then((res) => res.json<{ - minValue: number - maxValue: number - medianValue: number | null - averageValue: number | null - }>()) - - const { - minValue, - maxValue, - medianValue, - averageValue - } = res[0] - - return { - minValue: minValue || stat.defaultValue, - maxValue: maxValue || stat.defaultValue, - medianValue: medianValue ?? stat.defaultValue, - averageValue: averageValue ?? stat.defaultValue - } -} - @TraceService() export default class GameStatAPIService extends APIService { @Route({ @@ -261,18 +155,7 @@ export default class GameStatAPIService extends APIService { const stat: GameStat = req.ctx.state.stat const player: Player = req.ctx.state.player - await player.aliases.loadItems() - const aliasIds = player.aliases.getIdentifiers() - - let whereConditions = `WHERE game_stat_id = ${stat.id} AND player_alias_id IN (${aliasIds.join(', ')})` - if (startDate) { - whereConditions += ` AND created_at >= '${formatDateForClickHouse(new Date(startDate))}'` - } - if (endDate) { - // when using YYYY-MM-DD, use the end of the day - const end = endDate.length === 10 ? endOfDay(new Date(endDate)) : new Date(endDate) - whereConditions += ` AND created_at <= '${formatDateForClickHouse(end)}'` - } + const whereConditions = await stat.buildMetricsWhereConditions(startDate, endDate, player) const query = ` WITH (SELECT count() FROM player_game_stat_snapshots ${whereConditions}) AS count @@ -328,15 +211,8 @@ export default class GameStatAPIService extends APIService { req.ctx.throw(400, 'This stat is not globally available') } - let whereConditions = `WHERE game_stat_id = ${stat.id}` - if (startDate) { - whereConditions += ` AND created_at >= '${formatDateForClickHouse(new Date(startDate))}'` - } - if (endDate) { - // when using YYYY-MM-DD, use the end of the day - const end = endDate.length === 10 ? endOfDay(new Date(endDate)) : new Date(endDate) - whereConditions += ` AND created_at <= '${formatDateForClickHouse(end)}'` - } + let whereConditions = await stat.buildMetricsWhereConditions(startDate, endDate) + if (playerId) { try { const player = await em.repo(Player).findOneOrFail({ @@ -363,8 +239,8 @@ export default class GameStatAPIService extends APIService { }).then((res) => res.json()) const history = await Promise.all(snapshots.map((snapshot) => new PlayerGameStatSnapshot().hydrate(em, snapshot))) - const [count, globalValue] = await getGlobalValueMetrics(clickhouse, stat, whereConditions) - const playerValue = await getPlayerValueMetrics(clickhouse, stat, whereConditions) + const [count, globalValue] = await stat.getGlobalValueMetrics(clickhouse, whereConditions) + const playerValue = await stat.getPlayerValueMetrics(clickhouse, whereConditions) return { status: 200, diff --git a/src/services/game-stat.service.ts b/src/services/game-stat.service.ts index bd8a115b..2422c034 100644 --- a/src/services/game-stat.service.ts +++ b/src/services/game-stat.service.ts @@ -9,20 +9,27 @@ import PlayerGameStat from '../entities/player-game-stat' import triggerIntegrations from '../lib/integrations/triggerIntegrations' import updateAllowedKeys from '../lib/entities/updateAllowedKeys' import { TraceService } from '../lib/tracing/trace-service' +import { buildDateValidationSchema } from '../lib/dates/dateValidationSchema' @TraceService() export default class GameStatService extends Service { @Route({ method: 'GET' }) + @Validate({ + query: buildDateValidationSchema(false, false) + }) @HasPermission(GameStatPolicy, 'index') async index(req: Request): Promise { + const { metricsStartDate, metricsEndDate } = req.query + const em: EntityManager = req.ctx.em const stats = await em.getRepository(GameStat).find({ game: req.ctx.state.game }) for (const stat of stats) { if (stat.global) { await stat.recalculateGlobalValue(req.ctx.state.includeDevData) + await stat.loadMetrics(req.ctx.clickhouse, metricsStartDate, metricsEndDate) } } diff --git a/tests/services/game-stat/index.test.ts b/tests/services/game-stat/index.test.ts index 2fc7bcc8..48f704c3 100644 --- a/tests/services/game-stat/index.test.ts +++ b/tests/services/game-stat/index.test.ts @@ -4,6 +4,7 @@ import PlayerFactory from '../../fixtures/PlayerFactory' import PlayerGameStatFactory from '../../fixtures/PlayerGameStatFactory' import createUserAndToken from '../../utils/createUserAndToken' import createOrganisationAndGame from '../../utils/createOrganisationAndGame' +import PlayerGameStatSnapshot from '../../../src/entities/player-game-stat-snapshot' describe('Game stat service - index', () => { it('should return a list of game stats', async () => { @@ -73,4 +74,142 @@ describe('Game stat service - index', () => { expect(res.body.stats[0].globalValue).toBe(50) }) + + it('should load metrics filtered by startDate', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const stat = await new GameStatFactory([game]).global().state(() => ({ globalValue: 0 })).one() + const player = await new PlayerFactory([game]).one() + await em.persistAndFlush([stat, player]) + + const values: [Date, number][] = [ + [new Date('2025-06-09T09:00:00.000Z'), 1], + [new Date('2025-06-10T09:00:00.000Z'), 5], + [new Date('2025-06-11T09:00:00.000Z'), 7] + ] + + await clickhouse.insert({ + table: 'player_game_stat_snapshots', + values: await Promise.all(values.map(async ([date, value]) => { + const playerStat = await new PlayerGameStatFactory().construct(player, stat).state(() => ({ value })).one() + await em.persistAndFlush(playerStat) + stat.globalValue += value + + const snapshot = new PlayerGameStatSnapshot() + snapshot.construct(playerStat.player.aliases[0], playerStat) + snapshot.change = value + snapshot.createdAt = date + + return snapshot.toInsertable() + })), + format: 'JSONEachRow' + }) + + const res = await request(app) + .get(`/games/${game.id}/game-stats`) + .query({ metricsStartDate: values[1][0].toISOString() }) + .auth(token, { type: 'bearer' }) + .expect(200) + + // only changes from the last 2 dates + expect(res.body.stats[0].metrics.globalCount).toBe(2) + expect(res.body.stats[0].metrics.globalValue.minValue).toBe(5) + expect(res.body.stats[0].metrics.globalValue.maxValue).toBe(12) + expect(res.body.stats[0].metrics.playerValue.minValue).toBe(5) + expect(res.body.stats[0].metrics.playerValue.maxValue).toBe(7) + }) + + it('should load metrics filtered by endDate', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const stat = await new GameStatFactory([game]).global().state(() => ({ globalValue: 0 })).one() + const player = await new PlayerFactory([game]).one() + await em.persistAndFlush([stat, player]) + + const values: [Date, number][] = [ + [new Date('2025-06-09T09:00:00.000Z'), 1], + [new Date('2025-06-10T09:00:00.000Z'), 5], + [new Date('2025-06-11T09:00:00.000Z'), 7] + ] + + await clickhouse.insert({ + table: 'player_game_stat_snapshots', + values: await Promise.all(values.map(async ([date, value]) => { + const playerStat = await new PlayerGameStatFactory().construct(player, stat).state(() => ({ value })).one() + await em.persistAndFlush(playerStat) + stat.globalValue += value + + const snapshot = new PlayerGameStatSnapshot() + snapshot.construct(playerStat.player.aliases[0], playerStat) + snapshot.change = value + snapshot.createdAt = date + + return snapshot.toInsertable() + })), + format: 'JSONEachRow' + }) + + const res = await request(app) + .get(`/games/${game.id}/game-stats`) + .query({ metricsEndDate: values[1][0].toISOString() }) + .auth(token, { type: 'bearer' }) + .expect(200) + + // only changes from the first 2 dates + expect(res.body.stats[0].metrics.globalCount).toBe(2) + expect(res.body.stats[0].metrics.globalValue.minValue).toBe(5) + expect(res.body.stats[0].metrics.globalValue.maxValue).toBe(13) + expect(res.body.stats[0].metrics.playerValue.minValue).toBe(1) + expect(res.body.stats[0].metrics.playerValue.maxValue).toBe(5) + }) + + it('should load metrics filtered by both startDate and endDate', async () => { + const [organisation, game] = await createOrganisationAndGame() + const [token] = await createUserAndToken({}, organisation) + + const stat = await new GameStatFactory([game]).global().state(() => ({ globalValue: 0 })).one() + const player = await new PlayerFactory([game]).one() + await em.persistAndFlush([stat, player]) + + const values: [Date, number][] = [ + [new Date('2025-06-09T09:00:00.000Z'), 1], + [new Date('2025-06-10T09:00:00.000Z'), 5], + [new Date('2025-06-11T09:00:00.000Z'), 7] + ] + + await clickhouse.insert({ + table: 'player_game_stat_snapshots', + values: await Promise.all(values.map(async ([date, value]) => { + const playerStat = await new PlayerGameStatFactory().construct(player, stat).state(() => ({ value })).one() + await em.persistAndFlush(playerStat) + stat.globalValue += value + + const snapshot = new PlayerGameStatSnapshot() + snapshot.construct(playerStat.player.aliases[0], playerStat) + snapshot.change = value + snapshot.createdAt = date + + return snapshot.toInsertable() + })), + format: 'JSONEachRow' + }) + + const res = await request(app) + .get(`/games/${game.id}/game-stats`) + .query({ + metricsStartDate: '2025-06-10', + metricsEndDate: '2025-06-10' + }) + .auth(token, { type: 'bearer' }) + .expect(200) + + // only changes from the middle date + expect(res.body.stats[0].metrics.globalCount).toBe(1) + expect(res.body.stats[0].metrics.globalValue.minValue).toBe(5) + expect(res.body.stats[0].metrics.globalValue.maxValue).toBe(5) + expect(res.body.stats[0].metrics.playerValue.minValue).toBe(5) + expect(res.body.stats[0].metrics.playerValue.maxValue).toBe(5) + }) }) From 604597566a32327705abc29a080353a1d7f553d3 Mon Sep 17 00:00:00 2001 From: tudor <7089284+tudddorrr@users.noreply.github.com> Date: Tue, 17 Jun 2025 23:14:56 +0100 Subject: [PATCH 5/5] 0.76.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0373be86..caca67e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "game-services", - "version": "0.75.0", + "version": "0.76.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "game-services", - "version": "0.75.0", + "version": "0.76.0", "license": "MIT", "dependencies": { "@clickhouse/client": "^1.11.0", diff --git a/package.json b/package.json index e0cb0f5d..5da61ae1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "game-services", - "version": "0.75.0", + "version": "0.76.0", "description": "", "main": "src/index.ts", "scripts": {