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
4 changes: 1 addition & 3 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
// rule list, and the beacon's function is the one file they cover. A repo
// told to use no arrow functions anywhere would be unusable, so the rules
// arrive as an override rather than at the top level.
"extends": [
"./node_modules/@kensio/yulin/dist/config/oxlint/cffjs2.oxlintrc.json"
],
"extends": ["./node_modules/@kensio/yulin/cffjs2.oxlintrc.json"],

"plugins": ["eslint", "typescript", "unicorn", "oxc", "vitest", "jsdoc"],

Expand Down
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.14",
"@kensio/yulin": "^1.21.0",
"@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.

14 changes: 2 additions & 12 deletions src/beacon-rollup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,18 +87,8 @@ const sender = "c_ip, cs_user_agent";
*/
const loggedHour = "cast(timestamp_ms AS bigint) / 3600000";

/**
* One visitor's identical events in one hour, counted no further than the cap.
*
* `least(count(*), 60)` says this in fewer characters and is what Athena would
* take. Yulin's query engine has no `least` and answers the whole query from a
* declaration rather than running it, which would leave this rule with no test
* that executes. Raised as KensioSoftware/yulin#1141. The `CASE` is the same
* arithmetic in a form both engines run.
*/
const cappedCount =
`CASE WHEN count(*) > ${String(beaconEventCap)}` +
` THEN ${String(beaconEventCap)} ELSE count(*) END`;
/** One visitor's identical events in one hour, counted no further than the cap. */
const cappedCount = `least(count(*), ${String(beaconEventCap)})`;

/** Events by page and by name, added by both. */
const beaconEventTotals: RollupTotals = { added: ["events"] };
Expand Down
122 changes: 45 additions & 77 deletions src/cdk/beacon-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { CachePolicy, Distribution } from "aws-cdk-lib/aws-cloudfront";
import { S3BucketOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
import { PolicyStatement, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Match, Template } from "aws-cdk-lib/assertions";
import { App, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib/core";
import { type App, CfnOutput, RemovalPolicy, Stack } from "aws-cdk-lib/core";
import { describe, expect, it } from "vitest";

import { deployStacks } from "#test/simulated-deployment.js";
Expand Down Expand Up @@ -58,6 +57,9 @@ describe("answering the beacon's collection path", () => {
new CfnOutput(stack, "DistributionDomainName", {
value: distribution.distributionDomainName,
});
new CfnOutput(stack, "DistributionId", {
value: distribution.distributionId,
});
new CfnOutput(stack, "SiteBucketName", { value: bucketName });

new BeaconPath(stack, "RainlyticsBeacon", {
Expand All @@ -76,6 +78,7 @@ describe("answering the beacon's collection path", () => {
return {
simAws,
bucketName,
distributionId: stack?.output("DistributionId") ?? "",
get: async (pathAndQuery: string): Promise<Response> =>
http.fetch(`https://${host}${pathAndQuery}`, { redirect: "manual" }),
};
Expand Down Expand Up @@ -190,125 +193,90 @@ describe("answering the beacon's collection path", () => {
});

describe("what the distribution is given", () => {
/**
* These read the synthesised template rather than the deployed
* simulation. Yulin models no cache policy on a behaviour and its
* CloudFront has no `ListFunctions` or `GetFunction`, so a deployed
* distribution has nothing to ask about either. Raised as
* KensioSoftware/yulin#1130 and KensioSoftware/yulin#1131.
*
* What the deployed function does is covered above, by requests going
* into the simulation and coming back 204.
*/
const synthesised = (props: Partial<BeaconPathProps> = {}): Template => {
const stack = new Stack(new App(), "SiteStack", {
env: { account: "123456789012", region: "eu-west-2" },
});
new Bucket(stack, "SiteBucket", { bucketName: "site-bucket" });
const origin = S3BucketOrigin.withOriginAccessControl(
Bucket.fromBucketName(stack, "SiteOrigin", "site-bucket"),
);
const distribution = new Distribution(stack, "SiteDistribution", {
defaultBehavior: { origin },
});
new BeaconPath(stack, "RainlyticsBeacon", {
...props,
distribution,
origin,
});

return Template.fromStack(stack);
};

/** The managed `CachingOptimized` policy, under the fixed id AWS gives it. */
const cachingOptimized = "658327ea-f89d-4fab-a63d-7e88639e58f6";

/** The distribution properties, matched on the beacon's own behaviour. */
const beaconBehaviour = (
properties: Record<string, unknown>,
): Record<string, unknown> => {
const matched = Match.objectLike({
PathPattern: defaultBeaconPath,
...properties,
});
/** The beacon's own behaviour, read off the deployed distribution. */
const beaconBehaviour = async (props: Partial<BeaconPathProps> = {}) => {
const { simAws, distributionId } = await deployBeacon(props);
const read = await simAws
.cloudFront()
.getDistribution({ input: { Id: distributionId } });

return {
DistributionConfig: Match.objectLike({
CacheBehaviors: Match.arrayWith([matched]),
}),
};
return read.Distribution?.DistributionConfig?.CacheBehaviors?.Items?.find(
(behaviour) =>
behaviour.PathPattern === (props.path ?? defaultBeaconPath),
);
};

it("leaves the query string out of the cache key", () => {
/** The one function the deployed account holds, live. */
const publishedFunction = async (props: Partial<BeaconPathProps> = {}) => {
const { simAws } = await deployBeacon(props);
const listed = await simAws
.cloudFront()
.listFunctions({ input: { Stage: "LIVE" } });

return listed.FunctionList.Items[0];
};

it("leaves the query string out of the cache key", async () => {
// Given a beacon path taking the default cache policy.
// When the stack is synthesised.
const template = synthesised();
// When the stack is deployed.
const behaviour = await beaconBehaviour();

// Then the behaviour carries the managed policy that keys on the path
// alone. The payload travels in the query string, and a policy keying
// on it would make every event a cache entry of its own.
template.hasResourceProperties(
"AWS::CloudFront::Distribution",
beaconBehaviour({ CachePolicyId: cachingOptimized }),
);
expect(behaviour?.CachePolicyId).toBe(cachingOptimized);
});

it("takes a cache policy a site would rather use", () => {
it("takes a cache policy a site would rather use", async () => {
// Given a site standardising on one managed policy across its
// behaviours, this one keying on nothing and storing nothing.
const cachePolicy = CachePolicy.CACHING_DISABLED;

// When the beacon is deployed with it.
const template = synthesised({ cachePolicy });
const behaviour = await beaconBehaviour({ cachePolicy });

// Then that is the policy on the behaviour.
template.hasResourceProperties(
"AWS::CloudFront::Distribution",
beaconBehaviour({ CachePolicyId: cachePolicy.cachePolicyId }),
);
expect(behaviour?.CachePolicyId).toBe(cachePolicy.cachePolicyId);
});

it("runs the function before the cache is consulted", () => {
it("runs the function before the cache is consulted", async () => {
// Given the same stack.
const template = synthesised();
const behaviour = await beaconBehaviour();

// Then the function is associated at viewer-request. CloudFront
// reaches that event before the cache lookup and before any origin
// request, and it is what makes the 204 free of both.
const atViewerRequest = Match.objectLike({
EventType: "viewer-request",
});
template.hasResourceProperties(
"AWS::CloudFront::Distribution",
beaconBehaviour({ FunctionAssociations: [atViewerRequest] }),
);
expect(
behaviour?.FunctionAssociations?.Items?.map(
(association) => association.EventType,
),
).toStrictEqual(["viewer-request"]);
});

it("deploys the function on the JS 2.0 runtime", () => {
it("deploys the function on the JS 2.0 runtime", async () => {
// Given the same stack.
const template = synthesised();
const summary = await publishedFunction();

// Then the function names the runtime its source is written against.
// The lint rules on `beacon-204.cff.js` hold it to JS 2.0's
// restrictions, and JS 1.0 has its own.
template.hasResourceProperties("AWS::CloudFront::Function", {
FunctionConfig: Match.objectLike({ Runtime: "cloudfront-js-2.0" }),
});
expect(summary?.FunctionConfig.Runtime).toBe("cloudfront-js-2.0");
});

it("takes a function name where the account needs a chosen one", () => {
it("takes a function name where the account needs a chosen one", async () => {
// Given two sites in one account. CloudFront function names are unique
// across an account, and CDK derives one from the construct's path in
// the tree.
const functionName = `beacon-${faker.string.alphanumeric(8)}`;

// When the beacon is deployed under a name of its own.
const template = synthesised({ functionName });
const summary = await publishedFunction({ functionName });

// Then that is the name it carries.
template.hasResourceProperties("AWS::CloudFront::Function", {
Name: functionName,
});
expect(summary?.Name).toBe(functionName);
});
});
});
4 changes: 2 additions & 2 deletions src/cdk/log-delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ describe("delivering CloudFront access logs", () => {
* The distribution is deployed rather than invented. A delivery source
* names a distribution by ARN, and AWS refuses one naming a distribution
* that does not exist, so a fabricated id makes the whole delivery a thing
* production would have rejected. See KensioSoftware/yulin#993, which is
* about the simulation catching up to that.
* production would have rejected. Simulated CloudWatch Logs refuses it as
* well, since KensioSoftware/yulin#993.
*
* Everything goes in one us-east-1 stack. A real consumer keeps the
* distribution wherever their site is, and the cases about that split are
Expand Down
32 changes: 18 additions & 14 deletions src/cli/summary-answer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { RollupSummaries } from "../cdk/rollup-summaries.js";
import type { RollupSummariesProps } from "../cdk/summary-configuration.js";
import { partitionPrefix } from "../partitions.js";
import { cacheHitRatio, pageviews } from "../rollup-questions.js";
import type { Rollup } from "../rollups.js";
import { defaultVisitorSaltParameter } from "../visitor-identity.js";
import { rainlyticsCommands } from "./command.js";
import { runCli } from "./run.js";

Expand All @@ -35,18 +35,6 @@ import { runCli } from "./run.js";
describe("the named questions, answered from stored summaries", () => {
let intercepted: SimSdk | undefined;

/**
* The pageviews question with its visitor count turned off.
*
* These cases are about reading a stored answer back, and a visitor count
* would be a second query nothing here can answer. Yulin's Athena engine
* has no `sha256`, `to_utf8` or `to_hex`, so the count comes back empty
* under a SUCCEEDED state and the run refuses it, leaving no summary to
* read. KensioSoftware/yulin#1082 is that gap. The name is the same, so
* every key and question below is the one a shipped deployment writes.
*/
const viewsOnly: Rollup = { ...pageviews, countsVisitors: false };

/** The hour the traffic in these cases happened in. */
const anHour = new Date("2026-08-23T08:00:00.000Z");

Expand Down Expand Up @@ -94,7 +82,7 @@ describe("the named questions, answered from stored summaries", () => {
new RollupSummaries(stack, "RainlyticsSummaries", {
table,
workgroup,
rollups: [viewsOnly, cacheHitRatio],
rollups: [pageviews, cacheHitRatio],
granularities: ["hourly"],
summariesBucketName,
removalPolicy: RemovalPolicy.DESTROY,
Expand All @@ -114,6 +102,22 @@ describe("the named questions, answered from stored summaries", () => {
intercepted.intercept(AthenaClient);
intercepted.intercept(S3Client);

// The salt secret, put where a site's operator puts it. Nothing in the
// stack creates it, because CloudFormation writes no SecureString.
// `docs/visitors/` has the command. The summaries below carry a visitor
// count, and the schedules need it before they fire.
await simAws
.region("us-east-1")
.account()
.ssm()
.putParameter({
input: {
Name: defaultVisitorSaltParameter,
Type: "SecureString",
Value: faker.string.hexadecimal({ length: 64, prefix: "" }),
},
});

return {
simAws,
logBucketName,
Expand Down
14 changes: 4 additions & 10 deletions src/functions/rollup-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
summarisedWindow,
windowPlaceholder,
} from "../rollups.js";
import { visitorCountSql } from "../visitor-counts.js";
import { defaultVisitorSaltParameter } from "../visitor-identity.js";
import { visitorSaltPlaceholder } from "../visitor-identity.js";
import { handler } from "./rollup-summary.js";
Expand Down Expand Up @@ -178,20 +179,13 @@ describe("one run of the rollup summary job", () => {
});

/**
* A visitor count the simulated engine can answer.
* The visitor count a schedule carries beside the pageviews question.
*
* The shipped one is `count(DISTINCT to_hex(sha256(to_utf8(...))))`, and
* Yulin has neither the digest nor a distinct count over an expression.
* KensioSoftware/yulin#1082 is that gap. This counts distinct addresses
* over the same window and carries the salt where the shipped query carries
* it, so what these cases cover is the run around the count.
* The shipped query, narrowed the way `aRun` narrows the question above.
* `visitor-counts.test.ts` covers who one identifier stands for.
*/
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const aVisitorCount = (): string =>
`SELECT count(DISTINCT c_ip) AS visitors\n` +
` FROM "rainlytics"."cloudfront_logs"\n` +
` WHERE ${windowPlaceholder}\n` +
` AND ${visitorSaltPlaceholder} <> ''\n`;
visitorCountSql(rollupRequest({ range: summarisedWindow }));

/**
* A quarter past nine, on both clocks.
Expand Down
Loading