From 572eea9274c2d4d10fd128e79fec154e31cdcb4a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:45:56 +0000 Subject: [PATCH 1/4] ci(docs): Validate static-sites links against route changes Co-Authored-By: Shannon Anahata --- .../check-static-sites-doc-links.yml | 53 ++++ scripts/check-doc-link-contract.mjs | 157 ++++++++++ scripts/lib/doc-link-contract.mjs | 289 ++++++++++++++++++ scripts/lib/doc-link-contract.test.mjs | 87 ++++++ 4 files changed, 586 insertions(+) create mode 100644 .github/workflows/check-static-sites-doc-links.yml create mode 100644 scripts/check-doc-link-contract.mjs create mode 100644 scripts/lib/doc-link-contract.mjs create mode 100644 scripts/lib/doc-link-contract.test.mjs diff --git a/.github/workflows/check-static-sites-doc-links.yml b/.github/workflows/check-static-sites-doc-links.yml new file mode 100644 index 00000000000000..b150cdcc6e729b --- /dev/null +++ b/.github/workflows/check-static-sites-doc-links.yml @@ -0,0 +1,53 @@ +name: Check static-sites docs links + +on: + pull_request: + branches: [master] + paths: + - 'docs/**/*.md' + - 'docs/**/*.mdx' + - 'docs/platforms/**/config.yml' + - 'middleware.ts' + - 'redirects.js' + - 'scripts/check-doc-link-contract.mjs' + - 'scripts/lib/doc-link-contract.mjs' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + check-static-sites-doc-links: + name: Check static-sites docs links + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout sentry-docs + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + + - name: Fetch docs base commit + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: git fetch --no-tags --depth=1 origin "$BASE_SHA" + + - name: Checkout static-sites + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: getsentry/static-sites + ref: master + path: static-sites + fetch-depth: 1 + + - name: Check consumer links against candidate docs routes + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + node scripts/check-doc-link-contract.mjs \ + --docs-root . \ + --consumer-root static-sites/packages/www-astro \ + --baseline-ref "$BASE_SHA" \ + --only-if-route-changed true diff --git a/scripts/check-doc-link-contract.mjs b/scripts/check-doc-link-contract.mjs new file mode 100644 index 00000000000000..deaff58d3114e2 --- /dev/null +++ b/scripts/check-doc-link-contract.mjs @@ -0,0 +1,157 @@ +#!/usr/bin/env node + +import {execFileSync} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; + +import { + extractDocsLinks, + uniqueLinksByPath, + validateLinks, +} from './lib/doc-link-contract.mjs'; + +function parseArgs(argv) { + const args = {}; + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (!argument.startsWith('--')) continue; + const [key, inlineValue] = argument.slice(2).split('=', 2); + args[key] = inlineValue ?? argv[++index]; + } + return args; +} + +function checkoutWorktree(repoRoot, ref, prefix) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + execFileSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', tempRoot, ref], { + stdio: 'ignore', + }); + return { + path: tempRoot, + cleanup() { + execFileSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', tempRoot], { + stdio: 'ignore', + }); + }, + }; +} + +function missingByPath(results) { + return new Map( + results + .filter(result => result.resolution.status !== 'valid') + .filter(result => result.resolution.status !== 'external') + .map(result => [result.pathname, result]) + ); +} + +function routeTopologyChanged(docsRoot, baseRef) { + const output = execFileSync( + 'git', + ['-C', docsRoot, 'diff', '--name-status', '--find-renames', baseRef, 'HEAD'], + {encoding: 'utf8'} + ); + + return output.split('\n').some(line => { + if (!line) return false; + const [status, ...files] = line.split('\t'); + if (files.some(file => file === 'redirects.js' || file === 'middleware.ts')) + return true; + if ( + files.some(file => + /^docs\/platforms\/[^/]+\/(?:guides\/[^/]+\/)?config\.yml$/.test(file) + ) + ) { + return true; + } + return ( + /^[ADR]/.test(status) && files.some(file => /^docs\/.*\.(?:md|mdx)$/.test(file)) + ); + }); +} + +function printIssues(title, issues) { + if (issues.length === 0) return; + console.log(`\n${title} (${issues.length}):`); + for (const issue of issues) { + console.log(`- ${issue.raw}`); + console.log(` ${issue.file}:${issue.line}`); + console.log(` ${issue.resolution.chain.join(' -> ')} [${issue.resolution.status}]`); + } +} + +const args = parseArgs(process.argv.slice(2)); +const docsRoot = path.resolve(args['docs-root'] || process.cwd()); +const consumerRoot = path.resolve(args['consumer-root'] || '.'); +const baselineRef = args['baseline-ref']; +const consumerBaselineRef = args['consumer-baseline-ref']; +const consumerRepoRoot = path.resolve(args['consumer-repo-root'] || consumerRoot); + +if (!fs.existsSync(path.join(docsRoot, 'redirects.js'))) { + console.error(`docs root does not look like sentry-docs: ${docsRoot}`); + process.exit(2); +} +if (!fs.existsSync(consumerRoot)) { + console.error(`consumer root does not exist: ${consumerRoot}`); + process.exit(2); +} + +if ( + args['only-if-route-changed'] && + baselineRef && + !routeTopologyChanged(docsRoot, baselineRef) +) { + console.log('No docs route-topology changes detected; skipping cross-repo link check.'); + process.exit(0); +} + +const links = [...uniqueLinksByPath(extractDocsLinks(consumerRoot)).values()]; +const candidateResults = validateLinks(docsRoot, links); +const candidateMissing = missingByPath(candidateResults); +let regressions = [...candidateMissing.values()]; +let existing = []; + +if (baselineRef) { + const baseline = checkoutWorktree(docsRoot, baselineRef, 'sentry-docs-baseline-'); + try { + const baselineMissing = missingByPath(validateLinks(baseline.path, links)); + regressions = [...candidateMissing.values()].filter( + issue => !baselineMissing.has(issue.pathname) + ); + existing = [...candidateMissing.values()].filter(issue => + baselineMissing.has(issue.pathname) + ); + } finally { + baseline.cleanup(); + } +} else if (consumerBaselineRef) { + const baseline = checkoutWorktree( + consumerRepoRoot, + consumerBaselineRef, + 'docs-consumer-baseline-' + ); + try { + const relativeConsumerRoot = path.relative(consumerRepoRoot, consumerRoot); + const baselineConsumerRoot = path.join(baseline.path, relativeConsumerRoot); + const baselinePaths = uniqueLinksByPath(extractDocsLinks(baselineConsumerRoot)); + regressions = [...candidateMissing.values()].filter( + issue => !baselinePaths.has(issue.pathname) + ); + existing = [...candidateMissing.values()].filter(issue => + baselinePaths.has(issue.pathname) + ); + } finally { + baseline.cleanup(); + } +} + +console.log(`Validated ${links.length} unique docs.sentry.io paths.`); +printIssues('Newly broken links', regressions); +if (existing.length) { + console.log(`\nExisting unresolved paths (not blocking): ${existing.length}`); +} + +if (regressions.length) process.exit(1); +console.log('\nNo newly broken docs links.'); diff --git a/scripts/lib/doc-link-contract.mjs b/scripts/lib/doc-link-contract.mjs new file mode 100644 index 00000000000000..ace53d77b175e8 --- /dev/null +++ b/scripts/lib/doc-link-contract.mjs @@ -0,0 +1,289 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {createRequire} from 'node:module'; + +const require = createRequire(import.meta.url); + +const CONSUMER_EXTENSIONS = new Set([ + '.astro', + '.js', + '.json', + '.jsx', + '.md', + '.mdx', + '.mjs', + '.ts', + '.tsx', + '.txt', + '.xml', + '.yaml', + '.yml', +]); + +const SKIPPED_DIRECTORIES = new Set(['.git', '.next', 'dist', 'node_modules']); + +function normalizePathname(value) { + let pathname = value || '/'; + if (!pathname.startsWith('/')) pathname = `/${pathname}`; + pathname = pathname.replace(/\/+/g, '/'); + if (pathname !== '/' && !pathname.endsWith('/')) pathname += '/'; + return pathname; +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function compileRedirect(redirect) { + const names = []; + const segments = redirect.source.split('/').filter(Boolean); + const parts = segments.map(segment => { + const star = segment.match(/^:(\w+)\*$/); + if (star) { + names.push(star[1]); + return '(.*)'; + } + + const param = segment.match(/^:(\w+)$/); + if (param) { + names.push(param[1]); + return '([^/]+)'; + } + + // Next redirect sources occasionally use an explicit segment alternation. + if (/^\([\w|-]+\)$/.test(segment)) { + names.push(null); + return segment; + } + + return escapeRegex(segment); + }); + + return { + ...redirect, + names, + regex: new RegExp(`^/${parts.join('/')}/?$`), + }; +} + +function applyRedirect(redirect, pathname) { + const match = redirect.regex.exec(pathname); + if (!match) return null; + + const params = new Map(); + redirect.names.forEach((name, index) => { + if (name) params.set(name, match[index + 1] || ''); + }); + + return redirect.destination + .replace(/:(\w+)\*/g, (_, name) => params.get(name) || '') + .replace(/:(\w+)/g, (_, name) => params.get(name) || ''); +} + +function parseMiddlewareRedirects(filePath) { + if (!fs.existsSync(filePath)) return []; + const source = fs.readFileSync(filePath, 'utf8'); + const match = source.match( + /const USER_DOCS_REDIRECTS:\s*Redirect\[\]\s*=\s*\[([\s\S]*?)\n\];/ + ); + if (!match) return []; + + return Array.from( + match[1].matchAll(/\{\s*from:\s*'([^']+)',\s*to:\s*'([^']+)',?\s*\}/g), + entry => ({source: entry[1], destination: entry[2], owner: 'middleware.ts'}) + ); +} + +export function loadRedirects(docsRoot) { + const absoluteDocsRoot = path.resolve(docsRoot); + const redirectsPath = path.join(absoluteDocsRoot, 'redirects.js'); + const middlewarePath = path.join(absoluteDocsRoot, 'middleware.ts'); + let configRedirects = []; + + if (fs.existsSync(redirectsPath)) { + const resolved = require.resolve(redirectsPath); + delete require.cache[resolved]; + const loaded = require(resolved); + configRedirects = (loaded.userDocsRedirects || []).map(redirect => ({ + ...redirect, + owner: 'redirects.js', + })); + } + + // next.config redirects run before middleware in production. Preserve that order. + return [...configRedirects, ...parseMiddlewareRedirects(middlewarePath)].map( + compileRedirect + ); +} + +function isDraft(filePath) { + const start = fs.readFileSync(filePath, 'utf8').slice(0, 2048); + return /^draft:\s*true\s*$/m.test(start); +} + +function isPageFile(filePath) { + return fs.existsSync(filePath) && !isDraft(filePath); +} + +function docsFileExists(docsRoot, relativePath) { + const base = path.join(docsRoot, 'docs', relativePath); + return [ + `${base}.md`, + `${base}.mdx`, + path.join(base, 'index.md'), + path.join(base, 'index.mdx'), + ].some(isPageFile); +} + +function appRouteExists(docsRoot, pathname) { + const relative = pathname.replace(/^\//, '').replace(/\/$/, ''); + if (!relative) return true; + const appPath = path.join(docsRoot, 'app', relative); + return ['page.js', 'page.jsx', 'page.ts', 'page.tsx'].some(file => + fs.existsSync(path.join(appPath, file)) + ); +} + +function configuredPlatformRouteExists(docsRoot, segments) { + if (segments[0] !== 'platforms' || segments.length < 2) return false; + const platformRoot = path.join(docsRoot, 'docs', 'platforms', segments[1]); + if (!fs.existsSync(path.join(platformRoot, 'config.yml'))) return false; + + if (segments.length === 2) { + return docsFileExists(docsRoot, `platforms/${segments[1]}/common/index`); + } + + if (segments[2] === 'guides' && segments.length === 4) { + return fs.existsSync(path.join(platformRoot, 'guides', segments[3], 'config.yml')); + } + + return false; +} + +export function routeExists(docsRoot, pathname) { + docsRoot = path.resolve(docsRoot); + const normalized = normalizePathname(pathname); + if (normalized === '/') return true; + + const relative = normalized.replace(/^\//, '').replace(/\/$/, ''); + if (docsFileExists(docsRoot, relative) || appRouteExists(docsRoot, normalized)) { + return true; + } + + const segments = relative.split('/'); + if (configuredPlatformRouteExists(docsRoot, segments)) return true; + if (segments[0] !== 'platforms' || segments.length < 3) return false; + + const platform = segments[1]; + if (segments[2] === 'guides' && segments.length >= 5) { + const guide = segments[3]; + const rest = segments.slice(4).join('/'); + return ( + docsFileExists(docsRoot, `platforms/${platform}/guides/${guide}/${rest}`) || + docsFileExists(docsRoot, `platforms/${platform}/common/${rest}`) + ); + } + + const rest = segments.slice(2).join('/'); + return docsFileExists(docsRoot, `platforms/${platform}/common/${rest}`); +} + +export function resolveDocsPath(docsRoot, pathname, maxHops = 10) { + const redirects = loadRedirects(docsRoot); + let current = normalizePathname(pathname); + const chain = [current]; + const seen = new Set(chain); + + for (let hop = 0; hop < maxHops; hop++) { + let redirect; + for (const candidate of redirects) { + if (candidate.regex.test(current)) { + redirect = candidate; + break; + } + } + if (!redirect) { + return { + chain, + finalPath: current, + status: routeExists(docsRoot, current) ? 'valid' : 'missing', + }; + } + + const destination = applyRedirect(redirect, current); + if (!destination) break; + if (/^https?:\/\//.test(destination)) { + return {chain: [...chain, destination], finalPath: destination, status: 'external'}; + } + + current = normalizePathname(destination.split(/[?#]/, 1)[0]); + chain.push(current); + if (seen.has(current)) { + return {chain, finalPath: current, status: 'cycle'}; + } + seen.add(current); + } + + return {chain, finalPath: current, status: 'too-many-redirects'}; +} + +function walkFiles(root) { + if (!fs.existsSync(root)) return []; + const files = []; + const stack = [root]; + while (stack.length) { + const current = stack.pop(); + for (const entry of fs.readdirSync(current, {withFileTypes: true})) { + if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue; + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) stack.push(fullPath); + else if (CONSUMER_EXTENSIONS.has(path.extname(entry.name))) files.push(fullPath); + } + } + return files; +} + +function lineForOffset(content, offset) { + return content.slice(0, offset).split('\n').length; +} + +export function extractDocsLinks(consumerRoot) { + const links = []; + const regex = /https?:\/\/docs\.sentry\.io\/[^\s"'<>\\)\]}]+/g; + + for (const filePath of walkFiles(consumerRoot)) { + const content = fs.readFileSync(filePath, 'utf8'); + for (const match of content.matchAll(regex)) { + const raw = match[0].replace(/[.,;:!?]+$/, '').replace(/&/g, '&'); + try { + const url = new URL(raw); + if (/[${}:]/.test(url.pathname) || url.pathname.startsWith('/_next/')) continue; + links.push({ + file: path.relative(consumerRoot, filePath), + line: lineForOffset(content, match.index || 0), + pathname: normalizePathname(decodeURIComponent(url.pathname)), + raw, + }); + } catch { + // Ignore malformed text that merely resembles a URL. + } + } + } + + return links; +} + +export function uniqueLinksByPath(links) { + const byPath = new Map(); + for (const link of links) { + if (!byPath.has(link.pathname)) byPath.set(link.pathname, link); + } + return byPath; +} + +export function validateLinks(docsRoot, links) { + return links.map(link => ({ + ...link, + resolution: resolveDocsPath(docsRoot, link.pathname), + })); +} diff --git a/scripts/lib/doc-link-contract.test.mjs b/scripts/lib/doc-link-contract.test.mjs new file mode 100644 index 00000000000000..413b26d92ef68a --- /dev/null +++ b/scripts/lib/doc-link-contract.test.mjs @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import {extractDocsLinks, resolveDocsPath, routeExists} from './doc-link-contract.mjs'; + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-link-contract-')); + fs.mkdirSync(path.join(root, 'docs', 'new'), {recursive: true}); + fs.writeFileSync(path.join(root, 'docs', 'new', 'index.mdx'), '---\ntitle: New\n---\n'); + fs.mkdirSync(path.join(root, 'docs', 'platforms', 'javascript', 'common'), { + recursive: true, + }); + fs.writeFileSync( + path.join(root, 'docs', 'platforms', 'javascript', 'config.yml'), + 'title: JavaScript\n' + ); + fs.writeFileSync( + path.join(root, 'docs', 'platforms', 'javascript', 'common', 'index.mdx'), + '---\ntitle: JavaScript\n---\n' + ); + fs.writeFileSync( + path.join(root, 'redirects.js'), + `module.exports = {userDocsRedirects: [ + {source: '/old/:path*', destination: '/new/:path*'}, + {source: '/broken/:path*', destination: '/missing/:path*'}, + ]};\n` + ); + fs.writeFileSync( + path.join(root, 'middleware.ts'), + 'const USER_DOCS_REDIRECTS: Redirect[] = [];\n' + ); + return root; +} + +test('resolves parameterized redirects to an existing page', () => { + const root = fixture(); + try { + assert.deepEqual(resolveDocsPath(root, '/old/'), { + chain: ['/old/', '/new/'], + finalPath: '/new/', + status: 'valid', + }); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); + +test('reports a redirect whose final route is missing', () => { + const root = fixture(); + try { + assert.equal(resolveDocsPath(root, '/broken/child/').status, 'missing'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); + +test('recognizes generated platform roots', () => { + const root = fixture(); + try { + assert.equal(routeExists(root, '/platforms/javascript/'), true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); + +test('extracts literal docs links and ignores templates', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-link-consumer-')); + try { + fs.writeFileSync( + path.join(root, 'links.ts'), + [ + 'const valid = "https://docs.sentry.io/product/issues/";', + 'const template = `https://docs.sentry.io/${path}`;', + 'const next = "https://docs.sentry.io/_next/:path*";', + ].join('\n') + ); + assert.deepEqual( + extractDocsLinks(root).map(link => link.pathname), + ['/product/issues/'] + ); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } +}); From 9dbad7e7cd1a29e2860c62da87a492d10e8e6e37 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:49:50 +0000 Subject: [PATCH 2/4] test(docs): convert doc-link-contract tests to vitest CI runs vitest, not node:test. Match the existing .mjs vitest pattern so the suite is discovered. Co-Authored-By: Shannon Anahata --- scripts/lib/doc-link-contract.test.mjs | 96 +++++++++++++------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/scripts/lib/doc-link-contract.test.mjs b/scripts/lib/doc-link-contract.test.mjs index 413b26d92ef68a..6b03b8132bfd9a 100644 --- a/scripts/lib/doc-link-contract.test.mjs +++ b/scripts/lib/doc-link-contract.test.mjs @@ -1,8 +1,7 @@ -import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import test from 'node:test'; +import {describe, expect, it} from 'vitest'; import {extractDocsLinks, resolveDocsPath, routeExists} from './doc-link-contract.mjs'; @@ -35,53 +34,54 @@ function fixture() { return root; } -test('resolves parameterized redirects to an existing page', () => { - const root = fixture(); - try { - assert.deepEqual(resolveDocsPath(root, '/old/'), { - chain: ['/old/', '/new/'], - finalPath: '/new/', - status: 'valid', - }); - } finally { - fs.rmSync(root, {recursive: true, force: true}); - } -}); +describe('doc-link-contract', () => { + it('resolves parameterized redirects to an existing page', () => { + const root = fixture(); + try { + expect(resolveDocsPath(root, '/old/')).toEqual({ + chain: ['/old/', '/new/'], + finalPath: '/new/', + status: 'valid', + }); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); -test('reports a redirect whose final route is missing', () => { - const root = fixture(); - try { - assert.equal(resolveDocsPath(root, '/broken/child/').status, 'missing'); - } finally { - fs.rmSync(root, {recursive: true, force: true}); - } -}); + it('reports a redirect whose final route is missing', () => { + const root = fixture(); + try { + expect(resolveDocsPath(root, '/broken/child/').status).toBe('missing'); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); -test('recognizes generated platform roots', () => { - const root = fixture(); - try { - assert.equal(routeExists(root, '/platforms/javascript/'), true); - } finally { - fs.rmSync(root, {recursive: true, force: true}); - } -}); + it('recognizes generated platform roots', () => { + const root = fixture(); + try { + expect(routeExists(root, '/platforms/javascript/')).toBe(true); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); -test('extracts literal docs links and ignores templates', () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-link-consumer-')); - try { - fs.writeFileSync( - path.join(root, 'links.ts'), - [ - 'const valid = "https://docs.sentry.io/product/issues/";', - 'const template = `https://docs.sentry.io/${path}`;', - 'const next = "https://docs.sentry.io/_next/:path*";', - ].join('\n') - ); - assert.deepEqual( - extractDocsLinks(root).map(link => link.pathname), - ['/product/issues/'] - ); - } finally { - fs.rmSync(root, {recursive: true, force: true}); - } + it('extracts literal docs links and ignores templates', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-link-consumer-')); + try { + fs.writeFileSync( + path.join(root, 'links.ts'), + [ + 'const valid = "https://docs.sentry.io/product/issues/";', + 'const template = `https://docs.sentry.io/${path}`;', + 'const next = "https://docs.sentry.io/_next/:path*";', + ].join('\n') + ); + expect(extractDocsLinks(root).map(link => link.pathname)).toEqual([ + '/product/issues/', + ]); + } finally { + fs.rmSync(root, {recursive: true, force: true}); + } + }); }); From ce1b460399054b3c4f2d661a4fc9f64a4a5152e7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:52:40 +0000 Subject: [PATCH 3/4] ci(docs): use internal app token to checkout private static-sites Default GITHUB_TOKEN cannot read getsentry/static-sites (private), so the cross-repo check failed at checkout with repository not found. Mint a scoped Sentry internal app token for the consumer checkout only. --- .github/workflows/check-static-sites-doc-links.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/check-static-sites-doc-links.yml b/.github/workflows/check-static-sites-doc-links.yml index b150cdcc6e729b..c7f43fa1a84949 100644 --- a/.github/workflows/check-static-sites-doc-links.yml +++ b/.github/workflows/check-static-sites-doc-links.yml @@ -11,6 +11,7 @@ on: - 'redirects.js' - 'scripts/check-doc-link-contract.mjs' - 'scripts/lib/doc-link-contract.mjs' + - '.github/workflows/check-static-sites-doc-links.yml' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} @@ -34,6 +35,17 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: git fetch --no-tags --depth=1 origin "$BASE_SHA" + # static-sites is private; default GITHUB_TOKEN cannot read it. + - name: Get token for static-sites checkout + id: static-sites-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 + with: + app-id: ${{ vars.SENTRY_INTERNAL_APP_ID }} + private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} + owner: getsentry + repositories: | + static-sites + - name: Checkout static-sites uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: @@ -41,6 +53,7 @@ jobs: ref: master path: static-sites fetch-depth: 1 + token: ${{ steps.static-sites-token.outputs.token }} - name: Check consumer links against candidate docs routes env: From e2cef213eed51b92f45cc7d7b16adaff2ec544e9 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:57:12 +0000 Subject: [PATCH 4/4] ci(docs): validate static-sites links from a committed inventory SENTRY_INTERNAL_APP is not installed on private static-sites, so docs CI cannot checkout the consumer repo. Commit the www-astro docs.sentry.io path inventory and resolve against that instead. Keep live consumer extraction on the static-sites side. --- .../check-static-sites-doc-links.yml | 27 +- scripts/check-doc-link-contract.mjs | 86 ++- .../static-sites-www-astro-docs-paths.json | 550 ++++++++++++++++++ 3 files changed, 632 insertions(+), 31 deletions(-) create mode 100644 scripts/data/static-sites-www-astro-docs-paths.json diff --git a/.github/workflows/check-static-sites-doc-links.yml b/.github/workflows/check-static-sites-doc-links.yml index c7f43fa1a84949..f64e1c1a00a8f5 100644 --- a/.github/workflows/check-static-sites-doc-links.yml +++ b/.github/workflows/check-static-sites-doc-links.yml @@ -11,6 +11,7 @@ on: - 'redirects.js' - 'scripts/check-doc-link-contract.mjs' - 'scripts/lib/doc-link-contract.mjs' + - 'scripts/data/static-sites-www-astro-docs-paths.json' - '.github/workflows/check-static-sites-doc-links.yml' concurrency: @@ -35,32 +36,14 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: git fetch --no-tags --depth=1 origin "$BASE_SHA" - # static-sites is private; default GITHUB_TOKEN cannot read it. - - name: Get token for static-sites checkout - id: static-sites-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3 - with: - app-id: ${{ vars.SENTRY_INTERNAL_APP_ID }} - private-key: ${{ secrets.SENTRY_INTERNAL_APP_PRIVATE_KEY }} - owner: getsentry - repositories: | - static-sites - - - name: Checkout static-sites - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - repository: getsentry/static-sites - ref: master - path: static-sites - fetch-depth: 1 - token: ${{ steps.static-sites-token.outputs.token }} - - - name: Check consumer links against candidate docs routes + # static-sites is private and SENTRY_INTERNAL_APP is not installed there, so docs CI + # validates a committed path inventory instead of checking out the consumer repo. + - name: Check inventory links against candidate docs routes env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | node scripts/check-doc-link-contract.mjs \ --docs-root . \ - --consumer-root static-sites/packages/www-astro \ + --links-file scripts/data/static-sites-www-astro-docs-paths.json \ --baseline-ref "$BASE_SHA" \ --only-if-route-changed true diff --git a/scripts/check-doc-link-contract.mjs b/scripts/check-doc-link-contract.mjs index deaff58d3114e2..9d6eac8963fbc3 100644 --- a/scripts/check-doc-link-contract.mjs +++ b/scripts/check-doc-link-contract.mjs @@ -57,8 +57,16 @@ function routeTopologyChanged(docsRoot, baseRef) { return output.split('\n').some(line => { if (!line) return false; const [status, ...files] = line.split('\t'); - if (files.some(file => file === 'redirects.js' || file === 'middleware.ts')) + if ( + files.some( + file => + file === 'redirects.js' || + file === 'middleware.ts' || + file === 'scripts/data/static-sites-www-astro-docs-paths.json' + ) + ) { return true; + } if ( files.some(file => /^docs\/platforms\/[^/]+\/(?:guides\/[^/]+\/)?config\.yml$/.test(file) @@ -77,26 +85,68 @@ function printIssues(title, issues) { console.log(`\n${title} (${issues.length}):`); for (const issue of issues) { console.log(`- ${issue.raw}`); - console.log(` ${issue.file}:${issue.line}`); + if (issue.file) { + console.log(` ${issue.file}:${issue.line || 1}`); + } console.log(` ${issue.resolution.chain.join(' -> ')} [${issue.resolution.status}]`); } } +function loadLinksFromFile(linksFile) { + const payload = JSON.parse(fs.readFileSync(linksFile, 'utf8')); + const paths = Array.isArray(payload) ? payload : payload.paths; + if (!Array.isArray(paths)) { + throw new Error(`links file must be an array or {paths: []}: ${linksFile}`); + } + + const relative = path.basename(linksFile); + return paths.map(pathname => ({ + file: relative, + line: 1, + pathname, + raw: `https://docs.sentry.io${pathname}`, + })); +} + +function writeLinksFile(linksFile, consumerRoot, links) { + const payload = { + source: consumerRoot, + generated_at: new Date().toISOString(), + generated_note: + 'Regenerate from a static-sites checkout: node scripts/check-doc-link-contract.mjs --consumer-root /packages/www-astro --write-links-file scripts/data/static-sites-www-astro-docs-paths.json', + paths: [...uniqueLinksByPath(links).keys()].sort(), + }; + fs.mkdirSync(path.dirname(linksFile), {recursive: true}); + fs.writeFileSync(linksFile, `${JSON.stringify(payload, null, 2)}\n`); + console.log(`Wrote ${payload.paths.length} paths to ${linksFile}`); +} + const args = parseArgs(process.argv.slice(2)); const docsRoot = path.resolve(args['docs-root'] || process.cwd()); -const consumerRoot = path.resolve(args['consumer-root'] || '.'); +const linksFile = args['links-file'] ? path.resolve(args['links-file']) : null; +const writeLinksFilePath = args['write-links-file'] + ? path.resolve(args['write-links-file']) + : null; +const consumerRoot = args['consumer-root'] ? path.resolve(args['consumer-root']) : null; const baselineRef = args['baseline-ref']; const consumerBaselineRef = args['consumer-baseline-ref']; -const consumerRepoRoot = path.resolve(args['consumer-repo-root'] || consumerRoot); +const consumerRepoRoot = path.resolve( + args['consumer-repo-root'] || consumerRoot || process.cwd() +); + +if (writeLinksFilePath) { + if (!consumerRoot || !fs.existsSync(consumerRoot)) { + console.error(`--write-links-file requires an existing --consumer-root`); + process.exit(2); + } + writeLinksFile(writeLinksFilePath, consumerRoot, extractDocsLinks(consumerRoot)); + process.exit(0); +} if (!fs.existsSync(path.join(docsRoot, 'redirects.js'))) { console.error(`docs root does not look like sentry-docs: ${docsRoot}`); process.exit(2); } -if (!fs.existsSync(consumerRoot)) { - console.error(`consumer root does not exist: ${consumerRoot}`); - process.exit(2); -} if ( args['only-if-route-changed'] && @@ -107,7 +157,21 @@ if ( process.exit(0); } -const links = [...uniqueLinksByPath(extractDocsLinks(consumerRoot)).values()]; +let links; +if (linksFile) { + if (!fs.existsSync(linksFile)) { + console.error(`links file does not exist: ${linksFile}`); + process.exit(2); + } + links = loadLinksFromFile(linksFile); +} else { + if (!consumerRoot || !fs.existsSync(consumerRoot)) { + console.error(`consumer root does not exist: ${consumerRoot}`); + process.exit(2); + } + links = [...uniqueLinksByPath(extractDocsLinks(consumerRoot)).values()]; +} + const candidateResults = validateLinks(docsRoot, links); const candidateMissing = missingByPath(candidateResults); let regressions = [...candidateMissing.values()]; @@ -127,6 +191,10 @@ if (baselineRef) { baseline.cleanup(); } } else if (consumerBaselineRef) { + if (!consumerRoot) { + console.error(`--consumer-baseline-ref requires --consumer-root`); + process.exit(2); + } const baseline = checkoutWorktree( consumerRepoRoot, consumerBaselineRef, diff --git a/scripts/data/static-sites-www-astro-docs-paths.json b/scripts/data/static-sites-www-astro-docs-paths.json new file mode 100644 index 00000000000000..5be4af9e9da87a --- /dev/null +++ b/scripts/data/static-sites-www-astro-docs-paths.json @@ -0,0 +1,550 @@ +{ + "source": "getsentry/static-sites packages/www-astro", + "generated_at": "2026-07-30T20:56:13.741Z", + "generated_note": "Regenerate from a static-sites checkout: node scripts/check-doc-link-contract.mjs --consumer-root /packages/www-astro --write-links-file scripts/data/static-sites-www-astro-docs-paths.json", + "paths": [ + "/", + "/accounts/membership/", + "/accounts/sso/", + "/ai/", + "/ai/agent-plugin/", + "/ai/mcp/", + "/ai/monitoring/", + "/ai/monitoring/agents/", + "/ai/monitoring/agents/dashboards/", + "/ai/monitoring/conversations/", + "/ai/monitoring/mcp/dashboard/", + "/ai/monitoring/mcp/getting-started/", + "/api/", + "/api/auth/", + "/api/projects/", + "/api/projects/delete-a-project/", + "/api/projects/post-project-keys/", + "/api/projects/submit-user-feedback/", + "/api/teams/", + "/cli/", + "/cli/installation/", + "/clients/", + "/clients/cocoa/", + "/clients/javascript/sourcemaps/", + "/clients/javascript/tips/", + "/clients/node/typescript/", + "/concepts/data-management/event-grouping/", + "/concepts/key-terms/enrich-data/", + "/concepts/key-terms/tracing/", + "/concepts/key-terms/tracing/distributed-tracing/", + "/concepts/key-terms/tracing/span-metrics/", + "/concepts/key-terms/tracing/trace-view/", + "/concepts/otlp/", + "/concepts/otlp/direct/", + "/concepts/otlp/forwarding/", + "/concepts/otlp/forwarding/pipelines/sentry-exporter/", + "/concepts/otlp/sentry-with-otel/", + "/enriching-error-data/breadcrumbs/", + "/enriching-error-data/context/", + "/enriching-error-data/environments/", + "/error-reporting/configuration/", + "/error-reporting/quickstart/", + "/getting-started/", + "/guides/", + "/hosted/clients/javascript/sourcemaps/", + "/hosted/clients/ruby/", + "/hosted/learn/context/", + "/hosted/learn/sensitive-data/", + "/integrations/coding-agents/", + "/integrations/coding-agents/claude/", + "/integrations/compliance/vanta/", + "/integrations/cursor/", + "/integrations/data-forwarding/amazon-sqs/", + "/integrations/data-forwarding/splunk/", + "/integrations/debugging/rookout/", + "/integrations/deployment/expo/", + "/integrations/deployment/vercel/", + "/integrations/feature-flag/launchdarkly/", + "/integrations/feature-flag/split/", + "/integrations/integration-platform/", + "/integrations/integration-platform/ui-components/stacktrace-link/", + "/integrations/issue-tracking/asana/", + "/integrations/issue-tracking/clickup/", + "/integrations/issue-tracking/incidentio/", + "/integrations/issue-tracking/jira/", + "/integrations/issue-tracking/linear/", + "/integrations/issue-tracking/sentry-linear-agent/", + "/integrations/issue-tracking/shortcut/", + "/integrations/issue-tracking/teamwork/", + "/integrations/notification-incidents/opsgenie/", + "/integrations/notification-incidents/pagerduty/", + "/integrations/notification-incidents/pagertree/", + "/integrations/notification-incidents/rootly/", + "/integrations/notification-incidents/slack/", + "/integrations/notification-incidents/spikesh/", + "/integrations/session-replay/fullstory/", + "/integrations/session-replay/jam/", + "/integrations/slack/", + "/integrations/source-code-mgmt/", + "/integrations/source-code-mgmt/azure-devops/", + "/integrations/source-code-mgmt/bitbucket/", + "/integrations/source-code-mgmt/github/", + "/integrations/source-code-mgmt/gitlab/", + "/learn/breadcrumbs/", + "/learn/context/", + "/learn/issue-owners/", + "/learn/membership/", + "/learn/rollups/", + "/learn/sensitive-data/", + "/learn/sso/", + "/organization/", + "/organization/data-storage-location/", + "/organization/integrations/notification-incidents/slack/", + "/platform-redirect/", + "/platforms/", + "/platforms/android/", + "/platforms/android/configuration/app-not-respond/", + "/platforms/android/configuration/integrations/jetpack-compose/", + "/platforms/android/data-management/data-collected/", + "/platforms/android/enriching-events/breadcrumbs/", + "/platforms/android/enriching-events/screenshots/", + "/platforms/android/integrations/jetpack-compose/", + "/platforms/android/logs/", + "/platforms/android/manual-setup/", + "/platforms/android/performance/", + "/platforms/android/profiling/", + "/platforms/android/session-replay/", + "/platforms/android/user-feedback/", + "/platforms/android/user-feedback/configuration/", + "/platforms/android/using-ndk/", + "/platforms/apple/", + "/platforms/apple/data-management/data-collected/", + "/platforms/apple/dsym/", + "/platforms/apple/guides/ios/", + "/platforms/apple/guides/ios/manual-setup/", + "/platforms/apple/guides/ios/performance/instrumentation/swiftui-instrumentation/", + "/platforms/apple/guides/ios/session-replay/", + "/platforms/apple/guides/ios/user-feedback/", + "/platforms/apple/guides/macos/", + "/platforms/apple/guides/tvos/", + "/platforms/apple/guides/visionos/", + "/platforms/apple/guides/watchos/", + "/platforms/apple/install/swift-package-manager/", + "/platforms/cocoa/", + "/platforms/dart/", + "/platforms/dart/guides/flutter/", + "/platforms/dart/guides/flutter/data-management/data-collected/", + "/platforms/dart/guides/flutter/manual-setup/", + "/platforms/dart/guides/flutter/session-replay/", + "/platforms/dotnet/", + "/platforms/dotnet/guides/aspnet/", + "/platforms/dotnet/guides/aspnetcore/", + "/platforms/dotnet/guides/extensions-logging/", + "/platforms/dotnet/guides/maui/", + "/platforms/dotnet/guides/xamarin/", + "/platforms/dotnet/guides/xamarin/performance/", + "/platforms/dotnet/tracing/instrumentation/opentelemetry/", + "/platforms/elixir/", + "/platforms/flutter/", + "/platforms/flutter/session-replay/", + "/platforms/flutter/user-feedback/", + "/platforms/go/", + "/platforms/go/crons/", + "/platforms/go/data-management/data-collected/", + "/platforms/go/guides/echo/performance/instrumentation/opentelemetry/", + "/platforms/go/tracing/instrumentation/opentelemetry/", + "/platforms/godot/", + "/platforms/java/", + "/platforms/java/crons/", + "/platforms/java/guides/log4j2/", + "/platforms/java/guides/logback/", + "/platforms/java/guides/spring-boot/", + "/platforms/java/guides/spring-boot/crons/", + "/platforms/java/guides/spring-boot/data-management/data-collected/", + "/platforms/java/guides/spring/", + "/platforms/java/integrations/graphql/", + "/platforms/java/logs/", + "/platforms/java/tracing/instrumentation/opentelemetry/", + "/platforms/javascript/", + "/platforms/javascript/configuration/filtering/", + "/platforms/javascript/configuration/integrations/", + "/platforms/javascript/configuration/integrations/graphqlclient/", + "/platforms/javascript/configuration/options/", + "/platforms/javascript/configuration/releases/", + "/platforms/javascript/data-management/sensitive-data/", + "/platforms/javascript/electron/", + "/platforms/javascript/enriching-events/attachments/", + "/platforms/javascript/enriching-events/tags/", + "/platforms/javascript/guides/angular/", + "/platforms/javascript/guides/angular/configuration/options/", + "/platforms/javascript/guides/angular/session-replay/", + "/platforms/javascript/guides/angular/user-feedback/", + "/platforms/javascript/guides/astro/", + "/platforms/javascript/guides/astro/configuration/integrations/vercelai/", + "/platforms/javascript/guides/astro/configuration/options/", + "/platforms/javascript/guides/astro/manual-setup/", + "/platforms/javascript/guides/aws-lambda/", + "/platforms/javascript/guides/aws-lambda/configuration/integrations/vercelai/", + "/platforms/javascript/guides/azure-functions/", + "/platforms/javascript/guides/azure-functions/configuration/integrations/vercelai/", + "/platforms/javascript/guides/backbone/", + "/platforms/javascript/guides/bun/configuration/integrations/vercelai/", + "/platforms/javascript/guides/capacitor/session-replay/", + "/platforms/javascript/guides/cloudflare/", + "/platforms/javascript/guides/cloudflare/frameworks/hydrogen/", + "/platforms/javascript/guides/cloudflare/frameworks/pages/", + "/platforms/javascript/guides/cloudflare/frameworks/workers/", + "/platforms/javascript/guides/connect/configuration/integrations/vercelai/", + "/platforms/javascript/guides/connect/performance/database/", + "/platforms/javascript/guides/connect/typescript/", + "/platforms/javascript/guides/cordova/configuration/options/", + "/platforms/javascript/guides/deno/", + "/platforms/javascript/guides/electron/", + "/platforms/javascript/guides/electron/configuration/integrations/vercelai/", + "/platforms/javascript/guides/electron/session-replay/", + "/platforms/javascript/guides/ember/", + "/platforms/javascript/guides/ember/session-replay/", + "/platforms/javascript/guides/express/", + "/platforms/javascript/guides/express/configuration/integrations/vercelai/", + "/platforms/javascript/guides/fastify/configuration/integrations/vercelai/", + "/platforms/javascript/guides/gatsby/session-replay/", + "/platforms/javascript/guides/gcp-functions/", + "/platforms/javascript/guides/gcp-functions/configuration/integrations/vercelai/", + "/platforms/javascript/guides/hapi/configuration/integrations/vercelai/", + "/platforms/javascript/guides/hono/configuration/integrations/vercelai/", + "/platforms/javascript/guides/koa/", + "/platforms/javascript/guides/koa/configuration/integrations/vercelai/", + "/platforms/javascript/guides/nestjs/configuration/integrations/vercelai/", + "/platforms/javascript/guides/nextjs/", + "/platforms/javascript/guides/nextjs/ai-agent-monitoring/", + "/platforms/javascript/guides/nextjs/configuration/integrations/supabase/", + "/platforms/javascript/guides/nextjs/configuration/integrations/vercelai/", + "/platforms/javascript/guides/nextjs/configuration/options/", + "/platforms/javascript/guides/nextjs/configuration/releases/", + "/platforms/javascript/guides/nextjs/feature-flags/", + "/platforms/javascript/guides/nextjs/logs/", + "/platforms/javascript/guides/nextjs/opentelemetry/custom-setup/", + "/platforms/javascript/guides/nextjs/opentelemetry/using-opentelemetry-apis/", + "/platforms/javascript/guides/nextjs/session-replay/", + "/platforms/javascript/guides/nextjs/session-replay/privacy/", + "/platforms/javascript/guides/nextjs/sourcemaps/", + "/platforms/javascript/guides/nextjs/tracing/", + "/platforms/javascript/guides/nextjs/user-feedback/", + "/platforms/javascript/guides/node/", + "/platforms/javascript/guides/node/configuration/integrations/vercelai/", + "/platforms/javascript/guides/node/crons/", + "/platforms/javascript/guides/node/performance/database/", + "/platforms/javascript/guides/node/tracing/instrumentation/opentelemetry/", + "/platforms/javascript/guides/nuxt/", + "/platforms/javascript/guides/nuxt/configuration/integrations/vercelai/", + "/platforms/javascript/guides/react/", + "/platforms/javascript/guides/react/configuration/integrations/react-router/", + "/platforms/javascript/guides/react/configuration/integrations/redux/", + "/platforms/javascript/guides/react/configuration/options/", + "/platforms/javascript/guides/react/features/redux/", + "/platforms/javascript/guides/react/features/tanstack-router/", + "/platforms/javascript/guides/react/logs/", + "/platforms/javascript/guides/react/session-replay/", + "/platforms/javascript/guides/react/user-feedback/", + "/platforms/javascript/guides/remix/", + "/platforms/javascript/guides/remix/configuration/integrations/vercelai/", + "/platforms/javascript/guides/remix/session-replay/", + "/platforms/javascript/guides/remix/usage/", + "/platforms/javascript/guides/remix/user-feedback/", + "/platforms/javascript/guides/solid/", + "/platforms/javascript/guides/solid/configuration/options/", + "/platforms/javascript/guides/solid/session-replay/", + "/platforms/javascript/guides/solidstart/configuration/integrations/vercelai/", + "/platforms/javascript/guides/svelte/", + "/platforms/javascript/guides/svelte/configuration/options/", + "/platforms/javascript/guides/svelte/session-replay/", + "/platforms/javascript/guides/svelte/user-feedback/", + "/platforms/javascript/guides/sveltekit/", + "/platforms/javascript/guides/sveltekit/configuration/integrations/vercelai/", + "/platforms/javascript/guides/sveltekit/usage/", + "/platforms/javascript/guides/sveltekit/user-feedback/", + "/platforms/javascript/guides/vue/", + "/platforms/javascript/guides/vue/configuration/options/", + "/platforms/javascript/guides/vue/session-replay/", + "/platforms/javascript/guides/vue/user-feedback/", + "/platforms/javascript/install/loader/", + "/platforms/javascript/logs/", + "/platforms/javascript/metrics/", + "/platforms/javascript/performance/database/", + "/platforms/javascript/performance/instrumentation/automatic-instrumentation/", + "/platforms/javascript/performance/instrumentation/custom-instrumentation/", + "/platforms/javascript/session-replay/", + "/platforms/javascript/session-replay/configuration/", + "/platforms/javascript/session-replay/privacy/", + "/platforms/javascript/session-replay/understanding-sessions/", + "/platforms/javascript/sourcemaps/", + "/platforms/javascript/sourcemaps/uploading/", + "/platforms/javascript/sourcemaps/uploading/cli/", + "/platforms/javascript/tracing/", + "/platforms/javascript/tracing/instrumentation/custom-instrumentation/", + "/platforms/javascript/typescript/", + "/platforms/javascript/usage/", + "/platforms/javascript/user-feedback/", + "/platforms/javascript/user-feedback/configuration/", + "/platforms/kotlin-multiplatform/", + "/platforms/kotlin/guides/kotlin-multiplatform/data-management/data-collected/", + "/platforms/minidump/breakpad/", + "/platforms/minidump/crashpad/", + "/platforms/native/", + "/platforms/native/guides/breakpad/", + "/platforms/native/guides/crashpad/", + "/platforms/native/guides/minidumps/", + "/platforms/nintendo-switch/", + "/platforms/node/", + "/platforms/node/guides/express/", + "/platforms/node/guides/fastify/", + "/platforms/node/guides/hapi/", + "/platforms/node/guides/nestjs/", + "/platforms/opentelemetry/", + "/platforms/perl/", + "/platforms/php/", + "/platforms/php/crons/", + "/platforms/php/data-management/data-collected/", + "/platforms/php/guides/laravel/", + "/platforms/php/guides/laravel/crons/", + "/platforms/php/guides/laravel/logs/", + "/platforms/php/guides/laravel/performance/instrumentation/automatic-instrumentation/", + "/platforms/php/guides/laravel/profiling/", + "/platforms/php/guides/laravel/troubleshooting/", + "/platforms/php/guides/symfony/", + "/platforms/php/logs/", + "/platforms/playstation/", + "/platforms/python/", + "/platforms/python/configuration/integrations/redis/", + "/platforms/python/configuration/releases/", + "/platforms/python/crons/", + "/platforms/python/data-management/data-collected/", + "/platforms/python/enriching-events/breadcrumbs/", + "/platforms/python/enriching-events/tags/", + "/platforms/python/guides/aws-lambda/", + "/platforms/python/guides/bottle/", + "/platforms/python/guides/celery/", + "/platforms/python/guides/celery/configuration/integrations/default-integrations/", + "/platforms/python/guides/celery/crons/", + "/platforms/python/guides/django/", + "/platforms/python/guides/fastapi/", + "/platforms/python/guides/flask/", + "/platforms/python/guides/flask/configuration/integrations/", + "/platforms/python/guides/gcp-functions/", + "/platforms/python/guides/logging/configuration/releases/", + "/platforms/python/guides/pyramid/", + "/platforms/python/guides/rq/", + "/platforms/python/guides/starlette/", + "/platforms/python/guides/tornado/", + "/platforms/python/guides/tryton/data-collected/", + "/platforms/python/integrations/anthropic/", + "/platforms/python/integrations/django/", + "/platforms/python/integrations/fastapi/", + "/platforms/python/integrations/flask/", + "/platforms/python/integrations/google-genai/", + "/platforms/python/integrations/langchain/", + "/platforms/python/integrations/mcp/", + "/platforms/python/integrations/openai-agents/", + "/platforms/python/integrations/openai/", + "/platforms/python/logs/", + "/platforms/python/tracing/", + "/platforms/python/tracing/instrumentation/custom-instrumentation/ai-agents-module/", + "/platforms/python/tracing/instrumentation/opentelemetry/", + "/platforms/python/tracing/trace-propagation/", + "/platforms/python/tracing/trace-propagation/custom-instrumentation/", + "/platforms/react-native/", + "/platforms/react-native/configuration/touchevents/", + "/platforms/react-native/data-management/data-collected/", + "/platforms/react-native/guides/expo/sourcemaps/uploading/expo/", + "/platforms/react-native/integrations/component-tracking/", + "/platforms/react-native/logs/", + "/platforms/react-native/manual-setup/", + "/platforms/react-native/manual-setup/expo/", + "/platforms/react-native/manual-setup/hermes/", + "/platforms/react-native/migration/", + "/platforms/react-native/performance/", + "/platforms/react-native/session-replay/", + "/platforms/react-native/session-replay/privacy/", + "/platforms/react-native/sourcemaps/", + "/platforms/react-native/tracing/", + "/platforms/react-native/tracing/instrumentation/automatic-instrumentation/", + "/platforms/react-native/tracing/instrumentation/performance-metrics/", + "/platforms/react-native/user-feedback/", + "/platforms/ruby/", + "/platforms/ruby/crons/", + "/platforms/ruby/data-management/data-collected/", + "/platforms/ruby/guides/rails/", + "/platforms/ruby/guides/sidekiq/", + "/platforms/ruby/tracing/instrumentation/opentelemetry/", + "/platforms/rust/", + "/platforms/unity/", + "/platforms/unity/configuration/app-not-responding/", + "/platforms/unity/configuration/options/", + "/platforms/unreal/", + "/platforms/unrealengine/", + "/platforms/xbox/", + "/platforms/{jsGuides/", + "/platforms/{platform/", + "/pricing/", + "/product/accounts/choose-your-data-center/", + "/product/accounts/early-adopter-features/discord/", + "/product/accounts/membership/", + "/product/accounts/migration/", + "/product/accounts/sso/", + "/product/accounts/sso/okta-sso/okta-scim/", + "/product/agents/dashboards/", + "/product/ai-in-sentry/", + "/product/ai-in-sentry/ai-code-review/", + "/product/ai-in-sentry/ai-privacy-and-security/", + "/product/ai-in-sentry/seer/", + "/product/ai-in-sentry/seer/ai-code-review/", + "/product/ai-in-sentry/seer/autofix/", + "/product/ai-in-sentry/seer/code-review/", + "/product/ai-in-sentry/seer/issue-fix/", + "/product/ai-in-sentry/seer/root-cause-analysis/", + "/product/ai-in-sentry/sentry-prevent-ai/", + "/product/alerts/", + "/product/alerts/alert-types/", + "/product/alerts/best-practices/", + "/product/alerts/create-alerts/", + "/product/alerts/create-alerts/issue-alert-config/", + "/product/alerts/create-alerts/metric-alert-config/", + "/product/alerts/create-alerts/routing-alerts/", + "/product/alerts/create-alerts/uptime-alert-config/", + "/product/alerts/notifications/", + "/product/alerts/uptime-monitoring/", + "/product/crons/", + "/product/crons/getting-started/", + "/product/crons/job-monitoring/", + "/product/dashboards/", + "/product/dashboards/custom-dashboards/", + "/product/dashboards/sentry-dashboards/backend/queries/", + "/product/dashboards/sentry-dashboards/transaction-summary/", + "/product/dashboards/widget-builder/", + "/product/data-management-settings/event-grouping/", + "/product/data-management-settings/event-grouping/fingerprint-rules/", + "/product/data-management-settings/filtering/", + "/product/discover-queries/", + "/product/drains/", + "/product/drains/integration/cloudflare/", + "/product/drains/integration/heroku/", + "/product/drains/integration/shopify-hydrogen/", + "/product/drains/integration/supabase/", + "/product/drains/integration/vercel/", + "/product/drains/supabase/", + "/product/error-monitoring/breadcrumbs/", + "/product/error-monitoring/stacktrace/", + "/product/error-monitoring/user-impact/", + "/product/explore/logs/", + "/product/explore/logs/getting-started/", + "/product/explore/metrics/", + "/product/explore/metrics/getting-started/", + "/product/explore/profiling/", + "/product/explore/profiling/transaction-vs-continuous-profiling/", + "/product/explore/session-replay/", + "/product/explore/session-replay/mobile/", + "/product/explore/session-replay/web/", + "/product/explore/trace-explorer/", + "/product/explore/traces/", + "/product/insights/", + "/product/insights/ai/agents/", + "/product/insights/ai/agents/privacy/", + "/product/insights/backend/queries/", + "/product/insights/frontend/", + "/product/insights/frontend/web-vitals/", + "/product/insights/frontend/web-vitals/web-vitals-concepts/", + "/product/insights/mobile/", + "/product/insights/mobile/mobile-vitals/", + "/product/insights/overview/", + "/product/insights/overview/filters-display/", + "/product/integrations/", + "/product/integrations/azure-devops/", + "/product/integrations/github/", + "/product/integrations/integration-platform/", + "/product/integrations/integration-platform/internal-integration/", + "/product/integrations/notification-incidents/", + "/product/integrations/notification-incidents/amixr/", + "/product/integrations/notification-incidents/slack/", + "/product/integrations/project-mgmt/jira/", + "/product/integrations/source-code-mgmt/", + "/product/integrations/source-code-mgmt/github/", + "/product/issues/", + "/product/issues/issue-details/", + "/product/issues/issue-details/breadcrumbs/", + "/product/issues/issue-details/performance-issues/", + "/product/issues/issue-details/performance-issues/n-one-queries/", + "/product/issues/issue-details/replay-issues/hydration-error/", + "/product/issues/issue-details/replay-issues/rage-clicks/", + "/product/issues/issue-details/sentry-ai/", + "/product/issues/issue-details/sentry-seer/", + "/product/issues/issue-details/uptime-issues/", + "/product/issues/issue-owners/", + "/product/issues/issue-priority/", + "/product/issues/ownership-rules/", + "/product/issues/states-triage/", + "/product/issues/states-triage/escalating-issues/", + "/product/issues/suspect-commits/", + "/product/logs/", + "/product/metrics/", + "/product/metrics/metrics-examples/", + "/product/monitors-and-alerts/alerts/", + "/product/monitors-and-alerts/monitors/", + "/product/monitors-and-alerts/monitors/uptime-monitoring/", + "/product/monitors-and-alerts/monitors/uptime-monitoring/automatic-detection/", + "/product/performance/", + "/product/performance/filters-display/", + "/product/performance/getting-started/", + "/product/performance/metrics/", + "/product/performance/queries/", + "/product/performance/trace-view/", + "/product/performance/transaction-summary/", + "/product/performance/trends/", + "/product/performance/web-vitals/", + "/product/profiling/", + "/product/profiling/flame-charts-graphs/", + "/product/reference/search/searchable-properties/", + "/product/relay/", + "/product/releases/", + "/product/releases/associate-commits/", + "/product/releases/health/", + "/product/releases/setup/", + "/product/releases/setup/release-automation/", + "/product/releases/setup/release-automation/bitbucket-pipelines/", + "/product/releases/setup/release-automation/github-actions/", + "/product/releases/setup/release-automation/netlify/", + "/product/security/soc2/", + "/product/security/terms/", + "/product/seer/", + "/product/sentry-basics/concepts/tracing/trace-view/", + "/product/sentry-basics/distributed-tracing/", + "/product/sentry-basics/dsn-explainer/", + "/product/sentry-basics/grouping-and-fingerprints/", + "/product/sentry-basics/guides/discover/", + "/product/sentry-basics/guides/getting-started/", + "/product/sentry-basics/guides/integrate-frontend/upload-source-maps/", + "/product/sentry-basics/integrate-backend/", + "/product/sentry-basics/integrate-frontend/", + "/product/sentry-basics/integrate-frontend/configure-scms/", + "/product/sentry-basics/integrate-frontend/create-new-project/", + "/product/sentry-basics/integrate-frontend/upload-source-maps/", + "/product/sentry-basics/performance-monitoring/", + "/product/sentry-basics/search/searchable-properties/", + "/product/sentry-basics/tracing/", + "/product/sentry-basics/tracing/distributed-tracing/", + "/product/sentry-mcp/", + "/product/session-replay/", + "/product/session-replay/protecting-user-privacy/", + "/product/size-analysis/", + "/product/size-analysis/integrating-into-ci/", + "/product/stats/", + "/product/trace-explorer/", + "/product/tracing/", + "/product/user-feedback/", + "/product/user-feedback/setup/", + "/security-legal-pii/scrubbing/", + "/security-legal-pii/security/data-retention-periods/", + "/workflow/discover/", + "/workflow/integrations/global-integrations/slack/", + "/workflow/integrations/integration-platform/", + "/workflow/notifications/alerts/", + "/workflow/releases/", + "/workflow/releases/health/", + "/workflow/search/" + ] +}