Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
24 changes: 23 additions & 1 deletion server/src/routes/schemas.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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<Granularity, number> = { 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];
}
15 changes: 10 additions & 5 deletions server/src/routes/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
},
);

Expand Down
19 changes: 19 additions & 0 deletions server/test/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {
DAY,
HOUR,
MINUTE,
SECOND,
type BreakdownResponse,
type RealtimeResponse,
type SummaryResponse,
Expand Down Expand Up @@ -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<TimeseriesResponse>().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 = (
Expand Down
Loading