From 7d520a475082e43346adae11f916f8bcdf6614e8 Mon Sep 17 00:00:00 2001 From: David Ojo Date: Tue, 28 Apr 2026 17:38:09 +0100 Subject: [PATCH] Add creator list filter parse error metrics counter - Add filter-parse-metrics.utils.ts with lightweight in-process counter - Include route and error-category labels (unknown_key, invalid_value, schema_error) - Integrate counter into creators.controllers.ts httpListCreators - Add unit tests for metrics utility Closes #224 --- src/modules/creators/creators.controllers.ts | 24 ++++++++ src/utils/filter-parse-metrics.utils.test.ts | 49 +++++++++++++++ src/utils/filter-parse-metrics.utils.ts | 63 ++++++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/utils/filter-parse-metrics.utils.test.ts create mode 100644 src/utils/filter-parse-metrics.utils.ts diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index a2a3133..5f06c7d 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -14,6 +14,10 @@ import { attachTimestampHeader } from '../../utils/timestamp-headers.utils'; import { parsePublicQuery } from '../../utils/public-query-parse.utils'; import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; import { buildCreatorListRequestContext } from './creator-list-context.utils'; +import { + incrementFilterParseError, + type FilterParseErrorCategory, +} from '../../utils/filter-parse-metrics.utils'; /** * Controller for GET /api/v1/creators @@ -28,6 +32,9 @@ export const httpListCreators: AsyncController = async (req, res, next) => { // Validate query parameters const parsed = parsePublicQuery(CreatorListQuerySchema, ctx.query); if (!parsed.ok) { + // Increment filter parse error counter + const category = categorizeParseError(parsed.details); + incrementFilterParseError('/api/v1/creators', category); return sendValidationError(res, 'Invalid query parameters', parsed.details); } const validatedQuery = parsed.data; @@ -51,6 +58,23 @@ export const httpListCreators: AsyncController = async (req, res, next) => { } }; +/** + * Categorize a parse error based on the validation details. + * + * @param details - Validation error details from parsePublicQuery + * @returns The error category for metrics labeling + */ +function categorizeParseError( + details: Array<{ field: string; message: string }> +): FilterParseErrorCategory { + // Check for unknown key errors (strict mode violations) + if (details.some(d => d.message.includes('unrecognized') || d.message.includes('unknown'))) { + return 'unknown_key'; + } + // Default to invalid_value for type/range errors + return 'invalid_value'; +} + /** * Controller for GET /api/v1/creators/:id/stats * diff --git a/src/utils/filter-parse-metrics.utils.test.ts b/src/utils/filter-parse-metrics.utils.test.ts new file mode 100644 index 0000000..f0f9b11 --- /dev/null +++ b/src/utils/filter-parse-metrics.utils.test.ts @@ -0,0 +1,49 @@ +// src/utils/filter-parse-metrics.utils.test.ts +// Unit tests for filter parse error metrics counter. + +import { strict as assert } from 'assert'; +import { + incrementFilterParseError, + getFilterParseErrors, + resetFilterParseMetrics, +} from './filter-parse-metrics.utils'; + +function run() { + // Reset before each test + resetFilterParseMetrics(); + + // --- initial state --- + assert.deepEqual(getFilterParseErrors(), [], 'should start empty'); + + // --- single increment --- + incrementFilterParseError('/api/v1/creators', 'unknown_key'); + const errors = getFilterParseErrors(); + assert.equal(errors.length, 1, 'should have one entry'); + assert.equal(errors[0].route, '/api/v1/creators'); + assert.equal(errors[0].category, 'unknown_key'); + assert.equal(errors[0].count, 1); + + // --- multiple increments same key --- + incrementFilterParseError('/api/v1/creators', 'unknown_key'); + const errors2 = getFilterParseErrors(); + assert.equal(errors2.length, 1, 'should still have one entry'); + assert.equal(errors2[0].count, 2, 'count should be 2'); + + // --- different category --- + incrementFilterParseError('/api/v1/creators', 'invalid_value'); + const errors3 = getFilterParseErrors(); + assert.equal(errors3.length, 2, 'should have two entries'); + + // --- different route --- + incrementFilterParseError('/api/v1/other', 'unknown_key'); + const errors4 = getFilterParseErrors(); + assert.equal(errors4.length, 3, 'should have three entries'); + + // --- reset --- + resetFilterParseMetrics(); + assert.deepEqual(getFilterParseErrors(), [], 'should be empty after reset'); + + console.log('filter-parse-metrics.utils tests passed'); +} + +run(); diff --git a/src/utils/filter-parse-metrics.utils.ts b/src/utils/filter-parse-metrics.utils.ts new file mode 100644 index 0000000..dc5a2d3 --- /dev/null +++ b/src/utils/filter-parse-metrics.utils.ts @@ -0,0 +1,63 @@ +/** + * Lightweight in-process counter for filter parse errors. + * + * Tracks validation failures on creator list endpoints so operators can + * monitor malformed or malicious query patterns without external dependencies. + */ + +export type FilterParseErrorCategory = 'unknown_key' | 'invalid_value' | 'schema_error'; + +export interface FilterParseErrorEntry { + route: string; + category: FilterParseErrorCategory; + count: number; + lastOccurred: string; +} + +const counters = new Map(); + +function key(route: string, category: FilterParseErrorCategory): string { + return `${route}:${category}`; +} + +/** + * Increment the error counter for a specific route and error category. + * + * @param route - The API route where the error occurred (e.g., '/api/v1/creators') + * @param category - The type of parse error + */ +export function incrementFilterParseError( + route: string, + category: FilterParseErrorCategory +): void { + const k = key(route, category); + const existing = counters.get(k); + if (existing) { + existing.count += 1; + existing.lastOccurred = new Date().toISOString(); + } else { + counters.set(k, { + route, + category, + count: 1, + lastOccurred: new Date().toISOString(), + }); + } +} + +/** + * Get all filter parse error counters. + * + * @returns Array of error entries with route, category, count, and last occurrence + */ +export function getFilterParseErrors(): FilterParseErrorEntry[] { + return Array.from(counters.values()); +} + +/** + * Reset all filter parse error counters. + * Primarily for testing. + */ +export function resetFilterParseMetrics(): void { + counters.clear(); +}