diff --git a/docs/contributing/pages/components.mdx b/docs/contributing/pages/components.mdx index 08c432083954ce..03ec2d8b5b1019 100644 --- a/docs/contributing/pages/components.mdx +++ b/docs/contributing/pages/components.mdx @@ -242,3 +242,110 @@ Attributes: - `supported` (string[]) - `notSupported` (string[]) - `noGuides` (boolean) - hide this on all guides (takes precedence over `supported`/`notSupported`) + +## Onboarding Options + +If you're writing product feature specific docs, you can specify code block `onboardingOptions` metadata: + +````markdown +```go {"onboardingOptions": {"performance": "13-17"}} +// your code here +``` +```` + +the general syntax is `{"onboardingOptions": {"feature": "range"}}` where `feature` is the feature id +and `range` is the corresponding line range (similar to the line highlighting syntax). + +You can specify multiple features by separating them with a comma: + +`{"onboardingOptions": {"performance": "13-17", "profiling": "5-6"}}` + +The range visibility will be controlled by the `OnboardingOptionButtons` component: + +````jsx diff + +```` + +- `options` can either be either an object of this shape: + +```typescript +{ + id: 'error-monitoring' | 'performance' | 'profiling' | 'session-replay', + disabled: boolean, + checked: boolean +} +``` +or a string (one of these `id`s 👆) for convenience when using defaults. + + + The underlying implementation relies on the `onboardingOptions` metadata in the code blocks to be valid JSON syntax. + + +- default values: `checked: false` and `disabled: false` (`true` for `error-monitoring`). + +Example (output of the above): + + + +```go {"onboardingOptions": {"performance": "13-17"}} +import ( + "fmt" + "net/http" + + "github.com/getsentry/sentry-go" + sentrygin "github.com/getsentry/sentry-go/gin" + "github.com/gin-gonic/gin" +) + +// To initialize Sentry's handler, you need to initialize Sentry itself beforehand +if err := sentry.Init(sentry.ClientOptions{ + Dsn: "___PUBLIC_DSN___", + EnableTracing: true, + // Set TracesSampleRate to 1.0 to capture 100% + // of transactions for performance monitoring. + // We recommend adjusting this value in production, + TracesSampleRate: 1.0, +}); err != nil { + fmt.Printf("Sentry initialization failed: %v\n", err) +} + +// Then create your app +app := gin.Default() + +// Once it's done, you can attach the handler as one of your middleware +app.Use(sentrygin.New(sentrygin.Options{})) + +// Set up routes +app.GET("/", func(ctx *gin.Context) { + ctx.String(http.StatusOK, "Hello world!") +}) + +// And run it +app.Run(":3000") +``` + +You can conditionally render content based on the selected onboarding options using the +`OnboardingOption` component + +Example (toggle the `performance` option above to see the effect): + + + +```jsx + + This code block is wrapped in a `OnboardingOption` component and will only + be rendered when the `performance` option is selected. + +``` + diff --git a/docs/platforms/go/guides/gin/index.mdx b/docs/platforms/go/guides/gin/index.mdx index 63a1ab73721105..e81fd93f5c6e51 100644 --- a/docs/platforms/go/guides/gin/index.mdx +++ b/docs/platforms/go/guides/gin/index.mdx @@ -9,6 +9,13 @@ For a quick reference, there is a [complete example](https://github.com/getsentr ## Install + + ```bash go get github.com/getsentry/sentry-go/gin ``` @@ -17,7 +24,7 @@ go get github.com/getsentry/sentry-go/gin -```go +```go {"onboardingOptions": {"performance": "13-17"}} import ( "fmt" "net/http" diff --git a/docs/platforms/php/index.mdx b/docs/platforms/php/index.mdx index 4350150524f5b0..46bb3f07a44193 100644 --- a/docs/platforms/php/index.mdx +++ b/docs/platforms/php/index.mdx @@ -21,6 +21,14 @@ This Sentry PHP SDK provides support for PHP 7.2 or later. If you are using our ## Install + + Sentry captures data by using an SDK within your application’s runtime. These are platform-specific and allow Sentry to have a deep understanding of how your application works. Install the SDK using [Composer](https://getcomposer.org/). @@ -29,24 +37,42 @@ Install the SDK using [Composer](https://getcomposer.org/). composer require sentry/sentry ``` -## Configure + + +Install the Excimer extension via PECL: + +```bash +pecl install excimer +``` -After you’ve completed setting up a project in Sentry, Sentry will give you a value which we call a DSN or Data Source Name. It looks a lot like a standard URL, but it’s just a representation of the configuration required by the Sentry SDKs. It consists of a few pieces, including the protocol, public key, the server address, and the project identifier. +The Excimer PHP extension supports PHP 7.2 and up. Excimer requires Linux or macOS and doesn't support Windows. For additional ways to install Excimer, [see docs](/platforms/php/profiling/#installation). + + + +## Configure To capture all errors, even the one during the startup of your application, you should initialize the Sentry PHP SDK as soon as possible. -```php -\Sentry\init(['dsn' => '___PUBLIC_DSN___' ]); +```php {"onboardingOptions": {"performance": "3-4", "profiling": "5-6"}} +\Sentry\init([ + 'dsn' => '___PUBLIC_DSN___' , + // Specify a fixed sample rate + 'traces_sample_rate' => 1.0, + // Set a sampling rate for profiling - this is relative to traces_sample_rate + 'profiles_sample_rate' => 1.0, +]); ``` -## Usage +## Verify + +In PHP you can either capture a caught exception or capture the last error with captureLastError. ```php try { - $this->functionFailsForSure(); + $this->functionFailsForSure(); } catch (\Throwable $exception) { - \Sentry\captureException($exception); + \Sentry\captureException($exception); } ``` diff --git a/package.json b/package.json index bce850b7f336b7..d3d0c749eefc07 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "next-auth": "^4.24.5", "next-mdx-remote": "^4.4.1", "nextjs-toploader": "^1.6.6", + "parse-numeric-range": "^1.3.0", "platformicons": "^5.10.9", "prism-sentry": "^1.0.2", "prismjs": "^1.27.0", diff --git a/src/components/codeBlock/index.tsx b/src/components/codeBlock/index.tsx index 8e9057af073274..cbc88eaf4c9db5 100644 --- a/src/components/codeBlock/index.tsx +++ b/src/components/codeBlock/index.tsx @@ -30,8 +30,7 @@ export function CodeBlock({filename, language, children}: CodeBlockProps) { return; } - let code = - codeRef.current.textContent || codeRef.current.innerText.replace(/\n\n/g, '\n'); + let code = codeRef.current.innerText.replace(/\n\n/g, '\n'); // don't copy leading prompt for bash if (language === 'bash' || language === 'shell') { diff --git a/src/components/docPage/type.scss b/src/components/docPage/type.scss index cf2a2220ef85da..6f10b8f1cf8bd7 100644 --- a/src/components/docPage/type.scss +++ b/src/components/docPage/type.scss @@ -1,5 +1,4 @@ .prose { - h1, h2, h3, @@ -140,9 +139,54 @@ } } - dt+dd { + dt + dd { margin-bottom: var(--paragraph-margin-bottom); } + + [data-onboarding-option].hidden { + display: none; + } + [data-onboarding-option].animate-line { + animation: + slideLeft 0.2s ease-out forwards, + highlight 1.2s forwards; + } + [data-onboarding-option].animate-content { + animation: slideDown 0.2s ease-out forwards; + } +} + +@keyframes slideDown { + from { + transform: translateY(-12px); + } + to { + transform: translateY(0); + } +} + +@keyframes slideLeft { + from { + transform: translateX(-1ch); + } + to { + transform: translateX(0); + } +} + +@keyframes highlight { + 0% { + background-color: rgba(255, 255, 255, 0); + } + 10% { + background-color: rgba(255, 255, 255, 0.15); + } + 80% { + background-color: rgba(255, 255, 255, 0.15); + } + 100% { + background-color: rgba(255, 255, 255, 0); + } } .sidebar { @@ -164,7 +208,7 @@ h3.config-key { //

This paragraph has a normal bottom margin

//

This paragraph does not have a bottom margin

// -.content-flush-bottom>*:last-child { +.content-flush-bottom > *:last-child { margin-bottom: 0 !important; } diff --git a/src/components/onboarding/index.tsx b/src/components/onboarding/index.tsx new file mode 100644 index 00000000000000..c4d58d06def3ef --- /dev/null +++ b/src/components/onboarding/index.tsx @@ -0,0 +1,267 @@ +'use client'; + +import {ReactNode, useEffect, useReducer, useRef, useState} from 'react'; +import {QuestionMarkCircledIcon} from '@radix-ui/react-icons'; +import * as Tooltip from '@radix-ui/react-tooltip'; +import {Button, Checkbox, Theme} from '@radix-ui/themes'; + +import styles from './styles.module.scss'; + +const optionDetails: Record< + OptionId, + { + description: ReactNode; + name: string; + deps?: OptionId[]; + } +> = { + 'error-monitoring': { + name: 'Error Monitoring', + description: "Let's admit it, we all have errors.", + }, + performance: { + name: 'Performance Monitoring', + description: ( + + Automatic performance issue detection across services and context on who is + impacted, outliers, regressions, and the root cause of your slowdown. + + ), + }, + profiling: { + name: 'Profiling', + description: ( + + Requires Performance Monitoring + See the exact lines of code causing your performance bottlenecks, for faster + troubleshooting and resource optimization. + + ), + deps: ['performance'], + }, + 'session-replay': { + name: 'Session Replay', + description: ( + + Video-like reproductions of user sessions with debugging context to help you + confirm issue impact and troubleshoot faster. + + ), + }, +}; + +const OPTION_IDS = [ + 'error-monitoring', + 'performance', + 'profiling', + 'session-replay', +] as const; + +type OptionId = (typeof OPTION_IDS)[number]; + +type OnboardingOptionType = { + /** + * Unique identifier for the option, will control the visibility + * of ` somewhere on the page + * or lines of code specified in in a `{onboardingOptions: {this_id: 'line-range'}}` in a code block meta + */ + id: OptionId; + /** + * defaults to `true` + */ + checked?: boolean; + disabled?: boolean; +}; + +const validateOptionIds = (options: Pick[]) => { + options.forEach(option => { + if (!OPTION_IDS.includes(option.id)) { + throw new Error( + `Invalid option id: ${option.id}.\nValid options are: ${OPTION_IDS.map(opt => `"${opt}"`).join(', ')}` + ); + } + }); +}; + +export function OnboardingOption({ + children, + optionId, +}: { + children: React.ReactNode; + optionId: OptionId; +}) { + validateOptionIds([{id: optionId}]); + return
{children}
; +} + +export function OnboardingOptionButtons({ + options: initialOptions, +}: { + // convenience to allow passing option ids as strings when no additional config is required + options: (OnboardingOptionType | OptionId)[]; +}) { + const normalizedOptions = initialOptions.map(option => { + if (typeof option === 'string') { + return {id: option, disabled: option === 'error-monitoring'}; + } + return option; + }); + + validateOptionIds(normalizedOptions); + + const [options, setSelectedOptions] = useState( + normalizedOptions.map(option => ({ + ...option, + // default to checked if not excplicitly set + checked: option.checked ?? true, + })) + ); + const [touchedOptions, touchOptions] = useReducer(() => true, false); + + function handleCheckedChange(clickedOption: OnboardingOptionType, checked: boolean) { + touchOptions(); + const dependencies = optionDetails[clickedOption.id].deps ?? []; + const depenedants = + options.filter(opt => optionDetails[opt.id].deps?.includes(clickedOption.id)) ?? []; + setSelectedOptions(prev => { + // - select option and all dependencies + // - disable dependencies + if (checked) { + return prev.map(opt => { + if (opt.id === clickedOption.id) { + return { + ...opt, + checked: true, + }; + } + if (dependencies.includes(opt.id)) { + return {...opt, checked: true}; + } + return opt; + }); + } + // unselect option and all dependants + // Note: does not account for dependencies of multiple dependants + return prev.map(opt => { + if (opt.id === clickedOption.id) { + return { + ...opt, + checked: false, + }; + } + // deselect dependants + if (depenedants.find(dep => dep.id === opt.id)) { + return {...opt, checked: false}; + } + return opt; + }); + }); + } + useEffect(() => { + options.forEach(option => { + if (option.disabled) { + return; + } + const targetElements = document.querySelectorAll( + `[data-onboarding-option="${option.id}"]` + ); + targetElements.forEach(el => { + el.classList.toggle('hidden', !option.checked); + if (touchedOptions) { + if (el.classList.contains('code-line')) { + el.classList.toggle('animate-line', option.checked); + } else { + el.classList.toggle('animate-content', option.checked); + } + } + }); + if (option.checked && optionDetails[option.id].deps?.length) { + const dependenciesSelecor = optionDetails[option.id].deps!.map( + dep => `[data-onboarding-option="${dep}"]` + ); + const dependencies = document.querySelectorAll( + dependenciesSelecor.join(', ') + ); + + dependencies.forEach(dep => { + dep.classList.remove('hidden'); + }); + } + }); + }, [options, touchOptions]); + + const buttonsRef = useRef(null); + const containerTopPx = 80; + const [isSticky, setIsSticky] = useState(false); + + useEffect(() => { + const observer = new IntersectionObserver( + function ([buttonsContainer]) { + setIsSticky(!buttonsContainer.isIntersecting); + }, + { + // the 1 exptra px is important to trigger the observer + // https://stackoverflow.com/questions/16302483/event-to-detect-when-positionsticky-is-triggered + rootMargin: `-${containerTopPx + 1}px 0px 0px 0px`, + threshold: [1], + } + ); + + observer.observe(buttonsRef.current!); + }, []); + + return ( +
+ {options.map(option => ( + + ))} +
+ ); +} diff --git a/src/components/onboarding/styles.module.scss b/src/components/onboarding/styles.module.scss new file mode 100644 index 00000000000000..8772692f52f96d --- /dev/null +++ b/src/components/onboarding/styles.module.scss @@ -0,0 +1,85 @@ +.TooltipContent { + border-radius: 4px; + user-select: none; + padding: 8px 12px; + overflow-wrap: break-word; + max-width: 250px; + font-size: 12px; + line-height: 1.2; + text-align: center; + color: var(--gray-11); + background-color: white; + box-shadow: var(--shadow-6); + animation-duration: 100ms; + animation-timing-function: ease-in; +} + +.TooltipTitle { + font-weight: bold; + font-size: 12px; + color: var(--gray-12); + display: inline-block; + width: 100%; + padding-bottom: 8px; +} + +.TooltipContent[data-state='delayed-open'][data-side='top'] { + animation-name: slideDownAndFade; +} +.TooltipContent[data-state='delayed-open'][data-side='right'] { + animation-name: slideLeftAndFade; +} +.TooltipContent[data-state='delayed-open'][data-side='bottom'] { + animation-name: slideUpAndFade; +} +.TooltipContent[data-state='delayed-open'][data-side='left'] { + animation-name: slideRightAndFade; +} + +.TooltipArrow { + fill: white; +} + +@keyframes slideUpAndFade { + from { + opacity: 0; + transform: translateY(2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideRightAndFade { + from { + opacity: 0; + transform: translateX(-2px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideDownAndFade { + from { + opacity: 0; + transform: translateY(-2px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideLeftAndFade { + from { + opacity: 0; + transform: translateX(2px); + } + to { + opacity: 1; + transform: translateX(0); + } +} diff --git a/src/mdx.ts b/src/mdx.ts index d75f51c2275ca5..43317371c2f032 100644 --- a/src/mdx.ts +++ b/src/mdx.ts @@ -17,6 +17,7 @@ import getAppRegistry from './build/appRegistry'; import getPackageRegistry from './build/packageRegistry'; import {apiCategories} from './build/resolveOpenAPI'; import getAllFilesRecursively from './files'; +import rehypeOnboardingLines from './rehype-onboarding-lines'; import remarkCodeTabs from './remark-code-tabs'; import remarkCodeTitles from './remark-code-title'; import remarkComponentSpacing from './remark-component-spacing'; @@ -333,6 +334,7 @@ export async function getFileBySlug(slug: string) { }, ], [rehypePrismPlus, {ignoreMissing: true}], + rehypeOnboardingLines, [rehypePrismDiff, {remove: true}], rehypePresetMinify, ]; diff --git a/src/mdxComponents.ts b/src/mdxComponents.ts index cf1b1eec91bd2c..abd0b48465d8b7 100644 --- a/src/mdxComponents.ts +++ b/src/mdxComponents.ts @@ -11,6 +11,7 @@ import {GuideGrid} from './components/guideGrid'; import {JsBundleList} from './components/jsBundleList'; import {LambdaLayerDetail} from './components/lambdaLayerDetail'; import {LinkWithPlatformIcon} from './components/linkWithPlatformIcon'; +import {OnboardingOption, OnboardingOptionButtons} from './components/onboarding'; import {OrgAuthTokenNote} from './components/orgAuthTokenNote'; import {PageGrid} from './components/pageGrid'; import {ParamTable} from './components/paramTable'; @@ -60,6 +61,8 @@ export function mdxComponents( PlatformCategorySection, PlatformOrGuideName, PlatformSdkPackageName, + OnboardingOption, + OnboardingOptionButtons, RelayMetrics, SandboxLink, SignInNote, diff --git a/src/rehype-onboarding-lines.js b/src/rehype-onboarding-lines.js new file mode 100644 index 00000000000000..3f14d5cb6f3d84 --- /dev/null +++ b/src/rehype-onboarding-lines.js @@ -0,0 +1,105 @@ +/** + * @typedef {import('hast').Element} Element + * @typedef {import('hast').Root} Root + */ + +import rangeParser from 'parse-numeric-range'; +import {visit} from 'unist-util-visit'; + +/** + * Rehype plugin that adds the `data-onboarding-option="some-option-id"` attribute and `hidden` class name + * to each line of code based on the metastring of the code block. + * + * The metastring should be in the format of: + * `{"onboardingOptions": {"performance": "1, 3-4", "profiling": "5-6"}}` + * where the keys are the onboarding options, the line numbers can be individual or ranges separated by a comma. + * + * These lines will be hidden by default and shown based on the user's selection of `` + * + * **Note**: This plugin should be used after `rehype-prism-plus` as it relies on its output. + * + * @return {import('unified').Plugin<[], Root>} + */ +export default function rehypeOnboardingLines() { + return tree => { + visit(tree, {type: 'element', tagName: 'code'}, visitor); + }; +} +/** + * Parse the line numbers from the metastring + * @param {string} meta + * @return {number[]} + * @example + * parseLines('1, 3-4') // [1, 3, 4] + * parseLines('') // [] + */ +const parseLines = meta => { + const RE = /([\d,-]+)/; + // Remove space between {} e.g. {1, 3} + const parsedMeta = meta + .split(',') + .map(str => str.trim()) + .join(','); + if (RE.test(parsedMeta)) { + const strlineNumbers = RE.exec(parsedMeta)?.[1]; + if (!strlineNumbers) { + return []; + } + const lineNumbers = rangeParser(strlineNumbers); + return lineNumbers; + } + return []; +}; + +/** + * Create a closure that returns an onboarding option `id` for a given line if it exists + * + * @param {string} meta + * @return { (index:number) => string | undefined } + */ +const getOptionForLine = meta => { + // match the onboardingOptions object, but avoid `other stuff` + const optionsRE = /{"onboardingOptions":\s*({[^}]*})\s*}/; + let linesForOptions = {}; + const options = optionsRE.exec(meta)?.[1]; + if (!options) { + return () => undefined; + } + + // eval provides the convenience of not having to wrap the object properties in quotes + const parsedOptions = JSON.parse(options); + linesForOptions = Object.keys(parsedOptions).reduce((acc, key) => { + acc[key] = parseLines(parsedOptions[key]); + return acc; + }, {}); + return index => { + for (const key in linesForOptions) { + if (linesForOptions[key].includes(index + 1)) { + return key; + } + } + return undefined; + }; +}; + +/** + * @param {Element} node + */ +function visitor(node) { + const meta = /** @type {string} */ ( + node?.data?.meta || node?.properties?.metastring || '' + ); + + if (!meta.includes('onboardingOptions')) { + return; + } + + const optionForLine = getOptionForLine(meta); + + node.children.forEach((line, index) => { + const option = optionForLine(index); + if (option) { + line.properties['data-onboarding-option'] = option; + } + }); +} diff --git a/src/remark-format-code.js b/src/remark-format-code.js index 8f3e9ae20e9ce2..474a7e23e59de0 100644 --- a/src/remark-format-code.js +++ b/src/remark-format-code.js @@ -12,7 +12,10 @@ export default function remarkFormatCodeBlocks() { const formattingWork = codeNodes // skip code blocks with diff meta as they might have // broken syntax due to + and - characters - .filter(node => !node.meta?.includes('diff')) + // or with `onboardingOptions` as they need to have predictable line numbers + .filter( + node => !(node.meta?.includes('diff') || node.meta?.includes('onboardingOptions')) + ) .map(node => formatCode(node)); await Promise.all(formattingWork);