diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 296f4ce801e580..ce604276904fba 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -115,6 +115,7 @@ "api-docs/**/*.json", ".vscode/**", "static/app/data/world.json", - "tests/sentry/grouping/**/*.json" + "tests/sentry/grouping/**/*.json", + "pyproject.toml" ] } diff --git a/eslint.config.ts b/eslint.config.ts index 323d3a70330f78..babc317f3f6c80 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -919,6 +919,7 @@ export default typescript.config([ name: 'plugin/prettier', extends: [prettier], rules: { + curly: 'error', // import sorting is handled by oxfmt 'import/order': 'off', 'sort-imports': 'off', diff --git a/scripts/analyze-styled.ts b/scripts/analyze-styled.ts index 2dafee92cbbf80..e32090d803bf86 100644 --- a/scripts/analyze-styled.ts +++ b/scripts/analyze-styled.ts @@ -203,8 +203,12 @@ function getCommitsInDateRange(startDate: string, intervalDays: number): GitComm // CSV helper functions function escapeCsvField(field: string | number): string { - if (typeof field === 'number') return field.toString(); - if (field === null || field === undefined) return ''; + if (typeof field === 'number') { + return field.toString(); + } + if (field === null || field === undefined) { + return ''; + } const fieldStr = field.toString(); if (fieldStr.includes(',') || fieldStr.includes('"') || fieldStr.includes('\n')) { return `"${fieldStr.replace(/"/g, '""')}"`; @@ -213,7 +217,9 @@ function escapeCsvField(field: string | number): string { } function arrayToCSV(data: Array>): string { - if (data.length === 0) return ''; + if (data.length === 0) { + return ''; + } const headers = Object.keys(data[0] ?? {}); const csvHeaders = headers.map(escapeCsvField).join(','); @@ -647,7 +653,9 @@ class StyledComponentsDetector extends BaseDetector { private cssRuleCounts = new Map(); execute(node: ts.Node, context: DetectorContext): void { - if (!ts.isTaggedTemplateExpression(node)) return; + if (!ts.isTaggedTemplateExpression(node)) { + return; + } const taggedExpr = node; @@ -656,7 +664,9 @@ class StyledComponentsDetector extends BaseDetector { if (callExpr.expression.getText() === 'styled') { const component = callExpr.arguments[0]; - if (!component) return; + if (!component) { + return; + } let componentName = ''; let componentType: 'intrinsic' | 'component' | 'unknown' = 'unknown'; @@ -810,9 +820,13 @@ class StyledComponentsDetector extends BaseDetector { let staticExpressions = 0; this.styledComponents.forEach(sc => { - if (sc.componentType === 'component') totalReactComponents++; - else if (sc.componentType === 'intrinsic') totalIntrinsic++; - else unknownComponents++; + if (sc.componentType === 'component') { + totalReactComponents++; + } else if (sc.componentType === 'intrinsic') { + totalIntrinsic++; + } else { + unknownComponents++; + } // Count expressions if (sc.hasExpressions) { @@ -1040,7 +1054,9 @@ class StyledUsagePerFileDetector extends BaseDetector { // Track all TSX files we analyze this.totalTsxFiles.add(context.fileName); - if (!ts.isTaggedTemplateExpression(node)) return; + if (!ts.isTaggedTemplateExpression(node)) { + return; + } const taggedExpr = node; @@ -1124,7 +1140,9 @@ class FlexOnlyDivsDetector extends BaseDetector { private styledComponents: StyledComponent[] = []; execute(node: ts.Node, context: DetectorContext): void { - if (!ts.isTaggedTemplateExpression(node)) return; + if (!ts.isTaggedTemplateExpression(node)) { + return; + } const taggedExpr = node; @@ -1133,7 +1151,9 @@ class FlexOnlyDivsDetector extends BaseDetector { if (callExpr.expression.getText() === 'styled') { const component = callExpr.arguments[0]; - if (!component) return; + if (!component) { + return; + } let componentName = ''; let componentType: 'intrinsic' | 'component' | 'unknown' = 'unknown'; diff --git a/scripts/extractFormFields.ts b/scripts/extractFormFields.ts index 36f5f8472b559c..736b94314ff6b0 100644 --- a/scripts/extractFormFields.ts +++ b/scripts/extractFormFields.ts @@ -71,14 +71,22 @@ class FormFieldExtractor { for (const sourceFile of this.program.getSourceFiles()) { // Skip node_modules - if (sourceFile.fileName.includes('node_modules')) continue; + if (sourceFile.fileName.includes('node_modules')) { + continue; + } // Only process app files - if (!sourceFile.fileName.includes('static/app/')) continue; + if (!sourceFile.fileName.includes('static/app/')) { + continue; + } // Skip test and story files - if (sourceFile.fileName.includes('.spec.')) continue; - if (sourceFile.fileName.includes('.stories.')) continue; + if (sourceFile.fileName.includes('.spec.')) { + continue; + } + if (sourceFile.fileName.includes('.stories.')) { + continue; + } const fileFields = this.extractFromFile(sourceFile); fields.push(...fileFields); @@ -169,7 +177,9 @@ class FormFieldExtractor { // Extract 'name' attribute const nameAttr = this.getJsxAttribute(node, 'name'); - if (!nameAttr) return null; + if (!nameAttr) { + return null; + } // Extract metadata from render prop children const fieldMetadata = this.extractFieldMetadata(node, sourceFile); @@ -219,7 +229,9 @@ class FormFieldExtractor { // Extract label/hintText props from any component const label = this.getJsxAttributeExpression(node, 'label', sourceFile); - if (label && !metadata.label) metadata.label = label; + if (label && !metadata.label) { + metadata.label = label; + } if (this.hasJsxAttribute(node, 'hintText') && !metadata.hintText) { const hintText = this.getJsxAttributeExpression(node, 'hintText', sourceFile); @@ -230,7 +242,9 @@ class FormFieldExtractor { // Extract label from Meta.Label (text is in children) if (tagName?.endsWith('.Label') || tagName === 'Meta.Label') { const text = this.getJsxTextContent(node, sourceFile); - if (text && !metadata.label) metadata.label = text; + if (text && !metadata.label) { + metadata.label = text; + } } // Extract hintText from Meta.HintText (text is in children) @@ -269,7 +283,9 @@ class FormFieldExtractor { // Direct text: Name if (ts.isJsxText(child)) { const text = child.text.trim(); - if (text) return `"${text}"`; + if (text) { + return `"${text}"`; + } } // Expression: {t('Name')} -> t('Name') if (ts.isJsxExpression(child) && child.expression) { diff --git a/scripts/genPlatformProductInfo.ts b/scripts/genPlatformProductInfo.ts index 0a445e91d573af..16b31ac937ca7f 100644 --- a/scripts/genPlatformProductInfo.ts +++ b/scripts/genPlatformProductInfo.ts @@ -114,7 +114,9 @@ function parseLink(link: string): {lang: string; guide?: string} | null { const m = link.match( /\/platforms\/([\w-]+)(?:\/(?:guides|integrations)\/([\w-]+))?\/?/ ); - if (!m) return null; + if (!m) { + return null; + } return {lang: m[1]!, guide: m[2]}; } @@ -166,8 +168,12 @@ function unwrapTypeAssertion(expr: ts.Expression): ts.Expression { // Property key text from an Identifier or string literal name. function propertyKeyName(prop: ts.PropertyAssignment): string | undefined { - if (ts.isIdentifier(prop.name)) return prop.name.text; - if (ts.isStringLiteral(prop.name)) return prop.name.text; + if (ts.isIdentifier(prop.name)) { + return prop.name.text; + } + if (ts.isStringLiteral(prop.name)) { + return prop.name.text; + } return undefined; } @@ -181,12 +187,16 @@ function readPlatforms(): PlatformEntry[] { } const out: PlatformEntry[] = []; for (const element of init.elements) { - if (!ts.isObjectLiteralExpression(element)) continue; + if (!ts.isObjectLiteralExpression(element)) { + continue; + } let id: string | undefined; let link = ''; let deprecated = false; for (const prop of element.properties) { - if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isPropertyAssignment(prop)) { + continue; + } const key = propertyKeyName(prop); if (key === 'id' && ts.isStringLiteral(prop.initializer)) { id = prop.initializer.text; @@ -199,7 +209,9 @@ function readPlatforms(): PlatformEntry[] { deprecated = true; } } - if (!id) continue; + if (!id) { + continue; + } let lang: string | null = null; let guide: string | null = null; @@ -234,9 +246,13 @@ function readLegacyToggleableKeys(): Set { } const keys = new Set(); for (const prop of obj.properties) { - if (!ts.isPropertyAssignment(prop)) continue; + if (!ts.isPropertyAssignment(prop)) { + continue; + } const key = propertyKeyName(prop); - if (key) keys.add(key); + if (key) { + keys.add(key); + } } return keys; } @@ -255,7 +271,9 @@ function readWithMetricsOnboarding(): Set { } const out = new Set(); for (const element of arg.elements) { - if (ts.isStringLiteral(element)) out.add(element.text); + if (ts.isStringLiteral(element)) { + out.add(element.text); + } } return out; } @@ -266,10 +284,14 @@ interface Frontmatter { } function readFrontmatter(file: string): Frontmatter | null { - if (!existsSync(file)) return null; + if (!existsSync(file)) { + return null; + } const text = readFileSync(file, 'utf8'); const m = text.match(/^---\n([\s\S]*?)\n---/); - if (!m) return {}; + if (!m) { + return {}; + } return (parseYaml(m[1]!) as Frontmatter) ?? {}; } @@ -288,7 +310,9 @@ function findGuideOverridePage( ): string | null { for (const dir of ['guides', 'integrations']) { const f = path.join(DOCS_PLATFORMS_DIR, lang, dir, guide, feature, 'index.mdx'); - if (existsSync(f)) return f; + if (existsSync(f)) { + return f; + } } return null; } @@ -299,11 +323,17 @@ function isSupported( lang: string, guide: string | null ): boolean { - if (!fm) return false; + if (!fm) { + return false; + } const canonical = guide ? `${lang}.${guide}` : lang; if (Array.isArray(fm.supported) && fm.supported.length) { - if (fm.supported.includes(canonical)) return true; - if (!fm.supported.includes(lang)) return false; + if (fm.supported.includes(canonical)) { + return true; + } + if (!fm.supported.includes(lang)) { + return false; + } } if (Array.isArray(fm.notSupported)) { if (fm.notSupported.includes(canonical) || fm.notSupported.includes(lang)) { @@ -320,13 +350,23 @@ function isSupported( // nothing for platforms that have neither a curated toggle entry nor a wizard. function hasOnboardingWizard(platformId: string): boolean { const dir = path.join(GETTING_STARTED_DOCS_DIR, platformId); - if (!existsSync(dir)) return false; + if (!existsSync(dir)) { + return false; + } for (const entry of readdirSync(dir, {withFileTypes: true})) { - if (!entry.isFile()) continue; - if (entry.name.endsWith('.spec.tsx') || entry.name.endsWith('.spec.ts')) continue; - if (!entry.name.endsWith('.tsx') && !entry.name.endsWith('.ts')) continue; + if (!entry.isFile()) { + continue; + } + if (entry.name.endsWith('.spec.tsx') || entry.name.endsWith('.spec.ts')) { + continue; + } + if (!entry.name.endsWith('.tsx') && !entry.name.endsWith('.ts')) { + continue; + } const content = readFileSync(path.join(dir, entry.name), 'utf8'); - if (WIZARD_PATTERN.test(content)) return true; + if (WIZARD_PATTERN.test(content)) { + return true; + } } return false; } @@ -335,7 +375,9 @@ function deriveProducts( platform: PlatformEntry, withMetricsOnboarding: Set ): string[] { - if (!platform.lang) return []; + if (!platform.lang) { + return []; + } const products: string[] = []; for (const [product, feature] of Object.entries(FEATURES)) { let supported: boolean | null = null; @@ -352,9 +394,15 @@ function deriveProducts( : false; } - if (!supported) continue; - if (product === 'METRICS' && !withMetricsOnboarding.has(platform.id)) continue; - if (PRODUCT_EXCLUSIONS[platform.id]?.has(product)) continue; + if (!supported) { + continue; + } + if (product === 'METRICS' && !withMetricsOnboarding.has(platform.id)) { + continue; + } + if (PRODUCT_EXCLUSIONS[platform.id]?.has(product)) { + continue; + } products.push(product); } @@ -399,7 +447,9 @@ import type {PlatformKey} from 'sentry/types/project'; continue; } lines.push(` ${key}: [`); - for (const p of products) lines.push(` ProductSolution.${p},`); + for (const p of products) { + lines.push(` ProductSolution.${p},`); + } lines.push(' ],'); } lines.push('};'); @@ -417,15 +467,23 @@ function main() { // consumer (scmPlatformFeatures.tsx) routes between the two maps via // `platform in platformProductAvailability`, so curated entries here // would be redundant. - if (legacyKeys.has(platform.id)) continue; - if (platform.deprecated) continue; - if (platform.id === 'other') continue; + if (legacyKeys.has(platform.id)) { + continue; + } + if (platform.deprecated) { + continue; + } + if (platform.id === 'other') { + continue; + } // Only include platforms whose onboarding flow is wizard-driven. The // consumer surfaces information cards for these platforms because the // wizard CLI handles configuration; toggles aren't actionable. Platforms // with neither a curated toggle entry nor a wizard render nothing on the // SCM step. - if (!hasOnboardingWizard(platform.id)) continue; + if (!hasOnboardingWizard(platform.id)) { + continue; + } out[platform.id] = deriveProducts(platform, withMetricsOnboarding); } diff --git a/scripts/routes.ts b/scripts/routes.ts index 2b5f7894b48a54..e9f134f80ec2a2 100644 --- a/scripts/routes.ts +++ b/scripts/routes.ts @@ -212,7 +212,9 @@ const CONSTANTS: Record = { function resolveTemplate(expr: string): string { return expr.replace(/\$\{([^}]+)\}/g, (_, inner: string) => { const key = inner.trim(); - if (CONSTANTS[key] !== undefined) return CONSTANTS[key]; + if (CONSTANTS[key] !== undefined) { + return CONSTANTS[key]; + } const hint = (key.split(/[.[(\s]/)[0] ?? key).trim(); return `<${hint}>`; }); @@ -268,7 +270,9 @@ const ACCOUNT_SETTINGS_ROOTS = [ function remapFragment(fragmentPath: string): string | null { // :orgId/ is from experimentalSpaChildRoutes under /auth/login/ - if (fragmentPath === ':orgId/') return '/auth/login/:orgId/'; + if (fragmentPath === ':orgId/') { + return '/auth/login/:orgId/'; + } // accountSettingsChildren: bare paths like details/, security/mfa/:authId/, … if ( @@ -278,7 +282,9 @@ function remapFragment(fragmentPath: string): string | null { } // accountSettingsRoutes root (the variable's own path: 'account/') - if (fragmentPath === 'account/') return '/settings/account/'; + if (fragmentPath === 'account/') { + return '/settings/account/'; + } // projectSettingsChildren: incorrectly parented under account/ if (fragmentPath.startsWith('account/')) { @@ -296,19 +302,25 @@ function remapFragment(fragmentPath: string): string | null { } // transactionSummaryRoute root - if (fragmentPath === 'summary/') return '/performance/summary/'; + if (fragmentPath === 'summary/') { + return '/performance/summary/'; + } // Transaction summary tab children if (fragmentPath.startsWith('summary/')) { const suffix = fragmentPath.slice('summary/'.length); const topSegment = suffix.split('/')[0] + '/'; - if (SUMMARY_TABS.has(topSegment)) return `/performance/summary/${suffix}`; + if (SUMMARY_TABS.has(topSegment)) { + return `/performance/summary/${suffix}`; + } // Everything else under summary/ is a domainViewChildRoute return `/insights/${suffix}`; } // traceView root — assembled into /performance/ (and /dashboards/, /traces/) - if (fragmentPath === 'trace/:traceSlug/') return '/performance/trace/:traceSlug/'; + if (fragmentPath === 'trace/:traceSlug/') { + return '/performance/trace/:traceSlug/'; + } // alertChildRoutes entries: incorrectly parented under trace/:traceSlug/ if (fragmentPath.startsWith('trace/:traceSlug/')) { @@ -391,7 +403,9 @@ const seen = new Set(); for (const line of lines) { const indent = line.search(/\S/); - if (indent === -1) continue; + if (indent === -1) { + continue; + } let pathValue: string | null = null; let hasUnknown = false; @@ -409,7 +423,9 @@ for (const line of lines) { hasUnknown = pathValue.includes('<'); } - if (pathValue === null) continue; + if (pathValue === null) { + continue; + } while (stack.length > 0 && stack[stack.length - 1]!.indent >= indent) { stack.pop(); @@ -439,7 +455,9 @@ type MappedPath = RawPath & {wasFragment: boolean}; const allPaths: MappedPath[] = rawPaths.map(({fullPath, hasUnknown}) => { if (!fullPath.startsWith('/')) { const remapped = remapFragment(fullPath); - if (remapped) return {fullPath: remapped, hasUnknown, wasFragment: true}; + if (remapped) { + return {fullPath: remapped, hasUnknown, wasFragment: true}; + } return {fullPath, hasUnknown, wasFragment: true}; } return {fullPath, hasUnknown, wasFragment: false}; @@ -469,7 +487,9 @@ for (const {fullPath, hasUnknown} of deduped) { if (!showAll) { const providedInRoute = required.filter(p => effectiveParams[p] !== undefined); - if (required.length > 0 && providedInRoute.length === 0) continue; + if (required.length > 0 && providedInRoute.length === 0) { + continue; + } } let resolved = fullPath; diff --git a/scripts/type-coverage-diff.ts b/scripts/type-coverage-diff.ts index 2df476a9fa5b27..95b3f785fb4a5d 100644 --- a/scripts/type-coverage-diff.ts +++ b/scripts/type-coverage-diff.ts @@ -119,7 +119,9 @@ function parseArgs(): Options { } else if (arg === '--verbose' || arg === '-v') { opts.verbose = true; } else if (arg === '--ignore-files') { - if (!opts.ignoreFiles) opts.ignoreFiles = []; + if (!opts.ignoreFiles) { + opts.ignoreFiles = []; + } opts.ignoreFiles.push(args[++i]!); } else { console.error(colors.red(`Unknown option: ${arg}`)); @@ -361,14 +363,18 @@ function formatItems< color: (text: string) => string, formatter?: (item: T) => string ): void { - if (items.length === 0) return; + if (items.length === 0) { + return; + } console.log(colors.bold(`\n${title} (${items.length})`)); console.log('='.repeat(title.length + ` (${items.length})`.length)); // Sort by file, then by line const sortedItems = items.sort((a, b) => { - if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.file !== b.file) { + return a.file.localeCompare(b.file); + } return a.line - b.line; }); diff --git a/scripts/type-coverage.ts b/scripts/type-coverage.ts index 26c67222b850b9..cc8ade85514c2d 100644 --- a/scripts/type-coverage.ts +++ b/scripts/type-coverage.ts @@ -33,15 +33,24 @@ function parseArgs(): Options { const opts: Options = {tsconfigPath: 'tsconfig.json'}; for (let i = 0; i < args.length; i++) { const a = args[i]; - if (a === '--fail-below') opts.failBelow = Number(args[++i]); - else if (a === '--json') opts.json = true; - else if (a === '--project' || a === '-p') opts.tsconfigPath = args[++i]!; - else if (a === '--list-any') opts.listAny = true; - else if (a === '--list-nonnull') opts.listNonNull = true; - else if (a === '--list-type-assertions') opts.listTypeAssertions = true; - else if (a === '--detail') opts.detail = true; - else if (a === '--ignore-files') { - if (!opts.ignoreFiles) opts.ignoreFiles = []; + if (a === '--fail-below') { + opts.failBelow = Number(args[++i]); + } else if (a === '--json') { + opts.json = true; + } else if (a === '--project' || a === '-p') { + opts.tsconfigPath = args[++i]!; + } else if (a === '--list-any') { + opts.listAny = true; + } else if (a === '--list-nonnull') { + opts.listNonNull = true; + } else if (a === '--list-type-assertions') { + opts.listTypeAssertions = true; + } else if (a === '--detail') { + opts.detail = true; + } else if (a === '--ignore-files') { + if (!opts.ignoreFiles) { + opts.ignoreFiles = []; + } opts.ignoreFiles.push(args[++i]!); } } @@ -49,9 +58,13 @@ function parseArgs(): Options { } const isAny = (type: ts.Type, typeChecker: ts.TypeChecker) => { - if (type.flags & ts.TypeFlags.Any) return true; + if (type.flags & ts.TypeFlags.Any) { + return true; + } const typeText = typeChecker.typeToString(type); - if (typeText === 'any') return true; + if (typeText === 'any') { + return true; + } // Check for 'any' within generic types like Record, Array, etc. return /\bany\b/.test(typeText); }; @@ -67,7 +80,9 @@ function isContextuallyTypedCallbackParam( typeChecker: ts.TypeChecker ): boolean { const parent = param.parent; - if (!ts.isFunctionExpression(parent) && !ts.isArrowFunction(parent)) return false; + if (!ts.isFunctionExpression(parent) && !ts.isArrowFunction(parent)) { + return false; + } // Check for styled-components pattern: styled('div')`...${p => ...}...` let current: ts.Node | undefined = parent.parent; @@ -99,17 +114,23 @@ function isContextuallyTypedCallbackParam( // Original contextual type checking const contextualType = typeChecker.getContextualType(parent); - if (!contextualType) return false; + if (!contextualType) { + return false; + } const signatures = typeChecker.getSignaturesOfType( contextualType, ts.SignatureKind.Call ); - if (signatures.length === 0) return false; + if (signatures.length === 0) { + return false; + } const sig = signatures[0]!; const paramIndex = parent.parameters.indexOf(param); - if (paramIndex < 0 || paramIndex >= sig.parameters.length) return false; + if (paramIndex < 0 || paramIndex >= sig.parameters.length) { + return false; + } const paramType = typeChecker.getTypeOfSymbolAtLocation( sig.parameters[paramIndex]!, @@ -219,7 +240,9 @@ function countBindingPatternElements( let count = 0; for (const element of pattern.elements) { - if (ts.isOmittedExpression(element)) continue; + if (ts.isOmittedExpression(element)) { + continue; + } count++; let typed = false; @@ -565,14 +588,20 @@ function main() { function bump(file: string, typed: boolean) { const rec = (perFile[file] ||= {total: 0, typed: 0}); rec.total++; - if (typed) rec.typed++; + if (typed) { + rec.typed++; + } totals.total++; - if (typed) totals.typed++; + if (typed) { + totals.typed++; + } } // Analyze each source file for (const sourceFile of program.getSourceFiles()) { - if (!files.includes(sourceFile.fileName)) continue; + if (!files.includes(sourceFile.fileName)) { + continue; + } const relPath = path.relative(process.cwd(), sourceFile.fileName); const fileBump = (typed: boolean) => bump(relPath, typed); @@ -608,9 +637,15 @@ function main() { coverage: Number(((c.typed / c.total) * 100).toFixed(2)), })), }; - if (opts.listAny) data.anySymbols = anyHits; - if (opts.listNonNull) data.nonNullAssertions = nonNullHits; - if (opts.listTypeAssertions) data.typeAssertions = typeAssertionHits; + if (opts.listAny) { + data.anySymbols = anyHits; + } + if (opts.listNonNull) { + data.nonNullAssertions = nonNullHits; + } + if (opts.listTypeAssertions) { + data.typeAssertions = typeAssertionHits; + } console.log(JSON.stringify(data, null, 2)); } else if (opts.listAny || opts.listNonNull || opts.listTypeAssertions) { if (opts.listAny) { @@ -622,7 +657,9 @@ function main() { console.log(); // Sort hits by file path first, then by line number const sortedHits = anyHits.sort((a, b) => { - if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.file !== b.file) { + return a.file.localeCompare(b.file); + } return a.line - b.line; }); for (const hit of sortedHits) { @@ -644,7 +681,9 @@ function main() { console.log(); // Sort hits by file path first, then by line number const sortedHits = nonNullHits.sort((a, b) => { - if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.file !== b.file) { + return a.file.localeCompare(b.file); + } return a.line - b.line; }); for (const hit of sortedHits) { @@ -666,7 +705,9 @@ function main() { console.log(); // Sort hits by file path first, then by line number const sortedHits = typeAssertionHits.sort((a, b) => { - if (a.file !== b.file) return a.file.localeCompare(b.file); + if (a.file !== b.file) { + return a.file.localeCompare(b.file); + } return a.line - b.line; }); for (const hit of sortedHits) { @@ -703,7 +744,9 @@ function main() { if (worst.length) { console.log(colors.bold('Lowest coverage files:')); - for (const w of worst) console.log(` ${colors.dim(w.file)} ${w.pct.toFixed(2)}%`); + for (const w of worst) { + console.log(` ${colors.dim(w.file)} ${w.pct.toFixed(2)}%`); + } console.log(); } } diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index 874c9686f8faf9..c0edf8730a1653 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -539,9 +539,9 @@ def validate(self, data): description = data.get("description") description_length = len(description) if description else 0 - if description_length > 255: + if description_length > 350: raise serializers.ValidationError( - {"description": "Ensure description has no more than 255 characters."} + {"description": "Ensure description has no more than 350 characters."} ) if data.get("queries"): diff --git a/src/sentry/conf/server.py b/src/sentry/conf/server.py index 4d749efeeab79d..30750f3d0d7d81 100644 --- a/src/sentry/conf/server.py +++ b/src/sentry/conf/server.py @@ -885,6 +885,7 @@ def SOCIAL_AUTH_DEFAULT_USERNAME() -> str: "sentry.hybridcloud.tasks.deliver_webhooks", "sentry.incidents.tasks", "sentry.ingest.transaction_clusterer.tasks", + "sentry.integrations.data_forwarding.tasks", "sentry.integrations.github.tasks.codecov_account_link", "sentry.integrations.github.tasks.codecov_account_unlink", "sentry.integrations.github.tasks.link_all_repos", diff --git a/src/sentry/data_export/endpoints/data_export.py b/src/sentry/data_export/endpoints/data_export.py index ba92949eb6abfe..491ec87a00e009 100644 --- a/src/sentry/data_export/endpoints/data_export.py +++ b/src/sentry/data_export/endpoints/data_export.py @@ -4,6 +4,7 @@ import sentry_sdk from django.core.exceptions import ValidationError from rest_framework import serializers +from rest_framework.authentication import SessionAuthentication from rest_framework.request import Request from rest_framework.response import Response @@ -50,7 +51,14 @@ } logger = logging.getLogger(__name__) -MAX_SYNC_LIMIT = 10_000 +MAX_EXPORT_LIMIT = 10_000 + + +def is_api_or_agent_request(request: Request) -> bool: + """ + True when the request did NOT come from a logged-in browser session. + """ + return not isinstance(request.successful_authenticator, SessionAuthentication) class DataExportQuerySerializer(serializers.Serializer[dict[str, Any]]): @@ -274,7 +282,22 @@ def _get_project_id(self, request: Request) -> str: project_id = query_info["project"] return project_id - def _parse_limit(self, data: dict[str, Any]) -> tuple[int | None, bool]: + def _parse_limit(self, data: dict[str, Any], is_api_request: bool) -> tuple[int | None, bool]: + """ + Determine the export row limit and whether to run synchronously. + + Caller behavior: + - API-token / agent requests (`is_api_request=True`): hard cap of + ``MAX_EXPORT_LIMIT`` rows for every dataset. Unspecified or + larger limits are clamped down. Larger self-serve exports were + unreliable in practice; GDPR data-portability requests are + handled out-of-band via sentry.io/contact/gdpr/. + - Browser/session requests (`is_api_request=False`): + - logs full export: hard cap of ``MAX_EXPORT_LIMIT`` (the + sync download path is sized for this). + - discover / spans: no enforced cap; existing behavior is + preserved to avoid regressing in-product exports. + """ limit = data.get("limit") if limit is not None: @@ -282,13 +305,17 @@ def _parse_limit(self, data: dict[str, Any]) -> tuple[int | None, bool]: limit = int(limit) except (TypeError, ValueError): limit = None - run_sync = ( - limit is not None - and limit <= MAX_SYNC_LIMIT - and data["query_type"] == ExportQueryType.TRACE_ITEM_FULL_EXPORT_STR + + is_logs_full_export = ( + data["query_type"] == ExportQueryType.TRACE_ITEM_FULL_EXPORT_STR and data["query_info"].get("dataset") == "logs" ) - return limit, run_sync + + if is_api_request or is_logs_full_export: + if limit is None or limit > MAX_EXPORT_LIMIT: + limit = MAX_EXPORT_LIMIT + + return limit, is_logs_full_export def post(self, request: Request, organization: Organization) -> Response: """ @@ -335,7 +362,9 @@ def post(self, request: Request, organization: Organization) -> Response: return Response(serializer.errors, status=400) validated_data = serializer.validated_data - limit, run_sync = self._parse_limit(validated_data) + limit, run_sync = self._parse_limit( + validated_data, is_api_request=is_api_or_agent_request(request) + ) try: # If this user has sent a request with the same payload and organization, @@ -389,9 +418,11 @@ def _schedule_export_task( export_format = validated_data["format"] dataset = qi.get("dataset") if run_sync: + # `_parse_limit` guarantees a clamped int whenever run_sync is True. + assert limit is not None export_data_to_stored_blobs_sync( data_export=data_export, - export_limit=limit or MAX_SYNC_LIMIT, + export_limit=limit, environment_id=environment_id, ) else: diff --git a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py index 8f12d19dee14cb..c1f1bf6bc27b17 100644 --- a/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py +++ b/src/sentry/integrations/data_forwarding/amazon_sqs/forwarder.py @@ -27,7 +27,12 @@ class AmazonSQSForwarder(BaseDataForwarder): def get_event_payload( self, event: Event | GroupEvent, config: dict[str, Any] ) -> dict[str, Any]: - return serialize(event) + # We do this since serialize(event) returns datetime objects, and taskbroker doesn't support + # those as arguments. This dump -> load step gets around it, and leaves the payload being + # forwarded in the same format it has always been. + return orjson.loads( + orjson.dumps(serialize(event), option=orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS) + ) def is_unrecoverable_client_error(self, error: ClientError) -> bool: error_str = str(error) @@ -138,3 +143,66 @@ def sqs_send_message(message): raise return True + + def get_task_payload(self, event: Event | GroupEvent, config: dict[str, Any]) -> dict[str, Any]: + return { + "event_id": event.event_id, + "project_slug": event.project.slug, + "date": event.datetime.strftime("%Y-%m-%d"), + } + + @staticmethod + def forward_event_from_task( + *, + config: dict[str, Any], + event_payload: dict[str, Any], + task_payload: dict[str, Any], + ) -> None: + queue_url = config["queue_url"] + region = config["region"] + access_key = config["access_key"] + secret_key = config["secret_key"] + message_group_id = config.get("message_group_id") + s3_bucket = config.get("s3_bucket") + + boto3_args = { + "aws_access_key_id": access_key, + "aws_secret_access_key": secret_key, + "region_name": region, + } + + if s3_bucket: + date = task_payload["date"] + project_slug = task_payload["project_slug"] + event_id = task_payload["event_id"] + key = f"{project_slug}/{date}/{event_id}" + + s3_client = boto3.client( + service_name="s3", config=Config(signature_version="s3v4"), **boto3_args + ) + s3_client.put_object( + Bucket=s3_bucket, + Body=orjson.dumps( + event_payload, option=orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS + ).decode(), + Key=key, + ) + + url = f"https://{s3_bucket}.s3.{region}.amazonaws.com/{key}" + event_payload = {"s3Url": url, "eventID": event_id} + + message = orjson.dumps( + event_payload, option=orjson.OPT_UTC_Z | orjson.OPT_NON_STR_KEYS + ).decode() + + if len(message) > AWS_SQS_MAX_MESSAGE_SIZE: + return + + client = boto3.client(service_name="sqs", **boto3_args) + send_message_args = {"QueueUrl": queue_url, "MessageBody": message} + + if message_group_id: + send_message_args["MessageGroupId"] = message_group_id + send_message_args["MessageDeduplicationId"] = uuid4().hex + + client.send_message(**send_message_args) diff --git a/src/sentry/integrations/data_forwarding/base.py b/src/sentry/integrations/data_forwarding/base.py index 3c56a9db838140..fc5c6bd425ce63 100644 --- a/src/sentry/integrations/data_forwarding/base.py +++ b/src/sentry/integrations/data_forwarding/base.py @@ -1,11 +1,13 @@ import logging +import random from abc import ABC, abstractmethod from typing import Any, ClassVar -from sentry import ratelimits +from sentry import options, ratelimits from sentry.integrations.models.data_forwarder_project import DataForwarderProject from sentry.integrations.types import DataForwarderProviderSlug from sentry.services.eventstore.models import Event, GroupEvent +from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -57,13 +59,47 @@ def get_event_payload( ) -> dict[str, Any]: raise NotImplementedError + @abstractmethod + def get_task_payload(self, event: Event | GroupEvent, config: dict[str, Any]) -> dict[str, Any]: + """ + Allows providers to create task-safe payloads from the event to avoid refetching in the task. + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def forward_event_from_task( + *, + config: dict[str, Any], + event_payload: dict[str, Any], + task_payload: dict[str, Any], + ) -> None: + """ + Similar to forward_event, but raises any exception to allow for retries in a task. + The task_payload is derived from get_task_payload, to avoid needing to refetch the event. + """ + raise NotImplementedError + def post_process( self, event: Event | GroupEvent, data_forwarder_project: DataForwarderProject ) -> None: + from sentry.integrations.data_forwarding.tasks import forward_event + config = data_forwarder_project.get_config() self.initialize_variables(event, config) if self.is_ratelimited(event): return - payload = self.get_event_payload(event=event, config=config) - self.forward_event(event=event, payload=payload, config=config) + event_payload = self.get_event_payload(event=event, config=config) + if random.random() < options.get("data-forwarding.task-rollout-rate"): + task_payload = self.get_task_payload(event=event, config=config) + forward_event.delay( + data_forwarder_project_id=data_forwarder_project.id, + event_payload=event_payload, + task_payload=task_payload, + ) + metrics.incr( + "data_forwarding.post_process.task_scheduled", tags={"provider": self.provider} + ) + else: + self.forward_event(event=event, payload=event_payload, config=config) diff --git a/src/sentry/integrations/data_forwarding/segment/forwarder.py b/src/sentry/integrations/data_forwarding/segment/forwarder.py index 56d8b26882f597..0ea41a876ea769 100644 --- a/src/sentry/integrations/data_forwarding/segment/forwarder.py +++ b/src/sentry/integrations/data_forwarding/segment/forwarder.py @@ -98,7 +98,7 @@ def forward_event( self.endpoint, json=payload, auth=(write_key, ""), - timeout=10, + timeout=(3.5, 10), ) response.raise_for_status() except Exception: @@ -108,3 +108,43 @@ def forward_event( ) return False return True + + def get_task_payload(self, event: Event | GroupEvent, config: dict[str, Any]) -> dict[str, Any]: + # we avoid instantiating interfaces here as they're only going to be + # used if there's a User present + user_interface = event.interfaces.get("user") + if not user_interface: + return {"event_type": event.get_event_type(), "has_user": False} + + # if the user id is not present, we can't forward the event + return { + "event_type": event.get_event_type(), + "has_user": True if user_interface.id else False, + } + + @staticmethod + def forward_event_from_task( + *, + config: dict[str, Any], + event_payload: dict[str, Any], + task_payload: dict[str, Any], + ) -> None: + # we currently only support errors + if task_payload.get("event_type") != "error": + return + + if not task_payload.get("has_user", False): + return + + write_key = config["write_key"] + if not write_key: + return + + with http.build_session() as session: + response = session.post( + SegmentForwarder.endpoint, + json=event_payload, + auth=(write_key, ""), + timeout=(3.5, 10), + ) + response.raise_for_status() diff --git a/src/sentry/integrations/data_forwarding/splunk/forwarder.py b/src/sentry/integrations/data_forwarding/splunk/forwarder.py index 7959a66e73ddcf..315c9b16e44cfa 100644 --- a/src/sentry/integrations/data_forwarding/splunk/forwarder.py +++ b/src/sentry/integrations/data_forwarding/splunk/forwarder.py @@ -149,3 +149,30 @@ def forward_event( return False raise return True + + def get_task_payload(self, event: Event | GroupEvent, config: dict[str, Any]) -> dict[str, Any]: + return {"host": self.host} + + @staticmethod + def forward_event_from_task( + *, + config: dict[str, Any], + event_payload: dict[str, Any], + task_payload: dict[str, Any], + ) -> None: + token = config.get("token") + index = config.get("index") + instance = config.get("instance_url") + + if not token or not index or not instance: + return + + if not instance.endswith("/services/collector"): + instance = instance.rstrip("/") + "/services/collector" + + host = task_payload.get("host") + if host: + event_payload["host"] = host + + client = SplunkApiClient(instance, token) + client.request(event_payload) diff --git a/src/sentry/integrations/data_forwarding/tasks.py b/src/sentry/integrations/data_forwarding/tasks.py new file mode 100644 index 00000000000000..2698a0c0e13277 --- /dev/null +++ b/src/sentry/integrations/data_forwarding/tasks.py @@ -0,0 +1,87 @@ +import logging +from typing import Any + +from botocore.exceptions import ClientError, ParamValidationError +from requests import HTTPError, Timeout +from requests.exceptions import ChunkedEncodingError, ConnectionError, RequestException +from taskbroker_client.retry import Retry + +from sentry.integrations.data_forwarding import FORWARDER_REGISTRY +from sentry.integrations.models.data_forwarder_project import DataForwarderProject +from sentry.shared_integrations.exceptions import ( + ApiForbiddenError, + ApiHostError, + ApiInvalidRequestError, + ApiTimeoutError, + ApiUnauthorized, +) +from sentry.silo.base import SiloMode +from sentry.tasks.base import instrumented_task +from sentry.taskworker.namespaces import integrations_tasks +from sentry.utils import metrics + +logger = logging.getLogger(__name__) + + +_DATA_FORWARDING_RETRY_ON = (RequestException,) +_DATA_FORWARDING_RETRY_IGNORE = ( + ClientError, + ParamValidationError, + ApiUnauthorized, + ApiInvalidRequestError, + ApiForbiddenError, +) +_DATA_FORWARDING_SILENCED = ( + ChunkedEncodingError, + Timeout, + ApiHostError, + ApiTimeoutError, + ConnectionError, + HTTPError, + *_DATA_FORWARDING_RETRY_IGNORE, +) + + +@instrumented_task( + name="sentry.integrations.data_forwarding.tasks.forward_event", + namespace=integrations_tasks, + retry=Retry( + times=3, + delay=60 * 5, + on=_DATA_FORWARDING_RETRY_ON, + ignore=_DATA_FORWARDING_RETRY_IGNORE, + ), + processing_deadline_duration=12, + silo_mode=SiloMode.CELL, + silenced_exceptions=_DATA_FORWARDING_SILENCED, +) +def forward_event( + data_forwarder_project_id: int, + event_payload: dict[str, Any], + task_payload: dict[str, Any], +) -> None: + logging_ctx: dict[str, Any] = {"data_forwarder_project_id": data_forwarder_project_id} + try: + data_forwarder_project = DataForwarderProject.objects.select_related("data_forwarder").get( + id=data_forwarder_project_id + ) + except DataForwarderProject.DoesNotExist: + logger.warning("data_forwarding.data_forwarder_project_not_found", extra=logging_ctx) + return + + provider = data_forwarder_project.data_forwarder.provider + logging_ctx["provider"] = provider + logging_ctx["project_id"] = data_forwarder_project.project_id + + forwarder = FORWARDER_REGISTRY.get(provider) + if not forwarder: + logger.warning("data_forwarding.missing_provider", extra=logging_ctx) + return + + forwarder.forward_event_from_task( + config=data_forwarder_project.get_config(), + event_payload=event_payload, + task_payload=task_payload, + ) + metrics.incr("data_forwarding.forwarding_task_succeeded", tags={"provider": provider}) + logger.info("data_forwarding.forwarding_task_succeeded", extra=logging_ctx) diff --git a/src/sentry/issue_detection/detectors/io_main_thread_detector.py b/src/sentry/issue_detection/detectors/io_main_thread_detector.py index 765d9d0b2abeb3..7dd55f4dd21e2b 100644 --- a/src/sentry/issue_detection/detectors/io_main_thread_detector.py +++ b/src/sentry/issue_detection/detectors/io_main_thread_detector.py @@ -119,7 +119,7 @@ class FileIOMainThreadDetector(BaseIOMainThreadDetector): Checks for a file io span on the main thread """ - IGNORED_SUFFIXES = [".nib", ".plist", "kblayout_iphone.dat"] + IGNORED_SUFFIXES = [".nib", ".plist", "kblayout_iphone.dat", "kblayouts_iphone.dat"] SPAN_PREFIX = "file" type = DetectorType.FILE_IO_MAIN_THREAD settings_key = DetectorType.FILE_IO_MAIN_THREAD diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index ad9651c003abb9..5d4375d315280c 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -113,6 +113,12 @@ default=300, # 5 minutes flags=FLAG_AUTOMATOR_MODIFIABLE, ) +register( + "data-forwarding.task-rollout-rate", + type=Float, + default=0.0, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) # Redis register( diff --git a/src/sentry/profiles/consumers/process/factory.py b/src/sentry/profiles/consumers/process/factory.py index 48f2c4421cbc60..9fd1e5f510a573 100644 --- a/src/sentry/profiles/consumers/process/factory.py +++ b/src/sentry/profiles/consumers/process/factory.py @@ -11,15 +11,32 @@ from sentry.processing.backpressure.arroyo import HealthChecker, create_backpressure_step from sentry.profiles.task import process_profile_task +# Headers from consumer are Iterable[tuple[str, str | bytes]], from taskbroker are dict[str, str] +Headers = Iterable[tuple[str, str | bytes]] | dict[str, str] -def process_message(message: Message[KafkaPayload]) -> None: - if should_drop(message.payload.headers): + +def _process_profile_message( + message_bytes: bytes, + headers: Headers, + inline: bool = False, +) -> None: + """Process a profile message from Kafka. Used by both consumer and taskbroker passthrough.""" + if should_drop(headers): + return + + sampled = is_sampled(headers) + + if not sampled and not options.get("profiling.profile_metrics.unsampled_profiles.enabled"): return - sampled = is_sampled(message.payload.headers) + if inline: + process_profile_task(payload=message_bytes, sampled=sampled) + else: + process_profile_task.delay(payload=message_bytes, sampled=sampled) - if sampled or options.get("profiling.profile_metrics.unsampled_profiles.enabled"): - process_profile_task.delay(payload=message.payload.value, sampled=sampled) + +def process_message(message: Message[KafkaPayload]) -> None: + _process_profile_message(message.payload.value, message.payload.headers) class ProcessProfileStrategyFactory(ProcessingStrategyFactory[KafkaPayload]): @@ -42,7 +59,9 @@ def create_with_partitions( ) -def is_sampled(headers: Iterable[tuple[str, str | bytes]]) -> bool: +def is_sampled(headers: Headers) -> bool: + if isinstance(headers, dict): + return headers.get("sampled", "true") == "true" for k, v in headers: if k == "sampled": if isinstance(v, bytes): @@ -50,14 +69,14 @@ def is_sampled(headers: Iterable[tuple[str, str | bytes]]) -> bool: return True -HEADER_KEYS = {"project_id"} - - -def should_drop(headers: Iterable[tuple[str, str | bytes]]) -> bool: - context = {} - for k, v in headers: - if k == "project_id" and isinstance(v, bytes): - context[k] = v.decode("utf-8") +def should_drop(headers: Headers) -> bool: + if isinstance(headers, dict): + context = {"project_id": headers["project_id"]} if "project_id" in headers else {} + else: + context = {} + for k, v in headers: + if k == "project_id" and isinstance(v, bytes): + context[k] = v.decode("utf-8") if "project_id" in context and killswitch_matches_context( "profiling.killswitch.ingest-profiles", context diff --git a/src/sentry/profiles/task.py b/src/sentry/profiles/task.py index dd25fa2ca2e757..99df17d9dc9e02 100644 --- a/src/sentry/profiles/task.py +++ b/src/sentry/profiles/task.py @@ -62,7 +62,7 @@ from sentry.signals import first_profile_received from sentry.silo.base import SiloMode from sentry.tasks.base import instrumented_task -from sentry.taskworker.namespaces import ingest_profiling_tasks +from sentry.taskworker.namespaces import ingest_profiling_passthrough_tasks, ingest_profiling_tasks from sentry.utils import json, metrics from sentry.utils.arroyo_producer import SingletonProducer, get_arroyo_producer from sentry.utils.eap import hex_to_item_id @@ -121,6 +121,25 @@ def _get_profiles_producer_from_topic(topic: Topic) -> KafkaProducer: logger = logging.getLogger(__name__) +@instrumented_task( + name="sentry.profiles.task.process_profile_from_kafka", + namespace=ingest_profiling_passthrough_tasks, + processing_deadline_duration=60, + retry=Retry(times=2, delay=5), + compression_type=CompressionType.ZSTD, + silo_mode=SiloMode.CELL, + pass_headers=True, +) +def process_profile_from_kafka( + message_bytes: bytes, + headers: dict[str, str], +) -> None: + """Process a profile from raw Kafka message bytes (taskbroker passthrough mode).""" + from sentry.profiles.consumers.process.factory import _process_profile_message + + _process_profile_message(message_bytes, headers, inline=True) + + @instrumented_task( name="sentry.profiles.task.process_profile", namespace=ingest_profiling_tasks, diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index f6bd2124ae198d..8b10d5ef187e43 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -9,7 +9,6 @@ from rest_framework.request import Request from rest_framework.response import Response -from sentry import features from sentry.api.api_owners import ApiOwner from sentry.api.api_publish_status import ApiPublishStatus from sentry.api.base import cell_silo_endpoint @@ -25,14 +24,9 @@ from sentry.apidocs.parameters import GlobalParams, IssueParams from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import CELL_API_DEPRECATION_DATE -from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig -from sentry.issues.auto_source_code_config.code_mapping import get_sorted_code_mapping_configs from sentry.issues.endpoints.bases.group import GroupAiEndpoint from sentry.models.group import Group -from sentry.models.organization import Organization -from sentry.models.repository import Repository from sentry.ratelimits.config import RateLimitConfig -from sentry.seer.autofix.autofix import trigger_legacy_autofix from sentry.seer.autofix.autofix_agent import ( UNKNOWN_RUN_ID_FOR_GROUP, AutofixStep, @@ -51,13 +45,10 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, CodingAgentProviderType, - get_autofix_state, has_project_connected_repos, ) from sentry.seer.models import SeerPermissionError from sentry.types.ratelimit import RateLimit, RateLimitCategory -from sentry.users.services.user.service import user_service -from sentry.utils.cache import cache logger = logging.getLogger(__name__) @@ -78,30 +69,6 @@ def _parse_autofix_referrer(raw: str | None) -> AutofixReferrer: return AutofixReferrer.UNKNOWN -class AutofixRequestSerializer(CamelSnakeSerializer): - event_id = serializers.CharField( - required=False, - help_text="Run issue fix on a specific event. If not provided, the recommended event for the issue will be used.", - ) - instruction = serializers.CharField( - required=False, - help_text="Optional custom instruction to guide the issue fix process.", - allow_blank=True, - ) - pr_to_comment_on_url = serializers.URLField( - required=False, help_text="URL of a pull request where the issue fix should add comments." - ) - stopping_point = serializers.ChoiceField( - required=False, - choices=["root_cause", "solution", "code_changes", "open_pr"], - help_text="Where the issue fix process should stop. If not provided, will run to root cause.", - ) - referrer = serializers.CharField( - required=False, - help_text="Referrer identifying where the issue fix was triggered from.", - ) - - class ExplorerAutofixRequestSerializer(CamelSnakeSerializer): """Serializer for the agent-based autofix requests.""" @@ -185,34 +152,6 @@ class GroupAutofixEndpoint(GroupAiEndpoint): } ) - def _should_use_agent(self, request: Request, organization: Organization) -> bool: - """Check if explorer mode should be used based on query params and feature flags.""" - if request.GET.get("mode") != "explorer": - return False - - feature_names = [ - # Access to seer agent - "organizations:seer-explorer", - # Access to seer agent powered autofix - "organizations:autofix-on-explorer", - ] - - batch_features = features.batch_has( - feature_names, - organization=organization, - actor=request.user, - ) - - if batch_features is None: - return False - - org_features = batch_features.get(f"organization:{organization.id}", {}) - for feature_name in feature_names: - if bool(org_features.get(feature_name)): - return True - - return False - @extend_schema( operation_id="Start Seer Issue Fix", parameters=[ @@ -220,7 +159,7 @@ def _should_use_agent(self, request: Request, organization: Organization) -> boo IssueParams.ISSUES_OR_GROUPS, IssueParams.ISSUE_ID, ], - request=AutofixRequestSerializer, + request=ExplorerAutofixRequestSerializer, responses={ 202: inline_sentry_response_serializer("AutofixPostResponse", AutofixPostResponse), 400: RESPONSE_BAD_REQUEST, @@ -243,12 +182,6 @@ def post(self, request: Request, group: Group) -> Response: The process runs asynchronously, and you can get the state using the GET endpoint. """ - if self._should_use_agent(request, group.organization): - return self._post_agent(request, group) - return self._post_legacy(request, group) - - def _post_agent(self, request: Request, group: Group) -> Response: - """Handle POST for the agent-based autofix.""" if not has_project_connected_repos(group.organization, group.project): return Response( {"detail": "SCM integration is not configured for this project."}, @@ -334,28 +267,6 @@ def _post_agent(self, request: Request, group: Group) -> Response: return Response(status=status.HTTP_404_NOT_FOUND) raise PermissionDenied(SEER_PERMISSION_DENIED) - def _post_legacy(self, request: Request, group: Group) -> Response: - """Handle POST for legacy autofix.""" - serializer = AutofixRequestSerializer(data=request.data) - if not serializer.is_valid(): - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - data = serializer.validated_data - - stopping_point = data.get("stopping_point") - stopping_point = AutofixStoppingPoint(stopping_point) if stopping_point else None - - return trigger_legacy_autofix( - group=group, - # This event_id is the event that the user is looking at when they click the "Fix" button - event_id=data.get("event_id"), - user=request.user, - referrer=_parse_autofix_referrer(data.get("referrer")), - instruction=data.get("instruction"), - pr_to_comment_on_url=data.get("pr_to_comment_on_url"), - stopping_point=stopping_point, - ) - @extend_schema( operation_id="Retrieve Seer Issue Fix State", parameters=[ @@ -385,12 +296,6 @@ def get(self, request: Request, group: Group) -> Response: This endpoint although documented is still experimental and the payload may change in the future. """ - if self._should_use_agent(request, group.organization): - return self._get_agent(request, group) - return self._get_legacy(request, group) - - def _get_agent(self, request: Request, group: Group) -> Response: - """Handle GET for the agent-based autofix.""" try: state = get_autofix_agent_state(group.organization, group.id) except SeerPermissionError as e: @@ -411,7 +316,6 @@ def _get_agent(self, request: Request, group: Group) -> Response: organization_id=group.organization.id, ) - # Return the agent state directly - frontend will handle the format return Response( { "autofix": { @@ -431,101 +335,3 @@ def _get_agent(self, request: Request, group: Group) -> Response: } } ) - - def _get_legacy(self, request: Request, group: Group) -> Response: - """Handle GET for legacy autofix.""" - access_check_cache_key = f"autofix_access_check:{group.id}" - access_check_cache_value = cache.get(access_check_cache_key) - - check_repo_access = False - if not access_check_cache_value: - check_repo_access = True - - is_user_watching = request.GET.get("isUserWatching", False) - - try: - autofix_state = get_autofix_state( - group_id=group.id, - organization_id=group.organization.id, - check_repo_access=check_repo_access, - is_user_fetching=bool(is_user_watching), - ) - except SeerPermissionError: - logger.exception( - "group_ai_autofix.get.seer_permission_error", - extra={"group_id": group.id, "organization_id": group.organization.id}, - ) - - raise PermissionDenied("You are not authorized to access this autofix state") - - if autofix_state and autofix_state.coding_agents and request.user.id: - agent_providers = {a.provider for a in autofix_state.coding_agents.values()} - if CodingAgentProviderType.GITHUB_COPILOT_AGENT in agent_providers: - poll_github_copilot_agents(autofix_state, user_id=request.user.id) - if CodingAgentProviderType.CLAUDE_CODE_AGENT in agent_providers: - poll_claude_code_agents(autofix_state=autofix_state) - - if check_repo_access: - cache.set(access_check_cache_key, True, timeout=60) # 1 minute timeout - - response_state: dict[str, Any] | None = None - - if autofix_state: - response_state = autofix_state.dict() - user_ids = autofix_state.actor_ids - if user_ids: - users = user_service.serialize_many( - filter={"user_ids": user_ids, "organization_id": request.organization.id}, - as_user=request.user, - ) - - users_map = {user["id"]: user for user in users} - - response_state["users"] = users_map - - project = group.project - repositories = [] - - autofix_codebase_state = response_state.get("codebases", {}) - - repo_code_mappings: dict[str, RepositoryProjectPathConfig] = {} - if project: - code_mappings = get_sorted_code_mapping_configs(project=project) - for mapping in code_mappings: - repo = mapping.project_repository.repository - if repo.external_id: - repo_code_mappings[repo.external_id] = mapping - - for repo_external_id, repo_state in autofix_codebase_state.items(): - retrieved_mapping: RepositoryProjectPathConfig | None = repo_code_mappings.get( - repo_external_id, None - ) - - if not retrieved_mapping: - continue - - mapping_repo: Repository = retrieved_mapping.project_repository.repository - - repositories.append( - { - "integration_id": mapping_repo.integration_id, - "url": mapping_repo.url, - "external_id": repo_external_id, - "name": mapping_repo.name, - "provider": mapping_repo.provider, - "default_branch": retrieved_mapping.default_branch, - "is_readable": repo_state.get("is_readable", None), - "is_writeable": repo_state.get("is_writeable", None), - } - ) - - response_state["repositories"] = repositories - - # Remove unnecessary or sensitive data to reduce returned payload size - for key in ["usage", "signals"]: - response_state.pop(key, None) - for request_key in ["issue", "trace_tree", "profile", "issue_summary", "logs"]: - if "request" in response_state and request_key in response_state["request"]: - del response_state["request"][request_key] - - return Response({"autofix": response_state}) diff --git a/src/sentry/spans/buffer.py b/src/sentry/spans/buffer.py index fcbca35b4d2310..ad1aade2ed2133 100644 --- a/src/sentry/spans/buffer.py +++ b/src/sentry/spans/buffer.py @@ -102,7 +102,7 @@ import math import time import uuid -from collections.abc import Generator, MutableMapping, Sequence +from collections.abc import Generator, Mapping, MutableMapping, Sequence from hashlib import blake2b from typing import Any, NamedTuple, cast @@ -120,13 +120,18 @@ from sentry.spans.buffer_logger import ( BufferLogger, DeadlineUpdateLog, - FlusherLogEntry, FlusherLogger, FlushSegmentLog, InsertSpansMetrics, SubsegmentDebugLog, ) -from sentry.spans.buffer_types import EvalshaResult, InsertedSubsegment, Span, Subsegment +from sentry.spans.buffer_types import ( + EvalshaResult, + InsertedSubsegment, + LoadedSegmentData, + Span, + Subsegment, +) from sentry.spans.consumers.process_segments.types import attribute_value from sentry.spans.debug_trace_logger import DebugTraceLogger from sentry.spans.segment_key import ( @@ -223,7 +228,6 @@ def __init__(self, assigned_shards: list[int], slice_id: int | None = None): self.slice_id = slice_id self.add_buffer_sha: str | None = None self.any_shard_at_limit = False - self._last_decompress_latency_ms = 0 self._current_compression_level = None self._zstd_compressor: zstandard.ZstdCompressor | None = None self._zstd_decompressor = zstandard.ZstdDecompressor() @@ -657,7 +661,6 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: queue_keys = [] shard_factor = max(1, len(self.assigned_shards)) max_flush_segments = options.get("spans.buffer.max-flush-segments") - flusher_logger_enabled = options.get("spans.buffer.flusher-cumulative-logger-enabled") max_segments_per_shard = math.ceil(max_flush_segments / shard_factor) ids_start = time.monotonic() @@ -680,6 +683,7 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: acquired_locks = self._acquire_flush_locks([k for _, _, k, _ in segment_keys]) segment_keys = [entry for entry in segment_keys if entry[2] in acquired_locks] + segment_key_values = [k for _, _, k, _ in segment_keys] data_start = time.monotonic() with metrics.timer("spans.buffer.flush_segments.load_segment_data"): @@ -687,21 +691,23 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: segment_to_queue = { segment_key: queue_key for _, queue_key, segment_key, _ in segment_keys } - segments, payload_keys_map = self._load_segment_data( - [k for _, _, k, _ in segment_keys], - segment_to_queue, - now, - ) + loaded_segment_data, decompress_latency_ms = self._load_segment_data(segment_key_values) load_data_latency_ms = int((time.monotonic() - data_start) * 1000) + self._record_segment_loss_metrics( + segment_key_values, + segment_to_queue, + now, + loaded_segment_data.payloads, + ) + return_segments = {} num_has_root_spans = 0 any_shard_at_limit = False - flusher_log_entries: list[FlusherLogEntry] = [] for shard, queue_key, segment_key, score in segment_keys: segment_span_id = segment_key_to_span_id(segment_key).decode("ascii") - segment = segments.get(segment_key, []) + segment = loaded_segment_data.payloads.get(segment_key, []) project_id, _, _ = parse_segment_key(segment_key) if len(segment) >= max_segments_per_shard: any_shard_at_limit = True @@ -737,7 +743,7 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: queue_key=queue_key, spans=output_spans, project_id=int(project_id.decode("ascii")), - payload_keys=payload_keys_map.get(segment_key, []), + payload_keys=loaded_segment_data.payload_keys.get(segment_key, []), ) num_has_root_spans += int(has_root_span) @@ -751,24 +757,13 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: timestamp=now, ).emit(self._get_debug_trace_logger) - if flusher_logger_enabled and segment: - project_id, trace_id, _ = parse_segment_key(segment_key) - project_and_trace = f"{project_id.decode('ascii')}:{trace_id.decode('ascii')}" - flusher_log_entries.append( - FlusherLogEntry( - project_and_trace, - len(segment), - sum(len(s) for s in segment), - ) - ) - - if flusher_logger_enabled and flusher_log_entries: - self._flusher_logger.log( - flusher_log_entries, - load_ids_latency_ms, - load_data_latency_ms, - self._last_decompress_latency_ms, - ) + self._flusher_logger.log_loaded_segments( + segment_key_values, + loaded_segment_data.payloads, + load_ids_latency_ms=load_ids_latency_ms, + load_data_latency_ms=load_data_latency_ms, + decompress_latency_ms=decompress_latency_ms, + ) metrics.timing("spans.buffer.flush_segments.num_segments", len(return_segments)) metrics.timing("spans.buffer.flush_segments.has_root_span", num_has_root_spans) @@ -779,29 +774,37 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: def _load_segment_data( self, segment_keys: list[SegmentKey], - segment_to_queue: dict[SegmentKey, QueueKey], - now: int, - ) -> tuple[dict[SegmentKey, list[bytes]], dict[SegmentKey, list[PayloadKey]]]: + ) -> tuple[LoadedSegmentData, int]: """ Loads the segments from Redis, given a list of segment keys. :param segment_keys: List of segment keys to load. - :param segment_to_queue: Mapping of segment keys to their queue keys for TTL checking. - :param now: Current timestamp for age calculation. - :return: payloads mapping segment keys to lists of span payloads. + :return: Loaded payloads and payload keys, plus decompression latency. """ page_size = options.get("spans.buffer.segment-page-size") + payload_keys = self._load_payload_keys(segment_keys) + payloads, decompress_latency_ms = self._load_payloads_from_keys( + segment_keys, + payload_keys, + page_size, + ) - payloads: dict[SegmentKey, list[bytes]] = {key: [] for key in segment_keys} - payload_keys_map: dict[SegmentKey, list[PayloadKey]] = {key: [] for key in segment_keys} - self._last_decompress_latency_ms = 0 - decompress_latency_ms = 0.0 + return ( + LoadedSegmentData(payloads, payload_keys), + decompress_latency_ms, + ) - # Maps each payload key back to the segment it belongs to. - # Multiple distributed payload keys map to one segment. - scan_key_to_segment: dict[SegmentKey | PayloadKey, SegmentKey] = {} - cursors: dict[bytes, int] = {} + def _load_payload_keys( + self, segment_keys: Sequence[SegmentKey] + ) -> dict[SegmentKey, list[PayloadKey]]: + """ + Load the payload keys indexed under each segment key. + + Segment keys point to member-key indexes; indexes contain payload keys + that point to spans payloads for the segment. + """ + payload_keys: dict[SegmentKey, list[PayloadKey]] = {key: [] for key in segment_keys} with self.client.pipeline(transaction=False) as p: for key in segment_keys: @@ -811,17 +814,33 @@ def _load_segment_data( for key, payload_key_span_ids in zip(segment_keys, mk_results): project_id, trace_id, _ = parse_segment_key(key) project_and_trace = f"{project_id.decode('ascii')}:{trace_id.decode('ascii')}" - segment_payload_keys: list[PayloadKey] = [] for payload_key_span_id in payload_key_span_ids: payload_key = self._get_payload_key( project_and_trace, payload_key_span_id.decode("ascii") ) - segment_payload_keys.append(payload_key) - scan_key_to_segment[payload_key] = key - cursors[payload_key] = 0 - payload_keys_map[key] = segment_payload_keys + payload_keys[key].append(payload_key) + + return payload_keys - def _add_spans(key: SegmentKey, raw_data: bytes): + def _load_payloads_from_keys( + self, + segment_keys: Sequence[SegmentKey], + payload_keys: Mapping[SegmentKey, Sequence[PayloadKey]], + page_size: int, + ) -> tuple[dict[SegmentKey, list[bytes]], int]: + """ + Scan payload keys and return decompressed payloads grouped by segment key. + """ + payloads: dict[SegmentKey, list[bytes]] = {key: [] for key in segment_keys} + decompress_latency_ms = 0.0 + segment_by_payload_key = { + payload_key: segment_key + for segment_key, segment_payload_keys in payload_keys.items() + for payload_key in segment_payload_keys + } + cursors = {payload_key: 0 for payload_key in segment_by_payload_key} + + def _add_spans(key: SegmentKey, raw_data: bytes) -> None: """ Decompress and add spans to the segment. """ @@ -842,7 +861,7 @@ def _add_spans(key: SegmentKey, raw_data: bytes): scan_results = p.execute() for key, (cursor, scan_values) in zip(current_keys, scan_results): - segment_key = scan_key_to_segment[key] + segment_key = segment_by_payload_key[key] for scan_value in scan_values: if segment_key in payloads: _add_spans(segment_key, scan_value) @@ -852,6 +871,15 @@ def _add_spans(key: SegmentKey, raw_data: bytes): else: cursors[key] = cursor + return payloads, int(decompress_latency_ms) + + def _record_segment_loss_metrics( + self, + segment_keys: Sequence[SegmentKey], + segment_to_queue: dict[SegmentKey, QueueKey], + now: int, + payloads: dict[SegmentKey, list[bytes]], + ) -> None: # Fetch ingested counts for all segments to calculate dropped spans with self.client.pipeline(transaction=False) as p: for key in segment_keys: @@ -932,10 +960,6 @@ def _add_spans(key: SegmentKey, raw_data: bytes): # worst-case. metrics.incr("spans.buffer.empty_segments") - self._last_decompress_latency_ms = int(decompress_latency_ms) - - return payloads, payload_keys_map - def done_flush_segments(self, segment_keys: dict[SegmentKey, FlushedSegment]): metrics.timing("spans.buffer.done_flush_segments.num_segments", len(segment_keys)) with metrics.timer("spans.buffer.done_flush_segments"): diff --git a/src/sentry/spans/buffer_logger.py b/src/sentry/spans/buffer_logger.py index ee3a8641dcac61..6fae92f605a1d9 100644 --- a/src/sentry/spans/buffer_logger.py +++ b/src/sentry/spans/buffer_logger.py @@ -2,7 +2,7 @@ import logging import time -from collections.abc import Callable, Sequence +from collections.abc import Callable, Mapping, Sequence from typing import Any, NamedTuple, TypeVar from sentry_redis_tools.clients import RedisCluster, StrictRedis @@ -10,7 +10,7 @@ from sentry import options from sentry.spans.buffer_types import EvalshaData, EvalshaResult, InsertedSubsegment, Span from sentry.spans.debug_trace_logger import DebugTraceLogger -from sentry.spans.segment_key import SegmentKey +from sentry.spans.segment_key import SegmentKey, parse_segment_key from sentry.utils import metrics logger = logging.getLogger(__name__) @@ -166,21 +166,54 @@ def __init__(self) -> None: self._cumulative_decompress_latency_ms: int = 0 self._last_log_time: float | None = None - def log( + def log_loaded_segments( self, - entries: list[FlusherLogEntry], + segment_keys: Sequence[SegmentKey], + payloads: Mapping[SegmentKey, Sequence[bytes]], + *, load_ids_latency_ms: int, load_data_latency_ms: int, decompress_latency_ms: int, ) -> None: """ - Record a batch of flush operations and periodically log the top traces sorted by + Record loaded segments and periodically log the top traces sorted by cumulative bytes flushed. """ - if not options.get("spans.buffer.flusher-cumulative-logger-enabled"): return + entries: list[FlusherLogEntry] = [] + for segment_key in segment_keys: + segment = payloads.get(segment_key, []) + if not segment: + continue + + project_id, trace_id, _ = parse_segment_key(segment_key) + entries.append( + FlusherLogEntry( + f"{project_id.decode('ascii')}:{trace_id.decode('ascii')}", + len(segment), + sum(len(payload) for payload in segment), + ) + ) + + if not entries: + return + + self._log_entries( + entries, + load_ids_latency_ms, + load_data_latency_ms, + decompress_latency_ms, + ) + + def _log_entries( + self, + entries: list[FlusherLogEntry], + load_ids_latency_ms: int, + load_data_latency_ms: int, + decompress_latency_ms: int, + ) -> None: self._cumulative_load_ids_latency_ms += load_ids_latency_ms self._cumulative_load_data_latency_ms += load_data_latency_ms self._cumulative_decompress_latency_ms += decompress_latency_ms diff --git a/src/sentry/spans/buffer_types.py b/src/sentry/spans/buffer_types.py index 88b47cd6df3d9a..4b18624fd356f6 100644 --- a/src/sentry/spans/buffer_types.py +++ b/src/sentry/spans/buffer_types.py @@ -10,7 +10,7 @@ from collections.abc import Sequence from typing import Any, NamedTuple -from sentry.spans.segment_key import SegmentKey +from sentry.spans.segment_key import PayloadKey, SegmentKey type DataPoint = tuple[bytes, float] type EvalshaData = list[DataPoint] @@ -102,3 +102,8 @@ def queue_shard(self) -> int: @property def is_detached_segment(self) -> bool: return self.result.segment_key.endswith(self.subsegment.salt.encode("ascii")) + + +class LoadedSegmentData(NamedTuple): + payloads: dict[SegmentKey, list[bytes]] + payload_keys: dict[SegmentKey, list[PayloadKey]] diff --git a/src/sentry/spans/consumers/process_segments/message.py b/src/sentry/spans/consumers/process_segments/message.py index 18e7e5f0640aa4..a0d10c0147de38 100644 --- a/src/sentry/spans/consumers/process_segments/message.py +++ b/src/sentry/spans/consumers/process_segments/message.py @@ -1,7 +1,10 @@ +import hashlib import logging import types import uuid +from collections import OrderedDict from collections.abc import Mapping, Sequence +from datetime import datetime from typing import Any import sentry_sdk @@ -36,6 +39,7 @@ from sentry.spans.grouping.api import load_span_grouping_config from sentry.utils import metrics from sentry.utils.dates import to_datetime +from sentry.utils.last_seen import LAST_SEEN_INTERVAL_SECONDS from sentry.utils.outcomes import Outcome, OutcomeAggregator from sentry.utils.projectflags import set_project_flag_and_signal @@ -101,7 +105,41 @@ def _process_segment( _add_segment_name(segment_span, spans) _compute_breakdowns(segment_span, spans, project) - _create_models(segment_span, project) + + environment_name = attribute_value(segment_span, ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT) + release_name = attribute_value(segment_span, ATTRIBUTE_NAMES.SENTRY_RELEASE) + dist_name = attribute_value(segment_span, ATTRIBUTE_NAMES.SENTRY_DIST) + date = to_datetime(segment_span["end_timestamp"]) + + cache_key = f"{project.id}:{_to_string(environment_name)}:{_to_string(release_name)}:{_to_string(dist_name)}" + + cache = _get_cache() + cache_metric_name = "spans.consumers.process_segments.cache" + cached_timestamp = cache.get(cache_key) + timestamp = int(date.timestamp()) + + # If no cached value exists this is the first time we've seen this combination. Here + # we follow the maximalist path. Models are created and onboarding signals issued. + if cached_timestamp is None: + _create_models(project, environment_name, release_name, dist_name, date) + cache.set(cache_key, timestamp) + metrics.incr(cache_metric_name, tags={"outcome": "miss"}) + # If a cached value was found and the timestamp specified by the current event exceeds + # the previously cached timestamp by at least `LAST_SEEN_INTERVAL_SECONDS` then we + # perform small mutations on select models. From the code in this module this may appear + # to only save one or two cache lookups, however, certain billing logic tied to the + # feature flag check runs when this program is executed in getsentry. In the minimal + # case its three extra saved queries but up to six additional cache lookups have been + # observed. + elif timestamp - LAST_SEEN_INTERVAL_SECONDS >= cached_timestamp: + _bump_release_last_seen(project, environment_name, release_name, date) + cache.set(cache_key, timestamp) + metrics.incr(cache_metric_name, tags={"action": "bump", "outcome": "hit"}) + # If a cached value was found and the timestamp does NOT exceed the interval then we + # do nothing! This should be the majority of events. + else: + metrics.incr(cache_metric_name, tags={"action": "noop", "outcome": "hit"}) + _detect_performance_problems(segment_span, spans, project) _record_signals(segment_span, spans, project) @@ -205,16 +243,13 @@ def _compute_breakdowns( @metrics.wraps("spans.consumers.process_segments.create_models") -def _create_models(segment: CompatibleSpan, project: Project) -> None: +def _create_models( + project: Project, environment_name: Any, release_name: Any, dist_name: Any, date: datetime +) -> None: """ Creates the Environment and Release models, along with the necessary relationships between them and the Project model. """ - environment_name = attribute_value(segment, ATTRIBUTE_NAMES.SENTRY_ENVIRONMENT) - release_name = attribute_value(segment, ATTRIBUTE_NAMES.SENTRY_RELEASE) - dist_name = attribute_value(segment, ATTRIBUTE_NAMES.SENTRY_DIST) - date = to_datetime(segment["end_timestamp"]) - environment = Environment.get_or_create(project=project, name=environment_name) if not release_name: @@ -343,3 +378,93 @@ def _track_outcomes(segment_span: CompatibleSpan, spans: list[CompatibleSpan]) - category=DataCategory.SPAN_INDEXED, quantity=len(spans), ) + + +@metrics.wraps("spans.consumers.process_segments.bump_release_last_seen") +def _bump_release_last_seen( + project: Project, environment_name: Any, release_name: Any, date: datetime +) -> None: + if not release_name: + return + + environment = Environment.get_or_create(project=project, name=environment_name) + + try: + release = Release.get_or_create(project=project, version=release_name, date_added=date) + except ValidationError: + return + + # Bumps release-environment last-seen. + ReleaseEnvironment.get_or_create( + project=project, release=release, environment=environment, datetime=date + ) + + # Bumps release-project-environment last-seen. + ReleaseProjectEnvironment.get_or_create( + project=project, release=release, environment=environment, datetime=date + ) + + +def _to_string(s: Any) -> str: + return s if isinstance(s, str) else "" + + +class BoundedLRUCache: + """ + A bounded, in-memory LRU cache. + + :param max_size: The maximum number of keys the cache may contain. + The size of the cache is bounded to 100-bytes per entry. To determine how + much memory the cache will consume multiply `max_size` by 100. At a + `max_size` of one million entries the cache will consume 100 megabytes of + memory. + """ + + def __init__(self, max_size: int): + self.cache: OrderedDict[int, int] = OrderedDict() + self.max_size = max_size + + def get(self, key: str) -> int | None: + k = self._hash_key(key) + if k in self.cache: + self.cache.move_to_end(k) + return self.cache[k] + else: + return None + + def set(self, key: str, value: int) -> None: + k = self._hash_key(key) + self.cache[k] = value + self.cache.move_to_end(k) + if len(self.cache) > self.max_size: + self.cache.popitem(last=False) + return None + + def _hash_key(self, key: str) -> int: + """ + Return the hash of the `key` as an integer. + + Cache keys are strings and technically unbounded, though its likely bounds on string length + are enforced upstream. Nevertheless strings consume more memory than we should be + reasonably willing to allocate to this job. + + The `key` is hashed using `blake2b`. A digest-size of 11-bytes was chosen. 11-bytes is + significant because its the largest digest size before byte size increases to the next + 4-byte boundary. The next smallest boundary was a digest size of 7 at time of testing. + For one billion unique cache key permutations there is a 1 in 600 million chance of + collision. + + This method outputs a 36-byte integer. + """ + digest = hashlib.blake2b(key.encode(), digest_size=11).digest() + return int.from_bytes(digest, "big") + + +def _get_cache() -> BoundedLRUCache: + global cache + if cache is None: + cache = BoundedLRUCache(max_size=100_000) + return cache + + +cache: BoundedLRUCache | None = None diff --git a/src/sentry/tasks/base.py b/src/sentry/tasks/base.py index b696acbfec3763..988172abfc7f77 100644 --- a/src/sentry/tasks/base.py +++ b/src/sentry/tasks/base.py @@ -45,6 +45,7 @@ def instrumented_task( report_timeout_errors: bool = True, silenced_exceptions: tuple[type[BaseException], ...] | None = None, silo_mode: SiloMode | None = None, + pass_headers: bool = False, **kwargs, ) -> Callable[[Callable[P, R]], Task[P, R]]: """ @@ -127,6 +128,7 @@ def wrapped(func: Callable[P, R]) -> Task[P, R]: compression_type=compression_type, report_timeout_errors=report_timeout_errors, silenced_exceptions=silenced_exceptions, + pass_headers=pass_headers, )(func) if silo_mode: @@ -150,6 +152,7 @@ def wrapped(func: Callable[P, R]) -> Task[P, R]: compression_type=compression_type, report_timeout_errors=report_timeout_errors, silenced_exceptions=silenced_exceptions, + pass_headers=pass_headers, )(func) if silo_mode: diff --git a/src/sentry/taskworker/namespaces.py b/src/sentry/taskworker/namespaces.py index 374dfdf7cdcb84..bfadb09ea4886e 100644 --- a/src/sentry/taskworker/namespaces.py +++ b/src/sentry/taskworker/namespaces.py @@ -87,6 +87,11 @@ app_feature="profiles", ) +ingest_profiling_passthrough_tasks = app.taskregistry.create_namespace( + "ingest.profiling.passthrough", + app_feature="profiles", +) + ingest_transactions_tasks = app.taskregistry.create_namespace( "ingest.transactions", app_feature="transactions", diff --git a/src/sentry/utils/last_seen.py b/src/sentry/utils/last_seen.py index 62f29714b51a7b..75f3418dab9d11 100644 --- a/src/sentry/utils/last_seen.py +++ b/src/sentry/utils/last_seen.py @@ -7,6 +7,8 @@ from django.core.cache import cache from django.db.utils import OperationalError +LAST_SEEN_INTERVAL_SECONDS = 60 + class HasLastSeen(Protocol): id: int @@ -23,7 +25,7 @@ def try_bump_last_seen( metrics_tags: dict[str, str], ) -> bool: """Throttled last_seen bump — at most once per 60s per row via a cache-based lock.""" - if instance.last_seen >= datetime - timedelta(seconds=60): + if instance.last_seen >= datetime - timedelta(seconds=LAST_SEEN_INTERVAL_SECONDS): metrics_tags["bumped"] = "false" return False diff --git a/static/app/components/acl/useRole.tsx b/static/app/components/acl/useRole.tsx index 60a784fe1242ed..e662dfbf5fe9f3 100644 --- a/static/app/components/acl/useRole.tsx +++ b/static/app/components/acl/useRole.tsx @@ -28,7 +28,9 @@ function getProjectRole( project: DetailedProject | undefined, role: 'debugFilesRole' | 'attachmentsRole' ): string | undefined { - if (!project) return undefined; + if (!project) { + return undefined; + } if (role === 'debugFilesRole') { return project.debugFilesRole ?? undefined; diff --git a/static/app/components/charts/useChartXRangeSelection.spec.tsx b/static/app/components/charts/useChartXRangeSelection.spec.tsx index a09f269b1a91eb..88c4b6d391d336 100644 --- a/static/app/components/charts/useChartXRangeSelection.spec.tsx +++ b/static/app/components/charts/useChartXRangeSelection.spec.tsx @@ -133,9 +133,15 @@ describe('useChartXRangeSelection', () => { }), }), convertToPixel: jest.fn((_config, value) => { - if (value === 100) return 500; - if (value === 90) return 450; - if (value === 10) return 50; + if (value === 100) { + return 500; + } + if (value === 90) { + return 450; + } + if (value === 10) { + return 50; + } return 0; }), } as any; @@ -380,8 +386,12 @@ describe('useChartXRangeSelection', () => { }), }), convertToPixel: jest.fn((_config, value) => { - if (value === 100) return 500; // xMax - if (value === 90) return 480; // Above 60% threshold + if (value === 100) { + return 500; + } // xMax + if (value === 90) { + return 480; + } // Above 60% threshold return 0; }), } as any; @@ -694,10 +704,18 @@ describe('useChartXRangeSelection', () => { convertToPixel: jest.fn((_config, value) => { // Selection range pixels: xMin=50, xMax=150 // yMin pixel = 200 - if (value === 100) return 200; // xMax extent - if (value === 0) return 200; // yMin extent - if (value === 10) return 50; // selection xMin - if (value === 90) return 150; // selection xMax + if (value === 100) { + return 200; + } // xMax extent + if (value === 0) { + return 200; + } // yMin extent + if (value === 10) { + return 50; + } // selection xMin + if (value === 90) { + return 150; + } // selection xMax return 100; }), getDom: jest.fn().mockReturnValue({ @@ -775,10 +793,18 @@ describe('useChartXRangeSelection', () => { convertToPixel: jest.fn((_config, value) => { // Selection range pixels: xMin=50, xMax=150 // yMin pixel = 200 - if (value === 100) return 200; // xMax extent - if (value === 0) return 200; // yMin extent - if (value === 10) return 50; // selection xMin - if (value === 90) return 150; // selection xMax + if (value === 100) { + return 200; + } // xMax extent + if (value === 0) { + return 200; + } // yMin extent + if (value === 10) { + return 50; + } // selection xMin + if (value === 90) { + return 150; + } // selection xMax return 100; }), getDom: jest.fn().mockReturnValue({ @@ -854,10 +880,18 @@ describe('useChartXRangeSelection', () => { }), }), convertToPixel: jest.fn((_config, value) => { - if (value === 100) return 200; - if (value === 0) return 200; - if (value === 10) return 50; - if (value === 90) return 150; + if (value === 100) { + return 200; + } + if (value === 0) { + return 200; + } + if (value === 10) { + return 50; + } + if (value === 90) { + return 150; + } return 100; }), getDom: jest.fn().mockReturnValue({ diff --git a/static/app/components/charts/useChartXRangeSelection.tsx b/static/app/components/charts/useChartXRangeSelection.tsx index 23314a27235459..71008c93735478 100644 --- a/static/app/components/charts/useChartXRangeSelection.tsx +++ b/static/app/components/charts/useChartXRangeSelection.tsx @@ -156,14 +156,18 @@ export function useChartXRangeSelection({ const previousInitialSelection = usePrevious(initialSelection); const clearSelection = useCallback(() => { - if (!selectionState?.selection) return; + if (!selectionState?.selection) { + return; + } const chartInstance = chartRef.current?.getEchartsInstance(); chartInstance?.dispatchAction({type: 'brush', areas: []}); // Restore the tooltip as we clear selection - if (tooltipFrameRef.current) cancelAnimationFrame(tooltipFrameRef.current); + if (tooltipFrameRef.current) { + cancelAnimationFrame(tooltipFrameRef.current); + } tooltipFrameRef.current = requestAnimationFrame(() => { chartInstance?.setOption({tooltip: {show: true}}, {silent: true}); @@ -192,7 +196,9 @@ export function useChartXRangeSelection({ // Disable the tooltip as we start dragging, as it covers regions of the chart that the user // may want to select. The tooltip remains hidden until the box is cleared. - if (tooltipFrameRef.current) cancelAnimationFrame(tooltipFrameRef.current); + if (tooltipFrameRef.current) { + cancelAnimationFrame(tooltipFrameRef.current); + } tooltipFrameRef.current = requestAnimationFrame(() => { chartInstance.setOption( @@ -210,7 +216,9 @@ export function useChartXRangeSelection({ const onBrushEnd = useCallback( (evt, chartInstance) => { - if (!chartInstance) return; + if (!chartInstance) { + return; + } const area = evt.areas[0]; @@ -251,7 +259,9 @@ export function useChartXRangeSelection({ }, [chartRef]); const syncSelectionStates = useCallback(() => { - if (disabled) return; + if (disabled) { + return; + } const chartInstance = chartRef.current?.getEchartsInstance(); @@ -312,10 +322,14 @@ export function useChartXRangeSelection({ ]); useEffect(() => { - if (disabled || !selectionState?.selection) return; + if (disabled || !selectionState?.selection) { + return; + } const chartInstance = chartRef.current?.getEchartsInstance(); - if (!chartInstance) return; + if (!chartInstance) { + return; + } const handleInsideSelectionClick = (event: MouseEvent) => { const [selectedMin, selectedMax] = selectionState.selection.range; @@ -326,7 +340,9 @@ export function useChartXRangeSelection({ // @ts-expect-error TODO Abdullah Khan: chartInstance.getModel is a private method, but we access it to get the axis extremes // could not find a better way, this works out perfectly for now. Passing down the entire series data to the hook is more gross. const yAxis = chartInstance.getModel()?.getComponent?.('yAxis', 0); - if (!yAxis) return; + if (!yAxis) { + return; + } const yMin = yAxis.axis.scale.getExtent()[0]; const yMinPixel = chartInstance.convertToPixel({yAxisIndex: 0}, yMin); @@ -467,8 +483,9 @@ export function useChartXRangeSelection({ !selectionState?.actionMenuPosition || !actionMenuRenderer || !selectionState.isActionMenuVisible - ) + ) { return null; + } // We want the top right corner of the action menu to be aligned with the bottom left // corner of the selection box, when the menu is positioned to the left. Using a transform, saves us diff --git a/static/app/components/commandPalette/ui/cmdk.tsx b/static/app/components/commandPalette/ui/cmdk.tsx index 06ace3d3c18740..ea55cf34eb39e6 100644 --- a/static/app/components/commandPalette/ui/cmdk.tsx +++ b/static/app/components/commandPalette/ui/cmdk.tsx @@ -121,7 +121,9 @@ function CMDKActionWithResource({ ? data.map((item, i) => { // CommandPaletteActionGroup has an `actions` prop that CMDKAction doesn't // accept, so we skip groups here — they can't be auto-rendered as leaf nodes. - if ('actions' in item) return null; + if ('actions' in item) { + return null; + } return ; }) : null; diff --git a/static/app/components/commandPalette/ui/collection.tsx b/static/app/components/commandPalette/ui/collection.tsx index a4105dec3f7459..941f266147dc92 100644 --- a/static/app/components/commandPalette/ui/collection.tsx +++ b/static/app/components/commandPalette/ui/collection.tsx @@ -87,7 +87,9 @@ export function makeCollection(): CollectionInstance { unregister(key) { const node = nodes.current.get(key); - if (!node) return; + if (!node) { + return; + } nodes.current.delete(key); childIndex.current.get(node.parent)?.delete(key); childIndex.current.delete(key); diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 51974f46ef5077..3bb6c5d44cf5e0 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -177,7 +177,9 @@ export function CommandPalette({ !isLoading && !isEmptyPromptQuery; - if (!showSeerFallback) return [scored, scoredPrefixMap, false]; + if (!showSeerFallback) { + return [scored, scoredPrefixMap, false]; + } const truncated = state.query.length > 24 ? state.query.slice(0, 24) + '...' : state.query; @@ -690,10 +692,16 @@ function presortBySlotRef( const aEl = a.ref?.current ?? null; const bEl = b.ref?.current ?? null; - if (aEl === bEl) return 0; // both null, or same outlet element — preserve order + if (aEl === bEl) { + return 0; + } // both null, or same outlet element — preserve order - if (!aEl) return 1; // a has no slot ref → sort after b - if (!bEl) return -1; // b has no slot ref → sort a before b + if (!aEl) { + return 1; + } // a has no slot ref → sort after b + if (!bEl) { + return -1; + } // b has no slot ref → sort a before b return aEl.compareDocumentPosition(bEl) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1; }); } @@ -713,7 +721,9 @@ function scoreNode( let bestLength = Infinity; let matched = false; for (const candidate of [label, details, ...keywords]) { - if (!candidate) continue; + if (!candidate) { + continue; + } const result = fzf(candidate, query, false); if (result.end !== -1 && result.score > best) { best = result.score; @@ -856,7 +866,9 @@ function flattenActions( let root: CollectionTreeNode = item; while (root.parent !== null) { const parent = nodeMap.get(root.parent); - if (!parent) break; + if (!parent) { + break; + } root = parent; } nodeRootKey.set(item.key, root.key); @@ -871,9 +883,13 @@ function flattenActions( const rootBestScore = new Map(); for (const [key, score] of scores) { const node = nodeMap.get(key); - if (node?.parent === null && node.children.length === 0) continue; + if (node?.parent === null && node.children.length === 0) { + continue; + } const rootKey = nodeRootKey.get(key); - if (rootKey === undefined) continue; + if (rootKey === undefined) { + continue; + } const current = rootBestScore.get(rootKey); if (current === undefined || compareCommandPaletteScores(score, current) < 0) { rootBestScore.set(rootKey, score); @@ -914,7 +930,9 @@ function flattenActions( const usedSectionHeaders = new Set(); const flattened = collected.flatMap((item): CMDKFlatItem[] => { - if (seen.has(item.key)) return []; + if (seen.has(item.key)) { + return []; + } seen.add(item.key); if (item.children.length > 0) { @@ -927,7 +945,9 @@ function flattenActions( const shouldUseFallbackChildren = matched.length === 0 && scores.get(item.key)?.matched; const candidateChildren = shouldUseFallbackChildren ? fallbackChildren : matched; - if (!candidateChildren.length) return []; + if (!candidateChildren.length) { + return []; + } const sortedMatches = shouldUseFallbackChildren ? candidateChildren : candidateChildren.sort((a, b) => @@ -945,7 +965,9 @@ function flattenActions( const intermediatePath: string[] = []; while (root.parent !== null) { const parent = nodeMap.get(root.parent); - if (!parent) break; + if (!parent) { + break; + } intermediatePath.unshift(root.display.label); root = parent; } @@ -1045,7 +1067,9 @@ function getSourceAction( const headerMatch = actions.find( candidate => candidate.key === `${sourceActionKey}:header` ); - if (headerMatch) return headerMatch; + if (headerMatch) { + return headerMatch; + } // For nested groups the original header was replaced by the root ancestor header. // The prefix map stores the group label under a distinct `:source-label` key. diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 8c597a30dd056c..4eff37b15aa662 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -282,7 +282,9 @@ export function GlobalCommandPaletteActions() { }) .flatMap(section => section.items.filter(navItem => { - if (navItem.show === undefined) return true; + if (navItem.show === undefined) { + return true; + } return typeof navItem.show === 'function' ? navItem.show({...context, ...section}) : navItem.show; diff --git a/static/app/components/core/avatar/sentryAppAvatar.tsx b/static/app/components/core/avatar/sentryAppAvatar.tsx index 3a910251f5d241..3cbf965ae399f8 100644 --- a/static/app/components/core/avatar/sentryAppAvatar.tsx +++ b/static/app/components/core/avatar/sentryAppAvatar.tsx @@ -59,7 +59,9 @@ function getSentryAppAvatarProps( )?.avatarUrl; // If there is no upload URL, return null and fall - if (!uploadUrl) return null; + if (!uploadUrl) { + return null; + } return { type: 'upload', diff --git a/static/app/components/core/avatarButton/avatarButton.tsx b/static/app/components/core/avatarButton/avatarButton.tsx index beb53604439f25..8ab8f539c18dc0 100644 --- a/static/app/components/core/avatarButton/avatarButton.tsx +++ b/static/app/components/core/avatarButton/avatarButton.tsx @@ -141,24 +141,25 @@ function shouldPadImage(data: Uint8ClampedArray): 'fill' | 'padded' { if (!(data[3]!>=128 || data[51]!>=128 || data[99]!>=128 || data[147]!>=128 || data[195]!>=128 || data[243]!>=128 || data[291]!>=128 || data[339]!>=128 || data[387]!>=128 || - data[435]!>=128 || data[483]!>=128 || data[531]!>=128)) return 'padded'; + data[435]!>=128 || data[483]!>=128 || data[531]!>=128)) {return 'padded';} // oxfmt-ignore if (!(data[47]!>=128 || data[95]!>=128 || data[143]!>=128 || data[191]!>=128 || data[239]!>=128 || data[287]!>=128 || data[335]!>=128 || data[383]!>=128 || data[431]!>=128 || - data[479]!>=128 || data[527]!>=128 || data[575]!>=128)) return 'padded'; + data[479]!>=128 || data[527]!>=128 || data[575]!>=128)) {return 'padded';} // oxfmt-ignore if (!(data[3]!>=128 || data[7]!>=128 || data[11]!>=128 || data[15]!>=128 || data[19]!>=128 || data[23]!>=128 || data[27]!>=128 || data[31]!>=128 || data[35]!>=128 || - data[39]!>=128 || data[43]!>=128 || data[47]!>=128)) return 'padded'; + data[39]!>=128 || data[43]!>=128 || data[47]!>=128)) {return 'padded';} // oxfmt-ignore if (!(data[531]!>=128 || data[535]!>=128 || data[539]!>=128 || data[543]!>=128 || data[547]!>=128 || data[551]!>=128 || data[555]!>=128 || data[559]!>=128 || data[563]!>=128 || - data[567]!>=128 || data[571]!>=128 || data[575]!>=128)) return 'padded'; - if (data[3]! < 128 || data[47]! < 128 || data[531]! < 128 || data[575]! < 128) + data[567]!>=128 || data[571]!>=128 || data[575]!>=128)) {return 'padded';} + if (data[3]! < 128 || data[47]! < 128 || data[531]! < 128 || data[575]! < 128) { return 'padded'; + } return 'fill'; } @@ -171,7 +172,9 @@ function readPixels(img: HTMLImageElement): Uint8ClampedArray | null { canvas.width = SAMPLE_SIZE; canvas.height = SAMPLE_SIZE; const ctx = canvas.getContext('2d'); - if (!ctx) return null; + if (!ctx) { + return null; + } // Draw the image on a 12x12 canvas to make the sampling more efficient const naturalW = img.naturalWidth || img.width; @@ -199,7 +202,9 @@ function sampleAvatarColor( img: HTMLImageElement ): {hex: string | null; style: 'fill' | 'padded'} | null { const data = readPixels(img); - if (!data) return null; + if (!data) { + return null; + } const style = shouldPadImage(data); @@ -215,7 +220,9 @@ function sampleAvatarColor( for (let i = 0; i < data.length; i += 4) { /* eslint-disable @typescript-eslint/no-non-null-assertion */ - if (data[i + 3]! < 128) continue; + if (data[i + 3]! < 128) { + continue; + } const r = data[i]!, g = data[i + 1]!, @@ -238,7 +245,9 @@ function sampleAvatarColor( } const [r, g, b, count] = ccount > 0 ? [cr, cg, cb, ccount] : [ar, ag, ab, acount]; - if (count === 0) return {hex: null, style}; + if (count === 0) { + return {hex: null, style}; + } const toHex = (v: number) => Math.round(v / count) @@ -265,7 +274,9 @@ async function resolveImageAvatarColors( ): Promise<{chonk: string | undefined; style: 'fill' | 'padded'} | null> { const sampled = await fetchAvatarColor(url); - if (!sampled?.hex) return null; + if (!sampled?.hex) { + return null; + } const chonk = color(sampled.hex) .darken(theme === 'dark' ? 0.85 : 0.45) diff --git a/static/app/components/core/compactSelect/control.tsx b/static/app/components/core/compactSelect/control.tsx index 0091352c2eea7d..98a3e7cb23c557 100644 --- a/static/app/components/core/compactSelect/control.tsx +++ b/static/app/components/core/compactSelect/control.tsx @@ -429,7 +429,9 @@ export function Control({ const values = Array.isArray(value) ? value : [value]; const options = items .flatMap(item => { - if ('options' in item) return item.options; + if ('options' in item) { + return item.options; + } return item; }) .filter(item => values.includes(item.value)); @@ -461,8 +463,12 @@ export function Control({ }); const hasSelection = useMemo(() => { - if (value === undefined) return false; - if (Array.isArray(value)) return value.length > 0; + if (value === undefined) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } return true; }, [value]); diff --git a/static/app/components/core/compactSelect/gridList/index.tsx b/static/app/components/core/compactSelect/gridList/index.tsx index 0353809e9e78de..ad4dfde662b76c 100644 --- a/static/app/components/core/compactSelect/gridList/index.tsx +++ b/static/app/components/core/compactSelect/gridList/index.tsx @@ -132,7 +132,9 @@ function GridList({ > {virtualizer.items.map(row => { const item = listItems[row.index]; - if (!item) return null; + if (!item) { + return null; + } if (item.type === 'section') { return ( ({ {overlayIsOpen && virtualizer.items.map(row => { const item = listItems[row.index]; - if (!item) return null; + if (!item) { + return null; + } if (item.type === 'section') { return ( ( search: boolean | SearchConfig | undefined ): SearchConfig | undefined { - if (!search) return undefined; - if (search === true) return {}; + if (!search) { + return undefined; + } + if (search === true) { + return {}; + } return search; } @@ -466,7 +470,9 @@ export function getDuplicateOptionKeysInfo( optionCount += 1; const key = String(item.key); - if (duplicates.has(key)) continue; + if (duplicates.has(key)) { + continue; + } if (seen.has(key)) { duplicates.add(key); diff --git a/static/app/components/core/form/field/baseField.tsx b/static/app/components/core/form/field/baseField.tsx index da2d5e18b6d239..30e7e9136b3838 100644 --- a/static/app/components/core/form/field/baseField.tsx +++ b/static/app/components/core/form/field/baseField.tsx @@ -150,9 +150,13 @@ export function BaseField( } function animateRowHighlight(node: HTMLElement | null) { - if (!node) return; + if (!node) { + return; + } const name = node.getAttribute('name'); - if (!name) return; + if (!name) { + return; + } const fieldRow = node.closest(`#${CSS.escape(name)}`); if (fieldRow) { fieldRow.dataset.highlight = ''; diff --git a/static/app/components/core/form/scrapsForm.tsx b/static/app/components/core/form/scrapsForm.tsx index 5b8067e8670a91..b343c723a1b7e7 100644 --- a/static/app/components/core/form/scrapsForm.tsx +++ b/static/app/components/core/form/scrapsForm.tsx @@ -81,7 +81,7 @@ function SubmitButton(props: ButtonProps) { variant="primary" type="submit" form={form.formId} - busy={isSubmitting} + busy={isSubmitting || props.busy} disabled={isSubmitting || props.disabled} /> )} diff --git a/static/app/components/core/inspector.tsx b/static/app/components/core/inspector.tsx index 140deb3491790f..3458e5c0cea322 100644 --- a/static/app/components/core/inspector.tsx +++ b/static/app/components/core/inspector.tsx @@ -727,12 +727,16 @@ function isTraceElement(el: unknown): el is TraceElement { } function getComponentName(el: unknown): string { - if (!isTraceElement(el)) return 'unknown'; + if (!isTraceElement(el)) { + return 'unknown'; + } return el.dataset.sentryComponent || el.dataset.sentryElement || 'unknown'; } function getSourcePath(el: unknown): string { - if (!isTraceElement(el)) return 'unknown path'; + if (!isTraceElement(el)) { + return 'unknown path'; + } return el.dataset.sentrySourcePath?.split(/static\//)[1] || 'unknown path'; } @@ -748,7 +752,9 @@ function getComponentStorybookFile( stories: Record ): string | null { const sourcePath = getSourcePath(el); - if (!sourcePath) return null; + if (!sourcePath) { + return null; + } const mdxSourcePath = sourcePath.replace(/\.tsx$/, '.mdx'); @@ -765,7 +771,9 @@ function getComponentStorybookFile( } function getSourcePathFromMouseEvent(event: MouseEvent): TraceElement[] | null { - if (!event.target || !isTraceElement(event.target)) return null; + if (!event.target || !isTraceElement(event.target)) { + return null; + } const target = event.target; @@ -773,7 +781,9 @@ function getSourcePathFromMouseEvent(event: MouseEvent): TraceElement[] | null { ? target : target.closest('[data-sentry-source-path]'); - if (!head) return null; + if (!head) { + return null; + } const trace: TraceElement[] = [head as TraceElement]; @@ -783,7 +793,9 @@ function getSourcePathFromMouseEvent(event: MouseEvent): TraceElement[] | null { const next = head.parentElement?.closest( '[data-sentry-source-path]' ) as TraceElement | null; - if (!next || next === head) break; + if (!next || next === head) { + break; + } trace.push(next); head = next; } @@ -792,12 +804,16 @@ function getSourcePathFromMouseEvent(event: MouseEvent): TraceElement[] | null { } function isCoreComponent(el: unknown): boolean { - if (!isTraceElement(el)) return false; + if (!isTraceElement(el)) { + return false; + } return el.dataset.sentrySourcePath?.includes('app/components/core') ?? false; } function isViewComponent(el: unknown): boolean { - if (!isTraceElement(el)) return false; + if (!isTraceElement(el)) { + return false; + } return el.dataset.sentrySourcePath?.includes('app/views') ?? false; } diff --git a/static/app/components/events/interfaces/frame/frameRegisters/utils.tsx b/static/app/components/events/interfaces/frame/frameRegisters/utils.tsx index b5c4555320d68a..6c3ad10e89ca50 100644 --- a/static/app/components/events/interfaces/frame/frameRegisters/utils.tsx +++ b/static/app/components/events/interfaces/frame/frameRegisters/utils.tsx @@ -59,8 +59,12 @@ export function getSortedRegisters( } // Mapped registers come before unmapped ones - if (indexA !== -1) return -1; - if (indexB !== -1) return 1; + if (indexA !== -1) { + return -1; + } + if (indexB !== -1) { + return 1; + } } // Fallback: natural sort (handles numeric suffixes correctly) diff --git a/static/app/components/events/ourlogs/ourlogsDrawer.tsx b/static/app/components/events/ourlogs/ourlogsDrawer.tsx index 40f8d396578211..d7e9e8fc25e454 100644 --- a/static/app/components/events/ourlogs/ourlogsDrawer.tsx +++ b/static/app/components/events/ourlogs/ourlogsDrawer.tsx @@ -161,7 +161,6 @@ export function OurlogsDrawer({ diff --git a/static/app/components/pipeline/pipelineIntegrationAwsLambda.spec.tsx b/static/app/components/pipeline/pipelineIntegrationAwsLambda.spec.tsx index 6df2cfc1eeb082..11964547949873 100644 --- a/static/app/components/pipeline/pipelineIntegrationAwsLambda.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationAwsLambda.spec.tsx @@ -50,12 +50,15 @@ describe('ProjectSelectStep', () => { }); }); - it('shows submitting state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { ProjectsStore.loadInitialData([ProjectFixture()]); render(); - expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', () => { @@ -159,10 +162,13 @@ describe('CloudFormationStep', () => { }); }); - it('shows verifying state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { render(); - expect(screen.getByRole('button', {name: 'Verifying...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); }); diff --git a/static/app/components/pipeline/pipelineIntegrationAwsLambda.tsx b/static/app/components/pipeline/pipelineIntegrationAwsLambda.tsx index 722117873ddb81..6aeddc8d4c6b41 100644 --- a/static/app/components/pipeline/pipelineIntegrationAwsLambda.tsx +++ b/static/app/components/pipeline/pipelineIntegrationAwsLambda.tsx @@ -101,8 +101,8 @@ function ProjectSelectStep({ {t('Currently only supports Node and Python Lambda functions.')} - - {isAdvancing ? t('Submitting...') : t('Continue')} + + {t('Continue')} @@ -292,9 +292,7 @@ function CloudFormationStep({ )} - - {isAdvancing ? t('Verifying...') : t('Continue')} - + {t('Continue')} @@ -436,7 +434,8 @@ function InstrumentationStep({ {failedNames.size > 0 && ( diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx index bdd175d0f9ef66..3b9faecb693cc6 100644 --- a/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationBitbucket.spec.tsx @@ -81,7 +81,7 @@ describe('BitbucketAuthorizeStep', () => { ).toBeInTheDocument(); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize Bitbucket'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when authorizeUrl is not provided', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx b/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx index e1e415c1710f5b..783b5193d39dc7 100644 --- a/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx +++ b/static/app/components/pipeline/pipelineIntegrationBitbucket.tsx @@ -59,11 +59,7 @@ function BitbucketAuthorizeStep({ )} - {isAdvancing ? ( - - ) : isWaitingForCallback ? ( + {isWaitingForCallback && !isAdvancing ? ( @@ -72,6 +68,7 @@ function BitbucketAuthorizeStep({ size="sm" variant="primary" onClick={openPopup} + busy={isAdvancing} disabled={!stepData?.authorizeUrl} > {t('Authorize Bitbucket')} diff --git a/static/app/components/pipeline/pipelineIntegrationClaudeCode.spec.tsx b/static/app/components/pipeline/pipelineIntegrationClaudeCode.spec.tsx index 29c6504563bf33..42602764ef6e8a 100644 --- a/static/app/components/pipeline/pipelineIntegrationClaudeCode.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationClaudeCode.spec.tsx @@ -27,12 +27,15 @@ describe('ClaudeCodeApiKeyStep', () => { }); }); - it('shows loading state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { render( ); - expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationClaudeCode.tsx b/static/app/components/pipeline/pipelineIntegrationClaudeCode.tsx index 38f8ff475a3951..5d4e66a6b3c5a1 100644 --- a/static/app/components/pipeline/pipelineIntegrationClaudeCode.tsx +++ b/static/app/components/pipeline/pipelineIntegrationClaudeCode.tsx @@ -55,8 +55,8 @@ function ClaudeCodeApiKeyStep({ )} - - {isAdvancing ? t('Submitting...') : t('Continue')} + + {t('Continue')} diff --git a/static/app/components/pipeline/pipelineIntegrationCursor.spec.tsx b/static/app/components/pipeline/pipelineIntegrationCursor.spec.tsx index bd2ecc06e285f1..d1e38d68e73c7b 100644 --- a/static/app/components/pipeline/pipelineIntegrationCursor.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationCursor.spec.tsx @@ -27,10 +27,13 @@ describe('CursorApiKeyStep', () => { }); }); - it('shows loading state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { render(); - expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationCursor.tsx b/static/app/components/pipeline/pipelineIntegrationCursor.tsx index b25adac5e4d360..2a7b0efa2104a7 100644 --- a/static/app/components/pipeline/pipelineIntegrationCursor.tsx +++ b/static/app/components/pipeline/pipelineIntegrationCursor.tsx @@ -55,8 +55,8 @@ function CursorApiKeyStep({ )} - - {isAdvancing ? t('Submitting...') : t('Continue')} + + {t('Continue')} diff --git a/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx b/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx index acb35c76c6ed02..c7e7a26c75d7ef 100644 --- a/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationDiscord.spec.tsx @@ -60,7 +60,7 @@ describe('DiscordOAuthLoginStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize Discord'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when oauthUrl is not provided', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationGitHub.spec.tsx b/static/app/components/pipeline/pipelineIntegrationGitHub.spec.tsx index 52d6be862be72b..c50f0c5ad5fddf 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitHub.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitHub.spec.tsx @@ -61,7 +61,7 @@ describe('GitHubOAuthLoginStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize GitHub'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when isInitializing', () => { @@ -276,7 +279,7 @@ describe('OrgSelectionStep', () => { ).toBeInTheDocument(); }); - it('shows completing state when isAdvancing in fresh install view', () => { + it('shows busy state when isAdvancing in fresh install view', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Completing...'})).toBeDisabled(); + expect( + screen.getByRole('button', {name: 'Reopen installation window'}) + ).toHaveAttribute('aria-busy', 'true'); }); }); diff --git a/static/app/components/pipeline/pipelineIntegrationGitHub.tsx b/static/app/components/pipeline/pipelineIntegrationGitHub.tsx index 67e3e2e2848914..5d5d7258334e0a 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitHub.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitHub.tsx @@ -207,7 +207,7 @@ function FreshInstallSteps({ onInstall: () => void; popupBlockedNotice?: React.ReactNode; }) { - if (isAdvancing) { + if (isWaitingForCallback || isAdvancing) { return ( @@ -215,22 +215,7 @@ function FreshInstallSteps({ 'Complete the installation in the popup window. Once finished, this page will update automatically.' )} - - - ); - } - - if (isWaitingForCallback) { - return ( - - - {t( - 'Complete the installation in the popup window. Once finished, this page will update automatically.' - )} - - diff --git a/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx b/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx index a28b5023d962bb..08128de0aa4efe 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitLab.spec.tsx @@ -246,7 +246,7 @@ describe('InstallationConfigStep', () => { }); }); - it('shows submitting state when isAdvancing is true', async () => { + it('shows busy state when isAdvancing is true', async () => { render( { await userEvent.click(screen.getByRole('button', {name: 'Next'})); await userEvent.click(screen.getByRole('button', {name: 'Next'})); - expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', async () => { @@ -317,7 +320,7 @@ describe('GitLabOAuthLoginStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when oauthUrl is not provided', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationGitLab.tsx b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx index 990607c4a88045..1f8d9e8011db16 100644 --- a/static/app/components/pipeline/pipelineIntegrationGitLab.tsx +++ b/static/app/components/pipeline/pipelineIntegrationGitLab.tsx @@ -202,8 +202,8 @@ function InstallationConfigStep({ } - - {isAdvancing ? t('Submitting...') : t('Continue')} + + {t('Continue')} diff --git a/static/app/components/pipeline/pipelineIntegrationOpsgenie.spec.tsx b/static/app/components/pipeline/pipelineIntegrationOpsgenie.spec.tsx index 949d2cb7b6ff93..20485bbad609be 100644 --- a/static/app/components/pipeline/pipelineIntegrationOpsgenie.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationOpsgenie.spec.tsx @@ -69,14 +69,17 @@ describe('OpsgenieInstallationConfigStep', () => { }); }); - it('shows loading state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { render( ); - expect(screen.getByRole('button', {name: 'Submitting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Continue'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationOpsgenie.tsx b/static/app/components/pipeline/pipelineIntegrationOpsgenie.tsx index 25ac49818e3436..798932c6b6694a 100644 --- a/static/app/components/pipeline/pipelineIntegrationOpsgenie.tsx +++ b/static/app/components/pipeline/pipelineIntegrationOpsgenie.tsx @@ -115,8 +115,8 @@ function OpsgenieInstallationConfigStep({ )} - - {isAdvancing ? t('Submitting...') : t('Continue')} + + {t('Continue')} diff --git a/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx b/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx index 4d32b37f841f2d..1a96bcab70d6f1 100644 --- a/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationPagerDuty.spec.tsx @@ -58,7 +58,7 @@ describe('PagerDutyInstallStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Installing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Install PagerDuty App'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables install button when installUrl is not provided', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx b/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx index 19f757c8899cc4..31a8d4edba0c8f 100644 --- a/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx +++ b/static/app/components/pipeline/pipelineIntegrationPagerDuty.tsx @@ -50,11 +50,7 @@ function PagerDutyInstallStep({ )} - {isAdvancing ? ( - - ) : popupStatus === 'popup-open' ? ( + {popupStatus === 'popup-open' && !isAdvancing ? ( @@ -63,6 +59,7 @@ function PagerDutyInstallStep({ size="sm" variant="primary" onClick={openPopup} + busy={isAdvancing} disabled={!stepData?.installUrl} > {t('Install PagerDuty App')} diff --git a/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx b/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx index eabeccf709e695..5e1c6596311760 100644 --- a/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationPerforce.spec.tsx @@ -116,14 +116,17 @@ describe('PerforceInstallationConfigStep', () => { }); }); - it('shows loading state when isAdvancing', () => { + it('shows busy state when isAdvancing', () => { render( ); - expect(screen.getByRole('button', {name: 'Connecting...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Connect'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables submit button when isInitializing', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationPerforce.tsx b/static/app/components/pipeline/pipelineIntegrationPerforce.tsx index 2fac86a7bf5a91..ac2632d542d55e 100644 --- a/static/app/components/pipeline/pipelineIntegrationPerforce.tsx +++ b/static/app/components/pipeline/pipelineIntegrationPerforce.tsx @@ -194,8 +194,8 @@ function PerforceInstallationConfigStep({ )} - - {isAdvancing ? t('Connecting...') : t('Connect')} + + {t('Connect')} diff --git a/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx b/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx index 517aacb93bc7cf..bfe05026e1f08c 100644 --- a/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationSlack.spec.tsx @@ -58,7 +58,7 @@ describe('SlackOAuthLoginStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize Slack'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when oauthUrl is not provided', () => { diff --git a/static/app/components/pipeline/pipelineIntegrationVercel.spec.tsx b/static/app/components/pipeline/pipelineIntegrationVercel.spec.tsx index f24ce5c609ea48..8a8eb4d2e48241 100644 --- a/static/app/components/pipeline/pipelineIntegrationVercel.spec.tsx +++ b/static/app/components/pipeline/pipelineIntegrationVercel.spec.tsx @@ -58,7 +58,7 @@ describe('VercelOAuthLoginStep', () => { }); }); - it('shows loading state when isAdvancing is true', () => { + it('shows busy state when isAdvancing is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize Vercel'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); it('disables authorize button when oauthUrl is not provided', () => { diff --git a/static/app/components/pipeline/shared/oauthLoginStep.spec.tsx b/static/app/components/pipeline/shared/oauthLoginStep.spec.tsx index c3cf4b042c71c3..80a49324510ba1 100644 --- a/static/app/components/pipeline/shared/oauthLoginStep.spec.tsx +++ b/static/app/components/pipeline/shared/oauthLoginStep.spec.tsx @@ -197,7 +197,7 @@ describe('OAuthLoginStep', () => { ).not.toBeInTheDocument(); }); - it('shows loading state when isLoading is true', () => { + it('shows busy state when isLoading is true', () => { render( { /> ); - expect(screen.getByRole('button', {name: 'Authorizing...'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Authorize GitLab'})).toHaveAttribute( + 'aria-busy', + 'true' + ); }); }); diff --git a/static/app/components/pipeline/shared/oauthLoginStep.tsx b/static/app/components/pipeline/shared/oauthLoginStep.tsx index ca152484c4c853..a985e53a08083d 100644 --- a/static/app/components/pipeline/shared/oauthLoginStep.tsx +++ b/static/app/components/pipeline/shared/oauthLoginStep.tsx @@ -67,16 +67,18 @@ export function OAuthLoginStep({ )} - {isLoading ? ( - - ) : popupStatus === 'popup-open' ? ( + {popupStatus === 'popup-open' && !isLoading ? ( ) : ( - )} diff --git a/static/app/components/replays/canvasReplayerPlugin.spec.ts b/static/app/components/replays/canvasReplayerPlugin.spec.ts index fd960d27e67a51..9e2bd57c254d68 100644 --- a/static/app/components/replays/canvasReplayerPlugin.spec.ts +++ b/static/app/components/replays/canvasReplayerPlugin.spec.ts @@ -22,16 +22,22 @@ jest.mock('lodash/debounce', () => jest.fn().mockImplementation((callback, timeout) => { let timeoutId: ReturnType | null = null; const debounced = jest.fn((...args) => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } timeoutId = setTimeout(() => callback(...args), timeout); }); const cancel = jest.fn(() => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }); const flush = jest.fn(() => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } callback(); }); @@ -197,8 +203,12 @@ describe('canvasReplayerPlugin', () => { const canvas2 = createCanvasNode(); const replayer = createReplayer(requestedId => { - if (requestedId === id1) return canvas1; - if (requestedId === id2) return canvas2; + if (requestedId === id1) { + return canvas1; + } + if (requestedId === id2) { + return canvas2; + } return null; }); diff --git a/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx b/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx index d301f45b6f8fd0..0729dae919811b 100644 --- a/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx +++ b/static/app/components/searchQueryBuilder/askSeer/askSeerOption.tsx @@ -41,7 +41,9 @@ export function AskSeerOption({state}: {state: ComboBoxState}) { ); const handleClick = () => { - if (optionDisableOverride) return; + if (optionDisableOverride) { + return; + } trackAnalytics('ai_query.interface', { organization, area: analyticsArea, diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx index bc39f8e350ddc8..d6fbc16d25cb5d 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerComboBox.tsx @@ -195,7 +195,9 @@ export function AskSeerComboBox({ onInputChange: setSearchQuery, defaultFilter: () => true, onSelectionChange(key) { - if (typeof key !== 'string') return; + if (typeof key !== 'string') { + return; + } if (key === 'none-of-these') { trackAnalytics('ai_query.rejected', { diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx index 98e744a8e1883c..362f04cd0967fd 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerPollingComboBox.tsx @@ -228,7 +228,9 @@ export function AskSeerPollingComboBox({ onInputChange: setSearchQuery, defaultFilter: () => true, onSelectionChange(key) { - if (typeof key !== 'string') return; + if (typeof key !== 'string') { + return; + } if (key === 'none-of-these') { trackAnalytics('ai_query.rejected', { diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerSearchPopover.tsx b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerSearchPopover.tsx index bd63dfc9993140..05fc2b1e857b30 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerSearchPopover.tsx +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/askSeerSearchPopover.tsx @@ -25,10 +25,14 @@ export function AskSeerSearchPopover(props: PopoverProps) { { popoverRef.current = element; - if (!element || !props.containerRef.current) return; + if (!element || !props.containerRef.current) { + return; + } const resizeObserver = new ResizeObserver(entries => { - if (!props.containerRef.current) return; + if (!props.containerRef.current) { + return; + } element.style.width = `${entries[0]?.target.clientWidth}px`; }); diff --git a/static/app/components/searchQueryBuilder/askSeerCombobox/utils.ts b/static/app/components/searchQueryBuilder/askSeerCombobox/utils.ts index 1179c5a73a9d13..d6a98c202975d4 100644 --- a/static/app/components/searchQueryBuilder/askSeerCombobox/utils.ts +++ b/static/app/components/searchQueryBuilder/askSeerCombobox/utils.ts @@ -114,12 +114,16 @@ function formatToken(token: string): string { } export function formatQueryToNaturalLanguage(query: string): string { - if (!query.trim()) return ''; + if (!query.trim()) { + return ''; + } const tokens = query.match(/(?:[^\s"]|"[^"]*")+/g) || []; const formattedTokens = tokens.map(formatToken); const formattedQuery = formattedTokens.reduce((result, token, index) => { - if (index === 0) return token; + if (index === 0) { + return token; + } const currentOriginalToken = tokens[index] || ''; const prevOriginalToken = tokens[index - 1] || ''; diff --git a/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx b/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx index 51f18bd912124a..05c0416a7a25ac 100644 --- a/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx +++ b/static/app/components/searchQueryBuilder/hooks/useSelectOnDrag.tsx @@ -88,7 +88,9 @@ function getItemIndexAtPosition( ) { for (const [i, key] of keys.entries()) { const coords = coordinates[key]; - if (!coords) continue; + if (!coords) { + continue; + } // If we are above this item, we must be in between this and the // previous item on the row above it. diff --git a/static/app/components/searchQueryBuilder/tokens/combobox.tsx b/static/app/components/searchQueryBuilder/tokens/combobox.tsx index fa06d8fe557e53..5d343c5edef841 100644 --- a/static/app/components/searchQueryBuilder/tokens/combobox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/combobox.tsx @@ -477,7 +477,9 @@ export function SearchQueryBuilderCombobox< /^\w$/.test(e.key) || e.key === ',' ) { - if (isOpen || isCtrlKeyPressed(e)) return; + if (isOpen || isCtrlKeyPressed(e)) { + return; + } state.open(); return; } diff --git a/static/app/components/searchQueryBuilder/tokens/filter/filterOperator.tsx b/static/app/components/searchQueryBuilder/tokens/filter/filterOperator.tsx index ddc0b80aceda82..cd30b54cb9fbe2 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/filterOperator.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/filterOperator.tsx @@ -267,7 +267,9 @@ export function FilterOperator({state, item, token, onOpenChange}: FilterOperato onlyOperator={onlyOperator} {...mergeProps(triggerProps, filterButtonProps, focusWithinProps)} ref={r => { - if (!r || !triggerProps.ref) return; + if (!r || !triggerProps.ref) { + return; + } if (typeof triggerProps.ref === 'function') { triggerProps.ref(r); diff --git a/static/app/components/searchQueryBuilder/tokens/filter/valueListBox.tsx b/static/app/components/searchQueryBuilder/tokens/filter/valueListBox.tsx index d840a31b454e51..aa2c2ebb94ff4d 100644 --- a/static/app/components/searchQueryBuilder/tokens/filter/valueListBox.tsx +++ b/static/app/components/searchQueryBuilder/tokens/filter/valueListBox.tsx @@ -25,13 +25,17 @@ function constrainAndAlignListBox({ referenceRef, refsToSync, }: ConstrainAndAlignListBoxArgs) { - if (!referenceRef.current || !popoverRef.current) return; + if (!referenceRef.current || !popoverRef.current) { + return; + } const referenceRect = referenceRef.current.getBoundingClientRect(); const popoverRect = popoverRef.current.getBoundingClientRect(); refsToSync.forEach(ref => { - if (!ref.current) return; + if (!ref.current) { + return; + } ref.current.style.maxWidth = `${referenceRect.width}px`; }); @@ -129,7 +133,9 @@ export function ValueListBox>({ (element: HTMLUListElement | null) => { listBoxRef.current = element; - if (!element) return; + if (!element) { + return; + } const refsToSync = [listBoxRef, popoverRef]; diff --git a/static/app/components/searchQueryBuilder/tokens/freeText.tsx b/static/app/components/searchQueryBuilder/tokens/freeText.tsx index ce47ca0d307c3c..dc85843fea7c51 100644 --- a/static/app/components/searchQueryBuilder/tokens/freeText.tsx +++ b/static/app/components/searchQueryBuilder/tokens/freeText.tsx @@ -631,8 +631,12 @@ function SearchQueryBuilderInputInternal({ if ( parsedText?.some(textToken => { - if (textToken.type !== Token.FILTER) return false; - if (textToken.negated) return `!${textToken.key.text}` === filterValue; + if (textToken.type !== Token.FILTER) { + return false; + } + if (textToken.negated) { + return `!${textToken.key.text}` === filterValue; + } return textToken.key.text === filterValue; }) ) { diff --git a/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx b/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx index d256d2141c6d8f..739c0d0340a7b2 100644 --- a/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx +++ b/static/app/components/searchQueryBuilder/tokens/useSortedFilterKeyItems.tsx @@ -195,7 +195,9 @@ export function useSortedFilterKeyItems({ const flatKeys = useMemo(() => { const keys = Object.values(filterKeys); - if (!asyncKeys?.length) return keys; + if (!asyncKeys?.length) { + return keys; + } return [...keys, ...asyncKeys.filter(k => !staticKeyValues.has(k.key))]; }, [filterKeys, asyncKeys, staticKeyValues]); @@ -212,7 +214,9 @@ export function useSortedFilterKeyItems({ // Merged lookup of static + async keys, used for validating search results. // Without this, async-only keys would be filtered out by the `filterKeys` check. const allKeysLookup = useMemo(() => { - if (!asyncKeys?.length) return filterKeys; + if (!asyncKeys?.length) { + return filterKeys; + } const merged = {...filterKeys}; for (const tag of asyncKeys) { diff --git a/static/app/icons/icons.stories.tsx b/static/app/icons/icons.stories.tsx index 7f2f91f064a6ff..773b28396f552d 100644 --- a/static/app/icons/icons.stories.tsx +++ b/static/app/icons/icons.stories.tsx @@ -1829,7 +1829,9 @@ function Section(props: CategorySectionProps) { const iconFilter = createIconFilter(props.searchTerm); filteredIcons = filteredIcons.filter(iconFilter); } - if (filteredIcons.length === 0) return null; + if (filteredIcons.length === 0) { + return null; + } return ( diff --git a/static/app/stories/moduleExports.tsx b/static/app/stories/moduleExports.tsx index 2b627febaf1e34..71fb72c91200c5 100644 --- a/static/app/stories/moduleExports.tsx +++ b/static/app/stories/moduleExports.tsx @@ -5,7 +5,9 @@ import {Heading} from '@sentry/scraps/text'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; export function ModuleExports(props: {exports: TypeLoader.TypeLoaderResult['exports']}) { - if (!props.exports?.exports || !props.exports.module) return null; + if (!props.exports?.exports || !props.exports.module) { + return null; + } const lines = []; // canonical source: @sentry/scraps/ (no deep imports) @@ -65,7 +67,9 @@ export function ModuleExports(props: {exports: TypeLoader.TypeLoaderResult['expo } } - if (!lines.length) return null; + if (!lines.length) { + return null; + } return ( diff --git a/static/app/stories/view/index.tsx b/static/app/stories/view/index.tsx index 8131e850511439..4c89f17e36ca83 100644 --- a/static/app/stories/view/index.tsx +++ b/static/app/stories/view/index.tsx @@ -71,7 +71,9 @@ function StoryDetail() { while (queue.length > 0) { const node = queue.pop(); - if (!node) break; + if (!node) { + break; + } if (node.filesystemPath === location.query.name) { storyNode = node; @@ -176,7 +178,9 @@ function getStoryFromParams( while (queue.length > 0) { const node = queue.pop(); - if (!node) break; + if (!node) { + break; + } if (node.category === context.category && node.slug === context.slug) { return node; diff --git a/static/app/stories/view/storyExports.tsx b/static/app/stories/view/storyExports.tsx index fc966cdff3b2cf..013265036bf0bd 100644 --- a/static/app/stories/view/storyExports.tsx +++ b/static/app/stories/view/storyExports.tsx @@ -141,8 +141,12 @@ function MDXStoryTitle(props: {story: MDXStoryDescriptor}) { function StoryTabList() { const {story} = useStory(); - if (!isMDXStory(story)) return null; - if (story.exports.frontmatter?.layout === 'document') return null; + if (!isMDXStory(story)) { + return null; + } + if (story.exports.frontmatter?.layout === 'document') { + return null; + } return ( @@ -296,7 +300,9 @@ function StoryGrid(props: React.ComponentProps) { function StoryModuleExports(props: { exports: TypeLoader.TypeLoaderResult['exports'] | undefined; }) { - if (!props.exports) return null; + if (!props.exports) { + return null; + } return ; } diff --git a/static/app/stories/view/storyTableOfContents.tsx b/static/app/stories/view/storyTableOfContents.tsx index f05a2a3f6c5fb4..8cd45ee7586ebc 100644 --- a/static/app/stories/view/storyTableOfContents.tsx +++ b/static/app/stories/view/storyTableOfContents.tsx @@ -87,7 +87,9 @@ function useActiveSection(entries: Entry[]): [string, (id: string) => void] { const [activeId, setActiveId] = useState(''); useLayoutEffect(() => { - if (entries.length === 0) return void 0; + if (entries.length === 0) { + return void 0; + } const observer = new IntersectionObserver( intersectionEntries => { @@ -175,7 +177,9 @@ export function StoryTableOfContents() { const nestedEntries = useMemo(() => nestContentEntries(entries), [entries]); const [activeId, setActiveId] = useActiveSection(entries); - if (nestedEntries.length === 0) return null; + if (nestedEntries.length === 0) { + return null; + } return ( diff --git a/static/app/utils/api/useFetchParallelPages.spec.tsx b/static/app/utils/api/useFetchParallelPages.spec.tsx deleted file mode 100644 index fcdab06f0f5774..00000000000000 --- a/static/app/utils/api/useFetchParallelPages.spec.tsx +++ /dev/null @@ -1,281 +0,0 @@ -import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {useFetchParallelPages} from 'sentry/utils/api/useFetchParallelPages'; - -const MOCK_API_ENDPOINT = '/api-tokens/'; - -function getQueryOptionsFactory() { - return jest.fn().mockImplementation(({cursor, per_page}) => - apiOptions.as()(MOCK_API_ENDPOINT, { - query: {cursor, per_page}, - staleTime: Infinity, - }) - ); -} - -describe('useFetchParallelPages', () => { - it('should not call the queryFn when enabled is false', () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: false, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - }); - - it('should immediately switch to isFetching=true when the prop is changed', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result, rerender} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: false, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - - rerender({enabled: true, getQueryOptions, hits: 13, perPage: 10}); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - expect(getQueryOptions).toHaveBeenCalled(); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('pending')); - expect(result.current.isFetching).toBeTruthy(); - }); - - it('should call the queryFn zero times, when hits is 0', () => { - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 0, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('success'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - }); - - it('should call the queryFn zero times, and flip to state=success when hits is 0', () => { - const getQueryOptions = getQueryOptionsFactory(); - - const {result, rerender} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: false, - getQueryOptions, - hits: 0, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - - rerender({enabled: true, getQueryOptions, hits: 0, perPage: 10}); - - expect(result.current.status).toBe('success'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - }); - - it('should call the queryFn 1 times, when hits is less than the perPage size', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 7, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).toHaveBeenCalledTimes(1); - }); - - it('should call the queryFn N times, depending on how many hits we expect', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 21, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).toHaveBeenCalledTimes(3); - }); - - it('should return a list of all pages that have been resolved', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.pages).toEqual([ - 'results starting at 0', - 'results starting at 10', - ]); - expect(result.current.error).toEqual([]); - }); - - it('should reduce isError and isFetching', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.isError).toBeFalsy(); - }); - - it('should return the final ResponseHeader that has been resolved', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - headers: {Link: 'next: 0:10:0'}, - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - headers: {Link: 'next: 0:20:0'}, - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - // Wait for the query to resolve - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.lastResponseHeaders?.Link).toBe('next: 0:20:0'); - }); - - it('should have isFetching=true as long as something is outstanding', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - asyncDelay: 200, - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - asyncDelay: 500, - }); - - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchParallelPages, { - initialProps: { - enabled: true, - getQueryOptions, - hits: 13, - perPage: 10, - }, - }); - - // No responses have resolved - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - // Both responses have resolved - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.pages).toEqual([ - 'results starting at 0', - 'results starting at 10', - ]); - }); -}); diff --git a/static/app/utils/api/useFetchParallelPages.stories.tsx b/static/app/utils/api/useFetchParallelPages.stories.tsx deleted file mode 100644 index 636bb837533bdd..00000000000000 --- a/static/app/utils/api/useFetchParallelPages.stories.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import {Fragment, useCallback} from 'react'; - -import {StructuredEventData} from 'sentry/components/structuredEventData'; -import * as Storybook from 'sentry/stories'; -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {useFetchParallelPages} from 'sentry/utils/api/useFetchParallelPages'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -export default Storybook.story('useFetchParallelPages', story => { - story('WARNING!', () => ( - -

- Using this hook might not be a good idea! -
- Pagination is a good strategy to limit the amount of data that a server needs to - fetch at a given time; it also limits the amount of data that the browser needs to - hold in memory. Loading all data with this hook could cause rate-limiting, memory - exhaustion, slow rendering, and other problems. -

-

- Before implementing a parallel-fetch you should first think about building new api - endpoints that return just the data you need (in a paginated way), or look at the - feature design itself and make adjustments. -

-
- )); - - story('useFetchParallelPages', () => { - const organization = useOrganization(); - - const hits = 200; // the maximum number of items we expect to fetch - - const {pages, isFetching} = useFetchParallelPages<{data: unknown}>({ - enabled: true, - hits, - getQueryOptions: useCallback( - ({cursor, per_page}) => - apiOptions.as<{data: unknown}>()( - '/organizations/$organizationIdOrSlug/projects/', - { - path: {organizationIdOrSlug: organization.slug}, - query: {cursor, per_page}, - staleTime: Infinity, - } - ), - [organization.slug] - ), - perPage: 20, - }); - - return ( - -

- useFetchParallelPages will fetch all pages of data for a given - query. The return value of the hook will update as requests complete, meaning - that the UI can update incrementally. -

-

- Note that you need to set hits and perPage so the - helper can know how many requests to make. If you don't already know how many - results to expect then it can be helpful to manually request the first full page - of results, check the X-Hits response header, then use the hook to - fetch the complete list of results. Use the same perPage value in - both callsites to leverage the query cache. -

-

- Note that getQueryOptions needs to be a stable reference, so wrap - it with useCallback. -

- -
- ); - }); -}); diff --git a/static/app/utils/api/useFetchParallelPages.tsx b/static/app/utils/api/useFetchParallelPages.tsx deleted file mode 100644 index 19bfaf3ae0d932..00000000000000 --- a/static/app/utils/api/useFetchParallelPages.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import type {UseQueryOptions} from '@tanstack/react-query'; -import {useQueryClient} from '@tanstack/react-query'; - -import {defined} from 'sentry/utils'; -import type {ApiResponse} from 'sentry/utils/api/apiFetch'; -import type {ApiQueryKey} from 'sentry/utils/api/apiQueryKey'; - -interface Props { - /** - * Whether or not to start fetched when the hook is mounted - */ - enabled: boolean; - - /** - * Generate the queryOptions to use, given the pagination params - */ - getQueryOptions: (pagination: { - cursor: string; - per_page: number; - }) => UseQueryOptions, Error, Data, ApiQueryKey>; - - /** - * The total number of records within the dataset - * - * This, combined with `perPage`, determines the number of fetches to make - */ - hits: number; - /** - * The size of each page to fetch - * - * This, combined with `hits`, determines the number of fetches to make - */ - perPage: number; -} - -interface ResponsePage { - data: undefined | Data; - error: Error | undefined; - headers: ApiResponse['headers'] | undefined; - isError: boolean; - isFetching: boolean; - status: 'pending' | 'error' | 'success'; -} - -interface State { - error: Error[] | undefined; - isError: boolean; - isFetching: boolean; - lastResponseHeaders: ApiResponse['headers'] | undefined; - pages: Data[]; - status: 'pending' | 'error' | 'success'; -} - -/** - * A query hook to fetch a fixed number of list pages all at once. - * - * See also: `useFetchSequentialPages()` - * - * - * Using this hook might not be a good idea! - * Pagination is a good strategy to limit the amount of data that a server - * needs to fetch at a given time, it also limits the amount of data that the - * browser needs to hold in memory. Loading all data with this hook could - * cause rate-limiting, memory exhaustion, slow rendering, and other problems. - * - * Before implementing a parallel-fetch you should first think about - * building new api endpoints that return just the data you need (in a - * paginated way), or look at the feature design itself and make adjustments. - * - * - * EXAMPLE: You want to fetch 100 items to show in a list, but the max-page-size - * is set to only 50. - * In the well-behaved case this might seem fine, but in the pathological - * case (in the extreme) there could be too many users to do this safely! - * Knowing that you have to make - * - * | Request | Waterfall | - * | -------------- | ------------- | - * | ?cursor=0:0:0 | ========== | - * | ?cursor=0:50:0 | ======= | - * | | ^ ^ ^ | - * | | t=0 t=1 | - * | | t=2 | - * - * At t=0 the hook will return `data=Array(0)` because no records are fetched yet. - * - Both requests will start at the same time, but are not guaranteed to end at - * the same time, or in order. - * - If the network saturated with many requests (which can happen during - * pageload) then some requests might still need to wait before starting. - * - Each response (in this case 2) will cause a re-render. - * - Responses will return out of order (in this case items 50 to 100 return - * before items 0 to 50) which could cause layout shift. - */ -export function useFetchParallelPages({ - enabled, - hits, - getQueryOptions, - perPage, -}: Props): State { - const queryClient = useQueryClient(); - - const responsePages = useRef(new Map>()); - - const cursors = useMemo( - () => - Array.from({length: Math.ceil(hits / perPage)}) - .fill(0) - .map((_, i) => `0:${perPage * i}:0`), - [hits, perPage] - ); - - const [state, setState] = useState>({ - pages: [], - error: undefined, - lastResponseHeaders: undefined, - status: enabled ? (cursors.length ? 'pending' : 'success') : 'pending', - isError: false, - isFetching: enabled && Boolean(cursors.length), - }); - - const fetch = useCallback(async () => { - await Promise.allSettled( - cursors.map(async cursor => { - try { - responsePages.current.set(cursor, { - data: undefined, - error: undefined, - headers: undefined, - status: 'pending', - isError: false, - isFetching: true, - }); - - const response = await queryClient.fetchQuery( - getQueryOptions({cursor, per_page: perPage}) - ); - - responsePages.current.set(cursor, { - data: response.json, - error: undefined, - headers: response.headers, - status: 'success', - isError: false, - isFetching: false, - }); - } catch (error) { - responsePages.current.set(cursor, { - data: undefined, - error: error as Error, - headers: undefined, - status: 'error', - isError: true, - isFetching: false, - }); - } finally { - const values = Array.from(responsePages.current.values()); - setState({ - pages: values.map(value => value.data).filter(defined), - error: values.map(value => value.error).filter(defined), - lastResponseHeaders: values.at(-1)?.headers, - status: values.some(value => value.status === 'error') - ? 'error' - : values.some(value => value.status === 'pending') - ? 'pending' - : 'success', - isError: values.map(value => value.isError).some(Boolean), - isFetching: values.map(value => value.isFetching).some(Boolean), - }); - } - }) - ); - }, [cursors, getQueryOptions, perPage, queryClient]); - - useEffect(() => { - if (enabled) { - if (cursors.length) { - setState(prev => ({...prev, status: 'pending', isFetching: true})); - fetch(); - } else { - setState(prev => ({...prev, status: 'success', isFetching: false})); - } - } else { - setState(prev => ({...prev, status: 'pending', isFetching: false})); - } - }, [cursors, enabled, fetch]); - - return state; -} diff --git a/static/app/utils/api/useFetchSequentialPages.spec.tsx b/static/app/utils/api/useFetchSequentialPages.spec.tsx deleted file mode 100644 index c95b5438425e00..00000000000000 --- a/static/app/utils/api/useFetchSequentialPages.spec.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import {renderHookWithProviders, waitFor} from 'sentry-test/reactTestingLibrary'; - -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {useFetchSequentialPages} from 'sentry/utils/api/useFetchSequentialPages'; -import {getPaginationPageLink} from 'sentry/views/organizationStats/utils'; - -const MOCK_API_ENDPOINT = '/api-tokens/'; - -function getQueryOptionsFactory() { - return jest.fn().mockImplementation(({cursor, per_page}) => - apiOptions.as()(MOCK_API_ENDPOINT, { - query: {cursor, per_page}, - staleTime: Infinity, - }) - ); -} - -describe('useFetchSequentialPages', () => { - it('should not call the queryFn when enabled is false', () => { - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: false, - getQueryOptions, - perPage: 10, - }, - }); - - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - }); - - it('should immediatly swith to isFetching=true when the enabled prop is changed', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - headers: {Link: getPaginationPageLink({numRows: 0, pageSize: 100, offset: 0})}, - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result, rerender} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: false, - getQueryOptions, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).not.toHaveBeenCalled(); - - rerender({enabled: true, getQueryOptions, perPage: 10}); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - expect(getQueryOptions).toHaveBeenCalled(); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - }); - - it('should call the queryFn 1 times when the response has no pagination header', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).toHaveBeenCalledTimes(1); - }); - - it('should call the queryFn 1 times when the first response has no next page', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'text result', - headers: {Link: getPaginationPageLink({numRows: 0, pageSize: 10, offset: 0})}, - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).toHaveBeenCalledTimes(1); - }); - - it('should call the queryFn N times, until the response has no next page', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - headers: {Link: getPaginationPageLink({numRows: 13, pageSize: 10, offset: 0})}, - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - headers: {Link: getPaginationPageLink({numRows: 13, pageSize: 10, offset: 10})}, - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - expect(result.current.status).toBe('pending'); - expect(result.current.isFetching).toBeTruthy(); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(getQueryOptions).toHaveBeenCalledTimes(2); - }); - - it('should return a list of all pages that have been resolved', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - headers: {Link: getPaginationPageLink({numRows: 13, pageSize: 10, offset: 0})}, - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - headers: {Link: getPaginationPageLink({numRows: 13, pageSize: 10, offset: 10})}, - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.pages).toEqual([ - 'results starting at 0', - 'results starting at 10', - ]); - expect(result.current.error).toBeUndefined(); - }); - - it('should stop fetching if there is an error', async () => { - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - statusCode: 429, - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - await waitFor(() => expect(result.current.status).toBe('error')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.isError).toBeTruthy(); - expect(result.current.error).toEqual(expect.objectContaining({status: 429})); - }); - - it('should return the final ResponseHeader that was resolved', async () => { - const secondLinkHeader = getPaginationPageLink({ - numRows: 13, - pageSize: 10, - offset: 10, - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 0', - headers: {Link: getPaginationPageLink({numRows: 13, pageSize: 10, offset: 0})}, - match: [MockApiClient.matchQuery({cursor: '0:0:0', per_page: 10})], - }); - MockApiClient.addMockResponse({ - url: MOCK_API_ENDPOINT, - body: 'results starting at 10', - headers: {Link: secondLinkHeader}, - match: [MockApiClient.matchQuery({cursor: '0:10:0', per_page: 10})], - }); - const getQueryOptions = getQueryOptionsFactory(); - - const {result} = renderHookWithProviders(useFetchSequentialPages, { - initialProps: { - enabled: true, - getQueryOptions, - perPage: 10, - }, - }); - - await waitFor(() => expect(result.current.status).toBe('success')); - expect(result.current.isFetching).toBeFalsy(); - expect(result.current.lastResponseHeaders?.Link).toBe(secondLinkHeader); - }); -}); diff --git a/static/app/utils/api/useFetchSequentialPages.stories.tsx b/static/app/utils/api/useFetchSequentialPages.stories.tsx deleted file mode 100644 index 1be92267e74abd..00000000000000 --- a/static/app/utils/api/useFetchSequentialPages.stories.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import {Fragment, useCallback, useRef} from 'react'; - -import {InlineCode} from '@sentry/scraps/code'; - -import {StructuredEventData} from 'sentry/components/structuredEventData'; -import * as Storybook from 'sentry/stories'; -import {apiOptions} from 'sentry/utils/api/apiOptions'; -import {useFetchSequentialPages} from 'sentry/utils/api/useFetchSequentialPages'; -import {useOrganization} from 'sentry/utils/useOrganization'; - -export default Storybook.story('useFetchSequentialPages', story => { - story('WARNING!', () => ( - -

- Using this hook might not be a good idea! -
- Pagination is a good strategy to limit the amount of data that a server needs to - fetch at a given time, it also limits the amount of data that the browser needs to - hold in memory. Loading all data with this hook could cause rate-limiting, memory - exhaustion, slow rendering, and other problems. -

-

- Before implementing a sequential-fetch you should first think about building new - api endpoints that return just the data you need (in a paginated way), or look at - the feature design itself and make adjustments. -

-
- )); - - story('useFetchSequentialPages', () => { - const organization = useOrganization(); - const {pages, isFetching} = useFetchSequentialPages<{data: unknown}>({ - enabled: true, - initialCursor: undefined, - getQueryOptions: useCallback( - ({cursor, per_page}) => - apiOptions.as<{data: unknown}>()( - '/organizations/$organizationIdOrSlug/projects/', - { - path: {organizationIdOrSlug: organization.slug}, - query: {cursor, per_page}, - staleTime: Infinity, - } - ), - [organization.slug] - ), - perPage: 20, - }); - - return ( - -

- useFetchSequentialPages will fetch all pages of data for a given - query. After all pages are fetched the full list of responses is returned as - `pages`. The UI doesn't incrementally render as data is coming in. -

-

- Note that getQueryOptions needs to be a stable reference, so wrap - it with useCallback. -

- -
- ); - }); - - story('Interrupt a sequential series', () => { - const organization = useOrganization(); - const pagesFetched = useRef(0); - - const {pages, isFetching} = useFetchSequentialPages<{data: unknown}>({ - enabled: true, - initialCursor: undefined, - getQueryOptions: useCallback( - ({cursor, per_page}) => { - pagesFetched.current++; - if (pagesFetched.current > 2) { - return; - } - - return apiOptions.as<{data: unknown}>()( - '/organizations/$organizationIdOrSlug/projects/', - { - path: {organizationIdOrSlug: organization.slug}, - query: {cursor, per_page}, - staleTime: Infinity, - } - ); - }, - [organization.slug] - ), - perPage: 1, - }); - - return ( - -

- You can stop a series of requests from continuing by returning a{' '} - undefined from the{' '} - getQueryOptions callback. -

-

Here we limit the number of pages to 2

- -
- ); - }); -}); diff --git a/static/app/utils/api/useFetchSequentialPages.tsx b/static/app/utils/api/useFetchSequentialPages.tsx deleted file mode 100644 index 5b436a3171561c..00000000000000 --- a/static/app/utils/api/useFetchSequentialPages.tsx +++ /dev/null @@ -1,183 +0,0 @@ -import {useCallback, useEffect, useRef, useState} from 'react'; -import type {UseQueryOptions} from '@tanstack/react-query'; -import {useQueryClient} from '@tanstack/react-query'; - -import {defined} from 'sentry/utils'; -import type {ApiResponse} from 'sentry/utils/api/apiFetch'; -import type {ApiQueryKey} from 'sentry/utils/api/apiQueryKey'; -import {parseLinkHeader, type ParsedHeader} from 'sentry/utils/parseLinkHeader'; - -interface Props { - /** - * Whether or not to start fetching when the hook is mounted - */ - enabled: boolean; - - /** - * Generate the queryOptions to use, given the pagination params. - * If `undefined` is returned iteration will not continue. - */ - getQueryOptions: (pagination: { - cursor: string; - per_page: number; - }) => undefined | UseQueryOptions, Error, Data, ApiQueryKey>; - - /** - * You must set the page size to be used. - * - * This will be passed back as an argument into getQueryOptions - */ - perPage: number; - - /** - * The initial cursor to use when fetching. - * - * Default: `0:0:0` - */ - initialCursor?: undefined | string; -} - -interface ResponsePage { - data: undefined | Data; - error: unknown; - headers: ApiResponse['headers'] | undefined; - isError: boolean; - isFetching: boolean; - status: 'pending' | 'error' | 'success'; -} - -interface State { - error: unknown; - isError: boolean; - isFetching: boolean; - lastResponseHeaders: ApiResponse['headers'] | undefined; - pages: Data[]; - status: 'pending' | 'error' | 'success'; -} - -/** - * A query hook that fetches multiple pages of data from the same endpoint, one-by-one. - * - * See also: `useFetchParallelPages()` - * - * `useFetchSequentialPages` is an iterator for fetch calls. It makes it possible - * to fetch ALL data from an api endpoint. - * - * - * Using this hook might not be a good idea! - * Pagination is a good stratergy to limit the amount of data that a server - * needs to fetch at a given time, it also limits the amount of data that the - * browser needs to hold in memory. Loading all data with this hook could - * cause rate-limiting, memory exhaustion, slow rendering, and other problems. - * - * Before implementing a sequential-fetch you should first think about - * building new api endpoints that return just the data you need (in a - * paginated way), or look at the feature design itself and make adjustments. - * - * - * EXAMPLE: you want to make a request for all user's within a project... - * In the well-behaved case this might seem fine, but in the pathological - * case (in the extreme) there could be too many users to do this safely! - * If there are 64 users in the project, but the max page-size is only 50, then - * you can expect two calls to be made. The network waterfall would look like: - * - * | Request | Waterfall | - * | -------------- | ------------------- | - * | ?cursor=0:0:0 | ======== | - * | ?cursor=0:50:0 | ======== | - * | | ^ ^ ^ | - * | | t=0 t=1 t=2 | - * - * At t=0 the hook will return `data=Array(0)` because no records are fetched yet. - * At t=1 the hook will return `data=Array(50)` and will has `isFetching=true` - * Finally at t=2 all data will be fetched and combined: `data=Array(64)` - */ -export function useFetchSequentialPages({ - enabled, - getQueryOptions, - initialCursor, - perPage, -}: Props): State { - const queryClient = useQueryClient(); - - const responsePages = useRef(new Map>()); - const [state, setState] = useState>({ - pages: [], - error: undefined, - lastResponseHeaders: undefined, - status: 'pending', - isError: false, - isFetching: enabled, - }); - - const fetch = useCallback( - async function recursiveFetch() { - let parsedHeader: ParsedHeader | undefined = { - cursor: initialCursor ?? '0:0:0', - href: '', - results: true, - }; - try { - while (parsedHeader?.results) { - const cursor = parsedHeader.cursor; - const queryOptions = getQueryOptions({cursor, per_page: perPage}); - if (!queryOptions) { - break; - } - const response = await queryClient.fetchQuery(queryOptions); - - responsePages.current.set(cursor, { - data: response.json, - error: undefined, - headers: response.headers, - status: 'success', - isError: false, - isFetching: false, - }); - - parsedHeader = parseLinkHeader(response.headers.Link ?? null)?.next; - } - } catch (error) { - responsePages.current.set(parsedHeader?.cursor!, { - data: undefined, - error, - headers: undefined, - status: 'error', - isError: true, - isFetching: false, - }); - } finally { - const values = Array.from(responsePages.current.values()); - setState({ - pages: values.map(value => value.data).filter(defined), - error: values.map(value => value.error).at(0), - lastResponseHeaders: values.at(-1)?.headers, - status: values.some(value => value.status === 'error') - ? 'error' - : values.some(value => value.status === 'pending') - ? 'pending' - : 'success', - isError: values.map(value => value.isError).some(Boolean), - isFetching: values.map(value => value.isFetching).every(Boolean), - }); - } - }, - [initialCursor, getQueryOptions, perPage, queryClient] - ); - - useEffect(() => { - if (!enabled) { - return; - } - - setState(prev => ({ - ...prev, - status: 'pending', - isFetching: true, - })); - - fetch(); - }, [enabled, fetch]); - - return state; -} diff --git a/static/app/utils/array/segmentSequentialBy.tsx b/static/app/utils/array/segmentSequentialBy.tsx index a42b1ab3e89b17..ae136cf7958654 100644 --- a/static/app/utils/array/segmentSequentialBy.tsx +++ b/static/app/utils/array/segmentSequentialBy.tsx @@ -16,7 +16,9 @@ export function segmentSequentialBy( data: T[], predicate: Predicate ): Array> { - if (!data.length) return []; + if (!data.length) { + return []; + } const firstDatum: T = data.at(0)!; let previousPredicateValue = predicate(firstDatum); diff --git a/static/app/utils/discover/fieldRenderers.tsx b/static/app/utils/discover/fieldRenderers.tsx index 01f1516794da10..4ba014cf7e6d37 100644 --- a/static/app/utils/discover/fieldRenderers.tsx +++ b/static/app/utils/discover/fieldRenderers.tsx @@ -1101,7 +1101,9 @@ const getContextIcon = (value: string) => { */ const dropVersion = (value: string) => { const valueArray = value.split(' '); - if (valueArray.length > 1) valueArray.pop(); + if (valueArray.length > 1) { + valueArray.pop(); + } return valueArray.join(' '); }; diff --git a/static/app/utils/replays/hooks/useReplayData.spec.tsx b/static/app/utils/replays/hooks/useReplayData.spec.tsx index 339237f68be583..b89f2d0b3ec5b4 100644 --- a/static/app/utils/replays/hooks/useReplayData.spec.tsx +++ b/static/app/utils/replays/hooks/useReplayData.spec.tsx @@ -45,7 +45,9 @@ describe('useReplayData', () => { }); afterEach(() => { - jest.runOnlyPendingTimers(); + act(() => { + jest.runOnlyPendingTimers(); + }); jest.useRealTimers(); }); @@ -370,10 +372,12 @@ describe('useReplayData', () => { await act(() => jest.advanceTimersByTimeAsync(0)); - await waitFor(() => expect(mockedErrorEventsMetaCall1).toHaveBeenCalledTimes(1)); - expect(mockedErrorEventsMetaCall2).toHaveBeenCalledTimes(1); - expect(mockedIssuePlatformEventsMetaCall1).toHaveBeenCalledTimes(1); - expect(mockedIssuePlatformEventsMetaCall2).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(mockedErrorEventsMetaCall1).toHaveBeenCalledTimes(1); + expect(mockedErrorEventsMetaCall2).toHaveBeenCalledTimes(1); + expect(mockedIssuePlatformEventsMetaCall1).toHaveBeenCalledTimes(1); + expect(mockedIssuePlatformEventsMetaCall2).toHaveBeenCalledTimes(1); + }); await waitFor(() => { expect(result.current).toStrictEqual( @@ -448,16 +452,17 @@ describe('useReplayData', () => { }); const expectedReplayData = { + attachmentError: undefined, attachments: [], errors: [], feedbackEvents: [], fetchError: undefined, - isError: true, + isError: false, isPending: true, onRetry: expect.any(Function), projectSlug: null, replayRecord: undefined, - status: 'error', + status: 'pending', } as Record; // Immediately we will see the replay call is made @@ -566,15 +571,16 @@ describe('useReplayData', () => { expect(mockedErrorEventsMetaCall).toHaveBeenCalledTimes(2); }); - result.current.onRetry(); - - await act(() => jest.advanceTimersByTimeAsync(0)); + await act(async () => { + result.current.onRetry(); + await jest.runAllTimersAsync(); + }); await waitFor(() => { expect(mockedReplayCall).toHaveBeenCalledTimes(2); }); await waitFor(() => { - expect(mockedErrorEventsMetaCall).toHaveBeenCalledTimes(2); + expect(mockedErrorEventsMetaCall).toHaveBeenCalledTimes(4); }); }); }); diff --git a/static/app/utils/replays/hooks/useReplayData.tsx b/static/app/utils/replays/hooks/useReplayData.tsx index 7ddda09ce62267..cdf37ee03709cc 100644 --- a/static/app/utils/replays/hooks/useReplayData.tsx +++ b/static/app/utils/replays/hooks/useReplayData.tsx @@ -1,12 +1,19 @@ import {useCallback, useMemo} from 'react'; -import {skipToken, useQuery, useQueryClient} from '@tanstack/react-query'; +import { + queryOptions, + skipToken, + useInfiniteQuery, + useQueries, + useQuery, + useQueryClient, +} from '@tanstack/react-query'; import {ALL_ACCESS_PROJECTS} from 'sentry/components/pageFilters/constants'; -import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {defined} from 'sentry/utils'; +import {useFetchAllPages} from 'sentry/utils/api/apiFetch'; +import {apiOptions, selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; import {safeParseQueryKey} from 'sentry/utils/api/apiQueryKey'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {useFetchParallelPages} from 'sentry/utils/api/useFetchParallelPages'; -import {useFetchSequentialPages} from 'sentry/utils/api/useFetchSequentialPages'; import {DiscoverDatasets} from 'sentry/utils/discover/types'; import type {FeedbackEvent} from 'sentry/utils/feedback/types'; import {parseLinkHeader} from 'sentry/utils/parseLinkHeader'; @@ -92,6 +99,8 @@ const REPLAY_ERROR_FIELDS = [ 'title', ] as const; +const EMPTY_PAGES: Array<{data: RawReplayError[]}> = []; + interface Result { attachmentError: undefined | Error[]; attachments: unknown[]; @@ -174,15 +183,30 @@ export function useReplayData({ Boolean(projectSlug) && Boolean(replayRecord); + const attachmentCursors = Array.from( + {length: Math.ceil((replayRecord?.count_segments ?? 0) / segmentsPerPage)}, + (_, i) => `0:${segmentsPerPage * i}:0` + ); + const { pages: attachmentPages, status: fetchAttachmentsStatus, - error: fetchAttachmentsError, - } = useFetchParallelPages({ - enabled: enableAttachments, - getQueryOptions: getAttachmentsQueryOptions, - hits: replayRecord?.count_segments ?? 0, - perPage: segmentsPerPage, + errors: fetchAttachmentsError, + } = useQueries({ + queries: enableAttachments + ? attachmentCursors.map(cursor => + getAttachmentsQueryOptions({cursor, per_page: segmentsPerPage}) + ) + : [], + combine: results => ({ + pages: results.map(r => r.data).filter(defined), + status: results.some(r => r.status === 'error') + ? 'error' + : results.some(r => r.status === 'pending') + ? 'pending' + : 'success', + errors: results.map(r => r.error).filter(defined), + }), }); const getErrorsQueryOptions = useCallback( @@ -215,64 +239,95 @@ export function useReplayData({ [orgSlug, replayRecord] ); - const getPlatformErrorsQueryOptions = useCallback( - ({cursor, per_page}: {cursor: string; per_page: number}) => { - const finishedAtClone = new Date(replayRecord?.finished_at ?? ''); - finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1); - - return apiOptions.as<{data: RawReplayError[]}>()( - '/organizations/$organizationIdOrSlug/events/', - { - path: {organizationIdOrSlug: orgSlug}, - query: { - referrer: 'replay_details', - dataset: DiscoverDatasets.ISSUE_PLATFORM, - field: REPLAY_ERROR_FIELDS, - start: replayRecord?.started_at?.toISOString() ?? '', - end: finishedAtClone.toISOString(), - project: ALL_ACCESS_PROJECTS, - query: `replayId:[${replayRecord?.id}]`, - per_page, - cursor, - }, - staleTime: Infinity, - } - ); - }, - [orgSlug, replayRecord] + const errorCursors = Array.from( + {length: Math.ceil((replayRecord?.count_errors ?? 0) / errorsPerPage)}, + (_, i) => `0:${errorsPerPage * i}:0` ); const enableErrors = Boolean(replayRecord) && Boolean(projectSlug); const { pages: errorPages, status: fetchErrorsStatus, - lastResponseHeaders: lastErrorsResponseHeaders, - } = useFetchParallelPages<{data: RawReplayError[]}>({ - enabled: enableErrors, - hits: replayRecord?.count_errors ?? 0, - getQueryOptions: getErrorsQueryOptions, - perPage: errorsPerPage, + lastLinkHeader, + } = useQueries({ + queries: enableErrors + ? errorCursors.map(cursor => + queryOptions({ + ...getErrorsQueryOptions({ + cursor, + per_page: errorsPerPage, + }), + select: selectJsonWithHeaders, + }) + ) + : [], + combine: results => ({ + pages: results.map(r => r.data?.json).filter(defined), + status: results.some(r => r.status === 'error') + ? 'error' + : results.some(r => r.status === 'pending') + ? 'pending' + : 'success', + lastLinkHeader: parseLinkHeader(results.at(-1)?.data?.headers.Link ?? null), + }), }); - const links = parseLinkHeader(lastErrorsResponseHeaders?.Link ?? null); const enableExtraErrors = Boolean(replayRecord) && - (!replayRecord?.count_errors || Boolean(links.next?.results)) && + (!replayRecord?.count_errors || Boolean(lastLinkHeader.next?.results)) && fetchErrorsStatus === 'success'; - const {pages: extraErrorPages, status: fetchExtraErrorsStatus} = - useFetchSequentialPages<{data: RawReplayError[]}>({ - enabled: enableExtraErrors, - initialCursor: links.next?.cursor, - getQueryOptions: getErrorsQueryOptions, - perPage: errorsPerPage, - }); - const {pages: platformErrorPages, status: fetchPlatformErrorsStatus} = - useFetchSequentialPages<{data: RawReplayError[]}>({ - enabled: true, - getQueryOptions: getPlatformErrorsQueryOptions, - perPage: errorsPerPage, - }); + const replayEnd = getReplayEndTimestamp(replayRecord); + + const extraErrorsResult = useInfiniteQuery({ + ...apiOptions.asInfinite<{data: RawReplayError[]}>()( + '/organizations/$organizationIdOrSlug/events/', + { + path: enableExtraErrors ? {organizationIdOrSlug: orgSlug} : skipToken, + query: { + referrer: 'replay_details', + dataset: DiscoverDatasets.ERRORS, + field: REPLAY_ERROR_FIELDS, + start: replayRecord?.started_at?.toISOString() ?? '', + end: replayEnd, + project: ALL_ACCESS_PROJECTS, + query: `replayId:[${replayRecord?.id}]`, + per_page: errorsPerPage, + cursor: lastLinkHeader.next?.cursor ?? '0:0:0', + }, + staleTime: Infinity, + } + ), + select: data => data.pages.map(p => p.json), + }); + useFetchAllPages({result: extraErrorsResult}); + const extraErrorPages = extraErrorsResult.data ?? EMPTY_PAGES; + const fetchExtraErrorsStatus = extraErrorsResult.status; + + const platformErrorsResult = useInfiniteQuery({ + ...apiOptions.asInfinite<{data: RawReplayError[]}>()( + '/organizations/$organizationIdOrSlug/events/', + { + path: replayRecord ? {organizationIdOrSlug: orgSlug} : skipToken, + query: { + referrer: 'replay_details', + dataset: DiscoverDatasets.ISSUE_PLATFORM, + field: REPLAY_ERROR_FIELDS, + start: replayRecord?.started_at?.toISOString() ?? '', + end: replayEnd, + project: ALL_ACCESS_PROJECTS, + query: `replayId:[${replayRecord?.id}]`, + per_page: errorsPerPage, + cursor: '0:0:0', + }, + staleTime: Infinity, + } + ), + select: data => data.pages.map(p => p.json), + }); + useFetchAllPages({result: platformErrorsResult}); + const platformErrorPages = platformErrorsResult.data ?? EMPTY_PAGES; + const fetchPlatformErrorsStatus = platformErrorsResult.status; const clearQueryCache = useCallback(() => { if (!replayId) { @@ -311,10 +366,9 @@ export function useReplayData({ }, [orgSlug, replayId, projectSlug, queryClient]); const {allErrors, feedbackEventIds} = useMemo(() => { - const errors = errorPages - .concat(extraErrorPages) - .concat(platformErrorPages) - .flatMap(page => page.data); + const errors = [...errorPages, ...extraErrorPages, ...platformErrorPages].flatMap( + page => page.data + ); const feedbackIds = errors ?.filter(error => error?.title.includes('User Feedback')) @@ -346,7 +400,7 @@ export function useReplayData({ enableAttachments ? fetchAttachmentsStatus : undefined, enableErrors ? fetchErrorsStatus : undefined, enableExtraErrors ? fetchExtraErrorsStatus : undefined, - fetchPlatformErrorsStatus, + replayRecord ? fetchPlatformErrorsStatus : undefined, ]; const isError = allStatuses.includes('error') || feedbackEventsError; @@ -358,7 +412,7 @@ export function useReplayData({ attachments: attachmentPages.flat(2), errors: allErrors, fetchError: fetchReplayError ?? undefined, - attachmentError: fetchAttachmentsError ?? undefined, + attachmentError: fetchAttachmentsError?.length ? fetchAttachmentsError : undefined, feedbackEvents, isError, isPending, @@ -381,3 +435,12 @@ export function useReplayData({ allErrors, ]); } + +function getReplayEndTimestamp(replayRecord: ReplayRecord | undefined): string { + if (!replayRecord?.finished_at) { + return ''; + } + const d = new Date(replayRecord.finished_at); + d.setSeconds(d.getSeconds() + 1); + return d.toISOString(); +} diff --git a/static/app/utils/string/ellipsize.tsx b/static/app/utils/string/ellipsize.tsx index 587d1b29ecf5db..6d9c632c6e106e 100644 --- a/static/app/utils/string/ellipsize.tsx +++ b/static/app/utils/string/ellipsize.tsx @@ -10,16 +10,21 @@ import {ELLIPSIS} from 'sentry/utils/string/unicode'; * ellipsize('short', 10) // returns 'short' */ export function ellipsize(str: string, length: number): string { - if (length < 0 || isNaN(length)) + if (length < 0 || isNaN(length)) { throw new TypeError( `Invalid string length argument value of ${length} provided to ellipsize` ); + } - if (str.length <= length) return str; + if (str.length <= length) { + return str; + } // If the string is only whitespace, skip `trimRight` since trimming will completely erase the string. If the string was a long sequence of whitespace characters, that would return a loose ellipsis const trimmed = str.trim(); - if (trimmed.length === 0) return `${str.slice(0, length)}${ELLIPSIS}`; + if (trimmed.length === 0) { + return `${str.slice(0, length)}${ELLIPSIS}`; + } // Otherwise, trim normally return `${str.slice(0, length).trimEnd()}${ELLIPSIS}`; diff --git a/static/app/utils/string/looksLikeAJSONArray.tsx b/static/app/utils/string/looksLikeAJSONArray.tsx index 859b07882d997b..1edc98a1a36062 100644 --- a/static/app/utils/string/looksLikeAJSONArray.tsx +++ b/static/app/utils/string/looksLikeAJSONArray.tsx @@ -11,6 +11,8 @@ export function looksLikeAJSONArray(value: string) { const trimmedValue = value.trim(); // The string '[Filtered]' looks array-like, but it's actually a Relay special string - if (trimmedValue === '[Filtered]') return false; + if (trimmedValue === '[Filtered]') { + return false; + } return trimmedValue.startsWith('[') && trimmedValue.endsWith(']'); } diff --git a/static/app/utils/url/testUtils.tsx b/static/app/utils/url/testUtils.tsx index 3aa3f3f399f0cb..6b9b8af1cdbce4 100644 --- a/static/app/utils/url/testUtils.tsx +++ b/static/app/utils/url/testUtils.tsx @@ -5,16 +5,22 @@ export function testableDebounce(callback: () => void, delay?: number) { let timeoutId: ReturnType | null = null; const debounced = () => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } timeoutId = setTimeout(() => callback(), delay); }; const cancel = () => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } }; const flush = () => { - if (timeoutId) clearTimeout(timeoutId); + if (timeoutId) { + clearTimeout(timeoutId); + } callback(); }; diff --git a/static/app/utils/url/useQueryParamState.tsx b/static/app/utils/url/useQueryParamState.tsx index a5224a5f7fb276..7b7e536285a987 100644 --- a/static/app/utils/url/useQueryParamState.tsx +++ b/static/app/utils/url/useQueryParamState.tsx @@ -83,7 +83,9 @@ export function useQueryParamState({ // Sync local state when URL query params change (e.g., browser back/forward navigation) useEffect(() => { - if (!syncStateWithUrl) return; + if (!syncStateWithUrl) { + return; + } setLocalState(deserializeValue()); }, [deserializeValue, syncStateWithUrl]); diff --git a/static/app/utils/useResizable.tsx b/static/app/utils/useResizable.tsx index 954eb2cbc88d3e..539992bd57b8ab 100644 --- a/static/app/utils/useResizable.tsx +++ b/static/app/utils/useResizable.tsx @@ -106,7 +106,9 @@ export const useResizable = ({ const handleMouseMove = useCallback( (e: MouseEvent) => { - if (!isDraggingRef.current) return; + if (!isDraggingRef.current) { + return; + } const deltaX = e.clientX - startXRef.current; const newWidth = Math.max( diff --git a/static/app/views/alerts/rules/uptime/assertions/utils.tsx b/static/app/views/alerts/rules/uptime/assertions/utils.tsx index aa08b23dd36b49..b2cb46613c86e9 100644 --- a/static/app/views/alerts/rules/uptime/assertions/utils.tsx +++ b/static/app/views/alerts/rules/uptime/assertions/utils.tsx @@ -370,7 +370,9 @@ export const isLeafOp = (op: UptimeOp) => op.op === UptimeOpType.HEADER_CHECK; export function leafOpsMatchByValue(a: UptimeOp, b: UptimeOp): boolean { - if (a.op !== b.op) return false; + if (a.op !== b.op) { + return false; + } if ( a.op === UptimeOpType.STATUS_CODE_CHECK && diff --git a/static/app/views/alerts/rules/uptime/formErrors.spec.tsx b/static/app/views/alerts/rules/uptime/formErrors.spec.tsx index 44f4f39f491bfd..4c8b058b48ca8b 100644 --- a/static/app/views/alerts/rules/uptime/formErrors.spec.tsx +++ b/static/app/views/alerts/rules/uptime/formErrors.spec.tsx @@ -194,10 +194,12 @@ describe('AssertionFormError', () => { function Setter() { const previewCheckResult = usePreviewCheckResult(); useEffect(() => { - if ('data' in initial) + if ('data' in initial) { previewCheckResult?.setPreviewCheckData(initial.data ?? null); - if ('error' in initial) + } + if ('error' in initial) { previewCheckResult?.setPreviewCheckError(initial.error ?? null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return null; diff --git a/static/app/views/alerts/rules/uptime/formErrors.tsx b/static/app/views/alerts/rules/uptime/formErrors.tsx index 3f363fa00ee7ff..f618c18ae6ba0a 100644 --- a/static/app/views/alerts/rules/uptime/formErrors.tsx +++ b/static/app/views/alerts/rules/uptime/formErrors.tsx @@ -24,7 +24,9 @@ import {usePreviewCheckResult} from './previewCheckContext'; export function mapPreviewCheckErrorToMessage( error: PreviewCheckError | null ): string | null { - if (!error) return null; + if (!error) { + return null; + } return error.assertion.error === PreviewCheckErrorKind.COMPILATION_ERROR ? t('Assertion Compilation Error') @@ -112,7 +114,9 @@ export function mapPreviewCheckResultToMessage( response: PreviewCheckResult ): string | null { const result = response.check_result; - if (!result) return null; + if (!result) { + return null; + } if (result.assertion_failure_data) { return t('Assertion Failure'); @@ -137,14 +141,18 @@ function matchAssertionFailureDataLeafOp( (assertionOp.op === UptimeOpType.AND || assertionOp.op === UptimeOpType.OR) ) { const [failingChild] = failureDataOp.children; - if (!failingChild) return null; + if (!failingChild) { + return null; + } // For leaves, also match by value to distinguish siblings of the same op type. const match = assertionOp.children.find( c => c.op === failingChild.op && (isLeafOp(failingChild) ? leafOpsMatchByValue(failingChild, c) : true) ); - if (!match) return null; + if (!match) { + return null; + } return matchAssertionFailureDataLeafOp(failingChild, match); } @@ -174,12 +182,16 @@ function resolveErroredOpFromAssertPath( for (const segment of assertPath.slice(1)) { const index = Number.parseInt(segment, 10); - if (isNaN(index)) return null; + if (isNaN(index)) { + return null; + } if (current.op === UptimeOpType.AND || current.op === UptimeOpType.OR) { // eslint-disable-next-line @sentry/no-unnecessary-type-annotation const next: UptimeOp | undefined = current.children[index]; - if (!next) return null; + if (!next) { + return null; + } current = next; } else if (current.op === UptimeOpType.NOT) { current = current.operand; @@ -200,12 +212,16 @@ export function resolveErroredAssertionOp( previewCheckResult: ReturnType, rootOp: UptimeAndOp ): UptimeOp | null { - if (!previewCheckResult) return null; + if (!previewCheckResult) { + return null; + } const {data, error} = previewCheckResult; if (data) { const result = data.check_result; - if (!result) return null; + if (!result) { + return null; + } if (result.assertion_failure_data) { return resolveErroredOpFromAssertionFailureData( @@ -246,12 +262,16 @@ const ASSERTION_ERROR_TYPE_LABELS: Partial> = { function getFormAssertionErrorMessage( previewCheckResult: ReturnType ): string | null { - if (!previewCheckResult) return null; + if (!previewCheckResult) { + return null; + } const {data, error} = previewCheckResult; if (data) { const result = data.check_result; - if (!result) return null; + if (!result) { + return null; + } if (result.assertion_failure_data) { return t('Assertion Failed'); diff --git a/static/app/views/automations/components/actions/ticketActionSettingsButton.tsx b/static/app/views/automations/components/actions/ticketActionSettingsButton.tsx index 650f90384884db..03adfd926bb3ac 100644 --- a/static/app/views/automations/components/actions/ticketActionSettingsButton.tsx +++ b/static/app/views/automations/components/actions/ticketActionSettingsButton.tsx @@ -51,11 +51,15 @@ export function TicketActionSettingsButton() { // Find saved action data from the API response const savedActionData = useMemo(() => { - if (!automation) return; + if (!automation) { + return; + } for (const af of automation.actionFilters) { const found = af.actions?.find(a => a.id === action.id); - if (found) return found.data; + if (found) { + return found.data; + } } return; diff --git a/static/app/views/dashboards/dashboardRevisionsDiff.ts b/static/app/views/dashboards/dashboardRevisionsDiff.ts index 4ecc60f3908ba1..32d188f563ed9f 100644 --- a/static/app/views/dashboards/dashboardRevisionsDiff.ts +++ b/static/app/views/dashboards/dashboardRevisionsDiff.ts @@ -13,7 +13,9 @@ type FieldChange = {after: string; before: string; field: string}; type FilterChange = {after: string; before: string; label: string}; function formatTime(d: DashboardDetails): string | null { - if (d.period) return getRelativeSummary(d.period); + if (d.period) { + return getRelativeSummary(d.period); + } if (d.start && d.end) { const fmt = (s: string) => getFormattedDate(s, 'MMM D, YYYY', {local: !d.utc}); return `${fmt(d.start)} – ${fmt(d.end)}`; @@ -30,8 +32,12 @@ export function formatProjectIds( ids: number[] | undefined, resolveId: (id: number) => string | undefined ): string { - if (!ids?.length) return t('My Projects'); - if (ids.includes(ALL_ACCESS_PROJECTS)) return t('All Projects'); + if (!ids?.length) { + return t('My Projects'); + } + if (ids.includes(ALL_ACCESS_PROJECTS)) { + return t('All Projects'); + } return ids.map(id => resolveId(id) ?? String(id)).join(', '); } @@ -90,8 +96,12 @@ export function diffFilters( } function truncateDescription(value: string): string { - if (!value) return t('(empty)'); - if (value.length <= DESCRIPTION_PREVIEW_MAX_LENGTH) return value; + if (!value) { + return t('(empty)'); + } + if (value.length <= DESCRIPTION_PREVIEW_MAX_LENGTH) { + return value; + } return value.slice(0, DESCRIPTION_PREVIEW_MAX_LENGTH) + '…'; } @@ -177,7 +187,9 @@ export function diffWidgets( const titleCounts = new Map(); const fingerprintCounts = new Map(); for (const w of base.widgets) { - if (w.id) baseById.set(w.id, w); + if (w.id) { + baseById.set(w.id, w); + } titleCounts.set(w.title, (titleCounts.get(w.title) ?? 0) + 1); const fp = makeWidgetFingerprint(w); fingerprintCounts.set(fp, (fingerprintCounts.get(fp) ?? 0) + 1); diff --git a/static/app/views/dashboards/globalFilter/addFilter.tsx b/static/app/views/dashboards/globalFilter/addFilter.tsx index e9971cdf0646f1..cfacb5765dba49 100644 --- a/static/app/views/dashboards/globalFilter/addFilter.tsx +++ b/static/app/views/dashboards/globalFilter/addFilter.tsx @@ -160,7 +160,9 @@ export function AddFilter({ { - if (!selectedFilterKey || !selectedDataset) return; + if (!selectedFilterKey || !selectedDataset) { + return; + } let defaultFilterValue = ''; const fieldDefinition = diff --git a/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx b/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx index 879d859727bd92..40fc8c5a8ca6e9 100644 --- a/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx +++ b/static/app/views/dashboards/globalFilter/numericFilterSelector.tsx @@ -124,7 +124,9 @@ function useNativeOperatorFilter( isValidNumericFilterValue(stagedFilterValue, globalFilterToken, globalFilter); const buildFilterQuery = () => { - if (!globalFilterToken) return ''; + if (!globalFilterToken) { + return ''; + } return newNumericFilterQuery( stagedFilterValue, diff --git a/static/app/views/dashboards/globalFilter/utils.tsx b/static/app/views/dashboards/globalFilter/utils.tsx index 2d23e8152e653b..736c2255c3ed02 100644 --- a/static/app/views/dashboards/globalFilter/utils.tsx +++ b/static/app/views/dashboards/globalFilter/utils.tsx @@ -140,7 +140,9 @@ export function newNumericFilterQuery( valueType, token: filterToken, }); - if (!cleanedValue) return ''; + if (!cleanedValue) { + return ''; + } const newFilterValue = modifyFilterValue(filterToken.text, filterToken, cleanedValue); const newFilterTokens = parseFilterValue(newFilterValue, globalFilter); diff --git a/static/app/views/dashboards/revisionListItem.tsx b/static/app/views/dashboards/revisionListItem.tsx index d8d405f56ed0d5..9d6c01f71b5f0e 100644 --- a/static/app/views/dashboards/revisionListItem.tsx +++ b/static/app/views/dashboards/revisionListItem.tsx @@ -36,8 +36,12 @@ interface RevisionListItemProps { } function formatRevisionSource(source: DashboardRevision['source']): string { - if (source === 'pre-restore') return t('Revert Dashboard'); - if (source === 'edit-with-agent') return t('Edit with Seer'); + if (source === 'pre-restore') { + return t('Revert Dashboard'); + } + if (source === 'edit-with-agent') { + return t('Edit with Seer'); + } return t('Edit'); } @@ -155,7 +159,9 @@ export function RevisionDiffBody({ isLoading: boolean; snapshot: DashboardDetails | undefined; }) { - if (isLoading) return ; + if (isLoading) { + return ; + } if (isError) { return ( @@ -163,7 +169,9 @@ export function RevisionDiffBody({ ); } - if (!snapshot) return null; + if (!snapshot) { + return null; + } if (baseRevisionId === null) { return ( diff --git a/static/app/views/dashboards/sortableWidget.tsx b/static/app/views/dashboards/sortableWidget.tsx index a555465088826e..48df1ad674d3da 100644 --- a/static/app/views/dashboards/sortableWidget.tsx +++ b/static/app/views/dashboards/sortableWidget.tsx @@ -113,7 +113,9 @@ export function SortableWidget(props: Props) { const newOrderBy = `${sort.kind === 'desc' ? '-' : ''}${sort.field}`; // Override the widget queries to pass the temporary sort to the widget and expanded modal const widgetQueries = cloneDeep(widget.queries); - if (widgetQueries[0]) widgetQueries[0].orderby = newOrderBy; + if (widgetQueries[0]) { + widgetQueries[0].orderby = newOrderBy; + } setQueries(widgetQueries); }; diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx index 22502a896d4dc0..5c64fe3f927df7 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.spec.tsx @@ -386,7 +386,9 @@ function makeContextCapture() { return { ContextCapture, getSnapshot: () => { - if (!ref.current) throw new Error('ContextCapture not mounted'); + if (!ref.current) { + throw new Error('ContextCapture not mounted'); + } return ref.current(); }, }; diff --git a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx index 44b94d51c0da67..74956b9529d75c 100644 --- a/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/newWidgetBuilder.tsx @@ -94,7 +94,9 @@ export function WidgetBuilderV2({ const navigationElementRef = useRef(null); useEffect(() => { - if (navigationElementRef.current) return; + if (navigationElementRef.current) { + return; + } const navigationElement = document.querySelector( 'nav[aria-label="Primary Navigation"]' diff --git a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx index 9e6b380922f6c9..d08af8f1ac2ea7 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/trackEngagementAnalytics.tsx @@ -10,7 +10,9 @@ export function trackEngagementAnalytics( isSentryBuilt: boolean ) { // Handle edge-case of dashboard with no widgets. - if (!widgets.length) return; + if (!widgets.length) { + return; + } // For attributing engagement metrics initially track the ratio // of widgets reading from Transactions, Spans, Errors, and Issues, and Logs. diff --git a/static/app/views/dashboards/widgetBuilder/utils/transformPerformanceScoreBreakdownSeries.tsx b/static/app/views/dashboards/widgetBuilder/utils/transformPerformanceScoreBreakdownSeries.tsx index 6c6a51c10de2fb..70539d3947c010 100644 --- a/static/app/views/dashboards/widgetBuilder/utils/transformPerformanceScoreBreakdownSeries.tsx +++ b/static/app/views/dashboards/widgetBuilder/utils/transformPerformanceScoreBreakdownSeries.tsx @@ -16,7 +16,9 @@ export function transformPerformanceScoreBreakdownSeries( : `performance_score(measurements.score.${webVital})`; const series = multiSeries[key]; - if (!series?.data) return false; + if (!series?.data) { + return false; + } return series.data.some(([_timestamp, values]) => values.some(v => (v.count || 0) > 0) diff --git a/static/app/views/dashboards/widgetCard/index.tsx b/static/app/views/dashboards/widgetCard/index.tsx index 458aa36ab8cfec..412fdbe62bdf9e 100644 --- a/static/app/views/dashboards/widgetCard/index.tsx +++ b/static/app/views/dashboards/widgetCard/index.tsx @@ -575,7 +575,9 @@ function useConflictingFilterWarning({ widget: TWidget; }) { const conflictingFilterKeys = useMemo(() => { - if (!dashboardFilters) return []; + if (!dashboardFilters) { + return []; + } const widgetFilterKeys = widget.queries.flatMap(query => { const parseResult = parseQueryBuilderValue(query.conditions, getFieldDefinition); diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx index 1bfcf58e048709..ac904173b0ab8f 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.stories.tsx @@ -126,8 +126,12 @@ ${JSON.stringify(aliases)} const bField = b?.[newSort.field] ?? 0; const value = newSort.kind === 'asc' ? 1 : -1; - if (aField < bField) return -value; - if (aField > bField) return value; + if (aField < bField) { + return -value; + } + if (aField > bField) { + return value; + } return 0; }) .map(result => result[1]); @@ -362,7 +366,9 @@ function onChangeSort(newSort: Sort) { onTriggerCellAction={(actions: Actions, value: string | number) => { switch (actions) { case Actions.ADD: - if (!filter.includes(value)) setFilter([...filter, value]); + if (!filter.includes(value)) { + setFilter([...filter, value]); + } break; case Actions.EXCLUDE: setFilter(filter.filter(_value => _value !== value)); diff --git a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx index ec5bbed2315f40..d77b67f4bcef59 100644 --- a/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/tableWidget/tableWidgetVisualization.tsx @@ -261,7 +261,9 @@ export function TableWidgetVisualization(props: TableWidgetVisualizationProps) { canSort={column.sortable ?? false} title={{name}} onClick={e => { - if (!onChangeSort) return; + if (!onChangeSort) { + return; + } e.preventDefault(); const nextDirection = direction === 'desc' ? 'asc' : 'desc'; onChangeSort({ @@ -384,7 +386,9 @@ TableWidgetVisualization.LoadingPlaceholder = function ({ resizable={false} grid={{ renderHeadCell: (_tableColumn, columnIndex) => { - if (!columns) return null; + if (!columns) { + return null; + } const column = columns[columnIndex]!; const isStarredColumn = column.key === SpanFields.IS_STARRED_TRANSACTION; const hasAlias = !!aliases?.[column.key]; diff --git a/static/app/views/detectors/hooks/useFilteredAnomalyThresholdSeries.tsx b/static/app/views/detectors/hooks/useFilteredAnomalyThresholdSeries.tsx index e27abe5763f7b4..9db4211ca5beef 100644 --- a/static/app/views/detectors/hooks/useFilteredAnomalyThresholdSeries.tsx +++ b/static/app/views/detectors/hooks/useFilteredAnomalyThresholdSeries.tsx @@ -62,9 +62,15 @@ export function useFilteredAnomalyThresholdSeries({ const [upperThreshold, lowerThreshold, seerValue] = anomalyThresholdSeries; const filtered = []; - if (thresholdType !== AlertRuleThresholdType.BELOW) filtered.push(upperThreshold); - if (thresholdType !== AlertRuleThresholdType.ABOVE) filtered.push(lowerThreshold); - if (seerValue) filtered.push(seerValue); + if (thresholdType !== AlertRuleThresholdType.BELOW) { + filtered.push(upperThreshold); + } + if (thresholdType !== AlertRuleThresholdType.ABOVE) { + filtered.push(lowerThreshold); + } + if (seerValue) { + filtered.push(seerValue); + } return filtered.filter((s): s is NonNullable => !!s); }, [anomalyThresholdSeries, detector, isAnomalyDetection, directThresholdType]); diff --git a/static/app/views/discover/eventDetails.tsx b/static/app/views/discover/eventDetails.tsx index c51e684696025f..e83774eb21bcc6 100644 --- a/static/app/views/discover/eventDetails.tsx +++ b/static/app/views/discover/eventDetails.tsx @@ -48,7 +48,9 @@ export default function EventDetails() { ); useEffect(() => { - if (!event) return; + if (!event) { + return; + } if (event.groupID && event.eventID) { navigate({ diff --git a/static/app/views/discover/results/issueListSeerComboBox.tsx b/static/app/views/discover/results/issueListSeerComboBox.tsx index 8fc68a88f1ffbc..60812a4621b653 100644 --- a/static/app/views/discover/results/issueListSeerComboBox.tsx +++ b/static/app/views/discover/results/issueListSeerComboBox.tsx @@ -180,7 +180,9 @@ export function IssueListSeerComboBox({onSearch}: IssueListSeerComboBoxProps) { const applySeerSearchQuery = useCallback( (result: AskSeerSearchQuery, runId?: number) => { - if (!result) return; + if (!result) { + return; + } const { query: queryToUse, sort, diff --git a/static/app/views/discover/table/cellAction.tsx b/static/app/views/discover/table/cellAction.tsx index 5dbc0d89dbfa3f..8992d4355966b8 100644 --- a/static/app/views/discover/table/cellAction.tsx +++ b/static/app/views/discover/table/cellAction.tsx @@ -178,7 +178,9 @@ export function excludeFromFilter( */ export function copyToClipboard(value: string | number | string[]) { function stringifyValue(val: string | number | string[]): string { - if (!val) return ''; + if (!val) { + return ''; + } if (typeof val !== 'object') { return val.toString(); } @@ -278,7 +280,9 @@ function makeCellActions({ addMenuItem(Actions.OPEN_ROW_IN_EXPLORE, t('View span samples')); } - if (value) addMenuItem(Actions.COPY_TO_CLIPBOARD, t('Copy to clipboard')); + if (value) { + addMenuItem(Actions.COPY_TO_CLIPBOARD, t('Copy to clipboard')); + } if (allowActions) { addMenuItem(Actions.COPY_LINK, t('Copy link')); diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx index ce17b1c2409c92..47bcde19001bee 100644 --- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionChart.tsx @@ -69,7 +69,9 @@ export function Chart({ useLayoutEffect(() => { const chartInstance = chartRef.current?.getEchartsInstance(); - if (!chartInstance) return; + if (!chartInstance) { + return; + } const width = chartInstance.getDom().offsetWidth; diff --git a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionContent.tsx b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionContent.tsx index 02f5ee0790589f..ec4f1fa5bd8d4a 100644 --- a/static/app/views/explore/components/attributeBreakdowns/attributeDistributionContent.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/attributeDistributionContent.tsx @@ -130,7 +130,9 @@ export function AttributeDistribution() { const parsedLinks = parseLinkHeader(attributeBreakdownsPageLinks); const uniqueAttributeDistribution = useMemo(() => { - if (!attributeBreakdownsData) return []; + if (!attributeBreakdownsData) { + return []; + } const seen = new Set(); const filtered = Object.entries( diff --git a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx index 5973e29d2ea430..d00136d18b823a 100644 --- a/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/cohortComparisonChart.tsx @@ -103,7 +103,9 @@ export function Chart({ useLayoutEffect(() => { const chartInstance = chartRef.current?.getEchartsInstance(); - if (!chartInstance) return; + if (!chartInstance) { + return; + } const width = chartInstance.getDom().offsetWidth; diff --git a/static/app/views/explore/components/attributeBreakdowns/floatingTrigger.tsx b/static/app/views/explore/components/attributeBreakdowns/floatingTrigger.tsx index 08d27c55ada873..a06089df37cdb2 100644 --- a/static/app/views/explore/components/attributeBreakdowns/floatingTrigger.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/floatingTrigger.tsx @@ -22,7 +22,9 @@ export function FloatingTrigger({chartIndex, params}: Props) { const {selectionState, setSelectionState, clearSelection} = params; const handleZoomIn = useCallback(() => { - if (!selectionState) return; + if (!selectionState) { + return; + } trackAnalytics('explore.floating_trigger.zoom_in', {organization}); @@ -55,7 +57,9 @@ export function FloatingTrigger({chartIndex, params}: Props) { }, [clearSelection, selectionState, location, navigate, organization]); const handleFindAttributeBreakdowns = useCallback(() => { - if (!selectionState) return; + if (!selectionState) { + return; + } trackAnalytics('explore.floating_trigger.compare_attribute_breakdowns', { organization, diff --git a/static/app/views/explore/components/attributeBreakdowns/utils.tsx b/static/app/views/explore/components/attributeBreakdowns/utils.tsx index 65088727cd1f53..997e944c9ff1f5 100644 --- a/static/app/views/explore/components/attributeBreakdowns/utils.tsx +++ b/static/app/views/explore/components/attributeBreakdowns/utils.tsx @@ -13,7 +13,9 @@ export function calculateAttributePopulationPercentage( }>, cohortTotal: number ): number { - if (cohortTotal === 0) return 0; + if (cohortTotal === 0) { + return 0; + } const populatedCount = values.reduce((acc, curr) => acc + curr.value, 0); return (populatedCount / cohortTotal) * 100; @@ -53,7 +55,9 @@ export function formatChartXAxisLabel( const maxChars = Math.floor(pixelsPerLabel / pixelsPerCharacter); // If value fits, return it as-is - if (value.length <= maxChars) return value; + if (value.length <= maxChars) { + return value; + } // Otherwise, truncate and append '…' const truncatedLength = Math.max(1, maxChars - 2); // leaving space for (ellipsis) @@ -86,7 +90,9 @@ export function tooltipActionsHtmlRenderer( attributeName: string, theme: Theme ): string { - if (!value) return ''; + if (!value) { + return ''; + } const escapedAttributeName = escape(attributeName); const escapedValue = escape(value); diff --git a/static/app/views/explore/conversations/components/toolTags.tsx b/static/app/views/explore/conversations/components/toolTags.tsx index 4b572e6359bcaa..30dc0e725a18bb 100644 --- a/static/app/views/explore/conversations/components/toolTags.tsx +++ b/static/app/views/explore/conversations/components/toolTags.tsx @@ -1,9 +1,8 @@ import {useEffect, useRef, useState} from 'react'; -import styled from '@emotion/styled'; import {Tag} from '@sentry/scraps/badge'; import {Button} from '@sentry/scraps/button'; -import {Flex} from '@sentry/scraps/layout'; +import {Container, Flex} from '@sentry/scraps/layout'; import {t} from 'sentry/locale'; @@ -59,7 +58,16 @@ export function ToolTags({toolNames}: ToolTagsProps) { }, [toolNames, expanded, hiddenCount]); return ( - + {toolNames.map((toolName, index) => ( ))} {hiddenCount > 0 && !expanded && ( - - setExpanded(prev => !prev)} - > + + + )} {expanded && ( - setExpanded(prev => !prev)}> - {t('Show less')} - + + + )} - +
); } - -const ToolTagsContainer = styled(Flex)<{expanded: boolean}>` - align-items: center; - flex-direction: row; - gap: ${p => p.theme.space.sm}; - flex-wrap: wrap; - overflow: hidden; - position: relative; - max-height: ${p => (p.expanded ? '500px' : `${TWO_ROW_HEIGHT}px`)}; - transition: max-height 0.2s ease-in-out; -`; - -const ToggleButtonWrapper = styled('div')` - position: absolute; - right: 0; - bottom: 4px; - display: flex; - align-items: center; - height: 22px; - background: ${p => p.theme.tokens.background.primary}; - padding-left: ${p => p.theme.space.sm}; -`; - -const ToggleButton = styled(Button)` - flex-shrink: 0; -`; diff --git a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx index 011b06e0ed7ec0..7c2b462142a9fb 100644 --- a/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx +++ b/static/app/views/explore/hooks/useAttributeBreakdownsTooltip.tsx @@ -91,7 +91,9 @@ export function useAttributeBreakdownsTooltip({ // - Sets up the listener for clicks anywhere on the chart to toggle the frozen tooltip state. useEffect(() => { const chartInstance = chartRef.current?.getEchartsInstance(); - if (!chartInstance) return; + if (!chartInstance) { + return; + } if (frozenPosition) { chartInstance.dispatchAction({ @@ -146,7 +148,9 @@ export function useAttributeBreakdownsTooltip({ // This effect sets up the on click listeners for the tooltip actions. // e-charts tooltips do not support actions out of the box, so we need to handle them manually. useEffect(() => { - if (!frozenPosition) return; + if (!frozenPosition) { + return; + } const handleClickActions = (event: MouseEvent) => { event.preventDefault(); diff --git a/static/app/views/explore/hooks/useFilteredRankedAttributes.ts b/static/app/views/explore/hooks/useFilteredRankedAttributes.ts index 64d59267f7acb2..1c08c08700d7d7 100644 --- a/static/app/views/explore/hooks/useFilteredRankedAttributes.ts +++ b/static/app/views/explore/hooks/useFilteredRankedAttributes.ts @@ -31,9 +31,15 @@ export function useFilteredRankedAttributes({ return [...filtered].sort((a, b) => { const aOrder = a.order.rrr; const bOrder = b.order.rrr; - if (aOrder === null && bOrder === null) return 0; - if (aOrder === null) return 1; - if (bOrder === null) return -1; + if (aOrder === null && bOrder === null) { + return 0; + } + if (aOrder === null) { + return 1; + } + if (bOrder === null) { + return -1; + } return aOrder - bOrder; }); }, [rankedAttributes, searchQuery]); diff --git a/static/app/views/explore/hooks/useGroupByFields.tsx b/static/app/views/explore/hooks/useGroupByFields.tsx index 38e2a1c6956abb..b6abdc891ec502 100644 --- a/static/app/views/explore/hooks/useGroupByFields.tsx +++ b/static/app/views/explore/hooks/useGroupByFields.tsx @@ -43,7 +43,9 @@ export function useGroupByFields({ extraColumnKind: FieldKind.TAG, }) .filter(option => { - if (seen.has(option.value)) return false; + if (seen.has(option.value)) { + return false; + } seen.add(option.value); return true; }) diff --git a/static/app/views/explore/hooks/useTraceExploreAiQuerySetup.tsx b/static/app/views/explore/hooks/useTraceExploreAiQuerySetup.tsx index 1d602c83875a65..988aa2e860b9ca 100644 --- a/static/app/views/explore/hooks/useTraceExploreAiQuerySetup.tsx +++ b/static/app/views/explore/hooks/useTraceExploreAiQuerySetup.tsx @@ -24,7 +24,9 @@ export function useTraceExploreAiQuerySetup({ const memberProjects = projects.filter(p => p.isMember); useEffect(() => { - if (!enableAISearch) return; + if (!enableAISearch) { + return; + } const selectedProjects = pageFilters.selection.projects && @@ -40,7 +42,9 @@ export function useTraceExploreAiQuerySetup({ const projectsChanged = prevSet.size !== currentSet.size || ![...prevSet].every(id => currentSet.has(id)); - if (!projectsChanged) return; + if (!projectsChanged) { + return; + } } previousProjects.current = selectedProjects.map(Number); diff --git a/static/app/views/explore/hooks/useVisualizeFields.tsx b/static/app/views/explore/hooks/useVisualizeFields.tsx index 180bc918b8ecbb..54a197af3e4dbc 100644 --- a/static/app/views/explore/hooks/useVisualizeFields.tsx +++ b/static/app/views/explore/hooks/useVisualizeFields.tsx @@ -64,7 +64,9 @@ export function useVisualizeFields({ .filter(option => { // Filtering by value here, so it's based off of explicit tags i.e. `key` // or `tags[, ] - if (seen.has(option.value)) return false; + if (seen.has(option.value)) { + return false; + } seen.add(option.value); return true; }) diff --git a/static/app/views/explore/logs/exports/generateLogExportRowCountOptions.ts b/static/app/views/explore/logs/exports/generateLogExportRowCountOptions.ts index 97fb5fa4a64893..10c56ed6654e0e 100644 --- a/static/app/views/explore/logs/exports/generateLogExportRowCountOptions.ts +++ b/static/app/views/explore/logs/exports/generateLogExportRowCountOptions.ts @@ -16,8 +16,6 @@ const ROW_COUNT_VALUES = [ ROW_COUNT_VALUE_DEFAULT, ROW_COUNT_VALUE_SYNC_LIMIT, 10_000, - 50_000, - 100_000, ]; export function generateLogExportRowCountOptions(estimatedRowCount: number) { diff --git a/static/app/views/explore/logs/logsTab.tsx b/static/app/views/explore/logs/logsTab.tsx index fb02b65a6898f2..71e5c5b7b16d45 100644 --- a/static/app/views/explore/logs/logsTab.tsx +++ b/static/app/views/explore/logs/logsTab.tsx @@ -539,7 +539,6 @@ function LogsTabContentInner({datePageFilterProps}: LogsTabProps) { {tableTab === 'logs' ? ( { - if (!result) return; + if (!result) { + return; + } const { query: queryToUse, groupBys, diff --git a/static/app/views/explore/logs/logsToolbar.tsx b/static/app/views/explore/logs/logsToolbar.tsx index 7475d6523cf7f0..ca2b048c8ac33b 100644 --- a/static/app/views/explore/logs/logsToolbar.tsx +++ b/static/app/views/explore/logs/logsToolbar.tsx @@ -274,7 +274,9 @@ function VisualizeDropdown({ .filter(option => { // Filtering by value here, so it's based off of explicit tags i.e. `key` // or `tags[, ] - if (seen.has(option.value)) return false; + if (seen.has(option.value)) { + return false; + } seen.add(option.value); return true; }) diff --git a/static/app/views/explore/logs/styles.tsx b/static/app/views/explore/logs/styles.tsx index 8f695a5878de7c..2ada936b13af93 100644 --- a/static/app/views/explore/logs/styles.tsx +++ b/static/app/views/explore/logs/styles.tsx @@ -163,7 +163,6 @@ export const LogTable = styled(ContentsTable)<{minWidth: string}>` export const LogTableBody = styled(TableBody)<{ disableBodyPadding?: boolean; - expanded?: boolean; showHeader?: boolean; }>` ${p => @@ -181,15 +180,6 @@ export const LogTableBody = styled(TableBody)<{ /* If a parent renderer bails out, the element might default to 0px: which causes Tanstack Virtual to stay at 0. */ min-height: 1px; - - ${p => - p.expanded === undefined - ? '' - : ` - overflow-y: auto; - flex: 1; - min-height: 0; - `} `; export const LogDetailTableBodyCell = styled(TableBodyCell)` diff --git a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx index 322328bd2590a1..1ee096a891ee73 100644 --- a/static/app/views/explore/logs/tables/logsInfiniteTable.tsx +++ b/static/app/views/explore/logs/tables/logsInfiniteTable.tsx @@ -12,7 +12,7 @@ import { import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import type {Virtualizer} from '@tanstack/react-virtual'; -import {useVirtualizer, useWindowVirtualizer} from '@tanstack/react-virtual'; +import {useVirtualizer} from '@tanstack/react-virtual'; import {Button} from '@sentry/scraps/button'; import {Flex, Stack} from '@sentry/scraps/layout'; @@ -104,7 +104,6 @@ type LogsTableProps = { showVerticalScrollbar?: boolean; }; emptyRenderer?: () => React.ReactNode; - expanded?: boolean; localOnlyItemFilters?: { filterText: string; filteredItems: OurLogsResponseItem[]; @@ -121,7 +120,6 @@ const LOGS_GRID_SCROLL_PIXEL_REVERSE_THRESHOLD = LOGS_GRID_BODY_ROW_HEIGHT * 2; export function LogsInfiniteTable({ embedded = false, - expanded, localOnlyItemFilters, emptyRenderer, analyticsPageSource, @@ -276,29 +274,17 @@ export function LogsInfiniteTable({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchString, localOnlyItemFilters?.filterText]); - const isContainedVirtualizer = expanded !== undefined; - - const windowVirtualizer = useWindowVirtualizer({ - count: isContainedVirtualizer ? 0 : (data?.length ?? 0), - estimateSize, - overscan: 50, - getItemKey: (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, - scrollMargin: tableBodyRef.current?.offsetTop ?? 0, - }); - - const containerVirtualizer = useVirtualizer({ - count: isContainedVirtualizer ? (data?.length ?? 0) : 0, + const virtualizer = useVirtualizer({ + count: data?.length ?? 0, estimateSize, - overscan: expanded ? 50 : 25, + overscan: 35, getScrollElement: () => tableBodyRef?.current, getItemKey: (index: number) => data?.[index]?.[OurLogKnownFieldKey.ID] ?? index, }); - const virtualizer = isContainedVirtualizer ? containerVirtualizer : windowVirtualizer; - useLayoutEffect(() => { virtualizer.measure(); - }, [expanded, virtualizer]); + }, [virtualizer]); const virtualItems = virtualizer.getVirtualItems(); @@ -544,7 +530,6 @@ export function LogsInfiniteTable({ showHeader={!embedded} ref={tableBodyRef} disableBodyPadding={embeddedStyling?.disableBodyPadding} - expanded={expanded} > {paddingTop > 0 && ( @@ -625,7 +610,7 @@ export function LogsInfiniteTable({ diff --git a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx index 3113fb68b4b1ac..75be6f798313be 100644 --- a/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx +++ b/static/app/views/explore/metrics/hooks/useSortableMetricQueries.tsx @@ -53,7 +53,9 @@ export function useSortableMetricQueries({ const oldIndex = sortableItems.find(({id}) => id === active.id)?.index; const newIndex = sortableItems.find(({id}) => id === over?.id)?.index; - if (oldIndex === undefined || newIndex === undefined) return; + if (oldIndex === undefined || newIndex === undefined) { + return; + } reorderMetricQueries( arrayMove([...metricQueries], oldIndex, newIndex), diff --git a/static/app/views/explore/metrics/metricsTabSeerComboBox.tsx b/static/app/views/explore/metrics/metricsTabSeerComboBox.tsx index 5915b0fcabb030..442acdded38c6d 100644 --- a/static/app/views/explore/metrics/metricsTabSeerComboBox.tsx +++ b/static/app/views/explore/metrics/metricsTabSeerComboBox.tsx @@ -172,7 +172,9 @@ export function MetricsTabSeerComboBox({traceMetric}: MetricsTabSeerComboBoxProp const applySeerSearchQuery = useCallback( (result: AskSeerSearchQuery, runId?: number) => { - if (!result) return; + if (!result) { + return; + } const { query: queryToUse, groupBys, diff --git a/static/app/views/explore/replays/detail/ourlogs/index.tsx b/static/app/views/explore/replays/detail/ourlogs/index.tsx index 919b7ec6f37fdd..4c465a43a26af5 100644 --- a/static/app/views/explore/replays/detail/ourlogs/index.tsx +++ b/static/app/views/explore/replays/detail/ourlogs/index.tsx @@ -132,7 +132,6 @@ function OurLogsContent({replayId, startTimestampMs}: OurLogsContentProps) { allowPagination embedded embeddedOptions={embeddedOptions} - expanded localOnlyItemFilters={{ filteredItems: filteredLogItems, filterText: filterProps.searchTerm, diff --git a/static/app/views/explore/spans/charts/index.tsx b/static/app/views/explore/spans/charts/index.tsx index 5e0897d7b40210..dbb8968e0e03f4 100644 --- a/static/app/views/explore/spans/charts/index.tsx +++ b/static/app/views/explore/spans/charts/index.tsx @@ -281,7 +281,9 @@ function Chart({ } }, onInsideSelectionClick: params => { - if (!params.selectionState) return; + if (!params.selectionState) { + return; + } params.setSelectionState({ ...params.selectionState, @@ -289,7 +291,9 @@ function Chart({ }); }, onOutsideSelectionClick: params => { - if (!params.selectionState?.isActionMenuVisible) return; + if (!params.selectionState?.isActionMenuVisible) { + return; + } params.setSelectionState({ ...params.selectionState, diff --git a/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx b/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx index e05dc73cac1c17..845233eaff98ca 100644 --- a/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx +++ b/static/app/views/explore/spans/crossEvents/crossEventMetricsSearchBar.tsx @@ -64,10 +64,14 @@ export const SpansTabCrossEventMetricsSearchBar = memo( const onMetricChange = useCallback( (newMetric: TraceMetric) => { - if (!crossEvents) return; + if (!crossEvents) { + return; + } setCrossEvents?.( crossEvents.map((c, i) => { - if (i === index) return {type: 'metrics', query, metric: newMetric}; + if (i === index) { + return {type: 'metrics', query, metric: newMetric}; + } return c; }) ); @@ -81,10 +85,14 @@ export const SpansTabCrossEventMetricsSearchBar = memo( () => ({ initialQuery: query, onSearch: (newQuery: string) => { - if (!crossEvents) return; + if (!crossEvents) { + return; + } setCrossEvents?.( crossEvents.map((c, i) => { - if (i === index) return {type: 'metrics', query: newQuery, metric}; + if (i === index) { + return {type: 'metrics', query: newQuery, metric}; + } return c; }) ); diff --git a/static/app/views/explore/spans/crossEvents/crossEventSearchBar.tsx b/static/app/views/explore/spans/crossEvents/crossEventSearchBar.tsx index dc544af1e1cd07..7951ff6a5400d1 100644 --- a/static/app/views/explore/spans/crossEvents/crossEventSearchBar.tsx +++ b/static/app/views/explore/spans/crossEvents/crossEventSearchBar.tsx @@ -50,11 +50,15 @@ export const SpansTabCrossEventSearchBar = memo( () => ({ initialQuery: query, onSearch: (newQuery: string) => { - if (!crossEvents) return; + if (!crossEvents) { + return; + } setCrossEvents?.( crossEvents.map((c, i) => { - if (i === index) return {query: newQuery, type}; + if (i === index) { + return {query: newQuery, type}; + } return c; }) ); diff --git a/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx b/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx index e7194c61586c48..a048d923909f92 100644 --- a/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx +++ b/static/app/views/explore/spans/crossEvents/crossEventSearchBars.tsx @@ -102,7 +102,9 @@ export function SpansTabCrossEventSearchBars({ )} options={getCrossEventDatasetOptions(crossEventDatasetAvailability)} onChange={({value: newValue}) => { - if (!isCrossEventType(newValue)) return; + if (!isCrossEventType(newValue)) { + return; + } trackAnalytics('trace.explorer.cross_event_changed', { organization, @@ -112,7 +114,9 @@ export function SpansTabCrossEventSearchBars({ setCrossEvents( crossEvents.map((c, i) => { - if (i === index) return makeCrossEvent(newValue); + if (i === index) { + return makeCrossEvent(newValue); + } return c; }) ); diff --git a/static/app/views/explore/spans/spansTabSeerComboBox.tsx b/static/app/views/explore/spans/spansTabSeerComboBox.tsx index cf47b47ba701bb..5b0ba14570dbea 100644 --- a/static/app/views/explore/spans/spansTabSeerComboBox.tsx +++ b/static/app/views/explore/spans/spansTabSeerComboBox.tsx @@ -191,7 +191,9 @@ export function SpansTabSeerComboBox() { const applySeerSearchQuery = useCallback( (result: AskSeerSearchQuery, runId?: number) => { - if (!result) return; + if (!result) { + return; + } const { query: queryToUse, visualizations, diff --git a/static/app/views/explore/tables/aggregateColumnEditorModal.tsx b/static/app/views/explore/tables/aggregateColumnEditorModal.tsx index 53b39c7f89ee2b..fde9b71274c769 100644 --- a/static/app/views/explore/tables/aggregateColumnEditorModal.tsx +++ b/static/app/views/explore/tables/aggregateColumnEditorModal.tsx @@ -340,10 +340,14 @@ function GroupBySelector({ // synchronously while the user types. Merge in any server-only matches once // the debounced search returns. const options = useMemo(() => { - if (!hasSearch || searchedOptions.length === 0) return baseOptions; + if (!hasSearch || searchedOptions.length === 0) { + return baseOptions; + } const baseValues = new Set(baseOptions.map(o => o.value)); const additions = searchedOptions.filter(o => !baseValues.has(o.value)); - if (additions.length === 0) return baseOptions; + if (additions.length === 0) { + return baseOptions; + } return [...baseOptions, ...additions]; }, [hasSearch, baseOptions, searchedOptions]); @@ -583,10 +587,14 @@ function AttributeArgumentSelect({ // synchronously while the user types. Merge in any server-only matches once // the debounced search returns. const options = useMemo(() => { - if (!hasSearch || searchedOptions.length === 0) return baseOptions; + if (!hasSearch || searchedOptions.length === 0) { + return baseOptions; + } const baseValues = new Set(baseOptions.map(o => o.value)); const additions = searchedOptions.filter(o => !baseValues.has(o.value)); - if (additions.length === 0) return baseOptions; + if (additions.length === 0) { + return baseOptions; + } return [...baseOptions, ...additions]; }, [hasSearch, baseOptions, searchedOptions]); @@ -644,12 +652,19 @@ type AttributeKind = 'string' | 'number' | 'boolean'; function getSupportedAttributeKinds( functionName: string | undefined ): readonly AttributeKind[] { - if (!functionName) return ['number']; + if (!functionName) { + return ['number']; + } // COUNT renders a fixed SPAN_DURATION option and ignores tag collections. - if (functionName === AggregationKey.COUNT) return []; - if (NO_ARGUMENT_SPAN_AGGREGATES.includes(functionName as AggregationKey)) return []; - if (functionName === AggregationKey.COUNT_UNIQUE) + if (functionName === AggregationKey.COUNT) { + return []; + } + if (NO_ARGUMENT_SPAN_AGGREGATES.includes(functionName as AggregationKey)) { + return []; + } + if (functionName === AggregationKey.COUNT_UNIQUE) { return ['string', 'number', 'boolean']; + } return ['number']; } diff --git a/static/app/views/explore/tables/columnEditorModal.tsx b/static/app/views/explore/tables/columnEditorModal.tsx index 0b27d64626ef24..19b5ab51badaeb 100644 --- a/static/app/views/explore/tables/columnEditorModal.tsx +++ b/static/app/views/explore/tables/columnEditorModal.tsx @@ -230,7 +230,9 @@ function ColumnEditorRow({ // returns, merge in any attributes baseOptions doesn't already cover so the // user can still pick keys that weren't in the initial fetch. const options = useMemo(() => { - if (!hasSearch) return baseOptions; + if (!hasSearch) { + return baseOptions; + } const searched = buildColumnOptions({ columns: [], stringTags: searchedStringTags, @@ -239,10 +241,14 @@ function ColumnEditorRow({ hiddenKeys, traceItemType, }); - if (searched.length === 0) return baseOptions; + if (searched.length === 0) { + return baseOptions; + } const baseValues = new Set(baseOptions.map(o => o.value)); const additions = searched.filter(o => !baseValues.has(o.value)); - if (additions.length === 0) return baseOptions; + if (additions.length === 0) { + return baseOptions; + } return [...baseOptions, ...additions]; }, [ hasSearch, @@ -365,8 +371,12 @@ function buildColumnOptions({ }) .filter(option => { const hidden = hiddenKeys ?? []; - if (hidden.includes(option.value)) return false; - if (typeof option.label === 'string' && hidden.includes(option.label)) return false; + if (hidden.includes(option.value)) { + return false; + } + if (typeof option.label === 'string' && hidden.includes(option.label)) { + return false; + } return true; }) .toSorted((a, b) => sortKnownAttributes(a, b, traceItemType)); diff --git a/static/app/views/explore/utils/sortSearchedAttributes.tsx b/static/app/views/explore/utils/sortSearchedAttributes.tsx index d3d758954fba03..52826d71e5c635 100644 --- a/static/app/views/explore/utils/sortSearchedAttributes.tsx +++ b/static/app/views/explore/utils/sortSearchedAttributes.tsx @@ -93,7 +93,9 @@ export function sortKnownAttributes>( getFieldDefinition(a.value, getFieldDefinitionType(traceItemType)) !== null; const bKnown = getFieldDefinition(b.value, getFieldDefinitionType(traceItemType)) !== null; - if (aKnown !== bKnown) return aKnown ? -1 : 1; + if (aKnown !== bKnown) { + return aKnown ? -1 : 1; + } const aLabel = typeof a.label === 'string' ? a.label : (a.textValue ?? ''); const bLabel = typeof b.label === 'string' ? b.label : (b.textValue ?? ''); return aLabel.localeCompare(bLabel); diff --git a/static/app/views/insights/pages/agents/components/aiSpanList.tsx b/static/app/views/insights/pages/agents/components/aiSpanList.tsx index 59f364795e6120..1343f99208071d 100644 --- a/static/app/views/insights/pages/agents/components/aiSpanList.tsx +++ b/static/app/views/insights/pages/agents/components/aiSpanList.tsx @@ -48,14 +48,17 @@ function getNodeTimeBounds(node: AITraceSpanNode | AITraceSpanNode[]) { startTime = totalStartAndEndTime.startTime; endTime = totalStartAndEndTime.endTime; } else { - if (!node.startTimestamp || !node.endTimestamp) + if (!node.startTimestamp || !node.endTimestamp) { return {startTime: 0, endTime: 0, duration: 0}; + } startTime = node.startTimestamp; endTime = node.endTimestamp; } - if (endTime === 0) return {startTime: 0, endTime: 0, duration: 0}; + if (endTime === 0) { + return {startTime: 0, endTime: 0, duration: 0}; + } return { startTime, @@ -379,7 +382,9 @@ function calculateRelativeTiming( traceBounds: TraceBounds, compressedStartByNodeId?: Map ): {leftPercent: number; widthPercent: number} { - if (!node.value) return {leftPercent: 0, widthPercent: 0}; + if (!node.value) { + return {leftPercent: 0, widthPercent: 0}; + } let startTime: number, endTime: number; @@ -390,7 +395,9 @@ function calculateRelativeTiming( return {leftPercent: 0, widthPercent: 0}; } - if (traceBounds.duration === 0) return {leftPercent: 0, widthPercent: 0}; + if (traceBounds.duration === 0) { + return {leftPercent: 0, widthPercent: 0}; + } // Look up the pre-computed compressed start time for this node. // The span duration stays the same - only gaps between spans are compressed. diff --git a/static/app/views/insights/pages/agents/components/tracesTable.tsx b/static/app/views/insights/pages/agents/components/tracesTable.tsx index 6b7d43bba484b9..0db6a66986db9b 100644 --- a/static/app/views/insights/pages/agents/components/tracesTable.tsx +++ b/static/app/views/insights/pages/agents/components/tracesTable.tsx @@ -449,12 +449,16 @@ function AgentTags({agents}: {agents: string[]}) { const handleShowAll = () => { setShowAll(!showAll); - if (!containerRef.current) return; + if (!containerRef.current) { + return; + } // While the all tags are visible, observe the container to see if it displays more than one line (22px) // so we can reset the show all state accordingly const observer = new ResizeObserver(entries => { const containerElement = entries[0]?.target; - if (!containerElement || containerElement.clientHeight > 22) return; + if (!containerElement || containerElement.clientHeight > 22) { + return; + } setShowToggle(false); setShowAll(false); resizeObserverRef.current?.disconnect(); diff --git a/static/app/views/issueDetails/streamline/sidebar/participantList.tsx b/static/app/views/issueDetails/streamline/sidebar/participantList.tsx index 341d3d4b1a137c..bb5680f821d6fc 100644 --- a/static/app/views/issueDetails/streamline/sidebar/participantList.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/participantList.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {AvatarList, TeamAvatar, UserAvatar} from '@sentry/scraps/avatar'; import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; import {DateTime} from 'sentry/components/dateTime'; import {Overlay, PositionWrapper} from 'sentry/components/overlay'; @@ -30,7 +31,7 @@ export function ParticipantList({users, teams, hideTimestamp}: DropdownListProps const showHeaders = users.length > 0 && teams && teams.length > 0; return ( -
+
+ ); } diff --git a/static/app/views/issueDetails/streamline/sidebar/peopleSection.tsx b/static/app/views/issueDetails/streamline/sidebar/peopleSection.tsx index bb18e446037323..de0d38a39f81c5 100644 --- a/static/app/views/issueDetails/streamline/sidebar/peopleSection.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/peopleSection.tsx @@ -26,22 +26,24 @@ export function PeopleSection({ title={{t('People')}} sectionKey={SectionKey.PEOPLE} > - {hasParticipants && ( - - - {t('participating')} - - )} - {hasViewers && ( - - - {t('viewed')} - - )} + + {hasParticipants && ( + + + {t('participating')} + + )} + {hasViewers && ( + + + {t('viewed')} + + )} + ); } diff --git a/static/app/views/issueList/issueListCommandPaletteActions.tsx b/static/app/views/issueList/issueListCommandPaletteActions.tsx index 4005bc879d7421..63108087078693 100644 --- a/static/app/views/issueList/issueListCommandPaletteActions.tsx +++ b/static/app/views/issueList/issueListCommandPaletteActions.tsx @@ -65,8 +65,12 @@ interface IssueListCommandPaletteActionsProps { * child items under header groups). */ function getTagValueStrings(tag: Tag): string[] { - if (!tag.values?.length) return []; - if (typeof tag.values[0] === 'string') return tag.values as string[]; + if (!tag.values?.length) { + return []; + } + if (typeof tag.values[0] === 'string') { + return tag.values as string[]; + } const groups = tag.values as SearchGroup[]; return groups.flatMap(group => { diff --git a/static/app/views/navigation/mobileNavigation.tsx b/static/app/views/navigation/mobileNavigation.tsx index c10edcb3de0524..a872174c4caccc 100644 --- a/static/app/views/navigation/mobileNavigation.tsx +++ b/static/app/views/navigation/mobileNavigation.tsx @@ -228,7 +228,9 @@ export function MobilePageFrameNavigation() { }, [isOpen, view]); const handleClickOutside = useCallback((e: MouseEvent | TouchEvent) => { - if (toggleButtonRef.current?.contains(e.target as Node)) return; + if (toggleButtonRef.current?.contains(e.target as Node)) { + return; + } setIsOpen(false); }, []); @@ -244,7 +246,9 @@ export function MobilePageFrameNavigation() {