diff --git a/packages/core/src/time.ts b/packages/core/src/time.ts index a8db48a..6ffde07 100644 --- a/packages/core/src/time.ts +++ b/packages/core/src/time.ts @@ -86,6 +86,9 @@ export function nextBucket(bucket: number, granularity: Granularity, timeZone = } } +/** Most buckets {@link bucketsBetween} returns before it throws. */ +export const MAX_BUCKETS = 10_000; + /** Bucket starts covering the half-open interval [from, to). */ export function bucketsBetween( from: number, @@ -100,7 +103,8 @@ export function bucketsBetween( b = nextBucket(b, granularity, timeZone) ) { out.push(b); - if (out.length > 10_000) throw new RangeError('Range too large for the chosen granularity'); + if (out.length > MAX_BUCKETS) + throw new RangeError('Range too large for the chosen granularity'); } return out; } diff --git a/server/src/routes/schemas.ts b/server/src/routes/schemas.ts index be68360..6dcae50 100644 --- a/server/src/routes/schemas.ts +++ b/server/src/routes/schemas.ts @@ -1,4 +1,12 @@ -import { DAY, DIMENSIONS, type Dimension, type Granularity } from '@pulse/core'; +import { + DAY, + DIMENSIONS, + HOUR, + MAX_BUCKETS, + MINUTE, + type Dimension, + type Granularity, +} from '@pulse/core'; export const rangeQuerySchema = { type: 'object', @@ -61,3 +69,17 @@ export function resolveRange(q: RangeQuerystring, now: number): { from: number; if (from >= to) return { from: to - DAY, to }; return { from: Math.max(from, to - 400 * DAY), to }; } + +const BUCKET_MS: Record = { minute: MINUTE, hour: HOUR, day: DAY }; + +/** + * Longest range, in buckets, that a forced `interval` may cover. One less than + * MAX_BUCKETS because `from` snaps down to a bucket start, so a range n buckets + * long can touch n + 1 of them. With the 400-day clamp above, only `minute` can + * hit it. + */ +export const MAX_INTERVAL_SPAN = MAX_BUCKETS - 1; + +export function fitsInterval(range: { from: number; to: number }, interval: Granularity): boolean { + return range.to - range.from <= MAX_INTERVAL_SPAN * BUCKET_MS[interval]; +} diff --git a/server/src/routes/stats.ts b/server/src/routes/stats.ts index 6a58a7b..d050db4 100644 --- a/server/src/routes/stats.ts +++ b/server/src/routes/stats.ts @@ -2,7 +2,9 @@ import type { FastifyInstance, FastifyReply } from 'fastify'; import type { AppContext } from '../context.js'; import { toCsv } from '../stats/csv.js'; import { + MAX_INTERVAL_SPAN, breakdownQuerySchema, + fitsInterval, rangeQuerySchema, resolveRange, siteParamsSchema, @@ -38,11 +40,14 @@ export function statsRoutes(app: FastifyInstance, ctx: AppContext): void { { schema: { params: siteParamsSchema, querystring: timeseriesQuerySchema } }, async (req, reply) => { if (!ensureSite(req.params.siteId, reply)) return reply; - return stats.timeseries( - req.params.siteId, - resolveRange(req.query, ctx.now()), - req.query.interval, - ); + const range = resolveRange(req.query, ctx.now()); + const { interval } = req.query; + if (interval && !fitsInterval(range, interval)) { + return reply.code(400).send({ + error: `interval=${interval} covers at most ${MAX_INTERVAL_SPAN} ${interval}s; use a shorter range or a coarser interval`, + }); + } + return stats.timeseries(req.params.siteId, range, interval); }, ); diff --git a/server/test/api.test.ts b/server/test/api.test.ts index 1ea26e2..a0b01b9 100644 --- a/server/test/api.test.ts +++ b/server/test/api.test.ts @@ -1,6 +1,8 @@ import { + DAY, HOUR, MINUTE, + SECOND, type BreakdownResponse, type RealtimeResponse, type SummaryResponse, @@ -113,6 +115,23 @@ describe('stats API', () => { ); }); + it('answers 400 when a forced interval needs too many buckets', async () => { + const minute = (from: number, to: number) => + t.app.inject(`/api/sites/demo/timeseries?from=${from}&to=${to}&interval=minute`); + // The ranges from issue #1: 6 days worked, 8 days answered 500. + expect((await minute(NOW - 6 * DAY, NOW)).statusCode).toBe(200); + const tooLong = await minute(NOW - 8 * DAY, NOW); + expect(tooLong.statusCode).toBe(400); + expect(tooLong.json()).toEqual({ error: expect.stringContaining('interval=minute') }); + // Exact boundary. `from` is off the minute grid and `to` is past now, so the + // series gets an extra partial bucket at the front and nothing is trimmed at the end. + const to = NOW + 30 * SECOND; + const longest = await minute(to - 9_999 * MINUTE, to); + expect(longest.statusCode).toBe(200); + expect(longest.json().points).toHaveLength(10_000); + expect((await minute(to - 10_000 * MINUTE, to)).statusCode).toBe(400); + }); + it('breaks down by page, referrer, country and device', async () => { const range = `from=${NOW - 24 * HOUR}&to=${NOW + MINUTE}`; const pages = (