This commit is contained in:
@@ -51,7 +51,8 @@ Send JSON to `POST /api/telemetry` with `Content-Type: application/json` and eit
|
||||
`X-EC-Telemetry-Token: <EC_TELEMETRY_TOKEN>`. If both headers are present, they must
|
||||
contain the same secret. Keep the secret out of URLs, payloads and browser code,
|
||||
and use a different secret from host-agent credentials or account passwords.
|
||||
The JSON event format is unchanged.
|
||||
Request bodies are limited to 1 MiB (1,048,576 bytes), including requests without
|
||||
`Content-Length`. The limit applies to the UTF-8 JSON body before parsing.
|
||||
There are four events. Send them whether or not a beta is active.
|
||||
|
||||
### Event fields
|
||||
@@ -65,8 +66,11 @@ There are four events. Send them whether or not a beta is active.
|
||||
|
||||
`server` is the exact registered Electric Cherry server name. `map` is the
|
||||
current map. `players` is the full player array, including an empty array when
|
||||
there are no players. Names and GUIDs are strings; keep GUIDs as strings to
|
||||
preserve their precision. Round and stat values are nonnegative JSON integers.
|
||||
there are no players. `server`, `map`, and each player's `name` and `guid` must be
|
||||
nonempty strings of at most 255 UTF-16 code units (JavaScript string length).
|
||||
Values over the limit reject the entire report; accepted values are preserved
|
||||
without trimming or truncation. Keep GUIDs as strings to preserve their precision
|
||||
and leading zeros. Round and stat values are nonnegative JSON integers.
|
||||
Each GUID must appear only once within a report's player array.
|
||||
|
||||
Every event also accepts an optional per-player `ping`: the game server's current
|
||||
@@ -171,8 +175,9 @@ Success returns HTTP 200 with `{ "ok": true, "telemetry": { ... } }`.
|
||||
The returned telemetry contains the event and an Electric Cherry `receivedAt`
|
||||
timestamp. A missing/invalid machine token returns 401; an unset or invalid
|
||||
controller `EC_TELEMETRY_TOKEN` returns 503. Authentication happens before payload
|
||||
validation. Authenticated invalid JSON or fields return 400, an unknown/destroyed
|
||||
server 404, and a non-JSON content type 415.
|
||||
validation. Authenticated bodies over 1 MiB return 413 before JSON parsing.
|
||||
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
|
||||
content type 415.
|
||||
|
||||
The script sends each event once, in gameplay order, directly to Electric Cherry.
|
||||
There is no discovery request, beta token, boot/session ID, sequence number,
|
||||
@@ -207,7 +212,8 @@ name. Each entry includes the Electric Cherry receipt time, HTTP result, error
|
||||
reason, event, map, round, player count, content type, and submitted JSON. Rejected
|
||||
reports are included, even when the content type is wrong but the body is readable
|
||||
JSON after authentication. Unauthorized requests are rejected before their bodies
|
||||
are read and are not retained in per-server diagnostics. Authentication headers
|
||||
are read and are not retained in per-server diagnostics. Oversized bodies are
|
||||
rejected before parsing and are not retained either. Authentication headers
|
||||
and their secrets are never stored. Processing failures return 500 and record the internal error when diagnostic
|
||||
storage is available; failed database transactions do not update the scoreboard.
|
||||
|
||||
@@ -224,8 +230,9 @@ newest first, with caching disabled.
|
||||
Check the reporter's destination and server field. Calls that never reach
|
||||
Electric Cherry, malformed JSON, and bodies without a usable server name
|
||||
cannot appear in this server's log. Earlier requests are not backfilled.
|
||||
Missing/invalid telemetry credentials also produce no retained request; inspect
|
||||
the reporter's HTTP result for 401 or an unconfigured-controller 503.
|
||||
Missing/invalid telemetry credentials and oversized bodies also produce no
|
||||
retained request; inspect the reporter's HTTP result for 401, 413, or an
|
||||
unconfigured-controller 503.
|
||||
- **400 / 415:** inspect the error and payload. One invalid player's stats or a
|
||||
duplicate GUID rejects the entire report. These requests do not update the
|
||||
scoreboard.
|
||||
|
||||
@@ -59,8 +59,10 @@ export function parseGameEvent(value: unknown): GameEvent {
|
||||
throw new Error(`players[${index}] must be an object`);
|
||||
const player = value as Record<string, unknown>;
|
||||
for (const key of ['name', 'guid'] as const) {
|
||||
if (typeof player[key] !== 'string' || !player[key].trim())
|
||||
throw new Error(`players[${index}].${key} must be a nonempty string`);
|
||||
if (typeof player[key] !== 'string' || player[key].length > 255 || !player[key].trim())
|
||||
throw new Error(
|
||||
`players[${index}].${key} must be a nonempty string (up to 255 characters)`
|
||||
);
|
||||
}
|
||||
const identity: TelemetryPlayer = {
|
||||
name: player.name as string,
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { JSON_BODY_LIMIT } from '../../src/lib/server/request-body.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent, postTelemetry as post, TEST_TELEMETRY_TOKEN } from '../helpers/telemetry.js';
|
||||
|
||||
const events = ['game_start', 'round_change', 'heartbeat', 'game_end'] as const;
|
||||
const identityLimit = 255;
|
||||
let fixture: Fixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('EC_TELEMETRY_TOKEN', TEST_TELEMETRY_TOKEN);
|
||||
fixture = await newFixture();
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
async function storedEvidence() {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
return {
|
||||
telemetry: db().prepare('SELECT * FROM server_telemetry').all(),
|
||||
players: db().prepare('SELECT * FROM leaderboard_players').all(),
|
||||
matches: db().prepare('SELECT * FROM leaderboard_matches').all(),
|
||||
participation: db().prepare('SELECT * FROM leaderboard_match_players').all(),
|
||||
beta: db().prepare('SELECT * FROM beta_test_evidence').all(),
|
||||
lifecycle: db()
|
||||
.prepare(
|
||||
'SELECT active_since, inactive_since, game_started_at, game_ended_at FROM servers'
|
||||
)
|
||||
.all()
|
||||
};
|
||||
}
|
||||
|
||||
function streamedRequest(chunks: Uint8Array[], headers: Record<string, string> = {}) {
|
||||
let index = 0;
|
||||
const pull = vi.fn((controller: ReadableStreamDefaultController<Uint8Array>) => {
|
||||
if (index === chunks.length) controller.close();
|
||||
else controller.enqueue(chunks[index++]);
|
||||
});
|
||||
const cancel = vi.fn();
|
||||
const request = new Request('http://localhost/api/telemetry', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: new ReadableStream({ pull, cancel }, { highWaterMark: 0 }),
|
||||
duplex: 'half'
|
||||
} as RequestInit);
|
||||
return { request, pull, cancel };
|
||||
}
|
||||
|
||||
it.each(events)('preserves exact-limit player identities for %s', async (event) => {
|
||||
const payload = gameEvent(event, 17, 1);
|
||||
// Lengths use UTF-16 code units, while the request cap counts UTF-8 bytes.
|
||||
const name = ` ${'🍒'.repeat(126)}é `;
|
||||
const guid = `000${'9'.repeat(identityLimit - 3)}`;
|
||||
expect(name.length).toBe(identityLimit);
|
||||
payload.players[0] = { ...payload.players[0], name, guid };
|
||||
|
||||
const response = await post(payload);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).telemetry.players).toEqual(payload.players);
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()[0].players).toEqual(payload.players);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(db().prepare('SELECT name, guid FROM leaderboard_players').all()).toEqual([
|
||||
{ name, guid }
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves an opaque Unicode GUID at the length limit', async () => {
|
||||
const payload = gameEvent('game_start', 0, 1);
|
||||
payload.players[0].guid = ` ${'識'.repeat(identityLimit - 2)} `;
|
||||
const response = await post(payload);
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).telemetry.players).toEqual(payload.players);
|
||||
});
|
||||
|
||||
it.each(events)('rejects oversized %s identities without changing game evidence', async (event) => {
|
||||
expect((await post(gameEvent('heartbeat', 5))).status).toBe(200);
|
||||
const before = await storedEvidence();
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
try {
|
||||
for (const field of ['name', 'guid'] as const) {
|
||||
for (const length of [identityLimit + 1, 20_000]) {
|
||||
const payload = gameEvent(event, 17, 1);
|
||||
payload.players[0] = { ...payload.players[0], [field]: 'x'.repeat(length) };
|
||||
const response = await post(payload);
|
||||
expect(response.status).toBe(400);
|
||||
const result = await response.json();
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
expect(result.error).toContain(`players[0].${field}`);
|
||||
expect(result.error).toContain(String(identityLimit));
|
||||
}
|
||||
}
|
||||
expect(await storedEvidence()).toEqual(before);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts a telemetry body at the exact 1 MiB UTF-8 byte limit', async () => {
|
||||
const payload = gameEvent('heartbeat', 17, 1);
|
||||
const json = JSON.stringify({ ...payload, diagnostic: 'é🍒' });
|
||||
const bytes = Buffer.from(json + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(json)));
|
||||
expect(bytes.byteLength).toBe(1024 * 1024);
|
||||
const { request } = streamedRequest([bytes.subarray(0, 100), bytes.subarray(100)], {
|
||||
Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}`,
|
||||
'Content-Length': String(bytes.byteLength)
|
||||
});
|
||||
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
||||
const response = await POST({ request });
|
||||
expect(response.status).toBe(200);
|
||||
expect((await response.json()).telemetry).toMatchObject(payload);
|
||||
});
|
||||
|
||||
it.each(['declared', 'absent', 'understated'] as const)(
|
||||
'rejects body overflow with %s Content-Length before storing or broadcasting it',
|
||||
async (length) => {
|
||||
expect((await post(gameEvent('heartbeat', 5))).status).toBe(200);
|
||||
const before = await storedEvidence();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const diagnostics = db().prepare('SELECT * FROM telemetry_requests').all();
|
||||
const payload = JSON.stringify(gameEvent('game_end', 99));
|
||||
const bytes = Buffer.from(
|
||||
payload + ' '.repeat(JSON_BODY_LIMIT - Buffer.byteLength(payload))
|
||||
);
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${TEST_TELEMETRY_TOKEN}`
|
||||
};
|
||||
if (length !== 'absent')
|
||||
headers['Content-Length'] = length === 'declared' ? String(JSON_BODY_LIMIT + 1) : '1';
|
||||
const { request, pull, cancel } = streamedRequest(
|
||||
[bytes, Uint8Array.of(32), Buffer.from('unread')],
|
||||
headers
|
||||
);
|
||||
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
try {
|
||||
await expect(POST({ request })).rejects.toMatchObject({ status: 413 });
|
||||
expect(pull).toHaveBeenCalledTimes(length === 'declared' ? 0 : 2);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(await storedEvidence()).toEqual(before);
|
||||
expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual(diagnostics);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
it.each([401, 503])('preserves authentication rejection %s before body limits', async (status) => {
|
||||
if (status === 503) vi.stubEnv('EC_TELEMETRY_TOKEN', undefined);
|
||||
const before = await storedEvidence();
|
||||
const { request, pull } = streamedRequest([Buffer.alloc(JSON_BODY_LIMIT + 1)], {
|
||||
'Content-Length': String(JSON_BODY_LIMIT + 1)
|
||||
});
|
||||
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
try {
|
||||
expect((await POST({ request })).status).toBe(status);
|
||||
expect(pull).not.toHaveBeenCalled();
|
||||
expect(request.bodyUsed).toBe(false);
|
||||
expect(await storedEvidence()).toEqual(before);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(db().prepare('SELECT * FROM telemetry_requests').all()).toEqual([]);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user