feat(backend): log client-side render errors to the server (#62)

The frontend's error boundaries need somewhere to report to. A boundary that only shows a customer a message leaves nobody knowing it happened, which is the failure shape this project has designed against three times already.

POST /api/client-errors takes a report, truncates its fields, logs it with a [client-error] prefix and returns 204. No storage: the container log is where this project's operational visibility already lives, and a table with a retention policy and an admin screen is a subsystem larger than the issue.

An unrecognised context is a 400 rather than a log line under a guessed label, following parseItemFilters, which refuses a malformed filter instead of coercing it. Oversized fields go the other way and are truncated rather than refused, because an over-long report is still the only record of the failure.

The endpoint gets its own rate limiter rather than reusing passwordResetRequestLimiter, whose comment already warns that its caller-and-email key collapses every caller into one shared bucket on an endpoint without an email. The new one takes the default key generator, which also avoids the ERR_ERL_KEY_GEN_IPV6 warning the custom key produces.

Verified: 138 integration tests pass, 4 of them new, and 79 unit. The unit count rose by one without a test being written — routesAreWrapped.test.ts runs describe.each over the files in src/routes, so a new route file generates a case. The handler is synchronous and needs no asyncRoute wrapper.

Refs #62
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 17:23:18 -05:00
co-authored by Claude Opus 5
parent 94e2267eaf
commit c693672051
4 changed files with 138 additions and 0 deletions
@@ -0,0 +1,71 @@
import request from 'supertest';
import app from '../../src/app';
import { pool } from '../../src/db';
import { closeDb } from './setup/testDb';
// No resetDb: this endpoint never touches the database. The pool still has to
// be closed or Jest reports an open handle.
afterAll(async () => {
await pool.end();
await closeDb();
});
describe('POST /api/client-errors', () => {
let errorSpy: jest.SpyInstance;
beforeEach(() => {
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
});
afterEach(() => {
errorSpy.mockRestore();
});
it('accepts a well-formed report and logs it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'catalogue',
message: 'Cannot read properties of undefined',
stack: 'Error: Cannot read properties of undefined\n at ItemCard',
componentStack: '\n at Catalogue\n at App',
path: '/?category=3'
});
expect(res.status).toBe(204);
expect(errorSpy).toHaveBeenCalledTimes(1);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[client-error]');
expect(logged).toContain('context=catalogue');
expect(logged).toContain('Cannot read properties of undefined');
});
it('refuses a context it does not recognise rather than logging under a guess', async () => {
const res = await request(app)
.post('/api/client-errors')
.send({ context: 'checkout', message: 'boom' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('invalid context');
expect(errorSpy).not.toHaveBeenCalled();
});
it('refuses a report with no context at all', async () => {
const res = await request(app).post('/api/client-errors').send({ message: 'boom' });
expect(res.status).toBe(400);
expect(errorSpy).not.toHaveBeenCalled();
});
// Truncated rather than refused: an over-long report is still the only record
// of the failure, and dropping it would lose the thing worth having.
it('truncates an oversized message instead of refusing it', async () => {
const res = await request(app).post('/api/client-errors').send({
context: 'page',
message: 'x'.repeat(5000)
});
expect(res.status).toBe(204);
const logged = errorSpy.mock.calls[0][0] as string;
expect(logged).toContain('[truncated]');
expect(logged.length).toBeLessThan(2000);
});
});