diff --git a/package.json b/package.json index 5eab19e..dbc82ab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c112c93..84c8508 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: ^1.37.5 version: 1.37.5 '@kensio/yulin': - specifier: ^1.20.12 - version: 1.20.13(node-sql-parser@5.4.0) + specifier: ^1.20.14 + version: 1.20.14(node-sql-parser@5.4.0) '@semantic-release/exec': specifier: 7.1.0 version: 7.1.0(semantic-release@25.0.9(@typescript/typescript6@6.0.2)(supports-color@7.2.0))(supports-color@7.2.0) @@ -231,8 +231,8 @@ packages: resolution: {integrity: sha512-Djp3HOC3s3U3axh/Phb31vJ1QCYls5DasmPAJZ466ArFE2aed5a1zQCHVLh3sCP7BjER/YRqBXqlRIwVpG+wkw==} engines: {node: '>=24.0.0'} - '@kensio/yulin@1.20.13': - resolution: {integrity: sha512-nIz+14XrtXFV/r1WZUZm/qVq1ewvL0FOplKzNy1xLsQcG9hhjkT9q8F2mjkP2m5cIa04APDZkGOrVfTsLjSGtA==} + '@kensio/yulin@1.20.14': + resolution: {integrity: sha512-vao/M2vY9BsMbzZ7tVh1B1mfr/Na1LbDzKzqF7svw7G1y4FWqHpRUAWqNKhfZmMOXZPu88Tvo3e8jOBIspgjOA==} engines: {node: '>=24.0.0'} hasBin: true peerDependencies: @@ -2621,7 +2621,7 @@ snapshots: '@kensio/smartass@1.37.5': {} - '@kensio/yulin@1.20.13(node-sql-parser@5.4.0)': + '@kensio/yulin@1.20.14(node-sql-parser@5.4.0)': dependencies: '@faker-js/faker': 10.6.0 '@kensio/part-factory': 1.10.1 diff --git a/src/cdk/rollup-summaries.test.ts b/src/cdk/rollup-summaries.test.ts index cad5ad4..f624e3e 100644 --- a/src/cdk/rollup-summaries.test.ts +++ b/src/cdk/rollup-summaries.test.ts @@ -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, @@ -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 = {}, @@ -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, @@ -132,7 +119,13 @@ describe("computing rollup summaries on a schedule", () => { type Deployed = Awaited>; - /** 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. + */ const aRecord = ( at: Date, over: Readonly> = {}, @@ -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, }); @@ -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