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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"@aws-sdk/client-ssm": "^3.1119.0",
"@faker-js/faker": "^10.6.0",
"@kensio/smartass": "^1.37.5",
"@kensio/yulin": "^1.20.12",
"@kensio/yulin": "^1.20.14",
"@semantic-release/exec": "7.1.0",
"@types/node": "^26.1.1",
"@typescript/native": "npm:typescript@^7.0.2",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

72 changes: 56 additions & 16 deletions src/cdk/rollup-summaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import { partitionPrefix } from "../partitions.js";
import { pageviews } from "../rollup-questions.js";
import type { RollupSummary } from "../rollup-summaries.js";
import { summarySchemaVersion } from "../rollup-summaries.js";
import type { Rollup } from "../rollups.js";
import { defaultRedirectStatuses, windowPlaceholder } from "../rollups.js";
import {
defaultVisitorSaltParameter,
Expand All @@ -45,18 +44,6 @@ describe("computing rollup summaries on a schedule", () => {
*/
const theClosedHour = new Date("2026-08-23T08:00:00.000Z");

/**
* The pageviews question with its visitor count turned off.
*
* Every case here is about windows, keys, buckets and lag, and a visitor
* count in the middle of them would be a second query nothing can answer.
* Yulin's Athena engine has no `sha256`, `to_utf8` or `to_hex`, so the
* shipped count comes back empty under a SUCCEEDED state and the run
* refuses it. KensioSoftware/yulin#1082 is that gap, and the two cases
* below cover the wiring that reaches it.
*/
const viewsOnly: Rollup = { ...pageviews, countsVisitors: false };

/** A whole deployment in a simulated account, computing one question. */
const deployAnalytics = async (
over: Partial<RollupSummariesProps> = {},
Expand Down Expand Up @@ -93,7 +80,7 @@ describe("computing rollup summaries on a schedule", () => {
new RollupSummaries(stack, "RainlyticsSummaries", {
table,
workgroup,
rollups: [viewsOnly],
rollups: [pageviews],
granularities: ["hourly"],
summariesBucketName,
removalPolicy: RemovalPolicy.DESTROY,
Expand Down Expand Up @@ -132,7 +119,13 @@ describe("computing rollup summaries on a schedule", () => {

type Deployed = Awaited<ReturnType<typeof deployAnalytics>>;

/** One record, with everything a rollup reads set to something sensible. */
/**
* One record, with everything a rollup reads set to something sensible.
*
* Every record gets an address of its own. A case that says nothing about
* visitors then counts one per record, and a case that cares who came back
* hands `c-ip` in.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const aRecord = (
at: Date,
over: Readonly<Record<string, string>> = {},
Expand All @@ -148,6 +141,7 @@ describe("computing rollup summaries on a schedule", () => {
"cs(User-Agent)": "Mozilla/5.0%20(Macintosh)",
"x-edge-result-type": "Hit",
"c-country": "GB",
"c-ip": faker.internet.ipv4(),
...over,
});

Expand Down Expand Up @@ -405,10 +399,56 @@ describe("computing rollup summaries on a schedule", () => {
});
});

it("counts the visitors the closed hour saw", async () => {
// Given an hour holding two views from one address and one from another.
// Both are written out from the documentation ranges rather than drawn,
// because the number this asserts is how many of them there are.
const returning = "203.0.113.7";
const passingThrough = "198.51.100.24";
const deployed = await deployAnalytics();
await putDelivered(deployed, theClosedHour, [
aRecord(theClosedHour, { "c-ip": returning }),
aRecord(theClosedHour, { "c-ip": returning }),
aRecord(theClosedHour, { "c-ip": passingThrough }),
]);

// When the schedule fires.
await deployed.simAws.clock().advanceBy({ minutes: 16 });

// Then the summary carries three views and two visitors. The gap between
// the two numbers is the address that came back, counted once.
const summary = await summaryAt(deployed, closedHourKey);

expect(summary?.rows).toStrictEqual([{ path: "/", views: "3" }]);
expect(summary?.visitors).toStrictEqual({ distinct: 2, additive: false });
});

it("asks Athena nothing where the salt parameter is missing", async () => {
// Given a deployment naming a parameter nobody created, and an hour of
// traffic waiting to be counted.
const parameter = `/mine/${faker.string.uuid()}`;
const deployed = await deployAnalytics({ visitorSaltParameter: parameter });
const account = deployed.simAws.region("us-east-1").account();
await putDelivered(deployed, theClosedHour, [aRecord(theClosedHour)]);

// When the schedule fires.
await deployed.simAws.clock().advanceBy({ minutes: 16 });

// Then the run failed naming the parameter, having asked Athena nothing.
// A run that queried first would have paid for a window it then refused
// to write. Scheduler keeps a failed invocation to itself, and the
// simulation's record of it stands in for the log group.
const [failure] = account.scheduler().deliveryFailures;

expect(failure?.message).toContain(parameter);
expect(account.athena().queryExecutions()).toStrictEqual([]);
await expect(summaryAt(deployed, closedHourKey)).resolves.toBeUndefined();
});

it("hands the visitor count to the schedule without the salt", async () => {
// Given a deployment of the question as Rainlytics ships it, which counts
// visitors.
const deployed = await deployAnalytics({ rollups: [pageviews] });
const deployed = await deployAnalytics();

// When the schedule's target input is read back.
const schedule = await deployed.simAws
Expand Down