Skip to content

Track negotiated Markdown asset fetches with shared GA4 edge analytics#9624

Merged
vitorvasc merged 22 commits intoopen-telemetry:mainfrom
chalin:chalin-m24-md-nego-assert-ftch-2026-0409
Apr 10, 2026
Merged

Track negotiated Markdown asset fetches with shared GA4 edge analytics#9624
vitorvasc merged 22 commits intoopen-telemetry:mainfrom
chalin:chalin-m24-md-nego-assert-ftch-2026-0409

Conversation

@chalin
Copy link
Copy Markdown
Contributor

@chalin chalin commented Apr 10, 2026

  • Contributes to Address site observability and data access #9559
  • Supports Provide agent-friendly content & support #9449
  • Refactors schema GA4 sending into a shared edge-function helper
  • Keeps schema asset tracking behavior
  • Removes the fallback /schemas/:version Netlify header rule so the schema Edge Function owns YAML content-type preservation
  • Adds focused unit coverage
  • Emits asset_fetch for negotiated Markdown responses with asset_path and sparse original_path
  • Keeps direct .md pass-through tracking out of scope and documents it as a follow-up step
  • Makes index.html negotiation case-sensitive in the implementation
  • Adds live deployed-host checks for negotiated Markdown and schema delivery
  • Adds edge-function test/docs scaffolding and updates the asset analytics plan

✅ Live integration tests

$ npm run test:edge-functions:live -- 9624  

> test:edge-functions:live
> npm run _test:ef:live:markdown-negotiation; npm run _test:ef:live:schema-analytics 9624


> _test:ef:live:markdown-negotiation
> node netlify/edge-functions/markdown-negotiation/live-check.mjs

[live-check] https://opentelemetry.io
✔ GET /docs/concepts/resources/ with Accept: text/markdown → markdown + Vary: Accept (249.630042ms)
✔ GET same URL with HTML preferred → HTML (32.118916ms)
✔ GET /docs/concepts/resources/index.html with Accept: text/markdown → markdown + Vary: Accept (67.080375ms)
﹣ GET /docs/concepts/resources/index.HTML with Accept: text/markdown → redirect (0.097625ms) # Deferred while clarifying Netlify handling of uppercase index.HTML paths
✔ GET /search/ with Accept: text/markdown → HTML fallback + Vary: Accept (81.552083ms)
✔ HEAD /docs/concepts/resources/ with Accept: text/markdown → empty body (80.459125ms)
✔ GET /docs.html → redirect toward /docs/ (85.047792ms)
ℹ tests 7
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 1
ℹ todo 0
ℹ duration_ms 634.614583

> _test:ef:live:schema-analytics
> node netlify/edge-functions/schema-analytics/live-check.mjs 9624

[live-check] https://deploy-preview-9624--opentelemetry.netlify.app
✔ GET /schemas/1.40.0 → YAML response (1282.923208ms)
✔ HEAD /schemas/1.40.0 → success with empty body (96.040417ms)
✔ GET /schemas/latest → redirect (72.237292ms)
✔ GET /schemas/does-not-exist → not found (127.020125ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1618.288791

@chalin chalin requested a review from a team as a code owner April 10, 2026 12:28
@github-actions github-actions bot added the dependencies Pull requests that update a dependency file label Apr 10, 2026
@otelbot-docs otelbot-docs bot added the missing:docs-approval Co-owning SIG has provided approval, PR needs approval from docs maintainer label Apr 10, 2026
@otelbot-docs otelbot-docs bot requested review from a team and theletterf and removed request for a team April 10, 2026 12:48
@otelbot-docs otelbot-docs bot added the missing:sig-approval Co-owning SIG didn't provide an approval label Apr 10, 2026
@otelbot-docs otelbot-docs bot requested a review from a team April 10, 2026 14:59
@chalin chalin force-pushed the chalin-m24-md-nego-assert-ftch-2026-0409 branch from e18d305 to 792c4e0 Compare April 10, 2026 15:05
@chalin chalin force-pushed the chalin-m24-md-nego-assert-ftch-2026-0409 branch 2 times, most recently from 5572cc7 to e7c5b40 Compare April 10, 2026 15:32
@chalin chalin force-pushed the chalin-m24-md-nego-assert-ftch-2026-0409 branch from e7c5b40 to d64e6a7 Compare April 10, 2026 15:33
Copy link
Copy Markdown
Contributor Author

@chalin chalin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot (Opus 4.6) review (FYI):


Summary

This PR does three main things:

  1. Extracts shared GA4 analytics into a reusable library (lib/ga4-asset-fetch.ts) — refactoring the inline GA4 Measurement Protocol code previously embedded in schema-analytics.ts into a shared module.
  2. Adds asset_fetch GA4 event tracking to the markdown-negotiation edge function — for negotiated Markdown responses (GET 2xx only).
  3. Housekeeping — makes index.html case-sensitivity consistent, adds test scaffolding, cleans up cSpell dictionaries, and updates the analytics plan document.

Consistency & Accuracy Observations

✅ Plan-to-code alignment is strong

The asset-fetch-analytics.plan.md specifies event parameters (asset_group, asset_path, asset_ext, content_type, status_code, original_path) and the code in both markdown-negotiation/index.ts and schema-analytics/index.ts faithfully sends exactly these. The plan's gating rules (GET-only, 2xx for Markdown, 2xx+3xx for schemas) are correctly implemented.

✅ Shared library extraction is clean

The refactored lib/ga4-asset-fetch.ts correctly reproduces the same logic that was previously inlined in schema-analytics.ts (cookie parsing, client ID resolution, GA4 payload construction, waitUntil-based sending). The old schema-analytics.ts on main directly called Netlify.env.get(); the new shared module wraps this in netlifyEnvGet() with a graceful fallback + warning when globalThis.Netlify is absent — making it testable under Node without the Netlify runtime.

isIndexHtmlPath case-sensitivity change is intentional and correct

On main, isIndexHtmlPath used .toLowerCase().endsWith('/index.html') (case-insensitive). The PR changes it to exact-match pathname.endsWith('/index.html') (case-sensitive). The resolveMarkdownPath regex was similarly narrowed from /index\.html$/i to /index\.html$/. This aligns with the plan's statement that "path resolution is case-sensitive by design" and normal URL-path semantics. Both functions are consistent with each other.

original_path conditional inclusion is correct

...(url.pathname !== assetPath ? { original_path: url.pathname } : {}),

This correctly omits original_path when the request path already matches the resolved .md path (e.g., a direct /foo/index.md request — though those are filtered out earlier by shouldConsiderRequest). The compactStringParams function in the library also strips undefined values as a belt-and-suspenders measure. Test coverage confirms both cases.

✅ Removed netlify.toml headers block is justified

The removed /schemas/:version content-type header block was a fallback in case the edge function was removed. Since ensureSchemaContentType in the refactored schema-analytics/index.ts still explicitly sets application/yaml for 2xx schema responses, removing the TOML fallback is consistent. The code comment explaining the rationale has been preserved in the function itself.

⚠️ Minor observation: content_type for Markdown events will always be text/markdown

In markdown-negotiation/index.ts lines 57 and 68:

headers.set('content-type', 'text/markdown; charset=utf-8');
// ...
content_type: normalizeContentType(headers.get('content-type') ?? ''),

The content-type header is set to text/markdown; charset=utf-8 on line 57, and normalizeContentType on line 68 reads from the same headers object, so content_type in the event will always be text/markdown. This is accurate — but it means the dimension is effectively constant for this asset group. Not a bug, just worth noting that this parameter carries no additional signal for Markdown events specifically. The plan doesn't require it to vary, so this is fine.

⚠️ No GA4 integration test for schema-analytics handler

The schema-analytics/index.test.ts handler integration tests don't verify that enqueueAssetFetchEvent is actually called (no waitUntil spy or mocked Netlify.env). The tests verify content-type fixup and the tracking gate thoroughly, but the actual GA4 send path is only integration-tested via live checks. This is an existing gap that predates this PR (the old code had no unit tests at all), so it's strictly an improvement — but worth noting for completeness.

✅ Test coverage is thorough and well-structured

  • lib/ga4-asset-fetch.test.ts: Covers normalizeContentType, resolveClientId (no cookie, standard GA cookie, non-standard, multiple cookies), enqueueAssetFetchEvent (no-op without waitUntil, full payload verification including undefined stripping).
  • markdown-negotiation/analytics.test.ts: Covers GET with original_path, GET index.html, direct .md pass-through, HEAD (no event), and markdown-unavailable fallback (no event).
  • schema-analytics/index.test.ts: Covers ensureSchemaContentType, shouldTrackSchemaFetch for various methods/statuses/content types, and handler integration.
  • negotiation.test.ts: Covers prefersMarkdownOverHtml preference logic.

netlify.toml edge function registration is correct

The markdown-negotiation function on /* runs first (for all paths), and schema-analytics runs on /schemas/*. Both are correctly registered. The schema function now imports from the shared lib via relative path (../lib/ga4-asset-fetch.ts), and the re-export shim schema-analytics.ts at the edge-functions root is preserved.

✅ cSpell changes are self-consistent

chalin is moved to .cspell.yml (global word list) and removed from per-file cSpell:ignore in multiple localized sig-practices.md files. llms is added globally. The # patched comments on default_lang_commit are a known convention in this repo for localized pages.

Verdict

The PR is well-structured and internally consistent. The code faithfully implements the analytics plan. The shared library extraction is clean, test coverage is comprehensive, and the case-sensitivity change is intentional and documented. The only minor observations are that content_type is constant for Markdown events (by design) and that the schema handler integration tests don't verify the GA4 call path (an existing gap, not a regression). Overall this looks solid.

@otelbot-docs otelbot-docs bot added ready-to-be-merged This PR is ready to be merged by a maintainer and removed missing:docs-approval Co-owning SIG has provided approval, PR needs approval from docs maintainer missing:sig-approval Co-owning SIG didn't provide an approval labels Apr 10, 2026
@vitorvasc vitorvasc added this pull request to the merge queue Apr 10, 2026
Merged via the queue into open-telemetry:main with commit da166f2 Apr 10, 2026
25 checks passed
@chalin chalin deleted the chalin-m24-md-nego-assert-ftch-2026-0409 branch April 10, 2026 16:49
@chalin
Copy link
Copy Markdown
Contributor Author

chalin commented Apr 10, 2026

Events are being generated for negotiated assets as we can see from this realtime screenshot:

image

Live checks are passing on the production server:

$ npm run test:edge-functions:live

> test:edge-functions:live
> npm run _test:ef:live:markdown-negotiation; npm run _test:ef:live:schema-analytics


> _test:ef:live:markdown-negotiation
> node netlify/edge-functions/markdown-negotiation/live-check.mjs

[live-check] https://opentelemetry.io
✔ GET /docs/concepts/resources/ with Accept: text/markdown → markdown + Vary: Accept (348.232083ms)
✔ GET same URL with HTML preferred → HTML (38.887542ms)
✔ GET /docs/concepts/resources/index.html with Accept: text/markdown → markdown + Vary: Accept (75.67125ms)
﹣ GET /docs/concepts/resources/index.HTML with Accept: text/markdown → redirect (0.06475ms) # Deferred while clarifying Netlify handling of uppercase index.HTML paths
✔ GET /search/ with Accept: text/markdown → HTML fallback + Vary: Accept (77.460541ms)
✔ HEAD /docs/concepts/resources/ with Accept: text/markdown → empty body (74.477292ms)
✔ GET /docs.html → redirect toward /docs/ (89.23575ms)
ℹ tests 7
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 1
ℹ todo 0
ℹ duration_ms 745.309292

> _test:ef:live:schema-analytics
> node netlify/edge-functions/schema-analytics/live-check.mjs

[live-check] https://opentelemetry.io
✔ GET /schemas/1.40.0 → YAML response (71.44525ms)
✔ HEAD /schemas/1.40.0 → success with empty body (45.604292ms)
✔ GET /schemas/latest → redirect (19.161125ms)
✔ GET /schemas/does-not-exist → not found (85.467166ms)
ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 308.886709

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

analytics+observability blog dependencies Pull requests that update a dependency file lang:es lang:ja lang:pt lang:zh ready-to-be-merged This PR is ready to be merged by a maintainer

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants