[playersettings] Drop Growth.YIR telemetry and MakerPenSessionCount on write (#145)

* [playersettings] drop Growth.YIR telemetry on write

The client posts its Year-In-Review counters (Growth.YIR.UGC.TubeDistance,
Growth.YIR.DistanceTraveled, …) through PUT /playersettings with a new value every time,
so the change-detection dedupe never skips them. A two-minute tail at ~500 CCU showed
them as about half of all KV writes (3.7 writes/s overall, 448 writes from 448 PUTs).
Nothing server-side reads them and the client has none for a new player, so a PUT naming
one is answered 200 and nothing is stored. Keys stay unseeded for a player whose only
posts are telemetry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* [playersettings] also drop MakerPenSessionCount

A per-session counter the client bumps on every post, like the Growth.YIR telemetry; nothing
reads it. The maker pen PREFERENCES (MakerPen_SnappingMode and the other MakerPen_* keys) are
kept — the client reads those back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
devin
2026-10-26 23:47:04 -04:00
committed by GitHub
parent f6214fb824
commit 619b4c772c
4 changed files with 66 additions and 3 deletions
+7
View File
@@ -22,6 +22,13 @@ Player-settings worker served on the `playersettings` subdomain.
> Emptying the map with DELETE puts the player back to a first read: the next
> `GET` re-seeds the defaults.
> Keys under `Growth.YIR.` (the client's Year-In-Review telemetry — distance
> drawn, time with friends, …) and `MakerPenSessionCount` are **dropped** on
> `PUT`, not stored. The client posts them with a new value every time, so they
> were over half of all writes and nothing reads them. Real maker pen preferences
> (`MakerPen_SnappingMode`, …) are kept. See `IGNORED_SETTING_KEYS` /
> `IGNORED_SETTING_PREFIXES` in `default-settings.ts`.
## KV namespace
```sh
@@ -8,3 +8,21 @@ export const DEFAULT_SETTINGS: Array<{ Key: string; Value: string }> = [
{ Key: 'TUTORIAL_COMPLETE_MASK', Value: '11' },
{ Key: 'FIRST_TIME_IN_FLAGS', Value: '0' },
]
/**
* Settings the server DROPS on write, by exact key and by prefix. The client posts its
* Year-In-Review telemetry (`Growth.YIR.UGC.TubeDistance`, `Growth.YIR.DistanceTraveled`, …)
* and the `MakerPenSessionCount` counter through this endpoint as the player plays, and the
* values change every time, so they can never be deduplicated — measured at ~500 CCU they
* were over half of all settings writes. Nothing server-side reads them and the client works
* without them (a new player has none), so a PUT naming one is answered 200 and nothing is
* stored. Real maker pen preferences (`MakerPen_SnappingMode` and the other `MakerPen_*`
* keys) are kept: the client reads those back.
*/
export const IGNORED_SETTING_KEYS: ReadonlySet<string> = new Set(['MakerPenSessionCount'])
export const IGNORED_SETTING_PREFIXES: readonly string[] = ['Growth.YIR.']
/** Whether a posted setting is one the server drops rather than stores. */
export function isIgnoredSetting(key: string): boolean {
return IGNORED_SETTING_KEYS.has(key) || IGNORED_SETTING_PREFIXES.some((p) => key.startsWith(p))
}
@@ -6,7 +6,7 @@ import { mergePlayerSettings } from '@repo/domain'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { DEFAULT_SETTINGS } from './default-settings'
import { DEFAULT_SETTINGS, isIgnoredSetting } from './default-settings'
import {
AUTHED,
formOrJson,
@@ -196,7 +196,9 @@ const app = new Hono<App>()
'player’s other settings alone. A JSON body is also accepted, as one object or an',
'array, in either `key`/`value` or `Key`/`Value` casing; entries with an empty key are',
'dropped. An unparseable or empty body is a no-op 200, not a 400, and so is a PUT whose',
'values are already stored — nothing is written to KV. Empty body on success.',
'values are already stored — nothing is written to KV. Keys under `Growth.YIR.` (the',
'client’s Year-In-Review telemetry) and `MakerPenSessionCount`, which change on every',
'post and nothing reads, are dropped rather than stored. Empty body on success.',
].join(' '),
security: AUTHED,
requestBody: formOrJson(SettingFormWrite, SettingJsonWrite, 'The setting(s) to write'),
@@ -209,7 +211,7 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
const incoming = await parseSettings(c)
const incoming = (await parseSettings(c)).filter((s) => !isIgnoredSetting(s.key))
if (incoming.length === 0) return c.body(null, 200)
const patch: Record<string, string> = {}
@@ -164,6 +164,42 @@ describe('playersettings endpoints', () => {
)
})
it('PUT /playersettings drops Growth.YIR and MakerPenSessionCount without writing', async () => {
const stored = '{ "PlayerSessionCount": "5" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:11', stored)
const form = await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.UGC.TubeDistance', value: '1234.5' }, await bearer('11'))
)
expect(form.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'text')).toBe(stored)
// A JSON batch keeps the real settings (a maker pen PREFERENCE included) and drops
// only the telemetry and the session counter.
const json = await SELF.fetch(`${ORIGIN}/playersettings`, {
method: 'PUT',
headers: { 'content-type': 'application/json', ...(await bearer('11')) },
body: JSON.stringify([
{ key: 'Growth.YIR.DistanceTraveled', value: '99' },
{ key: 'MakerPenSessionCount', value: '3' },
{ key: 'MakerPen_SnappingMode', value: '2' },
]),
})
expect(json.status).toBe(200)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:11', 'json')).toEqual({
PlayerSessionCount: '5',
MakerPen_SnappingMode: '2',
})
// A player with nothing stored stays unseeded: no key is created for telemetry alone.
await SELF.fetch(
`${ORIGIN}/playersettings`,
putForm({ key: 'Growth.YIR.TimeSpentWithFriends', value: '7' }, await bearer('12'))
)
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:12', 'text')).toBeNull()
})
it('PUT /playersettings 200s with no parseable settings', async () => {
const res = await SELF.fetch(`${ORIGIN}/playersettings`, putForm({}, await bearer('9')))
expect(res.status).toBe(200)