Skip to content

Commit 6a0d990

Browse files
authored
Add x-cf-pages-analytics header when Web Analytics token is injected (#9817)
- Emit `x-cf-pages-analytics: 1` header when analytics script is added to HTML responses - Add comprehensive tests covering HTML with/without body, non-HTML responses, and missing analytics config - Header indicates when analytics injection is attempted regardless of HTMLRewriter success
1 parent a60e9da commit 6a0d990

File tree

4 files changed

+189
-0
lines changed

4 files changed

+189
-0
lines changed

.changeset/deep-mirrors-help.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/pages-shared": patch
3+
---
4+
5+
Add `x-cf-pages-analytics` header when Web Analytics token is injected
6+
7+
- Emit `x-cf-pages-analytics: 1` header when analytics script is added to HTML responses
8+
- Add comprehensive tests covering HTML with/without body, non-HTML responses, and missing analytics config
9+
- Header indicates when analytics injection is attempted regardless of HTMLRewriter success

packages/pages-shared/__tests__/asset-server/handler.test.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,6 +1069,179 @@ describe("asset-server handler", () => {
10691069
);
10701070
});
10711071
});
1072+
1073+
const findIndexHtmlAssetEntryForPath = async (path: string) => {
1074+
if (path === "/index.html") {
1075+
return "asset-key-index.html";
1076+
}
1077+
return null;
1078+
};
1079+
1080+
const fetchHtmlAsset = () =>
1081+
Promise.resolve(
1082+
Object.assign(
1083+
new Response(`
1084+
<!DOCTYPE html>
1085+
<html>
1086+
<body>
1087+
<h1>Hello World</h1>
1088+
</body>
1089+
</html>
1090+
`),
1091+
{ contentType: "text/html" }
1092+
)
1093+
);
1094+
1095+
const fetchHtmlAssetWithoutBody = () =>
1096+
Promise.resolve(
1097+
Object.assign(
1098+
new Response(`
1099+
<!DOCTYPE html>
1100+
<html>
1101+
<head>
1102+
<title>No Body</title>
1103+
</head>
1104+
</html>
1105+
`),
1106+
{ contentType: "text/html" }
1107+
)
1108+
);
1109+
1110+
const findStyleCssAssetEntryForPath = async (path: string) =>
1111+
path === "/style.css" ? "asset-key-style.css" : null;
1112+
1113+
const fetchCssAsset = () =>
1114+
Promise.resolve(
1115+
Object.assign(
1116+
new Response(`
1117+
body {
1118+
font-family: Arial, sans-serif;
1119+
color: #333;
1120+
}
1121+
`),
1122+
{ contentType: "text/css" }
1123+
)
1124+
);
1125+
1126+
test("should emit header when Web Analytics Token is injected", async () => {
1127+
const { response } = await getTestResponse({
1128+
request: "https://example.com/",
1129+
metadata: createMetadataObject({
1130+
deploymentId: "mock-deployment-id",
1131+
webAnalyticsToken: "test-analytics-token",
1132+
}) as Metadata,
1133+
findAssetEntryForPath: findIndexHtmlAssetEntryForPath,
1134+
fetchAsset: fetchHtmlAsset,
1135+
xWebAnalyticsHeader: true,
1136+
});
1137+
1138+
expect(response.status).toBe(200);
1139+
expect(response.headers.get("x-cf-pages-analytics")).toBe("1");
1140+
1141+
const responseText = await response.text();
1142+
expect(responseText).toContain(
1143+
'data-cf-beacon=\'{"token": "test-analytics-token"}\''
1144+
);
1145+
});
1146+
1147+
test("should not emit header when Web Analytics Token is not configured", async () => {
1148+
const { response } = await getTestResponse({
1149+
request: "https://example.com/",
1150+
metadata: createMetadataObject({
1151+
deploymentId: "mock-deployment-id",
1152+
}) as Metadata,
1153+
findAssetEntryForPath: findIndexHtmlAssetEntryForPath,
1154+
fetchAsset: fetchHtmlAsset,
1155+
});
1156+
1157+
expect(response.status).toBe(200);
1158+
expect(response.headers.get("x-cf-pages-analytics")).toBeNull();
1159+
1160+
const responseText = await response.text();
1161+
expect(responseText).not.toContain("data-cf-beacon");
1162+
});
1163+
1164+
test("should emit header for HTML without <body> element but not inject script", async () => {
1165+
const { response } = await getTestResponse({
1166+
request: "https://example.com/",
1167+
metadata: createMetadataObject({
1168+
deploymentId: "mock-deployment-id",
1169+
webAnalyticsToken: "test-analytics-token",
1170+
}) as Metadata,
1171+
findAssetEntryForPath: findIndexHtmlAssetEntryForPath,
1172+
fetchAsset: fetchHtmlAssetWithoutBody,
1173+
xWebAnalyticsHeader: true,
1174+
});
1175+
1176+
expect(response.status).toBe(200);
1177+
expect(response.headers.get("x-cf-pages-analytics")).toBe("1");
1178+
1179+
const responseText = await response.text();
1180+
expect(responseText).not.toContain("data-cf-beacon");
1181+
expect(responseText).toContain("<title>No Body</title>");
1182+
});
1183+
1184+
test("should not emit header for non-HTML responses", async () => {
1185+
const { response } = await getTestResponse({
1186+
request: "https://example.com/style.css",
1187+
metadata: createMetadataObject({
1188+
deploymentId: "mock-deployment-id",
1189+
webAnalyticsToken: "test-analytics-token",
1190+
}) as Metadata,
1191+
findAssetEntryForPath: findStyleCssAssetEntryForPath,
1192+
fetchAsset: fetchCssAsset,
1193+
});
1194+
1195+
expect(response.status).toBe(200);
1196+
expect(response.headers.get("x-cf-pages-analytics")).toBeNull();
1197+
expect(response.headers.get("content-type")).toBe("text/css");
1198+
1199+
const responseText = await response.text();
1200+
expect(responseText).not.toContain("data-cf-beacon");
1201+
expect(responseText).toContain("font-family: Arial");
1202+
});
1203+
1204+
test("should not emit header when xWebAnalyticsHeader is false", async () => {
1205+
const { response } = await getTestResponse({
1206+
request: "https://example.com/",
1207+
metadata: createMetadataObject({
1208+
deploymentId: "mock-deployment-id",
1209+
webAnalyticsToken: "test-analytics-token",
1210+
}) as Metadata,
1211+
findAssetEntryForPath: findIndexHtmlAssetEntryForPath,
1212+
fetchAsset: fetchHtmlAsset,
1213+
xWebAnalyticsHeader: false,
1214+
});
1215+
1216+
expect(response.status).toBe(200);
1217+
expect(response.headers.get("x-cf-pages-analytics")).toBeNull();
1218+
1219+
const responseText = await response.text();
1220+
expect(responseText).toContain(
1221+
'data-cf-beacon=\'{"token": "test-analytics-token"}\''
1222+
);
1223+
});
1224+
1225+
test("should not emit header when xWebAnalyticsHeader is undefined", async () => {
1226+
const { response } = await getTestResponse({
1227+
request: "https://example.com/",
1228+
metadata: createMetadataObject({
1229+
deploymentId: "mock-deployment-id",
1230+
webAnalyticsToken: "test-analytics-token",
1231+
}) as Metadata,
1232+
findAssetEntryForPath: findIndexHtmlAssetEntryForPath,
1233+
fetchAsset: fetchHtmlAsset,
1234+
xWebAnalyticsHeader: undefined,
1235+
});
1236+
1237+
expect(response.status).toBe(200);
1238+
expect(response.headers.get("x-cf-pages-analytics")).toBeNull();
1239+
1240+
const responseText = await response.text();
1241+
expect(responseText).toContain(
1242+
'data-cf-beacon=\'{"token": "test-analytics-token"}\''
1243+
);
1244+
});
10721245
});
10731246

10741247
interface HandlerSpies {
@@ -1121,6 +1294,7 @@ async function getTestResponse({
11211294
request: request instanceof Request ? request : new Request(request),
11221295
metadata,
11231296
xServerEnvHeader: "dev",
1297+
xWebAnalyticsHeader: options.xWebAnalyticsHeader,
11241298
logError: console.error,
11251299
findAssetEntryForPath: async (...args) => {
11261300
spies.findAssetEntryForPath++;

packages/pages-shared/asset-server/handler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ type FullHandlerContext<AssetEntry, ContentNegotiation, Asset> = {
114114
metadata: Metadata;
115115
xServerEnvHeader?: string;
116116
xDeploymentIdHeader?: boolean;
117+
xWebAnalyticsHeader?: boolean;
117118
logError: (err: Error) => void;
118119
setMetrics?: (metrics: HandlerMetrics) => void;
119120
findAssetEntryForPath: FindAssetEntryForPath<AssetEntry>;
@@ -162,6 +163,7 @@ export async function generateHandler<
162163
metadata,
163164
xServerEnvHeader,
164165
xDeploymentIdHeader,
166+
xWebAnalyticsHeader,
165167
logError,
166168
setMetrics,
167169
findAssetEntryForPath,
@@ -642,6 +644,9 @@ export async function generateHandler<
642644
isHTMLContentType(asset.contentType) &&
643645
metadata.analytics?.version === ANALYTICS_VERSION
644646
) {
647+
if (xWebAnalyticsHeader) {
648+
response.headers.set("x-cf-pages-analytics", "1");
649+
}
645650
return new HTMLRewriter()
646651
.on("body", {
647652
element(e) {

packages/wrangler/src/miniflare-cli/assets.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ async function generateAssetsFetch(
206206
request: request as unknown as WorkersRequest,
207207
metadata: metadata as Metadata,
208208
xServerEnvHeader: "dev",
209+
xWebAnalyticsHeader: false,
209210
logError: console.error,
210211
findAssetEntryForPath: async (path) => {
211212
const filepath = resolve(join(directory, path));

0 commit comments

Comments
 (0)