Skip to content

Commit da166f2

Browse files
authored
Track negotiated Markdown asset fetches with shared GA4 edge analytics (#9624)
1 parent 17418b2 commit da166f2

File tree

25 files changed

+1401
-320
lines changed

25 files changed

+1401
-320
lines changed

.cspell.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ words:
8585
# WARNING: the list below gets sorted by CI scripts. Any comment lines
8686
# will be pushed to the top here.
8787
- attrlink
88+
- chalin
8889
- Crossplane
8990
- crosspost
9091
- desaturate
@@ -95,6 +96,7 @@ words:
9596
- htmltest
9697
- jsonify
9798
- lfasiallc
99+
- llms
98100
- Loffay
99101
- logback
100102
- Mancuso

content/en/blog/2026/2025-year-in-review/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ author: >-
77
Hrabusa](https://github.com/tiffany76) (Grafana Labs)
88
sig: Comms
99
# prettier-ignore
10-
cSpell:ignore: chalin Hrabusa jaydeluca Msksgm Sugimoto triager triagers Vasconcellos vitorvasc windsonsea
10+
cSpell:ignore: Hrabusa jaydeluca Msksgm Sugimoto triager triagers Vasconcellos vitorvasc windsonsea
1111
---
1212

1313
As 2025 has come to an end, we're taking a moment to look back at everything the

content/en/docs/contributing/sig-practices.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description:
55
Learn how approvers and maintainers manage issues and contributions.
66
weight: 999
77
# prettier-ignore
8-
cSpell:ignore: chalin Comms contribfest docsy hotfixes inactivitiy onboarded triager triagers
8+
cSpell:ignore: Comms contribfest docsy hotfixes inactivitiy onboarded triager triagers
99
---
1010

1111
This pages includes guidelines and some common practices used by approvers and

content/es/docs/contributing/sig-practices.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ linkTitle: Prácticas del SIG
44
description:
55
Aprende cómo los aprobadores y mantenedores gestionan issues y contribuciones.
66
weight: 999
7-
default_lang_commit: 400dcdabbc210eb25cda6c864110127ad6229da8
8-
cSpell:ignore: chalin Comms contribfest docsy hotfixes triager triagers
7+
default_lang_commit: 400dcdabbc210eb25cda6c864110127ad6229da8 # patched
8+
cSpell:ignore: Comms contribfest docsy hotfixes triager triagers
99
---
1010

1111
Esta página incluye pautas y algunas prácticas comunes utilizadas por

content/ja/docs/contributing/sig-practices.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ title: 承認者およびメンテナーのための SIG のプラクティス
33
linkTitle: SIG のプラクティス
44
description: 承認者およびメンテナーがどのようにイシューやコントリビューションを管理するかを学びます。
55
weight: 999
6-
default_lang_commit: 0cdf20f0dcbf7305541f8eab3001c95ce805fbc0
6+
default_lang_commit: 0cdf20f0dcbf7305541f8eab3001c95ce805fbc0 # patched
77
drifted_from_default: true
8-
cSpell:ignore: chalin docsy
8+
cSpell:ignore: docsy
99
---
1010

1111
このページでは、承認者およびメンテナーが使用するガイドラインと一般的なプラクティスについて説明します。

content/pt/docs/contributing/sig-practices.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ linkTitle: Práticas do SIG
44
description:
55
Saiba como aprovadores e mantenedores gerenciam issues e contribuições.
66
weight: 999
7-
default_lang_commit: 6acef01464b667456e7ba6d151235e56d39c12ca
8-
cSpell:ignore: branch chalin Comms contribfest docsy mergeados
7+
default_lang_commit: 6acef01464b667456e7ba6d151235e56d39c12ca # patched
8+
cSpell:ignore: branch Comms contribfest docsy mergeados
99
---
1010

1111
Esta página inclui diretrizes e algumas práticas comuns utilizadas por

content/zh/docs/contributing/sig-practices.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ title: SIG 审批者与维护者的实践指南
33
linkTitle: SIG 实践
44
description: 了解审批者和维护者如何管理 Issue 和贡献内容。
55
weight: 999
6-
default_lang_commit: 2f850a610b5f7da5730265b32c25c9226dc09e5f
6+
default_lang_commit: 2f850a610b5f7da5730265b32c25c9226dc09e5f # patched
77
drifted_from_default: true
8-
cSpell:ignore: chalin Comms docsy
8+
cSpell:ignore: Comms docsy
99
---
1010

1111
本页包含审批者与维护者使用的指南和一些通用实践。

netlify.toml

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

26-
# Note: the schema-analytics Edge Function also sets this content-type for
27-
# /schemas/* 2xx responses, since Netlify custom headers don't reliably apply
28-
# once an Edge Function handles the route. This rule is kept as a fallback in
29-
# case the Edge Function is removed.
30-
[[headers]]
31-
for = "/schemas/:version"
32-
[headers.values]
33-
content-type = "application/yaml"
34-
3526
[[headers]]
3627
for = "/*"
3728
[headers.values]

netlify/edge-functions/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Netlify Edge Functions
2+
3+
This directory holds Edge Function entry points, shared helpers (`lib/`), and
4+
per-function folders (for example `markdown-negotiation/`, `schema-analytics/`).
5+
6+
## Node and `package.json`
7+
8+
[`package.json`](./package.json) in this directory sets `"type": "module"` so
9+
Node treats `*.test.ts` files here as ESM when using `node --test`, without
10+
changing module semantics for the rest of the repository.
11+
12+
## Tests
13+
14+
Per-function testing, if any, is documented in each folder’s README.
15+
16+
## Unit tests
17+
18+
To run all edge-function unit tests, from the repository root:
19+
20+
```console
21+
npm run test:edge-functions
22+
```
23+
24+
## Live tests
25+
26+
To run the live deployed-host tests:
27+
28+
```console
29+
npm run test:edge-functions:live -- [URL | PR_NUMBER]
30+
```
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// cSpell:ignore GOOGLEANALYTICS
2+
3+
import assert from 'node:assert/strict';
4+
import test from 'node:test';
5+
6+
import {
7+
enqueueAssetFetchEvent,
8+
normalizeContentType,
9+
resolveClientId,
10+
} from './ga4-asset-fetch.ts';
11+
12+
test('normalizeContentType extracts media type', () => {
13+
assert.equal(normalizeContentType('application/yaml'), 'application/yaml');
14+
assert.equal(normalizeContentType('text/html; charset=utf-8'), 'text/html');
15+
assert.equal(
16+
normalizeContentType(' Application/JSON ; q=0.9'),
17+
'application/json',
18+
);
19+
assert.equal(normalizeContentType(null), 'none');
20+
assert.equal(normalizeContentType(''), 'none');
21+
});
22+
23+
test('resolveClientId returns fallback when no GA cookie is present', () => {
24+
const request = new Request('https://example.com/', {
25+
headers: {},
26+
});
27+
assert.equal(resolveClientId(request), 'asset_fetch.anonymous');
28+
});
29+
30+
test('resolveClientId extracts client id from GA cookie', () => {
31+
const request = new Request('https://example.com/', {
32+
headers: { cookie: '_ga=GA1.1.123456789.1234567890' },
33+
});
34+
assert.equal(resolveClientId(request), '123456789.1234567890');
35+
});
36+
37+
test('resolveClientId returns raw value for non-standard GA cookie', () => {
38+
const request = new Request('https://example.com/', {
39+
headers: { cookie: '_ga=custom-value' },
40+
});
41+
assert.equal(resolveClientId(request), 'custom-value');
42+
});
43+
44+
test('resolveClientId finds GA cookie among multiple cookies', () => {
45+
const request = new Request('https://example.com/', {
46+
headers: { cookie: 'session=abc; _ga=GA1.2.111.222; other=xyz' },
47+
});
48+
assert.equal(resolveClientId(request), '111.222');
49+
});
50+
51+
test('enqueueAssetFetchEvent no-ops without waitUntil', () => {
52+
const request = new Request('https://example.com/schemas/1.40.0');
53+
// No waitUntil on context — should not throw.
54+
enqueueAssetFetchEvent(
55+
request,
56+
{},
57+
{
58+
asset_group: 'schema',
59+
asset_path: '/schemas/1.40.0',
60+
asset_ext: 'yaml',
61+
content_type: 'application/yaml',
62+
status_code: '200',
63+
},
64+
);
65+
});
66+
67+
test('enqueueAssetFetchEvent calls waitUntil with GA4 payload', async (t) => {
68+
const g = globalThis as Record<string, unknown>;
69+
const originalNetlify = g.Netlify;
70+
const originalFetch = globalThis.fetch;
71+
t.after(() => {
72+
g.Netlify = originalNetlify;
73+
globalThis.fetch = originalFetch;
74+
});
75+
76+
g.Netlify = {
77+
env: {
78+
get: (name: string) => {
79+
if (name === 'HUGO_SERVICES_GOOGLEANALYTICS_ID') return 'G-TEST123';
80+
if (name === 'GA4_API_SECRET') return 'secret-test';
81+
return undefined;
82+
},
83+
},
84+
};
85+
86+
let capturedBody: Record<string, unknown> | undefined;
87+
let capturedUrl: string | undefined;
88+
89+
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
90+
capturedUrl =
91+
input instanceof URL
92+
? input.toString()
93+
: typeof input === 'string'
94+
? input
95+
: input.url;
96+
if (init?.body) {
97+
capturedBody = JSON.parse(init.body as string);
98+
}
99+
return new Response('', { status: 200 });
100+
}) as typeof fetch;
101+
102+
let waitUntilPromise: Promise<unknown> | undefined;
103+
const context = {
104+
waitUntil: (p: Promise<unknown>) => {
105+
waitUntilPromise = p;
106+
},
107+
requestId: 'req-42',
108+
};
109+
110+
const request = new Request('https://example.com/schemas/1.40.0');
111+
112+
enqueueAssetFetchEvent(request, context, {
113+
asset_group: 'schema',
114+
asset_path: '/schemas/1.40.0',
115+
asset_ext: 'yaml',
116+
content_type: 'application/yaml',
117+
status_code: '200',
118+
});
119+
120+
assert.ok(waitUntilPromise, 'waitUntil should have been called');
121+
await waitUntilPromise;
122+
123+
assert.ok(capturedUrl, 'fetch should have been called');
124+
const url = new URL(capturedUrl!);
125+
assert.equal(url.searchParams.get('api_secret'), 'secret-test');
126+
assert.equal(url.searchParams.get('measurement_id'), 'G-TEST123');
127+
128+
assert.ok(capturedBody);
129+
assert.equal(capturedBody!.client_id, 'asset_fetch.anonymous');
130+
131+
const events = capturedBody!.events as Array<{
132+
name: string;
133+
params: Record<string, string>;
134+
}>;
135+
assert.equal(events.length, 1);
136+
assert.equal(events[0].name, 'asset_fetch');
137+
assert.equal(events[0].params.asset_group, 'schema');
138+
assert.equal(events[0].params.asset_path, '/schemas/1.40.0');
139+
assert.equal(events[0].params.asset_ext, 'yaml');
140+
assert.equal(events[0].params.content_type, 'application/yaml');
141+
assert.equal(events[0].params.status_code, '200');
142+
assert.equal(
143+
events[0].params.original_path,
144+
undefined,
145+
'undefined params should be stripped',
146+
);
147+
});

0 commit comments

Comments
 (0)