diff --git a/.env.example b/.env.example index d377318329ced..0c940ba50d254 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ # warning: variables prefixed with NEXT_PUBLIC_ will be made available to client-side code # be careful not to expose sensitive data (in this case your Algolia admin key) -NEXT_PUBLIC_ALGOLIA_APP_ID= -NEXT_PUBLIC_ALGOLIA_SEARCH_KEY= +# Algolia Insights (search click tracking). Must be the same application and a +# valid key for the index @sentry-internal/global-search queries, or every click +# event is rejected with a 401. +NEXT_PUBLIC_ALGOLIA_APP_ID=OOK48W9UCL +NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=2d64ec1106519cbc672d863b0d200782 NEXT_PUBLIC_SENTRY_DSN= \ No newline at end of file diff --git a/.github/workflows/algolia-index.yml b/.github/workflows/algolia-index.yml index 049849e2331b7..e7a44a928ede7 100644 --- a/.github/workflows/algolia-index.yml +++ b/.github/workflows/algolia-index.yml @@ -63,8 +63,6 @@ jobs: ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} DOCS_INDEX_NAME: ${{ secrets.DOCS_INDEX_NAME }} - NEXT_PUBLIC_ALGOLIA_APP_ID: ${{ secrets.NEXT_PUBLIC_ALGOLIA_APP_ID }} - NEXT_PUBLIC_ALGOLIA_SEARCH_KEY: ${{ secrets.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY }} ALGOLIA_SENTRY_DSN: ${{ secrets.ALGOLIA_SENTRY_DSN }} SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 @@ -76,8 +74,6 @@ jobs: ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }} DOCS_INDEX_NAME: ${{ secrets.DEVELOP_DOCS_INDEX_NAME }} - NEXT_PUBLIC_ALGOLIA_APP_ID: ${{ secrets.NEXT_PUBLIC_ALGOLIA_APP_ID }} - NEXT_PUBLIC_ALGOLIA_SEARCH_KEY: ${{ secrets.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY }} ALGOLIA_SENTRY_DSN: ${{ secrets.ALGOLIA_SENTRY_DSN }} SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 NEXT_PUBLIC_SENTRY_DSN: https://examplePublicKey@o0.ingest.sentry.io/0 diff --git a/scripts/algolia.ts b/scripts/algolia.ts index e03ba07f7689b..3edf184b1e1f1 100644 --- a/scripts/algolia.ts +++ b/scripts/algolia.ts @@ -14,6 +14,7 @@ import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; import {getDevDocsFrontMatter, getDocsFrontMatter} from '../src/mdx'; import {FrontMatter} from '../src/types'; +import {isVersioned} from '../src/versioning'; const ALGOLIA_SENTRY_DSN = process.env.ALGOLIA_SENTRY_DSN; if (ALGOLIA_SENTRY_DSN) { @@ -87,7 +88,13 @@ async function indexAndUpload() { : getDocsFrontMatter()); const allPages = pageFrontMatters.filter( - frontMatter => !frontMatter.draft && !frontMatter.noindex && frontMatter.title + frontMatter => + !frontMatter.draft && + !frontMatter.noindex && + frontMatter.title && + // Versioned pages document superseded SDK majors and outrank the current + // docs for the same query. They stay reachable via the version selector. + !isVersioned(frontMatter.slug) ); const pages = DRY_RUN ? allPages.slice(0, DRY_RUN_PAGE_LIMIT) : allPages; const uploadIndex = DRY_RUN ? null : index; diff --git a/src/components/agentSetupCallout/index.tsx b/src/components/agentSetupCallout/index.tsx index 0b0129ef80e50..703f8853d050e 100644 --- a/src/components/agentSetupCallout/index.tsx +++ b/src/components/agentSetupCallout/index.tsx @@ -4,6 +4,7 @@ import {ChevronDownIcon, ChevronRightIcon} from '@radix-ui/react-icons'; import * as Sentry from '@sentry/nextjs'; import Link from 'next/link'; import {useCallback, useState} from 'react'; +import {Copy} from 'react-feather'; import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent'; import {DocMetrics} from 'sentry-docs/metrics'; @@ -66,7 +67,7 @@ export function AgentSetupCallout({skill, platformName}: Props) { {prompt} @@ -108,21 +109,3 @@ export function AgentSetupCallout({skill, platformName}: Props) { ); } - -function CopyIcon() { - return ( - - - - - ); -} diff --git a/src/components/copyPromptButton/index.tsx b/src/components/copyPromptButton/index.tsx index 97543dc6cd6ea..21455df35cc22 100644 --- a/src/components/copyPromptButton/index.tsx +++ b/src/components/copyPromptButton/index.tsx @@ -5,6 +5,7 @@ import {Theme} from '@radix-ui/themes'; import * as Sentry from '@sentry/nextjs'; import {useTheme} from 'next-themes'; import {useCallback, useMemo, useState} from 'react'; +import {Copy} from 'react-feather'; import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent'; import {DocMetrics} from 'sentry-docs/metrics'; @@ -98,7 +99,7 @@ export function CopyPromptButton({skill, platformName}: Props) { type="button" aria-label="Copy setup prompt for AI agents" > - + {copied ? 'Copied!' : 'Copy Prompt'} @@ -117,21 +118,3 @@ export function CopyPromptButton({skill, platformName}: Props) { ); } - -function CopyIcon() { - return ( - - - - - ); -} diff --git a/src/components/header.tsx b/src/components/header.tsx index c51b32230da37..31df376c4f189 100644 --- a/src/components/header.tsx +++ b/src/components/header.tsx @@ -11,7 +11,6 @@ import SentryLogoSVG from 'sentry-docs/logos/sentry-logo-dark.svg'; import {Platform} from 'sentry-docs/types'; import {MagicIcon} from './cutomIcons/magic'; -import {useHomeSearchVisibility} from './homeSearchVisibility'; import {mainSections} from './navigationData'; import sidebarStyles from './sidebar/style.module.scss'; import {ThemeToggle} from './theme-toggle'; @@ -49,18 +48,10 @@ export function Header({ platforms = [], }: Props) { const isHomePage = pathname === '/'; - const [homeSearchVisible, setHomeSearchVisible] = useState(true); const [homeMobileNavOpen, setHomeMobileNavOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); - // Listen for home search visibility changes - useHomeSearchVisibility( - useCallback((isVisible: boolean) => { - setHomeSearchVisible(isVisible); - }, []) - ); - // Close mobile overlays on navigation useEffect(() => { setMobileSearchOpen(false); @@ -175,48 +166,21 @@ export function Header({ return () => window.removeEventListener('resize', handleResize); }, [sidebarOpen]); - // Show header search if: not on home page, OR on home page but home search is scrolled out of view - const showHeaderSearch = !isHomePage || !homeSearchVisible; - return (
{/* define a header-height variable for consumption by other components */} ); } +function QuickLink({href, title, desc}: {desc: string; href: string; title: string}) { + return ( + +
{title}
+
{desc}
+ + ); +} + function FooterLink({ children, external, diff --git a/src/components/homeAiSetupCard.tsx b/src/components/homeAiSetupCard.tsx new file mode 100644 index 0000000000000..4dee90473e9df --- /dev/null +++ b/src/components/homeAiSetupCard.tsx @@ -0,0 +1,82 @@ +'use client'; + +import * as Sentry from '@sentry/nextjs'; +import Link from 'next/link'; +import {useCallback, useState} from 'react'; +import {Check, Copy} from 'react-feather'; +import {usePlausibleEvent} from 'sentry-docs/hooks/usePlausibleEvent'; +import Claude from 'sentry-docs/icons/claude'; +import Codex from 'sentry-docs/icons/codex'; +import Cursor from 'sentry-docs/icons/cursor'; +import {DocMetrics} from 'sentry-docs/metrics'; + +import styles from './home.module.scss'; + +/** Keep in sync with /ai/agent-plugin/. */ +const INSTALL_COMMAND = 'npx @sentry/ai install'; + +export function HomeAiSetupCard() { + const [copied, setCopied] = useState(false); + const {emit} = usePlausibleEvent(); + + const copyCommand = useCallback(async () => { + emit('Copy AI Prompt', { + props: {page: window.location.pathname, title: 'Homepage Setup Card'}, + }); + + try { + await navigator.clipboard.writeText(INSTALL_COMMAND); + setCopied(true); + DocMetrics.copyAIPrompt(window.location.pathname, undefined, true, 'homepage_card'); + setTimeout(() => setCopied(false), 1500); + } catch (error) { + Sentry.logger.warn('clipboard.writeText failed', { + error: (error as Error)?.message, + errorName: (error as Error)?.name, + }); + DocMetrics.copyAIPrompt( + window.location.pathname, + undefined, + false, + 'homepage_card' + ); + setCopied(false); + } + }, [emit]); + + return ( +
+
+ + + + +
+

+ + Set up with a coding agent + +

+

+ One command teaches Claude Code, Cursor, Codex, and Grok how to install and + configure Sentry for you. +

+ +
+ ); +} diff --git a/src/components/homeSearchVisibility.tsx b/src/components/homeSearchVisibility.tsx deleted file mode 100644 index 36fbcced0c4b2..0000000000000 --- a/src/components/homeSearchVisibility.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import {useEffect, useRef} from 'react'; - -// Custom event to communicate search visibility across components -const SEARCH_VISIBILITY_EVENT = 'home-search-visibility'; - -export function HomeSearchObserver({children}: {children: React.ReactNode}) { - const ref = useRef(null); - - useEffect(() => { - const element = ref.current; - if (!element) { - return undefined; - } - - const observer = new IntersectionObserver( - ([entry]) => { - const isVisible = entry.isIntersecting; - (window as any).__homeSearchVisible = isVisible; - window.dispatchEvent( - new CustomEvent(SEARCH_VISIBILITY_EVENT, {detail: {isVisible}}) - ); - }, - { - threshold: 0, - rootMargin: '-64px 0px 0px 0px', // Account for header height - } - ); - - observer.observe(element); - return () => observer.disconnect(); - }, []); - - return ( -
- {children} -
- ); -} - -export function useHomeSearchVisibility(callback: (isVisible: boolean) => void) { - useEffect(() => { - const handler = (e: CustomEvent<{isVisible: boolean}>) => { - callback(e.detail.isVisible); - }; - - window.addEventListener(SEARCH_VISIBILITY_EVENT as any, handler as EventListener); - return () => { - window.removeEventListener( - SEARCH_VISIBILITY_EVENT as any, - handler as EventListener - ); - }; - }, [callback]); -} diff --git a/src/components/platformFilter/client.tsx b/src/components/platformFilter/client.tsx index 1661fb206745b..65d2f477f0c1e 100644 --- a/src/components/platformFilter/client.tsx +++ b/src/components/platformFilter/client.tsx @@ -56,7 +56,9 @@ export function PlatformFilterClient({platforms}: {platforms: Platform[]}) {
-

Platform-specific docs

+

+ Platform-specific docs +

If you use it, we probably support it.

diff --git a/src/components/platformSelector/index.tsx b/src/components/platformSelector/index.tsx index e151f71d04c31..9f7b35345d35c 100644 --- a/src/components/platformSelector/index.tsx +++ b/src/components/platformSelector/index.tsx @@ -84,10 +84,8 @@ export function PlatformSelector({ const currentPlatformKey = currentPlatform?.key; const pathname = usePathname(); - // Auto-open selector when on /platforms/ index page (no SDK selected) - const isOnPlatformsIndex = pathname === '/platforms/' || pathname === '/platforms'; - - const [open, setOpen] = useState(alwaysOpen || isOnPlatformsIndex); + // Not auto-opened on /platforms/: Radix Select locks body scroll while open. + const [open, setOpen] = useState(alwaysOpen); const [searchValue, setSearchValue] = useState(''); const matches = useMemo(() => { diff --git a/src/components/search/index.tsx b/src/components/search/index.tsx index 52cfecf08fbd8..3365a200b4f37 100644 --- a/src/components/search/index.tsx +++ b/src/components/search/index.tsx @@ -11,7 +11,7 @@ import { } from '@sentry-internal/global-search'; import {usePathname} from 'next/navigation'; import {Fragment, useCallback, useEffect, useRef, useState} from 'react'; -import algoliaInsights from 'search-insights'; +import {createInsightsClient} from 'search-insights'; import {useOnClickOutside} from 'sentry-docs/clientUtils'; import {isDeveloperDocs} from 'sentry-docs/isDeveloperDocs'; import {DocMetrics} from 'sentry-docs/metrics'; @@ -22,12 +22,6 @@ import styles from './search.module.scss'; import {SearchResultItems} from './searchResultItems'; import {relativizeUrl} from './util'; -// Initialize Algolia Insights -algoliaInsights('init', { - appId: process.env.NEXT_PUBLIC_ALGOLIA_APP_ID, - apiKey: process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY, -}); - // We dont want to track anyone cross page/sessions or use cookies // so just generate a random token each time the page is loaded and // treat it as a random user. @@ -58,6 +52,45 @@ const userDocsSites: SentryGlobalSearchConfig = [ const config = isDeveloperDocs ? developerDocsSites : userDocsSites; const search = new SentryGlobalSearch(config); +// Insights events are fire-and-forget, so rejected credentials are invisible: a +// stale NEXT_PUBLIC_ALGOLIA_SEARCH_KEY silently 401'd every click for months. +// Send them ourselves so a rejection gets reported once per page. +let insightsRejectionReported = false; +const algoliaInsights = createInsightsClient(async (url, data) => { + try { + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(data), + // text/plain keeps this a CORS simple request: an application/json + // preflight can be cancelled by the navigation a result click triggers. + headers: {'Content-Type': 'text/plain'}, + keepalive: true, + }); + if (!response.ok && !insightsRejectionReported) { + insightsRejectionReported = true; + captureException( + new Error(`Algolia Insights rejected an event with ${response.status}`) + ); + } + return response.ok; + } catch (error) { + if (!insightsRejectionReported) { + insightsRejectionReported = true; + captureException(error); + } + return false; + } +}); + +const insightsAppId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID; +const insightsApiKey = process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_KEY; +// Unconfigured deploys (previews) stay silent: search-insights throws on every +// call until it is initialized, so nothing may be sent either. +const insightsEnabled = Boolean(insightsAppId && insightsApiKey); +if (insightsEnabled) { + algoliaInsights('init', {appId: insightsAppId, apiKey: insightsApiKey}); +} + type Props = { autoFocus?: boolean; /** Called before the Kapa modal opens, so a parent overlay can close itself first. */ @@ -178,6 +211,42 @@ export function Search({ }; }, [autoFocus]); + const resultsRef = useRef(null); + const [placement, setPlacement] = useState<'bottom' | 'top'>('bottom'); + const showResults = query.length >= 2 && inputFocus; + + // Keep the dropdown inside the viewport: cap it to whichever side of the input + // has more room, and flip above only when that side is the top. + useEffect(() => { + if (!showResults) { + return undefined; + } + const GUTTER = 16; + const update = () => { + const input = inputRef.current; + const dropdown = resultsRef.current; + if (!input || !dropdown) { + return; + } + const {top, bottom} = input.getBoundingClientRect(); + const spaceBelow = window.innerHeight - bottom - GUTTER * 2; + const spaceAbove = top - GUTTER * 2; + const flip = spaceAbove > spaceBelow; + setPlacement(flip ? 'top' : 'bottom'); + dropdown.style.setProperty( + '--sgs-available-space', + `${Math.round(Math.max(flip ? spaceAbove : spaceBelow, 200))}px` + ); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, {passive: true}); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update); + }; + }, [showResults]); + const searchFor = useCallback( async ( inputQuery: string, @@ -273,6 +342,9 @@ export function Search({ const totalHits = results.reduce((a, x) => a + x.hits.length, 0); const trackSearchResultClick = useCallback((hit: Hit, position: number): void => { + if (!insightsEnabled) { + return; + } try { algoliaInsights('clickedObjectIDsAfterSearch', { eventName: 'documentation_search_result_click', @@ -368,8 +440,12 @@ export function Search({
- {query.length >= 2 && inputFocus && ( -
+ {showResults && ( +