Skip to content

Commit e267ae0

Browse files
authored
Adds asset tracking for explicit-path .md and .txt assets, and add cross-function coverage (#9627)
1 parent da166f2 commit e267ae0

File tree

15 files changed

+889
-35
lines changed

15 files changed

+889
-35
lines changed

netlify.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ path = "/*"
2323
function = "schema-analytics"
2424
path = "/schemas/*"
2525

26+
[[edge_functions]]
27+
function = "asset-tracking"
28+
pattern = "/(.*)\\.(md|txt)$"
29+
30+
# cSpell:ignore nosniff sameorigin
31+
2632
[[headers]]
2733
for = "/*"
2834
[headers.values]

netlify/edge-functions/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# Netlify Edge Functions
22

33
This directory holds Edge Function entry points, shared helpers (`lib/`), and
4-
per-function folders (for example `markdown-negotiation/`, `schema-analytics/`).
4+
per-function folders (for example `markdown-negotiation/`, `asset-tracking/`,
5+
`schema-analytics/`).
56

67
## Node and `package.json`
78

@@ -28,3 +29,11 @@ To run the live deployed-host tests:
2829
```console
2930
npm run test:edge-functions:live -- [URL | PR_NUMBER]
3031
```
32+
33+
Per-function live test launchers:
34+
35+
```console
36+
npm run _test:ef:live:markdown-negotiation -- [URL | PR_NUMBER]
37+
npm run _test:ef:live:asset-tracking -- [URL | PR_NUMBER]
38+
npm run _test:ef:live:schema-analytics -- [URL | PR_NUMBER]
39+
```
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default } from './asset-tracking/index.ts';
2+
export * from './asset-tracking/index.ts';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Asset Tracking
2+
3+
This folder contains the asset-tracking Edge Function implementation and its
4+
tests.
5+
6+
It tracks explicit asset-path requests by extension using `context.next()`
7+
(currently `*.md` and `*.txt`) and emits GA4 `asset_fetch` events for tracked
8+
`GET` requests regardless of response status.
9+
10+
If the request includes `X-Asset-Fetch-Ga-Info`, the function treats it as an
11+
internal subrequest and skips tracking. This prevents duplicate events when
12+
`markdown-negotiation` fetches sibling `index.md` assets internally.
13+
14+
### Live tests
15+
16+
To run the live checks over a server:
17+
18+
```console
19+
npm run _test:ef:live:asset-tracking -- --help
20+
```
Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
/**
2+
* Tests for asset-tracking Edge Function:
3+
*
4+
* - tracking gate (shouldTrackAssetFetch)
5+
* - handler integration (assetTracking default export)
6+
*
7+
* cSpell:ignore GOOGLEANALYTICS
8+
*/
9+
10+
import assert from 'node:assert/strict';
11+
import test from 'node:test';
12+
13+
import {
14+
ASSET_FETCH_GA_INFO_HEADER,
15+
INTERNAL_ASSET_FETCH_GA_INFO_VALUE,
16+
} from '../lib/ga4-asset-fetch.ts';
17+
import assetTracking, { shouldTrackAssetFetch } from './index.ts';
18+
19+
function setupNetlifyEnv(t: { after: (fn: () => void) => void }) {
20+
const g = globalThis as Record<string, unknown>;
21+
const originalNetlify = g.Netlify;
22+
t.after(() => {
23+
g.Netlify = originalNetlify;
24+
});
25+
26+
g.Netlify = {
27+
env: {
28+
get: (name: string) => {
29+
if (name === 'HUGO_SERVICES_GOOGLEANALYTICS_ID') return 'G-TEST';
30+
if (name === 'GA4_API_SECRET') return 'secret';
31+
return undefined;
32+
},
33+
},
34+
};
35+
}
36+
37+
function setupFetchMock(t: { after: (fn: () => void) => void }) {
38+
const originalFetch = globalThis.fetch;
39+
t.after(() => {
40+
globalThis.fetch = originalFetch;
41+
});
42+
43+
const ga4Bodies: Record<string, unknown>[] = [];
44+
45+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
46+
const url =
47+
input instanceof URL
48+
? input.toString()
49+
: typeof input === 'string'
50+
? input
51+
: input.url;
52+
53+
if (url.includes('google-analytics.com')) {
54+
if (init?.body) {
55+
ga4Bodies.push(JSON.parse(init.body as string));
56+
}
57+
return new Response('', { status: 200 });
58+
}
59+
60+
return new Response('unexpected fetch', { status: 500 });
61+
}) as typeof fetch;
62+
63+
return ga4Bodies;
64+
}
65+
66+
function createWaitUntilSpy() {
67+
const promises: Promise<unknown>[] = [];
68+
return {
69+
waitUntil: (p: Promise<unknown>) => {
70+
promises.push(p);
71+
},
72+
flush: () => Promise.all(promises),
73+
};
74+
}
75+
76+
test('shouldTrackAssetFetch accepts GET .md requests', () => {
77+
const request = new Request(
78+
'https://example.com/docs/concepts/resources/index.md',
79+
);
80+
const response = new Response('# Resources', {
81+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
82+
status: 200,
83+
});
84+
85+
assert.equal(shouldTrackAssetFetch(request, response), true);
86+
});
87+
88+
test('shouldTrackAssetFetch accepts GET .txt requests', () => {
89+
const request = new Request('https://example.com/llms.txt');
90+
const response = new Response('OpenTelemetry', {
91+
headers: { 'content-type': 'text/plain; charset=utf-8' },
92+
status: 200,
93+
});
94+
95+
assert.equal(shouldTrackAssetFetch(request, response), true);
96+
});
97+
98+
test('shouldTrackAssetFetch returns false for internal marked requests', () => {
99+
const request = new Request(
100+
'https://example.com/docs/concepts/resources/index.md',
101+
{
102+
headers: {
103+
[ASSET_FETCH_GA_INFO_HEADER]: INTERNAL_ASSET_FETCH_GA_INFO_VALUE,
104+
},
105+
},
106+
);
107+
const response = new Response('# Resources', {
108+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
109+
status: 200,
110+
});
111+
112+
assert.equal(shouldTrackAssetFetch(request, response), false);
113+
});
114+
115+
test('shouldTrackAssetFetch returns false for non-GET methods', () => {
116+
for (const method of ['HEAD', 'POST']) {
117+
const request = new Request(
118+
'https://example.com/docs/concepts/resources/index.md',
119+
{ method },
120+
);
121+
const response = new Response('# Resources', {
122+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
123+
status: 200,
124+
});
125+
126+
assert.equal(shouldTrackAssetFetch(request, response), false);
127+
}
128+
});
129+
130+
test('shouldTrackAssetFetch accepts non-2xx responses for tracked assets', () => {
131+
for (const status of [301, 404, 500]) {
132+
const request = new Request(
133+
'https://example.com/docs/concepts/resources/index.md',
134+
);
135+
const response = new Response('', {
136+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
137+
status,
138+
});
139+
140+
assert.equal(shouldTrackAssetFetch(request, response), true);
141+
}
142+
});
143+
144+
test('shouldTrackAssetFetch accepts non-markdown content type for tracked assets', () => {
145+
const request = new Request(
146+
'https://example.com/docs/concepts/resources/index.md',
147+
);
148+
const response = new Response('<html></html>', {
149+
headers: { 'content-type': 'text/html' },
150+
status: 200,
151+
});
152+
153+
assert.equal(shouldTrackAssetFetch(request, response), true);
154+
});
155+
156+
test('shouldTrackAssetFetch returns false for non-tracked extensions', () => {
157+
const request = new Request(
158+
'https://example.com/docs/concepts/resources/index.html',
159+
);
160+
const response = new Response('<html></html>', {
161+
headers: { 'content-type': 'text/html' },
162+
status: 200,
163+
});
164+
165+
assert.equal(shouldTrackAssetFetch(request, response), false);
166+
});
167+
168+
test('handler emits asset_fetch for explicit .md requests', async (t) => {
169+
setupNetlifyEnv(t);
170+
const ga4Bodies = setupFetchMock(t);
171+
const spy = createWaitUntilSpy();
172+
173+
const response = await assetTracking(
174+
new Request('https://example.com/docs/concepts/resources/index.md'),
175+
{
176+
next: async () =>
177+
new Response('# Resources', {
178+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
179+
status: 200,
180+
}),
181+
...spy,
182+
},
183+
);
184+
185+
await spy.flush();
186+
187+
assert.equal(response.status, 200);
188+
assert.equal(ga4Bodies.length, 1);
189+
190+
const event = (
191+
ga4Bodies[0].events as { name: string; params: Record<string, string> }[]
192+
)[0];
193+
assert.equal(event.name, 'asset_fetch');
194+
assert.equal(event.params.asset_group, 'markdown');
195+
assert.equal(event.params.asset_path, '/docs/concepts/resources/index.md');
196+
assert.equal(event.params.asset_ext, 'md');
197+
assert.equal(event.params.content_type, 'text/markdown');
198+
assert.equal(event.params.status_code, '200');
199+
assert.ok(!('original_path' in event.params));
200+
});
201+
202+
test('handler emits asset_fetch for explicit .txt requests', async (t) => {
203+
setupNetlifyEnv(t);
204+
const ga4Bodies = setupFetchMock(t);
205+
const spy = createWaitUntilSpy();
206+
207+
const response = await assetTracking(
208+
new Request('https://example.com/llms.txt'),
209+
{
210+
next: async () =>
211+
new Response('OpenTelemetry', {
212+
headers: { 'content-type': 'text/plain; charset=utf-8' },
213+
status: 200,
214+
}),
215+
...spy,
216+
},
217+
);
218+
219+
await spy.flush();
220+
221+
assert.equal(response.status, 200);
222+
assert.equal(ga4Bodies.length, 1);
223+
224+
const event = (
225+
ga4Bodies[0].events as { name: string; params: Record<string, string> }[]
226+
)[0];
227+
assert.equal(event.name, 'asset_fetch');
228+
assert.equal(event.params.asset_group, 'text');
229+
assert.equal(event.params.asset_path, '/llms.txt');
230+
assert.equal(event.params.asset_ext, 'txt');
231+
assert.equal(event.params.content_type, 'text/plain');
232+
assert.equal(event.params.status_code, '200');
233+
assert.ok(!('original_path' in event.params));
234+
});
235+
236+
test('handler skips asset_fetch for internal marked explicit .md requests', async (t) => {
237+
setupNetlifyEnv(t);
238+
const ga4Bodies = setupFetchMock(t);
239+
const spy = createWaitUntilSpy();
240+
241+
const response = await assetTracking(
242+
new Request('https://example.com/docs/concepts/resources/index.md', {
243+
headers: {
244+
[ASSET_FETCH_GA_INFO_HEADER]: INTERNAL_ASSET_FETCH_GA_INFO_VALUE,
245+
},
246+
}),
247+
{
248+
next: async () =>
249+
new Response('# Resources', {
250+
headers: { 'content-type': 'text/markdown; charset=utf-8' },
251+
status: 200,
252+
}),
253+
...spy,
254+
},
255+
);
256+
257+
await spy.flush();
258+
259+
assert.equal(response.status, 200);
260+
assert.equal(ga4Bodies.length, 0);
261+
});
262+
263+
test('handler emits asset_fetch for explicit .md requests regardless of response status', async (t) => {
264+
setupNetlifyEnv(t);
265+
const ga4Bodies = setupFetchMock(t);
266+
const spy = createWaitUntilSpy();
267+
268+
const response = await assetTracking(
269+
new Request('https://example.com/docs/concepts/resources/index.md'),
270+
{
271+
next: async () =>
272+
new Response('missing', {
273+
headers: { 'content-type': 'text/plain; charset=utf-8' },
274+
status: 404,
275+
}),
276+
...spy,
277+
},
278+
);
279+
280+
await spy.flush();
281+
282+
assert.equal(response.status, 404);
283+
assert.equal(ga4Bodies.length, 1);
284+
285+
const event = (
286+
ga4Bodies[0].events as { name: string; params: Record<string, string> }[]
287+
)[0];
288+
assert.equal(event.name, 'asset_fetch');
289+
assert.equal(event.params.asset_group, 'markdown');
290+
assert.equal(event.params.asset_path, '/docs/concepts/resources/index.md');
291+
assert.equal(event.params.asset_ext, 'md');
292+
assert.equal(event.params.content_type, 'text/plain');
293+
assert.equal(event.params.status_code, '404');
294+
});

0 commit comments

Comments
 (0)