-
Notifications
You must be signed in to change notification settings - Fork 465
Pull replay files from api #1725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a910ef4
e5de4d5
8e1a1af
4a80385
adffbb7
b990aa2
1eec5bf
b9e9eed
fb3de91
881a843
7f4fb39
ff1e0a2
ba20e7c
3420b1a
cded045
aee46a2
5735838
16c45d4
6730ab1
44dc690
c607228
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,8 +1,9 @@ | ||||||
import { AnalyticsRecord, GameID, GameRecord } from "../core/Schemas"; | ||||||
import { AnalyticsRecord, GameID, GameRecord, RedactedGameRecord, RedactedGameRecordSchema } from "../core/Schemas"; | ||||||
import { S3 } from "@aws-sdk/client-s3"; | ||||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; | ||||||
import { logger } from "./Logger"; | ||||||
import { replacer } from "../core/Util"; | ||||||
import { z } from "zod"; | ||||||
|
||||||
const config = getServerConfigFromServer(); | ||||||
|
||||||
|
@@ -28,14 +29,14 @@ export async function archive(gameRecord: GameRecord) { | |||||
try { | ||||||
gameRecord.gitCommit = config.gitCommit(); | ||||||
// Archive to R2 | ||||||
await archiveAnalyticsToR2(gameRecord); | ||||||
await archiveAnalyticsToR2(stripTurns(gameRecord)); | ||||||
|
||||||
// Archive full game if there are turns | ||||||
if (gameRecord.turns.length > 0) { | ||||||
log.info( | ||||||
`${gameRecord.info.gameID}: game has more than zero turns, attempting to write to full game to R2`, | ||||||
); | ||||||
await archiveFullGameToR2(gameRecord); | ||||||
await archiveFullGameToR2(stripPersistentIds(gameRecord)); | ||||||
} | ||||||
} catch (error: unknown) { | ||||||
// If the error is not an instance of Error, log it as a string | ||||||
|
@@ -56,7 +57,7 @@ export async function archive(gameRecord: GameRecord) { | |||||
} | ||||||
} | ||||||
|
||||||
async function archiveAnalyticsToR2(gameRecord: GameRecord) { | ||||||
function stripTurns(gameRecord: GameRecord): AnalyticsRecord { | ||||||
// Create analytics data object | ||||||
const { info, version, gitCommit, subdomain, domain } = gameRecord; | ||||||
const analyticsData: AnalyticsRecord = { | ||||||
|
@@ -66,15 +67,57 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { | |||||
subdomain, | ||||||
domain, | ||||||
}; | ||||||
return analyticsData; | ||||||
} | ||||||
|
||||||
function stripPersistentIds(gameRecord: GameRecord): RedactedGameRecord { | ||||||
// Create replay object | ||||||
const { | ||||||
info: { | ||||||
gameID, | ||||||
config, | ||||||
players: privatePlayers, | ||||||
start, | ||||||
end, | ||||||
duration, | ||||||
num_turns, | ||||||
winner, | ||||||
}, | ||||||
version, | ||||||
gitCommit, | ||||||
subdomain, | ||||||
domain, | ||||||
turns, | ||||||
} = gameRecord; | ||||||
const players = privatePlayers.map( | ||||||
({ clientID, persistentID: _, username, pattern, flag }) => ({ | ||||||
clientID, | ||||||
username, | ||||||
pattern, | ||||||
flag, | ||||||
}), | ||||||
); | ||||||
const replayData: RedactedGameRecord = { | ||||||
info: { gameID, config, players, start, end, duration, num_turns, winner }, | ||||||
version, | ||||||
gitCommit, | ||||||
subdomain, | ||||||
domain, | ||||||
turns, | ||||||
}; | ||||||
return replayData; | ||||||
} | ||||||
|
||||||
async function archiveAnalyticsToR2(gameRecord: AnalyticsRecord) { | ||||||
const { info } = gameRecord; | ||||||
try { | ||||||
// Store analytics data using just the game ID as the key | ||||||
const analyticsKey = `${info.gameID}.json`; | ||||||
|
||||||
await r2.putObject({ | ||||||
Bucket: bucket, | ||||||
Key: `${analyticsFolder}/${analyticsKey}`, | ||||||
Body: JSON.stringify(analyticsData, replacer), | ||||||
Body: JSON.stringify(gameRecord, replacer), | ||||||
ContentType: "application/json", | ||||||
}); | ||||||
|
||||||
|
@@ -99,20 +142,12 @@ async function archiveAnalyticsToR2(gameRecord: GameRecord) { | |||||
} | ||||||
} | ||||||
|
||||||
async function archiveFullGameToR2(gameRecord: GameRecord) { | ||||||
// Create a deep copy to avoid modifying the original | ||||||
const recordCopy = structuredClone(gameRecord); | ||||||
|
||||||
// Players may see this so make sure to clear PII | ||||||
recordCopy.info.players.forEach((p) => { | ||||||
p.persistentID = "REDACTED"; | ||||||
}); | ||||||
|
||||||
async function archiveFullGameToR2(gameRecord: RedactedGameRecord) { | ||||||
try { | ||||||
await r2.putObject({ | ||||||
Bucket: bucket, | ||||||
Key: `${gameFolder}/${recordCopy.info.gameID}`, | ||||||
Body: JSON.stringify(recordCopy, replacer), | ||||||
Key: `${gameFolder}/${gameRecord.info.gameID}`, | ||||||
Body: JSON.stringify(gameRecord, replacer), | ||||||
ContentType: "application/json", | ||||||
}); | ||||||
} catch (error) { | ||||||
|
@@ -125,37 +160,105 @@ async function archiveFullGameToR2(gameRecord: GameRecord) { | |||||
|
||||||
export async function readGameRecord( | ||||||
gameId: GameID, | ||||||
): Promise<GameRecord | null> { | ||||||
): Promise<RedactedGameRecord | string> { | ||||||
try { | ||||||
// Check if file exists and download in one operation | ||||||
const response = await r2.getObject({ | ||||||
Bucket: bucket, | ||||||
Key: `${gameFolder}/${gameId}`, // Fixed - needed to include gameFolder | ||||||
}); | ||||||
// Parse the response body | ||||||
if (response.Body === undefined) return null; | ||||||
if (response.Body === undefined) { | ||||||
log.warn(`${gameId}: Received empty response from R2`); | ||||||
return readGameRecordFallback(gameId); | ||||||
} | ||||||
|
||||||
const bodyContents = await response.Body.transformToString(); | ||||||
return JSON.parse(bodyContents) as GameRecord; | ||||||
return validateRecord(JSON.parse(bodyContents), gameId); | ||||||
} catch (error: unknown) { | ||||||
// If the error is not an instance of Error, log it as a string | ||||||
if (!(error instanceof Error)) { | ||||||
log.error( | ||||||
log.info( | ||||||
`${gameId}: Error reading game record from R2. Non-Error type: ${String(error)}`, | ||||||
); | ||||||
return null; | ||||||
} else { | ||||||
const { message, stack, name } = error; | ||||||
// Log the error for monitoring purposes | ||||||
log.info(`${gameId}: Error reading game record from R2: ${error}`, { | ||||||
message, | ||||||
stack, | ||||||
name, | ||||||
...(error && typeof error === "object" ? error : {}), | ||||||
Comment on lines
+187
to
+190
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it may be redundant to log both |
||||||
}); | ||||||
} | ||||||
const { message, stack, name } = error; | ||||||
// Log the error for monitoring purposes | ||||||
log.error(`${gameId}: Error reading game record from R2: ${error}`, { | ||||||
message, | ||||||
stack, | ||||||
name, | ||||||
...(error && typeof error === "object" ? error : {}), | ||||||
return readGameRecordFallback(gameId); | ||||||
} | ||||||
} | ||||||
|
||||||
export async function readGameRecordFallback( | ||||||
gameId: GameID, | ||||||
): Promise<RedactedGameRecord | string> { | ||||||
try { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current return type ( One option to improve this is to use a discriminated union and to generate a export type Result<T, E> =
| { success: true; value: T; error: never; }
| { success: false; value: never; error: E; };
Suggested change
return { success: false, error: error(404) }; |
||||||
const response = await fetch(config.replayUrl(gameId), { | ||||||
headers: { | ||||||
Accept: "application/json", | ||||||
}, | ||||||
signal: AbortSignal.timeout(5000), | ||||||
}); | ||||||
|
||||||
// Return null instead of throwing the error | ||||||
return null; | ||||||
if (!response.ok) { | ||||||
if (response.status === 404) { | ||||||
return "replay.not_found"; | ||||||
} | ||||||
|
||||||
throw new Error( | ||||||
`Http error: non-successful http status ${response.status}`, | ||||||
); | ||||||
} | ||||||
Comment on lines
+214
to
+216
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Consider being consistent with error handling. Mixing throw with errors as values can be a bit confusing. In this case I think it is better to log/return from this point in the code. |
||||||
|
||||||
const contentType = response.headers.get("Content-Type")?.split(";")?.[0]; | ||||||
if (contentType !== "application/json") { | ||||||
throw new Error( | ||||||
`Http error: unexpected content type "${response.headers.get("Content-Type")}"`, | ||||||
); | ||||||
} | ||||||
Comment on lines
+221
to
+223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above |
||||||
|
||||||
return validateRecord(await response.json(), gameId); | ||||||
} catch (error: unknown) { | ||||||
// If the error is not an instance of Error, log it as a string | ||||||
if (!(error instanceof Error)) { | ||||||
log.info( | ||||||
`${gameId}: Error reading game record from public api. Non-Error type: ${String(error)}`, | ||||||
); | ||||||
} else { | ||||||
const { message, stack, name } = error; | ||||||
log.info( | ||||||
`${gameId}: Error reading game record from public api: ${error}`, | ||||||
{ | ||||||
message, | ||||||
stack, | ||||||
name, | ||||||
...(error && typeof error === "object" ? error : {}), | ||||||
}, | ||||||
); | ||||||
} | ||||||
return "replay.error"; | ||||||
} | ||||||
} | ||||||
|
||||||
function validateRecord( | ||||||
json: unknown, | ||||||
gameId: GameID, | ||||||
): RedactedGameRecord | string { | ||||||
const parsed = RedactedGameRecordSchema.safeParse(json); | ||||||
|
||||||
if (!parsed.success) { | ||||||
const error = z.prettifyError(parsed.error); | ||||||
log.error(`${gameId}: Error parsing game record: ${error}`); | ||||||
return "replay.invalid"; | ||||||
} | ||||||
scottanderson marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
return parsed.data; | ||||||
} | ||||||
|
||||||
export async function gameRecordExists(gameId: GameID): Promise<boolean> { | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need a warning here?