diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d90db83d4327..23ca21bb1520 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -61,7 +61,7 @@ repos: types: [python] - id: sort-mypy-weaklist name: sort mypy weaklist - entry: python3 -m tools.mypy_helpers.sort_stronger_modules + entry: python3 -m tools.mypy_helpers.sort_weaklist files: ^pyproject\.toml$ language: python - id: check-mypy-weaklist diff --git a/api-docs/openapi.json b/api-docs/openapi.json index 1a85a26322c7..241c4afef517 100644 --- a/api-docs/openapi.json +++ b/api-docs/openapi.json @@ -118,9 +118,6 @@ "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/hooks/{hook_id}/": { "$ref": "paths/projects/service-hook-details.json" }, - "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/events/{event_id}/": { - "$ref": "paths/events/project-event-details.json" - }, "/api/0/projects/{organization_id_or_slug}/{project_id_or_slug}/issues/": { "$ref": "paths/events/project-issues.json" }, diff --git a/api-docs/paths/events/project-event-details.json b/api-docs/paths/events/project-event-details.json deleted file mode 100644 index b6925074239e..000000000000 --- a/api-docs/paths/events/project-event-details.json +++ /dev/null @@ -1,556 +0,0 @@ -{ - "get": { - "tags": ["Events"], - "description": "Return details on an individual event.", - "operationId": "Retrieve an Event for a Project", - "parameters": [ - { - "name": "organization_id_or_slug", - "in": "path", - "description": "The ID or slug of the organization the event belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "project_id_or_slug", - "in": "path", - "description": "The ID or slug of the project the event belongs to.", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "event_id", - "in": "path", - "description": "The ID of the event to retrieve. It is the hexadecimal ID as reported by the client.", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "../../components/schemas/event.json#/EventDetailed" - }, - "example": { - "eventID": "9999aaaaca8b46d797c23c6077c6ff01", - "dist": null, - "userReport": null, - "previousEventID": null, - "message": "", - "title": "This is an example Python exception", - "id": "9999aaafcc8b46d797c23c6077c6ff01", - "size": 107762, - "errors": [ - { - "data": { - "column": 8, - "source": "https://s1.sentry-cdn.com/_static/bloopbloop/sentry/dist/app.js.map", - "row": 15 - }, - "message": "Invalid location in sourcemap", - "type": "js_invalid_sourcemap_location" - } - ], - "platform": "javascript", - "nextEventID": "99f9e199e9a74a14bfef6196ad741619", - "type": "error", - "metadata": { - "type": "ForbiddenError", - "value": "GET /organizations/hellboy-meowmeow/users/ 403" - }, - "tags": [ - { - "value": "Chrome 83.0.4103", - "key": "browser", - "_meta": null - }, - { - "value": "Chrome", - "key": "browser.name", - "_meta": null - }, - { - "value": "prod", - "key": "environment", - "_meta": null - }, - { - "value": "yes", - "key": "handled", - "_meta": null - }, - { - "value": "error", - "key": "level", - "_meta": null - }, - { - "value": "generic", - "key": "mechanism", - "_meta": null - } - ], - "dateCreated": "2020-06-17T22:26:56.098086Z", - "dateReceived": "2020-06-17T22:26:56.428721Z", - "user": { - "username": null, - "name": "Hell Boy", - "ip_address": "192.168.1.1", - "email": "hell@boy.cat", - "data": { - "isStaff": false - }, - "id": "550747" - }, - "entries": [ - { - "type": "exception", - "data": { - "values": [ - { - "stacktrace": { - "frames": [ - { - "function": "ignoreOnError", - "errors": null, - "colNo": 23, - "vars": null, - "package": null, - "absPath": "webpack:////usr/src/getsentry/src/sentry/node_modules/@sentry/browser/esm/helpers.js", - "inApp": false, - "lineNo": 71, - "module": "usr/src/getsentry/src/sentry/node_modules/@sentry/browser/esm/helpers", - "filename": "/usr/src/getsentry/src/sentry/node_modules/@sentry/browser/esm/helpers.js", - "platform": null, - "instructionAddr": null, - "context": [ - [66, " }"], - [ - 67, - " // Attempt to invoke user-land function" - ], - [ - 68, - " // NOTE: If you are a Sentry user, and you are seeing this stack frame, it" - ], - [ - 69, - " // means the sentry.javascript SDK caught an error invoking your application code. This" - ], - [ - 70, - " // is expected behavior and NOT indicative of a bug with sentry.javascript." - ], - [ - 71, - " return fn.apply(this, wrappedArguments);" - ], - [ - 72, - " // tslint:enable:no-unsafe-any" - ], - [73, " }"], - [74, " catch (ex) {"], - [75, " ignoreNextOnError();"], - [76, " withScope(function (scope) {"] - ], - "symbolAddr": null, - "trust": null, - "symbol": null - }, - { - "function": "apply", - "errors": null, - "colNo": 24, - "vars": null, - "package": null, - "absPath": "webpack:////usr/src/getsentry/src/sentry/node_modules/reflux-core/lib/PublisherMethods.js", - "inApp": false, - "lineNo": 74, - "module": "usr/src/getsentry/src/sentry/node_modules/reflux-core/lib/PublisherMethods", - "filename": "/usr/src/getsentry/src/sentry/node_modules/reflux-core/lib/PublisherMethods.js", - "platform": null, - "instructionAddr": null, - "context": [ - [69, " */"], - [ - 70, - " triggerAsync: function triggerAsync() {" - ], - [71, " var args = arguments,"], - [72, " me = this;"], - [73, " _.nextTick(function () {"], - [74, " me.trigger.apply(me, args);"], - [75, " });"], - [76, " },"], - [77, ""], - [78, " /**"], - [ - 79, - " * Wraps the trigger mechanism with a deferral function." - ] - ], - "symbolAddr": null, - "trust": null, - "symbol": null - } - ], - "framesOmitted": null, - "registers": null, - "hasSystemFrames": true - }, - "module": null, - "rawStacktrace": { - "frames": [ - { - "function": "a", - "errors": null, - "colNo": 88800, - "vars": null, - "package": null, - "absPath": "https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js", - "inApp": false, - "lineNo": 81, - "module": null, - "filename": "/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js", - "platform": null, - "instructionAddr": null, - "context": [ - [76, "/*!"], - [77, " Copyright (c) 2018 Jed Watson."], - [ - 78, - " Licensed under the MIT License (MIT), see" - ], - [ - 79, - " http://jedwatson.github.io/react-select" - ], - [80, "*/"], - [ - 81, - "{snip} e,t)}));return e.handleEvent?e.handleEvent.apply(this,s):e.apply(this,s)}catch(e){throw c(),Object(o.m)((function(n){n.addEventProcessor((fu {snip}" - ], - [82, "/*!"], - [83, " * JavaScript Cookie v2.2.1"], - [ - 84, - " * https://github.com/js-cookie/js-cookie" - ], - [85, " *"], - [ - 86, - " * Copyright 2006, 2015 Klaus Hartl & Fagner Brack" - ] - ], - "symbolAddr": null, - "trust": null, - "symbol": null - }, - { - "function": null, - "errors": null, - "colNo": 149484, - "vars": null, - "package": null, - "absPath": "https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js", - "inApp": false, - "lineNo": 119, - "module": null, - "filename": "/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js", - "platform": null, - "instructionAddr": null, - "context": [ - [114, "/* @license"], - [115, "Papa Parse"], - [116, "v5.2.0"], - [117, "https://github.com/mholt/PapaParse"], - [118, "License: MIT"], - [ - 119, - "{snip} (){var e=arguments,t=this;r.nextTick((function(){t.trigger.apply(t,e)}))},deferWith:function(e){var t=this.trigger,n=this,r=function(){t.app {snip}" - ], - [120, "/**!"], - [ - 121, - " * @fileOverview Kickass library to create and place poppers near their reference elements." - ], - [122, " * @version 1.16.1"], - [123, " * @license"], - [ - 124, - " * Copyright (c) 2016 Federico Zivolo and contributors" - ] - ], - "symbolAddr": null, - "trust": null, - "symbol": null - } - ], - "framesOmitted": null, - "registers": null, - "hasSystemFrames": true - }, - "mechanism": { - "type": "generic", - "handled": true - }, - "threadId": null, - "value": "GET /organizations/hellboy-meowmeow/users/ 403", - "type": "ForbiddenError" - } - ], - "excOmitted": null, - "hasSystemFrames": true - } - }, - { - "type": "breadcrumbs", - "data": { - "values": [ - { - "category": "tracing", - "level": "debug", - "event_id": null, - "timestamp": "2020-06-17T22:26:55.266586Z", - "data": null, - "message": "[Tracing] pushActivity: idleTransactionStarted#1", - "type": "debug" - }, - { - "category": "xhr", - "level": "info", - "event_id": null, - "timestamp": "2020-06-17T22:26:55.619446Z", - "data": { - "url": "/api/0/internal/health/", - "status_code": 200, - "method": "GET" - }, - "message": null, - "type": "http" - }, - { - "category": "sentry.transaction", - "level": "info", - "event_id": null, - "timestamp": "2020-06-17T22:26:55.945016Z", - "data": null, - "message": "7787a027f3fb46c985aaa2287b3f4d09", - "type": "default" - } - ] - } - }, - { - "type": "request", - "data": { - "fragment": null, - "cookies": [], - "inferredContentType": null, - "env": null, - "headers": [ - [ - "User-Agent", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.97 Safari/537.36" - ] - ], - "url": "https://sentry.io/organizations/hellboy-meowmeow/issues/", - "query": [["project", "5236886"]], - "data": null, - "method": null - } - } - ], - "packages": {}, - "sdk": { - "version": "5.17.0", - "name": "sentry.javascript.browser" - }, - "_meta": { - "user": null, - "context": null, - "entries": {}, - "contexts": null, - "message": null, - "packages": null, - "tags": {}, - "sdk": null - }, - "contexts": { - "ForbiddenError": { - "status": 403, - "statusText": "Forbidden", - "responseJSON": { - "detail": "You do not have permission to perform this action." - }, - "type": "default" - }, - "browser": { - "version": "83.0.4103", - "type": "browser", - "name": "Chrome" - }, - "os": { - "version": "10", - "type": "os", - "name": "Windows" - }, - "trace": { - "span_id": "83db1ad17e67dfe7", - "type": "trace", - "trace_id": "da6caabcd90e45fdb81f6655824a5f88", - "op": "navigation" - }, - "organization": { - "type": "default", - "id": "323938", - "slug": "hellboy-meowmeow" - } - }, - "fingerprints": ["fbe908cc63d63ea9763fd84cb6bad177"], - "context": { - "resp": { - "status": 403, - "responseJSON": { - "detail": "You do not have permission to perform this action." - }, - "name": "ForbiddenError", - "statusText": "Forbidden", - "message": "GET /organizations/hellboy-meowmeow/users/ 403", - "stack": "Error\n at https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:480441\n at u (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:165:51006)\n at Generator._invoke (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:165:50794)\n at Generator.A.forEach.e. [as next] (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:165:51429)\n at n (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:16:68684)\n at s (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:16:68895)\n at https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:16:68954\n at new Promise ()\n at https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:16:68835\n at v (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:480924)\n at m (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:480152)\n at t.fetchMemberList (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:902983)\n at t.componentDidMount (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:900527)\n at t.componentDidMount (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:189:15597)\n at Pc (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:101023)\n at t.unstable_runWithPriority (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:189:3462)\n at Ko (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:45529)\n at Rc (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:97371)\n at Oc (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:87690)\n at https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:45820\n at t.unstable_runWithPriority (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:189:3462)\n at Ko (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:45529)\n at Zo (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:45765)\n at Jo (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:45700)\n at gc (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:84256)\n at Object.enqueueSetState (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:181:50481)\n at t.M.setState (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:173:1439)\n at t.onUpdate (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:543076)\n at a.n (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:119:149090)\n at a.emit (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:189:6550)\n at p.trigger (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:119:149379)\n at p.onInitializeUrlState (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/app.js:1:541711)\n at a.n (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:119:149090)\n at a.emit (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:189:6550)\n at Function.trigger (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:119:149379)\n at https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:119:149484\n at a (https://s1.sentry-cdn.com/_static/dde778f9f93a48e2b6e58ecb0c5eb8f2/sentry/dist/vendor.js:81:88800)" - } - }, - "release": { - "dateReleased": "2020-06-17T19:21:02.186004Z", - "newGroups": 4, - "commitCount": 11, - "url": "https://freight.getsentry.net/deploys/getsentry/production/8868/", - "data": {}, - "lastDeploy": { - "name": "b65bc521378269d3eaefdc964f8ef56621414943 to prod", - "url": null, - "environment": "prod", - "dateStarted": null, - "dateFinished": "2020-06-17T19:20:55.641748Z", - "id": "6883490" - }, - "deployCount": 1, - "dateCreated": "2020-06-17T18:45:31.042157Z", - "lastEvent": "2020-07-08T21:21:21Z", - "version": "b65bc521378269d3eaefdc964f8ef56621414943", - "firstEvent": "2020-06-17T22:25:14Z", - "lastCommit": { - "repository": { - "status": "active", - "integrationId": "2933", - "externalSlug": "getsentry/getsentry", - "name": "getsentry/getsentry", - "provider": { - "id": "integrations:github", - "name": "GitHub" - }, - "url": "https://github.com/getsentry/getsentry", - "id": "2", - "dateCreated": "2016-10-10T21:36:45.373994Z" - }, - "releases": [ - { - "dateReleased": "2020-06-23T13:26:18.427090Z", - "url": "https://freight.getsentry.net/deploys/getsentry/staging/2077/", - "dateCreated": "2020-06-23T13:22:50.420265Z", - "version": "f3783e5fe710758724f14267439fd46cc2bf5918", - "shortVersion": "f3783e5fe710758724f14267439fd46cc2bf5918", - "ref": "perf/source-maps-test" - }, - { - "dateReleased": "2020-06-17T19:21:02.186004Z", - "url": "https://freight.getsentry.net/deploys/getsentry/production/8868/", - "dateCreated": "2020-06-17T18:45:31.042157Z", - "version": "b65bc521378269d3eaefdc964f8ef56621414943", - "shortVersion": "b65bc521378269d3eaefdc964f8ef56621414943", - "ref": "master" - } - ], - "dateCreated": "2020-06-17T18:43:37Z", - "message": "feat(billing): Get a lot of money", - "id": "b65bc521378269d3eaefdc964f8ef56621414943" - }, - "shortVersion": "b65bc521378269d3eaefdc964f8ef56621414943", - "authors": [ - { - "username": "a37a1b4520ce46cea147ae2885a4e7e7", - "lastLogin": "2020-09-14T22:34:55.550640Z", - "isSuperuser": false, - "isManaged": false, - "experiments": {}, - "lastActive": "2020-09-15T22:13:20.503880Z", - "isStaff": false, - "id": "655784", - "isActive": true, - "has2fa": false, - "name": "hell.boy@sentry.io", - "avatarUrl": "https://secure.gravatar.com/avatar/eaa22e25b3a984659420831a77e4874e?s=32&d=mm", - "dateJoined": "2020-04-20T16:21:25.365772Z", - "emails": [ - { - "is_verified": false, - "id": "784574", - "email": "hellboy@gmail.com" - }, - { - "is_verified": true, - "id": "749185", - "email": "hell.boy@sentry.io" - } - ], - "avatar": { - "avatarUuid": null, - "avatarType": "letter_avatar" - }, - "hasPasswordAuth": false, - "email": "hell.boy@sentry.io" - } - ], - "owner": null, - "ref": "master", - "projects": [ - { - "name": "Sentry CSP", - "slug": "sentry-csp" - }, - { - "name": "Backend", - "slug": "sentry" - }, - { - "name": "Frontend", - "slug": "javascript" - } - ] - }, - "groupID": "1341191803" - } - } - } - }, - "403": { - "description": "Forbidden" - } - }, - "security": [ - { - "auth_token": ["project:read"] - } - ] - } -} diff --git a/devservices/config.yml b/devservices/config.yml index 81397fc1ee16..176478efee9d 100644 --- a/devservices/config.yml +++ b/devservices/config.yml @@ -84,13 +84,18 @@ x-sentry-service-config: repo_name: chartcuterie branch: master repo_link: https://github.com/getsentry/chartcuterie.git - taskbroker: + taskbroker: &taskbroker description: Service used to process asynchronous tasks remote: repo_name: taskbroker branch: main repo_link: https://github.com/getsentry/taskbroker.git mode: containerized + # ingest-profiles runs taskbroker in passthrough mode (STREAM-1041). Ideally + # this would be a remote dependency like taskbroker, but devservices doesn't + # support passing custom environment variables to remote services (DI-1956). + ingest-profiles: + description: Kafka consumer for processing profiling data via taskbroker passthrough mode memcached: description: Memcached used for caching spotlight: @@ -125,6 +130,8 @@ x-sentry-service-config: description: Dedicated taskworker for eval Relay project config tasks taskworker-scheduler: description: Task scheduler that can spawn tasks based on their schedules + taskworker-ingest-profiles: + description: Taskworker for ingest-profiles passthrough broker # Kafka consumer services for event ingestion ingest-events: description: Kafka consumer for processing ingested events @@ -134,8 +141,6 @@ x-sentry-service-config: description: Kafka consumer for processing ingested transactions ingest-monitors: description: Kafka consumer for processing monitor check-ins - ingest-profiles: - description: Kafka consumer for processing profiling data ingest-occurrences: description: Kafka consumer for processing issue occurrences ingest-feedback-events: @@ -241,6 +246,7 @@ x-sentry-service-config: ingest-occurrences, taskbroker, taskworker, + taskworker-ingest-profiles, post-process-forwarder-errors, post-process-forwarder-transactions, ] @@ -333,6 +339,7 @@ x-sentry-service-config: spotlight, taskbroker, taskworker, + taskworker-ingest-profiles, vroom, ] full: @@ -367,6 +374,7 @@ x-sentry-service-config: post-process-forwarder-issue-platform, taskbroker, taskworker, + taskworker-ingest-profiles, taskworker-scheduler, ] @@ -385,6 +393,8 @@ x-programs: command: sentry run taskworker --namespace relay --concurrency 1 --processing-pool-name eval-relay taskworker-scheduler: command: sentry run taskworker-scheduler + taskworker-ingest-profiles: + command: sentry run taskworker --rpc-host localhost:50052 --namespace ingest.profiling.passthrough ingest-events: command: sentry run consumer ingest-events --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset ingest-attachments: @@ -399,8 +409,6 @@ x-programs: command: sentry run consumer monitors-clock-tasks --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset monitors-incident-occurrences: command: sentry run consumer monitors-incident-occurrences --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset - ingest-profiles: - command: sentry run consumer ingest-profiles --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset ingest-occurrences: command: sentry run consumer ingest-occurrences --consumer-group=sentry-consumer --auto-offset-reset=latest --no-strict-offset-reset process-spans: @@ -483,6 +491,32 @@ services: - devservices labels: - orchestrator=devservices + # Taskbroker in passthrough mode for ingest-profiles topic (STREAM-1041). + # This is a local service rather than a remote dependency because devservices + # doesn't support passing custom environment variables to remote services (DI-1956). + ingest-profiles: + image: ghcr.io/getsentry/taskbroker:nightly + environment: + TASKBROKER_KAFKA_CLUSTER: 'kafka-kafka-1:9093' + TASKBROKER_KAFKA_TOPIC: 'profiles' + TASKBROKER_KAFKA_CONSUMER_GROUP: 'ingest-profiles' + TASKBROKER_CREATE_MISSING_TOPICS: 'true' + TASKBROKER_RAW_MODE: 'true' + TASKBROKER_RAW_NAMESPACE: 'ingest.profiling.passthrough' + TASKBROKER_RAW_APPLICATION: 'sentry' + TASKBROKER_RAW_TASKNAME: 'sentry.profiles.task.process_profile_from_kafka' + # TODO(STREAM-1041): Remove debug logging once passthrough mode is stable + TASKBROKER_LOG_FILTER: 'debug' + ports: + - '127.0.0.1:50052:50051' + networks: + - devservices + extra_hosts: + - host.docker.internal:host-gateway + labels: + - orchestrator=devservices + restart: unless-stopped + platform: linux/amd64 networks: devservices: diff --git a/eslint.config.ts b/eslint.config.ts index babc317f3f6c..34e59db6f485 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1407,6 +1407,8 @@ export default typescript.config([ ...(enableTypeAwareLinting && { '@typescript-eslint/no-unsafe-argument': 'error', '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-enum-comparison': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', '@typescript-eslint/no-unsafe-return': 'error', }), }, diff --git a/pyproject.toml b/pyproject.toml index 4330e14cf421..07e1c0cc6fce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,10 +91,10 @@ dependencies = [ "sentry-forked-email-reply-parser>=0.5.12.post1", "sentry-kafka-schemas>=2.1.27", "sentry-ophio>=1.1.3", - "sentry-protos>=0.10.0", + "sentry-protos>=0.13.0", "sentry-redis-tools>=0.5.0", "sentry-relay>=0.9.27", - "sentry-scm>=0.14.0", + "sentry-scm==0.16.0", "sentry-sdk[http2]>=2.59.0", "sentry-usage-accountant>=0.0.10", # remove once there are no unmarked transitive dependencies on setuptools @@ -397,8 +397,6 @@ module = [ "sentry.services.eventstore.models", "sentry.snuba.metrics.query_builder", "sentry.testutils.cases", - "tests.sentry.api.helpers.test_group_index", - "tests.sentry.issues.test_utils", ] disable_error_code = [ "arg-type", @@ -1640,7 +1638,6 @@ module = [ "sentry.web.frontend.debug.debug_error_embed", "sentry.web.frontend.debug.debug_feedback_issue", "sentry.web.frontend.debug.debug_generic_issue", - "sentry.web.frontend.debug.debug_incident_trigger_email", "sentry.web.frontend.debug.debug_invalid_identity_email", "sentry.web.frontend.debug.debug_mfa_added_email", "sentry.web.frontend.debug.debug_mfa_removed_email", diff --git a/src/sentry/api/bases/organization_events.py b/src/sentry/api/bases/organization_events.py index ee2821e4f358..225bb94a4395 100644 --- a/src/sentry/api/bases/organization_events.py +++ b/src/sentry/api/bases/organization_events.py @@ -152,7 +152,7 @@ def get_dataset(self, request: Request, organization: Organization) -> Any: # Feature flag the occurrence endpoint if ( dataset_label == SupportedTraceItemType.OCCURRENCES.value - and not EAPOccurrencesComparator.should_use_experiment("api.events.endpoints") + and not EAPOccurrencesComparator.should_use_experimental_data("api.events.endpoints") ): raise ParseError(detail=f"{dataset_label} is not supported currently") elif dataset_label == SupportedTraceItemType.REPLAYS.value and not features.has( diff --git a/src/sentry/api/endpoints/organization_events_timeseries.py b/src/sentry/api/endpoints/organization_events_timeseries.py index 485b7711f483..4e64cdcb7049 100644 --- a/src/sentry/api/endpoints/organization_events_timeseries.py +++ b/src/sentry/api/endpoints/organization_events_timeseries.py @@ -366,12 +366,24 @@ def serialize_stats_data( ) -> StatsResponse: # We need the current timestamp for the Ingestion Delay incomplete reason now = datetime.now().timestamp() + stats_meta = StatsMeta( + dataset=DATASET_LABELS[dataset], + start=snuba_params.start_date.timestamp() * 1000, + end=snuba_params.end_date.timestamp() * 1000, + ) + if snuba_params.debug: + debug_info = None + if isinstance(result, SnubaTSResult) and "debug_info" in result.data["meta"]: + debug_info = result.data["meta"]["debug_info"] + elif isinstance(result, dict): + debug_info = {} + for key, keyed_result in result.items(): + if "debug_info" in keyed_result.data["meta"]: + debug_info[key] = keyed_result.data["meta"]["debug_info"] + # ignore typing here cause we don't want the openapi docs to include debug_info + stats_meta["debug_info"] = debug_info # type: ignore[typeddict-unknown-key] response = StatsResponse( - meta=StatsMeta( - dataset=DATASET_LABELS[dataset], - start=snuba_params.start_date.timestamp() * 1000, - end=snuba_params.end_date.timestamp() * 1000, - ), + meta=stats_meta, timeSeries=self.serialize_result(result, axes, rollup, now), ) return response diff --git a/src/sentry/api/endpoints/organization_events_trace.py b/src/sentry/api/endpoints/organization_events_trace.py index a46ec41d5c32..85a9183cd73b 100644 --- a/src/sentry/api/endpoints/organization_events_trace.py +++ b/src/sentry/api/endpoints/organization_events_trace.py @@ -360,7 +360,7 @@ def load_performance_issues(self, light: bool, snuba_params: SnubaParams) -> Non control_data=occurrence_ids, experimental_data=eap_occurrence_ids, callsite=callsite, - is_experimental_data_a_null_result=len(eap_occurrence_ids) == 0, + is_experimental_data_nullish=len(eap_occurrence_ids) == 0, reasonable_match_comparator=lambda snuba, eap: { row["occurrence_id"] for row in eap }.issubset({row["occurrence_id"] for row in snuba}), @@ -827,7 +827,7 @@ def query_trace_data( control_data=transformed_results[1], experimental_data=eap_errors, callsite=errors_callsite, - is_experimental_data_a_null_result=len(eap_errors) == 0, + is_experimental_data_nullish=len(eap_errors) == 0, reasonable_match_comparator=lambda snuba, eap: {e["id"] for e in eap}.issubset( {e["id"] for e in snuba} ), diff --git a/src/sentry/api/endpoints/organization_trace_meta.py b/src/sentry/api/endpoints/organization_trace_meta.py index 704b40bad048..e20c0b3e00c8 100644 --- a/src/sentry/api/endpoints/organization_trace_meta.py +++ b/src/sentry/api/endpoints/organization_trace_meta.py @@ -94,7 +94,7 @@ def run_errors_query(trace_id: str, snuba_params: SnubaParams) -> int: snuba_count, eap_count, callsite, - is_experimental_data_a_null_result=(eap_count == 0), + is_experimental_data_nullish=(eap_count == 0), reasonable_match_comparator=lambda snuba, eap: eap <= snuba, debug_context={ "trace_id": trace_id, diff --git a/src/sentry/api/endpoints/organization_traces.py b/src/sentry/api/endpoints/organization_traces.py index 6f534e1474dc..a18d13551db1 100644 --- a/src/sentry/api/endpoints/organization_traces.py +++ b/src/sentry/api/endpoints/organization_traces.py @@ -369,7 +369,7 @@ def enrich_eap_traces_with_extra_data( traces_errors, eap_traces_errors, errors_callsite, - is_experimental_data_a_null_result=len(eap_traces_errors) == 0, + is_experimental_data_nullish=len(eap_traces_errors) == 0, reasonable_match_comparator=_reasonable_trace_count_map_match, debug_context=debug_context, ) @@ -386,7 +386,7 @@ def enrich_eap_traces_with_extra_data( traces_occurrences, eap_traces_occurrences, occurrences_callsite, - is_experimental_data_a_null_result=len(eap_traces_occurrences) == 0, + is_experimental_data_nullish=len(eap_traces_occurrences) == 0, reasonable_match_comparator=_reasonable_trace_count_map_match, debug_context=debug_context, ) diff --git a/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py b/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py index 5571be13a193..01c14a8b9ec8 100644 --- a/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py +++ b/src/sentry/api/endpoints/release_thresholds/utils/get_errors_counts_timeseries.py @@ -61,7 +61,7 @@ def get_errors_counts_timeseries_by_project_and_release( snuba_results, eap_results, "release_thresholds.get_errors_counts_timeseries", - is_experimental_data_a_null_result=len(eap_results) == 0, + is_experimental_data_nullish=len(eap_results) == 0, reasonable_match_comparator=lambda snuba_rows, eap_rows: keyed_counts_subset_match( snuba_rows, eap_rows, diff --git a/src/sentry/api/helpers/events.py b/src/sentry/api/helpers/events.py index 2161c40f2fec..4e35a3d088a0 100644 --- a/src/sentry/api/helpers/events.py +++ b/src/sentry/api/helpers/events.py @@ -191,7 +191,7 @@ def run_group_events_query( snuba_data, eap_data, callsite, - is_experimental_data_a_null_result=len(eap_data) == 0, + is_experimental_data_nullish=len(eap_data) == 0, reasonable_match_comparator=_reasonable_group_events_match, debug_context={ "group_id": group.id, diff --git a/src/sentry/api/helpers/group_index/index.py b/src/sentry/api/helpers/group_index/index.py index ef930c08ff02..74c296603618 100644 --- a/src/sentry/api/helpers/group_index/index.py +++ b/src/sentry/api/helpers/group_index/index.py @@ -175,11 +175,16 @@ def get_by_short_id( is_short_id_lookup: str, query: str, ) -> Group | None: - if is_short_id_lookup == "1" and looks_like_short_id(query): - try: - return Group.objects.by_qualified_short_id(organization_id, query) - except Group.DoesNotExist: - pass + if is_short_id_lookup != "1": + return None + # A short id token anywhere in the query is treated as a direct hit, + # so it composes with filters. + for token in query.split(): + if looks_like_short_id(token): + try: + return Group.objects.by_qualified_short_id(organization_id, token) + except Group.DoesNotExist: + continue return None diff --git a/src/sentry/api/helpers/group_index/update.py b/src/sentry/api/helpers/group_index/update.py index 2a114d781426..9d559a150da8 100644 --- a/src/sentry/api/helpers/group_index/update.py +++ b/src/sentry/api/helpers/group_index/update.py @@ -1052,7 +1052,7 @@ def handle_is_public( def handle_assigned_to( - assigned_actor: Actor, + assigned_actor: Actor | None, assigned_by: str | None, integration: str | None, group_list: Sequence[Group], @@ -1073,7 +1073,7 @@ def handle_assigned_to( if integration in [ActivityIntegration.SLACK.value, ActivityIntegration.MSTEAMS.value] else dict() ) - if assigned_actor: + if assigned_actor is not None: resolved_actor = assigned_actor.resolve() for group in group_list: assignment = GroupAssignee.objects.assign( diff --git a/src/sentry/api/serializers/models/group.py b/src/sentry/api/serializers/models/group.py index 2ddb6413f5d9..3cd8d93fba0b 100644 --- a/src/sentry/api/serializers/models/group.py +++ b/src/sentry/api/serializers/models/group.py @@ -142,6 +142,31 @@ class BaseGroupSerializerResponse(BaseGroupResponseOptional): annotations: list[GroupAnnotation] +class GroupDetailsResponseOptional(TypedDict, total=False): + # Included by default; removable via `?collapse=release|tags|stats` + firstRelease: dict[str, Any] | None + lastRelease: dict[str, Any] | None + tags: list[dict[str, Any]] + stats: dict[str, list[list[float]]] + # Opt-in via `?expand=...` + inbox: dict[str, Any] | None + owners: list[dict[str, Any]] | None + forecast: dict[str, Any] + integrationIssues: list[dict[str, Any]] + sentryAppIssues: list[dict[str, Any]] + latestEventHasAttachments: bool + + +class GroupDetailsResponse(BaseGroupSerializerResponse, GroupDetailsResponseOptional): + activity: list[dict[str, Any]] + seenBy: list[dict[str, Any]] + pluginActions: list[Any] + pluginIssues: list[dict[str, Any]] + pluginContexts: list[dict[str, Any]] + userReportCount: int + participants: list[dict[str, Any]] + + class SeenStats(TypedDict): times_seen: int first_seen: datetime | None diff --git a/src/sentry/api/serializers/rest_framework/dashboard.py b/src/sentry/api/serializers/rest_framework/dashboard.py index c0edf8730a16..6fbcbee946c4 100644 --- a/src/sentry/api/serializers/rest_framework/dashboard.py +++ b/src/sentry/api/serializers/rest_framework/dashboard.py @@ -462,13 +462,6 @@ def to_internal_value(self, data): return super().to_internal_value(data) def _validate_text_widget(self, data): - if not features.has( - "organizations:dashboards-text-widgets", - organization=self.context["organization"], - actor=self.context["request"].user, - ): - raise serializers.ValidationError({"display_type": "Text widgets are not enabled"}) - if data.get("widget_type"): raise serializers.ValidationError( {"widget_type": "Text widgets don't have a widget type or dataset"} diff --git a/src/sentry/apidocs/examples/issue_examples.py b/src/sentry/apidocs/examples/issue_examples.py index a7d9b09f887e..c9916e8cb05b 100644 --- a/src/sentry/apidocs/examples/issue_examples.py +++ b/src/sentry/apidocs/examples/issue_examples.py @@ -3,6 +3,7 @@ from drf_spectacular.utils import OpenApiExample from sentry.api.helpers.group_index.types import MutateIssueResponse +from sentry.api.serializers.models.group import GroupDetailsResponse from sentry.api.serializers.models.group_stream import StreamGroupSerializerSnubaResponse SIMPLE_ISSUE: StreamGroupSerializerSnubaResponse = { @@ -130,6 +131,78 @@ } +GROUP_DETAILS: GroupDetailsResponse = { + "id": "1", + "shareId": "123def456abc", + "shortId": "PUMP-STATION-1", + "title": "This is an example Python exception", + "culprit": "raven.scripts.runner in main", + "permalink": "https://sentry.io/the-interstellar-jurisdiction/pump-station/issues/1/", + "logger": None, + "level": "error", + "status": "unresolved", + "statusDetails": {}, + "substatus": "ongoing", + "isPublic": False, + "platform": "python", + "priority": "medium", + "priorityLockedAt": None, + "seerFixabilityScore": None, + "seerAutofixLastTriggered": None, + "seerExplorerAutofixLastTriggered": None, + "project": {"id": "2", "name": "Pump Station", "slug": "pump-station", "platform": "python"}, + "type": "default", + "issueType": "error", + "issueCategory": "error", + "metadata": {"title": "This is an example Python exception"}, + "numComments": 0, + "assignedTo": {"type": "user", "id": "1", "name": "John Doe", "email": "john.doe@example.com"}, + "isBookmarked": False, + "isSubscribed": True, + "subscriptionDetails": None, + "hasSeen": False, + "annotations": [], + "count": "150", + "userCount": 12, + "firstSeen": datetime.fromisoformat("2018-11-06T21:19:55Z"), + "lastSeen": datetime.fromisoformat("2018-12-06T21:19:55Z"), + # Always added by the detail view + "activity": [ + { + "data": {}, + "dateCreated": "2018-11-06T21:19:55Z", + "id": "0", + "type": "first_seen", + "user": None, + } + ], + "seenBy": [], + "pluginActions": [], + "pluginIssues": [], + "pluginContexts": [], + "userReportCount": 0, + "participants": [], + # Default-included unless suppressed via `?collapse=...` + "firstRelease": { + "version": "17642328ead24b51867165985996d04b29310337", + "shortVersion": "1764232", + "dateCreated": "2018-11-06T21:19:55.146Z", + "dateReleased": None, + "ref": None, + "url": None, + }, + "lastRelease": None, + "tags": [ + {"key": "browser", "name": "Browser", "totalValues": 150}, + {"key": "level", "name": "Level", "totalValues": 150}, + ], + "stats": { + "24h": [[1541451600.0, 557], [1541455200.0, 473]], + "30d": [[1538870400.0, 565], [1538956800.0, 12862]], + }, +} + + class IssueExamples: ORGANIZATION_GROUP_INDEX_GET = [ OpenApiExample( @@ -147,3 +220,11 @@ class IssueExamples: status_codes=["200"], ) ] + GROUP_DETAILS = [ + OpenApiExample( + "Return an issue", + value=GROUP_DETAILS, + response_only=True, + status_codes=["200"], + ) + ] diff --git a/src/sentry/apidocs/examples/metric_alert_examples.py b/src/sentry/apidocs/examples/metric_alert_examples.py deleted file mode 100644 index d1e61a0470a2..000000000000 --- a/src/sentry/apidocs/examples/metric_alert_examples.py +++ /dev/null @@ -1,226 +0,0 @@ -from drf_spectacular.utils import OpenApiExample - - -class MetricAlertExamples: - LIST_METRIC_ALERT_RULES = [ - OpenApiExample( - "List metric alert rules for an organization", - value=[ - { - "id": "7", - "name": "Counting Bad Request and Unauthorized Errors in Prod", - "organizationId": "237655244234", - "queryType": 0, - "dataset": "events", - "query": "tags[http.status_code]:[400, 401]", - "aggregate": "count()", - "thresholdType": 0, - "resolveThreshold": None, - "timeWindow": 1440, - "environment": "prod", - "triggers": [ - { - "id": "394289", - "alertRuleId": "17723", - "label": "critical", - "thresholdType": 0, - "alertThreshold": 100, - "resolveThreshold": None, - "dateCreated": "2023-09-25T22:15:26.375126Z", - "actions": [ - { - "id": "394280", - "alertRuleTriggerId": "92481", - "type": "slack", - "targetType": "specific", - "targetIdentifier": "30489048931789", - "inputChannelId": "#my-channel", - "integrationId": "8753467", - "sentryAppId": None, - "dateCreated": "2023-09-25T22:15:26.375126Z", - } - ], - }, - ], - "projects": ["super-cool-project"], - "owner": "user:53256", - "originalAlertRuleId": None, - "comparisonDelta": None, - "dateModified": "2023-09-25T22:15:26.375126Z", - "dateCreated": "2023-09-25T22:15:26.375126Z", - "createdBy": {"id": 983948, "name": "John Doe", "email": "john.doe@sentry.io"}, - } - ], - status_codes=["200"], - response_only=True, - ) - ] - - CREATE_METRIC_ALERT_RULE = [ - OpenApiExample( - "Create a metric alert rule for an organization", - value={ - "id": "177104", - "name": "Apdex % Check", - "organizationId": "4505676595200000", - "queryType": 2, - "dataset": "metrics", - "query": "", - "aggregate": "percentage(sessions_crashed, sessions) AS _crash_rate_alert_aggregate", - "thresholdType": 0, - "resolveThreshold": 80.0, - "timeWindow": 120, - "environment": None, - "triggers": [ - { - "id": "293990", - "alertRuleId": "177104", - "label": "critical", - "thresholdType": 0, - "alertThreshold": 75, - "resolveThreshold": 80.0, - "dateCreated": "2023-09-25T22:01:28.673305Z", - "actions": [ - { - "id": "281887", - "alertRuleTriggerId": "293990", - "type": "email", - "targetType": "team", - "targetIdentifier": "2378589792734981", - "inputChannelId": None, - "integrationId": None, - "sentryAppId": None, - "dateCreated": "2023-09-25T22:01:28.680793Z", - } - ], - }, - { - "id": "492849", - "alertRuleId": "482923", - "label": "warning", - "thresholdType": 1, - "alertThreshold": 50, - "resolveThreshold": 80, - "dateCreated": "2023-09-25T22:01:28.673305Z", - "actions": [], - }, - ], - "projects": ["our-project"], - "owner": "team:4505676595200000", - "originalAlertRuleId": None, - "comparisonDelta": 10080, - "dateModified": "2023-09-25T22:01:28.637506Z", - "dateCreated": "2023-09-25T22:01:28.637514Z", - "createdBy": { - "id": 2837708, - "name": "Jane Doe", - "email": "jane.doe@sentry.io", - }, - }, - status_codes=["201"], - response_only=True, - ) - ] - - GET_METRIC_ALERT_RULE = [ - OpenApiExample( - "Get detailed view about a metric alert rule", - value={ - "id": "177412243058", - "name": "My Metric Alert Rule", - "organizationId": "4505676595200000", - "queryType": 0, - "dataset": "events", - "query": "", - "aggregate": "count_unique(user)", - "thresholdType": 0, - "resolveThreshold": None, - "timeWindow": 60, - "environment": None, - "triggers": [ - { - "id": "294385908", - "alertRuleId": "177412243058", - "label": "critical", - "thresholdType": 0, - "alertThreshold": 31.0, - "resolveThreshold": None, - "dateCreated": "2023-09-26T22:14:17.557579Z", - "actions": [], - } - ], - "projects": ["my-coolest-project"], - "owner": "team:29508397892374892", - "dateModified": "2023-09-26T22:14:17.522166Z", - "dateCreated": "2023-09-26T22:14:17.522196Z", - "createdBy": { - "id": 2834985497897, - "name": "Somebody That I Used to Know", - "email": "anon@sentry.io", - }, - "eventTypes": ["default", "error"], - }, - status_codes=["200"], - response_only=True, - ) - ] - - UPDATE_METRIC_ALERT_RULE = [ - OpenApiExample( - "Update a metric alert rule", - value={ - "id": "345989573", - "name": "P30 Transaction Duration", - "organizationId": "02403489017", - "queryType": 1, - "dataset": "transactions", - "query": "", - "aggregate": "percentile(transaction.duration,0.3)", - "thresholdType": 1, - "resolveThreshold": None, - "timeWindow": 60, - "environment": None, - "triggers": [ - { - "id": "0543809890", - "alertRuleId": "345989573", - "label": "critical", - "thresholdType": 1, - "alertThreshold": 70.0, - "resolveThreshold": None, - "dateCreated": "2023-09-25T23:35:31.832084Z", - "actions": [], - } - ], - "projects": ["backend"], - "owner": "team:9390258908", - "originalAlertRuleId": None, - "comparisonDelta": 10080.0, - "dateModified": "2023-09-25T23:35:31.787866Z", - "dateCreated": "2023-09-25T23:35:31.787875Z", - "createdBy": { - "id": 902843590658, - "name": "Spongebob Squarepants", - "email": "spongebob.s@example.com", - }, - }, - status_codes=["200"], - response_only=True, - ) - ] - - GET_METRIC_ALERT_ANOMALIES = [ - OpenApiExample( - "Fetch a list of anomalies for a metric alert rule", - value=[ - { - "timestamp": 0.1, - "value": 100.0, - "anomaly": { - "anomaly_type": "anomaly_higher_confidence", - "anomaly_value": 100, - }, - } - ], - ) - ] diff --git a/src/sentry/apidocs/examples/preprod_examples.py b/src/sentry/apidocs/examples/preprod_examples.py index ae39246f6255..32cb50ece3f5 100644 --- a/src/sentry/apidocs/examples/preprod_examples.py +++ b/src/sentry/apidocs/examples/preprod_examples.py @@ -331,3 +331,240 @@ class PreprodExamples: response_only=True, ), ] + + _SNAPSHOT_VCS_INFO = { + "head_sha": "abc123def456", + "base_sha": "789xyz000111", + "provider": "github", + "head_repo_name": "org/repo", + "base_repo_name": "org/repo", + "head_ref": "feature-branch", + "base_ref": "main", + "pr_number": 42, + } + + _SNAPSHOT_IMAGE = { + "key": "a1b2c3d4e5f6", + "display_name": "Home Screen", + "group": "iPhone 15", + "image_file_name": "home_screen_iphone15.png", + "width": 1170, + "height": 2532, + } + + EXAMPLE_SNAPSHOT_DETAILS_DIFF = { + "head_artifact_id": "100", + "base_artifact_id": "99", + "project_id": "1", + "comparison_type": "diff", + "state": "UPLOADED", + "vcs_info": _SNAPSHOT_VCS_INFO, + "app_id": "com.example.app", + "is_selective": False, + "images": [_SNAPSHOT_IMAGE], + "image_count": 1, + "added": [], + "added_count": 0, + "removed": [], + "removed_count": 0, + "renamed": [], + "renamed_count": 0, + "changed": [ + { + "base_image": {**_SNAPSHOT_IMAGE, "key": "old_hash_123"}, + "head_image": _SNAPSHOT_IMAGE, + "diff_image_key": "diff_hash_456", + "diff": 0.02, + } + ], + "changed_count": 1, + "unchanged": [], + "unchanged_count": 0, + "errored": [], + "errored_count": 0, + "skipped": [], + "skipped_count": 0, + "diff_threshold": 0.01, + "comparison_state": "success", + "approval_status": "requires_approval", + "comparison_error_message": None, + "approvers": [], + } + + EXAMPLE_SNAPSHOT_DETAILS_SOLO = { + "head_artifact_id": "100", + "base_artifact_id": None, + "project_id": "1", + "comparison_type": "solo", + "state": "UPLOADED", + "vcs_info": { + "head_sha": "abc123def456", + "base_sha": None, + "provider": "github", + "head_repo_name": "org/repo", + "base_repo_name": None, + "head_ref": "main", + "base_ref": None, + "pr_number": None, + }, + "app_id": "com.example.app", + "is_selective": False, + "images": [_SNAPSHOT_IMAGE], + "image_count": 1, + "added": [], + "added_count": 0, + "removed": [], + "removed_count": 0, + "renamed": [], + "renamed_count": 0, + "changed": [], + "changed_count": 0, + "unchanged": [], + "unchanged_count": 0, + "errored": [], + "errored_count": 0, + "skipped": [], + "skipped_count": 0, + "diff_threshold": 0.01, + "comparison_state": None, + "approval_status": None, + "comparison_error_message": None, + "approvers": [], + } + + GET_SNAPSHOT_DETAILS = [ + OpenApiExample( + "Snapshot with Comparison (Diff)", + value=EXAMPLE_SNAPSHOT_DETAILS_DIFF, + status_codes=["200"], + response_only=True, + ), + OpenApiExample( + "Snapshot without Comparison (Solo)", + value=EXAMPLE_SNAPSHOT_DETAILS_SOLO, + status_codes=["200"], + response_only=True, + ), + ] + + EXAMPLE_SNAPSHOT_CREATED = { + "artifactId": "100", + "snapshotMetricsId": "200", + "imageCount": 5, + "snapshotUrl": "https://sentry.io/organizations/org/preprod/snapshots/100/", + } + + CREATE_SNAPSHOT = [ + OpenApiExample( + "Snapshot Created", + value=EXAMPLE_SNAPSHOT_CREATED, + status_codes=["200"], + response_only=True, + ), + ] + + EXAMPLE_SNAPSHOT_IMAGE_DETAIL_CHANGED = { + "image_file_name": "home_screen_iphone15.png", + "comparison_status": "changed", + "head_image": { + "content_hash": "a1b2c3d4e5f6", + "display_name": "Home Screen", + "group": "iPhone 15", + "image_file_name": "home_screen_iphone15.png", + "width": 1170, + "height": 2532, + "diff_threshold": 0.01, + "description": None, + "tags": None, + "image_url": "/api/0/projects/org-slug/project-slug/files/images/a1b2c3d4e5f6/", + }, + "base_image": { + "content_hash": "old_hash_123", + "display_name": "Home Screen", + "group": "iPhone 15", + "image_file_name": "home_screen_iphone15.png", + "width": 1170, + "height": 2532, + "diff_threshold": 0.01, + "description": None, + "tags": None, + "image_url": "/api/0/projects/org-slug/project-slug/files/images/old_hash_123/", + }, + "diff_image_url": "/api/0/projects/org-slug/project-slug/files/images/diff_hash_456/", + "diff_percentage": 0.02, + "previous_image_file_name": None, + } + + EXAMPLE_SNAPSHOT_IMAGE_DETAIL_ADDED = { + "image_file_name": "new_screen.png", + "comparison_status": "added", + "head_image": { + "content_hash": "new_hash_789", + "display_name": "New Screen", + "group": "iPhone 15", + "image_file_name": "new_screen.png", + "width": 1170, + "height": 2532, + "diff_threshold": None, + "description": None, + "tags": None, + "image_url": "/api/0/projects/org-slug/project-slug/files/images/new_hash_789/", + }, + "base_image": None, + "diff_image_url": None, + "diff_percentage": None, + "previous_image_file_name": None, + } + + GET_SNAPSHOT_IMAGE_DETAIL = [ + OpenApiExample( + "Changed Image", + value=EXAMPLE_SNAPSHOT_IMAGE_DETAIL_CHANGED, + status_codes=["200"], + response_only=True, + ), + OpenApiExample( + "Added Image", + value=EXAMPLE_SNAPSHOT_IMAGE_DETAIL_ADDED, + status_codes=["200"], + response_only=True, + ), + ] + + EXAMPLE_LATEST_BASE_SNAPSHOT = { + "head_artifact_id": "99", + "project_id": "1", + "project_slug": "my-project", + "app_id": "com.example.app", + "image_count": 2, + "images": [ + { + "key": "a1b2c3d4e5f6", + "display_name": "Home Screen", + "group": "iPhone 15", + "image_file_name": "home_screen_iphone15.png", + "width": 1170, + "height": 2532, + "image_url": "/api/0/projects/org-slug/my-project/files/images/a1b2c3d4e5f6/", + }, + ], + "diff_threshold": 0.01, + "date_added": "2025-01-15T10:30:00+00:00", + "vcs_info": { + "head_sha": "abc123def456", + "base_sha": None, + "head_ref": "main", + "base_ref": None, + "head_repo_name": "org/repo", + "pr_number": None, + }, + } + + GET_LATEST_BASE_SNAPSHOT = [ + OpenApiExample( + "Latest Base Snapshot", + value=EXAMPLE_LATEST_BASE_SNAPSHOT, + status_codes=["200"], + response_only=True, + ), + ] diff --git a/src/sentry/apidocs/parameters.py b/src/sentry/apidocs/parameters.py index 35a79c3fc797..0281061c276a 100644 --- a/src/sentry/apidocs/parameters.py +++ b/src/sentry/apidocs/parameters.py @@ -10,6 +10,10 @@ # NOTE: Please add new params by path vs query, then in alphabetical order +# Some Sentry IDs are 32-char hex, rather than the UUID dashed form. +# Those cases match this pattern with OpenApiTypes.STR +SENTRY_HEX_ID_PATTERN = r"^[0-9a-f]{32}$" + def build_typed_list(type: Any): """ @@ -397,6 +401,33 @@ class IssueParams: required=False, many=True, ) + + GROUP_DETAILS_EXPAND = OpenApiParameter( + name="expand", + description="Additional data to include in the response.", + enum=[ + "inbox", + "owners", + "forecast", + "integrationIssues", + "sentryAppIssues", + "latestEventHasAttachments", + ], + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + required=False, + many=True, + ) + + GROUP_DETAILS_COLLAPSE = OpenApiParameter( + name="collapse", + description="Fields to remove from the response to improve query performance.", + enum=["release", "tags", "stats"], + location=OpenApiParameter.QUERY, + type=OpenApiTypes.STR, + required=False, + many=True, + ) MUTATE_ISSUE_ID_LIST = OpenApiParameter( name="id", description="The list of issue IDs to mutate. It is optional for status updates, in which an implicit `update all` is assumed.", @@ -541,16 +572,6 @@ class IssueAlertParams: ) -class MetricAlertParams: - METRIC_RULE_ID = OpenApiParameter( - name="alert_rule_id", - location="path", - required=True, - type=int, - description="The ID of the rule you'd like to query.", - ) - - class DataForwarderParams: DATA_FORWARDER_ID = OpenApiParameter( name="data_forwarder_id", @@ -761,8 +782,9 @@ class MonitorParams: name="processing_error_id", location="path", required=False, - type=OpenApiTypes.UUID, - description="The ID of the processing error.", + type=OpenApiTypes.STR, + pattern=SENTRY_HEX_ID_PATTERN, + description="The ID of the processing error. It is a 32-character hexadecimal string.", ) @@ -788,8 +810,9 @@ class EventParams: name="event_id", location="path", required=True, - type=OpenApiTypes.UUID, - description="The ID of the event.", + type=OpenApiTypes.STR, + pattern=SENTRY_HEX_ID_PATTERN, + description="The ID of the event. It is a 32-character hexadecimal string as reported by the client.", ) FRAME_IDX = OpenApiParameter( @@ -935,8 +958,9 @@ class ReplayParams: name="replay_id", location="path", required=True, - type=OpenApiTypes.UUID, - description="""The ID of the replay you'd like to retrieve.""", + type=OpenApiTypes.STR, + pattern=SENTRY_HEX_ID_PATTERN, + description="""The ID of the replay you'd like to retrieve. It is a 32-character hexadecimal string.""", ) SEGMENT_ID = OpenApiParameter( diff --git a/src/sentry/data_export/processors/discover.py b/src/sentry/data_export/processors/discover.py index 3aa75c1d166f..272ade9dbffd 100644 --- a/src/sentry/data_export/processors/discover.py +++ b/src/sentry/data_export/processors/discover.py @@ -57,7 +57,11 @@ def __init__(self, organization: Organization, discover_query: dict[str, Any]): @staticmethod def get_projects(organization_id: int, query: dict[str, Any]) -> list[Project]: - projects = list(Project.objects.filter(id__in=query.get("project"))) + projects = list( + Project.objects.filter( + id__in=query.get("project") or [], organization_id=organization_id + ) + ) if len(projects) == 0: raise ExportError("Requested project does not exist") return projects diff --git a/src/sentry/discover/endpoints/discover_saved_query_detail.py b/src/sentry/discover/endpoints/discover_saved_query_detail.py index db245784a67c..77703a1f165e 100644 --- a/src/sentry/discover/endpoints/discover_saved_query_detail.py +++ b/src/sentry/discover/endpoints/discover_saved_query_detail.py @@ -181,6 +181,8 @@ def post(self, request: Request, organization, query) -> Response: if not self.has_feature(organization, request): return self.respond(status=404) + self.check_object_permissions(request, query) + query.visits = F("visits") + 1 query.last_visited = timezone.now() query.save(update_fields=["visits", "last_visited"]) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index b184dd0aaea1..ede59bbc69cf 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -68,8 +68,7 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:dashboards-edit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True, default=True) # Enable metrics enhanced performance for AM2+ customers as they transition from AM2 to AM3 manager.add("organizations:dashboards-metrics-transition", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable drilldown flow for dashboards - manager.add("organizations:dashboards-drilldown-flow", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) + # Enable prebuilt dashboards for insights modules manager.add("organizations:dashboards-prebuilt-insights-dashboards", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable the details widget for dashboards @@ -292,9 +291,9 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:autofix-on-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable autofix introspection for early stopping of autofix runs manager.add("organizations:seer-autofix-introspection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable Seer Workflows in Slack + # Enable Seer Workflows in Slack (released, kept until overrides are removed) manager.add("organizations:seer-slack-workflows", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) - # Enable Seer Agent in Slack via @mentions + # Enable Seer Agent in Slack via @mentions (released, kept until overrides are removed) manager.add("organizations:seer-slack-explorer", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Show Seer run ID in Slack notification footers manager.add("organizations:seer-run-id-in-slack", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) @@ -331,6 +330,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:issue-feed.eap-search", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Remove trace and breadcrumbs from issue summary input manager.add("organizations:issue-summary-experimental", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + # Enable the experimental "recommended" sort option in the issue stream + manager.add("organizations:issue-stream-recommended-sort", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Lets organizations manage grouping configs manager.add("organizations:set-grouping-config", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=True) @@ -404,7 +405,7 @@ def register_temporary_features(manager: FeatureManager) -> None: # Enable metric detector limits by plan type manager.add("organizations:workflow-engine-metric-detector-limit", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable seer activities to be evaluated in workflow engine - manager.add("organization:workflow-engine-evaluate-seer-activities", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) + manager.add("organizations:workflow-engine-evaluate-seer-activities", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Enable our logs product (known internally as ourlogs) in UI and backend manager.add("organizations:ourlogs-enabled", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable our logs product to be ingested via Relay. @@ -444,8 +445,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:sentry-app-webhook-circuit-breaker", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) # Override dry-run to enable real blocking per app-owner org during rollout manager.add("organizations:sentry-app-webhook-circuit-breaker-live-run", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=False) - # Create organiations via control-scoped domains in saas. - manager.add("organizations:create-org-control", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enables organization access to the new notification platform manager.add("organizations:notification-platform.internal-testing", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_details.py b/src/sentry/incidents/endpoints/organization_alert_rule_details.py index 698c27e8cc01..0c6c03506258 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_details.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_details.py @@ -15,17 +15,8 @@ from sentry.api.helpers.deprecation import deprecated from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework.project import ProjectField -from sentry.apidocs.constants import ( - RESPONSE_ACCEPTED, - RESPONSE_FORBIDDEN, - RESPONSE_NOT_FOUND, - RESPONSE_UNAUTHORIZED, -) -from sentry.apidocs.examples.metric_alert_examples import MetricAlertExamples -from sentry.apidocs.parameters import GlobalParams, MetricAlertParams from sentry.constants import ALERTS_API_DEPRECATION_DATE from sentry.incidents.endpoints.bases import WorkflowEngineOrganizationAlertRuleEndpoint -from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializer from sentry.incidents.endpoints.serializers.workflow_engine_detector import ( DetailedWorkflowEngineDetectorSerializer, WorkflowEngineDetectorSerializer, @@ -293,14 +284,6 @@ class OrganizationAlertRuleDetailsEndpoint(WorkflowEngineOrganizationAlertRuleEn @extend_schema( operation_id="(DEPRECATED) Retrieve a Metric Alert Rule for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG, MetricAlertParams.METRIC_RULE_ID], - responses={ - 200: AlertRuleSerializer, - 401: RESPONSE_UNAUTHORIZED, - 403: RESPONSE_FORBIDDEN, - 404: RESPONSE_NOT_FOUND, - }, - examples=MetricAlertExamples.GET_METRIC_ALERT_RULE, ) @track_alert_endpoint_execution("GET", "sentry-api-0-organization-alert-rule-details") @deprecated( @@ -329,15 +312,6 @@ def get( @extend_schema( operation_id="(DEPRECATED) Update a Metric Alert Rule", - parameters=[GlobalParams.ORG_ID_OR_SLUG, MetricAlertParams.METRIC_RULE_ID], - request=OrganizationAlertRuleDetailsPutSerializer, - responses={ - 200: AlertRuleSerializer, - 401: RESPONSE_UNAUTHORIZED, - 403: RESPONSE_FORBIDDEN, - 404: RESPONSE_NOT_FOUND, - }, - examples=MetricAlertExamples.UPDATE_METRIC_ALERT_RULE, ) @track_alert_endpoint_execution("PUT", "sentry-api-0-organization-alert-rule-details") @deprecated( @@ -371,13 +345,6 @@ def put( @extend_schema( operation_id="(DEPRECATED) Delete a Metric Alert Rule", - parameters=[GlobalParams.ORG_ID_OR_SLUG, MetricAlertParams.METRIC_RULE_ID], - responses={ - 202: RESPONSE_ACCEPTED, - 401: RESPONSE_UNAUTHORIZED, - 403: RESPONSE_FORBIDDEN, - 404: RESPONSE_NOT_FOUND, - }, ) @track_alert_endpoint_execution("DELETE", "sentry-api-0-organization-alert-rule-details") @deprecated( diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py index 954a28754672..c309bda611dd 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py @@ -41,19 +41,11 @@ from sentry.api.serializers import serialize from sentry.api.serializers.rest_framework.project import ProjectField from sentry.api.utils import to_valid_int_id -from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED -from sentry.apidocs.examples.metric_alert_examples import MetricAlertExamples -from sentry.apidocs.parameters import GlobalParams -from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.constants import ALERTS_API_DEPRECATION_DATE, ObjectStatus from sentry.db.models.manager.base_query_set import BaseQuerySet from sentry.db.postgres.transactions import in_test_hide_transaction_boundary from sentry.exceptions import InvalidParams from sentry.incidents.endpoints.bases import OrganizationAlertRuleBaseEndpoint -from sentry.incidents.endpoints.serializers.alert_rule import ( - AlertRuleSerializer, - AlertRuleSerializerResponse, -) from sentry.incidents.endpoints.serializers.workflow_engine_combined import ( WorkflowEngineCombinedRuleSerializer, ) @@ -725,17 +717,6 @@ class OrganizationAlertRuleIndexEndpoint(OrganizationAlertRuleBaseEndpoint, Aler @extend_schema( operation_id="(DEPRECATED) List an Organization's Metric Alert Rules", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - request=None, - responses={ - 200: inline_sentry_response_serializer( - "ListMetricAlertRules", list[AlertRuleSerializerResponse] - ), - 401: RESPONSE_UNAUTHORIZED, - 403: RESPONSE_FORBIDDEN, - 404: RESPONSE_NOT_FOUND, - }, - examples=MetricAlertExamples.LIST_METRIC_ALERT_RULES, # TODO: make ) @track_alert_endpoint_execution("GET", "sentry-api-0-organization-alert-rules") @deprecated( @@ -761,15 +742,6 @@ def get(self, request: Request, organization: Organization) -> HttpResponseBase: @extend_schema( operation_id="(DEPRECATED) Create a Metric Alert Rule for an Organization", - parameters=[GlobalParams.ORG_ID_OR_SLUG], - request=OrganizationAlertRuleIndexPostSerializer, - responses={ - 201: AlertRuleSerializer, - 401: RESPONSE_UNAUTHORIZED, - 403: RESPONSE_FORBIDDEN, - 404: RESPONSE_NOT_FOUND, - }, - examples=MetricAlertExamples.CREATE_METRIC_ALERT_RULE, ) @track_alert_endpoint_execution("POST", "sentry-api-0-organization-alert-rules") @deprecated( diff --git a/src/sentry/incidents/endpoints/serializers/alert_rule.py b/src/sentry/incidents/endpoints/serializers/alert_rule.py index f8afafe801e3..58eaf1282f6c 100644 --- a/src/sentry/incidents/endpoints/serializers/alert_rule.py +++ b/src/sentry/incidents/endpoints/serializers/alert_rule.py @@ -1,43 +1,10 @@ from __future__ import annotations -import logging -from collections import defaultdict -from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime -from typing import Any, TypedDict, cast +from typing import Any, TypedDict -from django.contrib.auth.models import AnonymousUser -from django.db.models import Max, Q, prefetch_related_objects from drf_spectacular.utils import extend_schema_serializer -from sentry import features -from sentry.api.serializers import Serializer, register, serialize -from sentry.incidents.models.alert_rule import ( - AlertRule, - AlertRuleActivity, - AlertRuleActivityType, - AlertRuleTrigger, - AlertRuleTriggerAction, -) -from sentry.incidents.models.incident import Incident -from sentry.models.rulesnooze import RuleSnooze -from sentry.sentry_apps.models.sentry_app_installation import prepare_ui_component -from sentry.sentry_apps.services.app import app_service -from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext -from sentry.snuba.dataset import Dataset -from sentry.snuba.models import ExtrapolationMode, SnubaQueryEventType -from sentry.users.models.user import User -from sentry.users.services.user import RpcUser -from sentry.users.services.user.service import user_service -from sentry.workflow_engine.utils.legacy_metric_tracking import report_used_legacy_models - -__all__ = [ - "AlertRuleSerializer", - "DetailedAlertRuleSerializer", -] - -logger = logging.getLogger(__name__) - class AlertRuleSerializerResponseOptional(TypedDict, total=False): environment: str | None @@ -98,283 +65,9 @@ class AlertRuleSerializerResponse(AlertRuleSerializerResponseOptional): class DetailedAlertRuleSerializerResponse(AlertRuleSerializerResponse, total=False): """ - Response type for DetailedAlertRuleSerializer, which includes additional - snooze-related fields beyond the base AlertRuleSerializerResponse. + Response type that includes additional snooze-related fields beyond the base + AlertRuleSerializerResponse. """ snoozeForEveryone: bool | None snoozeCreatedBy: str | None - - -@register(AlertRule) -class AlertRuleSerializer(Serializer): - """ - Serializer for returning an alert rule to the client - """ - - def __init__(self, expand: list[str] | None = None, prepare_component_fields: bool = False): - self.expand = expand or [] - self.prepare_component_fields = prepare_component_fields - - def get_attrs( - self, item_list: Sequence[Any], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> defaultdict[AlertRule, Any]: - alert_rules = {item.id: item for item in item_list} - prefetch_related_objects(item_list, "snuba_query__environment") - - result: defaultdict[AlertRule, dict[str, Any]] = defaultdict(dict) - triggers = AlertRuleTrigger.objects.filter(alert_rule__in=item_list).order_by("label") - serialized_triggers = serialize(list(triggers), **kwargs) - - trigger_actions = AlertRuleTriggerAction.objects.filter( - alert_rule_trigger__alert_rule_id__in=alert_rules.keys() - ).exclude(Q(sentry_app_config__isnull=True) | Q(sentry_app_id__isnull=True)) - - sentry_app_installations_by_sentry_app_id: Mapping[str, RpcSentryAppComponentContext] = {} - organization_ids = list({alert_rule.organization_id for alert_rule in alert_rules.values()}) - if self.prepare_component_fields: - sentry_app_ids = list(trigger_actions.values_list("sentry_app_id", flat=True)) - install_contexts = app_service.get_component_contexts( - filter={"app_ids": sentry_app_ids, "organization_id": organization_ids[0]}, - component_type="alert-rule-action", - ) - sentry_app_installations_by_sentry_app_id = { - str(context.installation.sentry_app.id): context - for context in install_contexts - if context.installation.sentry_app - } - - for trigger, serialized in zip(triggers, serialized_triggers): - errors = [] - alert_rule = alert_rules[trigger.alert_rule_id] - alert_rule_triggers = result[alert_rule].setdefault("triggers", []) - for action in serialized.get("actions", []): - if action is None: - continue - - # Prepare AlertRuleTriggerActions that are SentryApp components - install_context = None - sentry_app_id = str(action.get("sentryAppId")) - if sentry_app_id: - install_context = sentry_app_installations_by_sentry_app_id.get(sentry_app_id) - if install_context: - rpc_install = install_context.installation - rpc_component = install_context.component - rpc_app = rpc_install.sentry_app - assert rpc_app - - action["sentryAppInstallationUuid"] = rpc_install.uuid - - component = ( - prepare_ui_component( - rpc_install, - rpc_component, - None, - action.get("settings"), - ) - if rpc_component - else None - ) - if component is None: - errors.append({"detail": f"Could not fetch details from {rpc_app.name}"}) - action["disabled"] = True - continue - - action["formFields"] = component.app_schema.get("settings", {}) - - if errors: - result[alert_rule]["errors"] = errors - alert_rule_triggers.append(serialized) - - alert_rule_projects = set() - for alert_rule in alert_rules.values(): - if alert_rule.projects.exists(): - for project in alert_rule.projects.all(): - alert_rule_projects.add((alert_rule.id, project.slug)) - - snuba_alert_rule_projects = AlertRule.objects.filter( - id__in=[item.id for item in item_list] - ).values_list("id", "projects__slug") - - alert_rule_projects.update( - [(id, project_slug) for id, project_slug in snuba_alert_rule_projects if project_slug] - ) - - for alert_rule_id, project_slug in alert_rule_projects: - rule_result = result[alert_rules[alert_rule_id]].setdefault("projects", []) - rule_result.append(project_slug) - - rule_activities = list( - AlertRuleActivity.objects.filter( - alert_rule__in=item_list, type=AlertRuleActivityType.CREATED.value - ) - ) - - user_by_user_id: MutableMapping[int, RpcUser] = { - user.id: user - for user in user_service.get_many_by_id( - ids=[r.user_id for r in rule_activities if r.user_id is not None] - ) - } - for rule_activity in rule_activities: - if rule_activity.user_id is not None: - rpc_user = user_by_user_id.get(rule_activity.user_id) - else: - rpc_user = None - if rpc_user: - created_by = dict( - id=rpc_user.id, name=rpc_user.get_display_name(), email=rpc_user.email - ) - else: - created_by = None - result[alert_rules[rule_activity.alert_rule_id]]["created_by"] = created_by - - for item in item_list: - if item.user_id or item.team_id: - actor = item.owner - if actor: - result[item]["owner"] = actor.identifier - - if "original_alert_rule" in self.expand: - snapshot_activities = AlertRuleActivity.objects.filter( - alert_rule__in=item_list, - type=AlertRuleActivityType.SNAPSHOT.value, - ) - for activity in snapshot_activities: - result[alert_rules[activity.alert_rule_id]]["originalAlertRuleId"] = ( - activity.previous_alert_rule_id - ) - - if "latestIncident" in self.expand: - incident_map = {} - for incident in Incident.objects.filter( - id__in=Incident.objects.filter(alert_rule__in=alert_rules) - .values("alert_rule_id") - .annotate(incident_id=Max("id")) - .values("incident_id") - ): - incident_map[incident.alert_rule_id] = serialize(incident, user=user) - for alert_rule in alert_rules.values(): - result[alert_rule]["latestIncident"] = incident_map.get(alert_rule.id, None) - return result - - def serialize( - self, - obj: AlertRule, - attrs: Mapping[Any, Any], - user: User | RpcUser | AnonymousUser, - **kwargs: Any, - ) -> AlertRuleSerializerResponse: - # Mark that we're using legacy AlertRule models - report_used_legacy_models() - - from sentry.incidents.endpoints.utils import translate_threshold - from sentry.incidents.logic import translate_aggregate_field - - env = obj.snuba_query.environment - allow_mri = features.has( - "organizations:insights-alerts", - obj.organization, - actor=user, - ) - - # Trace metrics have complicated aggregated validation that require EAP SearchResolver and do NOT need translation as they do not have tags in the old format (eg. tags[sentry:user)) - is_trace_metric = ( - obj.snuba_query.dataset == Dataset.EventsAnalyticsPlatform.value - and obj.snuba_query.event_types - and SnubaQueryEventType.EventType.TRACE_ITEM_METRIC in obj.snuba_query.event_types - ) - - # Temporary: Translate aggregate back here from `tags[sentry:user]` to `user` for the frontend. - if not is_trace_metric: - aggregate = translate_aggregate_field( - obj.snuba_query.aggregate, - reverse=True, - allow_mri=allow_mri, - allow_eap=obj.snuba_query.dataset == Dataset.EventsAnalyticsPlatform.value, - ) - else: - aggregate = obj.snuba_query.aggregate - - # Apply transparency: Convert upsampled_count() back to count() for user-facing responses - # This hides the internal upsampling implementation from users - if aggregate == "upsampled_count()": - aggregate = "count()" - - extrapolation_mode = obj.snuba_query.extrapolation_mode - - data: AlertRuleSerializerResponse = { - "id": str(obj.id), - "name": obj.name, - "organizationId": str(obj.organization_id), - "status": obj.status, - "queryType": obj.snuba_query.type, - "dataset": obj.snuba_query.dataset, - "query": obj.snuba_query.query, - "aggregate": aggregate, - "thresholdType": obj.threshold_type, - "resolveThreshold": translate_threshold(obj, obj.resolve_threshold), - # TODO: Start having the frontend expect seconds - "timeWindow": obj.snuba_query.time_window / 60, - "environment": env.name if env else None, - # TODO: Start having the frontend expect seconds - "resolution": obj.snuba_query.resolution / 60, - "thresholdPeriod": obj.threshold_period, - "triggers": attrs.get("triggers", []), - "projects": sorted(attrs.get("projects", [])), - "owner": attrs.get("owner", None), - "originalAlertRuleId": attrs.get("originalAlertRuleId", None), - "comparisonDelta": obj.comparison_delta / 60 if obj.comparison_delta else None, - "dateModified": obj.date_modified, - "dateCreated": obj.date_added, - "createdBy": attrs.get("created_by", None), - "description": obj.description if obj.description is not None else "", - "sensitivity": obj.sensitivity, - "seasonality": obj.seasonality, - "detectionType": obj.detection_type, - } - rule_snooze = RuleSnooze.objects.filter( - Q(user_id=user.id) | Q(user_id=None), alert_rule=obj - ) - if rule_snooze.exists(): - data["snooze"] = True - - if "latestIncident" in self.expand: - data["latestIncident"] = attrs.get("latestIncident", None) - if "errors" in attrs: - data["errors"] = attrs["errors"] - - if extrapolation_mode is not None: - data["extrapolationMode"] = ExtrapolationMode(extrapolation_mode).name.lower() - - return data - - -class DetailedAlertRuleSerializer(AlertRuleSerializer): - def get_attrs( - self, item_list: Sequence[Any], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> defaultdict[AlertRule, Any]: - result = super().get_attrs(item_list, user, **kwargs) - query_to_alert_rule = {ar.snuba_query_id: ar for ar in item_list} - - for event_type in SnubaQueryEventType.objects.filter( - snuba_query_id__in=[item.snuba_query_id for item in item_list] - ): - event_types = result[query_to_alert_rule[event_type.snuba_query_id]].setdefault( - "event_types", [] - ) - event_types.append(SnubaQueryEventType.EventType(event_type.type).name.lower()) - - return result - - def serialize( - self, - obj: AlertRule, - attrs: Mapping[Any, Any], - user: User | RpcUser | AnonymousUser, - **kwargs: Any, - ) -> DetailedAlertRuleSerializerResponse: - data = cast(DetailedAlertRuleSerializerResponse, super().serialize(obj, attrs, user)) - data["eventTypes"] = sorted(attrs.get("event_types", [])) - data["snooze"] = False - return data diff --git a/src/sentry/incidents/endpoints/serializers/incident.py b/src/sentry/incidents/endpoints/serializers/incident.py index 80c5e236fc90..1f0bd5df9de4 100644 --- a/src/sentry/incidents/endpoints/serializers/incident.py +++ b/src/sentry/incidents/endpoints/serializers/incident.py @@ -1,23 +1,8 @@ -from collections import defaultdict -from collections.abc import Mapping, Sequence from datetime import datetime -from typing import Any, TypedDict +from typing import TypedDict -from django.contrib.auth.models import AnonymousUser -from django.db.models import prefetch_related_objects - -from sentry.api.serializers import Serializer, register, serialize from sentry.api.serializers.models.incidentactivity import IncidentActivitySerializerResponse -from sentry.incidents.endpoints.serializers.alert_rule import ( - AlertRuleSerializerResponse, - DetailedAlertRuleSerializer, -) -from sentry.incidents.models.incident import Incident, IncidentActivity, IncidentProject -from sentry.snuba.entity_subscription import apply_dataset_query_conditions -from sentry.snuba.models import SnubaQuery -from sentry.users.models.user import User -from sentry.users.services.user import RpcUser -from sentry.workflow_engine.utils.legacy_metric_tracking import report_used_legacy_models +from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializerResponse class IncidentSerializerResponse(TypedDict): @@ -39,105 +24,3 @@ class IncidentSerializerResponse(TypedDict): class DetailedIncidentSerializerResponse(IncidentSerializerResponse): discoverQuery: str - - -@register(Incident) -class IncidentSerializer(Serializer): - def __init__(self, expand: list[str] | None = None) -> None: - self.expand = expand or [] - - def get_attrs( - self, item_list: Sequence[Incident], user: User | RpcUser | AnonymousUser, **kwargs: Any - ) -> dict[Incident, Any]: - prefetch_related_objects(item_list, "alert_rule__snuba_query") - incident_projects = defaultdict(list) - for incident_project in IncidentProject.objects.filter( - incident__in=item_list - ).select_related("project"): - incident_projects[incident_project.incident_id].append(incident_project.project.slug) - - alert_rules = { - d["id"]: d - for d in serialize( - {i.alert_rule for i in item_list if i.alert_rule.id}, - user, - DetailedAlertRuleSerializer(expand=self.expand), - ) - } - - results = {} - for incident in item_list: - results[incident] = {"projects": incident_projects.get(incident.id, [])} - results[incident]["alert_rule"] = alert_rules.get(str(incident.alert_rule.id)) # type: ignore[assignment] - - if "activities" in self.expand: - # There could be many activities. An incident could seesaw between error/warning for a long period. - # e.g - every 1 minute for 10 months - activities = list(IncidentActivity.objects.filter(incident__in=item_list)[:1000]) - incident_activities = defaultdict(list) - for activity, serialized_activity in zip(activities, serialize(activities, user=user)): - incident_activities[activity.incident_id].append(serialized_activity) - for incident in item_list: - results[incident]["activities"] = incident_activities[incident.id] - - return results - - def serialize( - self, - obj: Incident, - attrs: Mapping[str, Any], - user: User | RpcUser | AnonymousUser, - **kwargs: Any, - ) -> IncidentSerializerResponse: - # Mark that we're using legacy Incident models (which depend on AlertRule) - report_used_legacy_models() - - date_closed = obj.date_closed.replace(second=0, microsecond=0) if obj.date_closed else None - return { - "id": str(obj.id), - "identifier": str(obj.identifier), - "organizationId": str(obj.organization_id), - "projects": attrs["projects"], - "alertRule": attrs["alert_rule"], - "activities": attrs["activities"] if "activities" in self.expand else None, - "status": obj.status, - "statusMethod": obj.status_method, - "type": obj.type, - "title": obj.title, - "dateStarted": obj.date_started, - "dateDetected": obj.date_detected, - "dateCreated": obj.date_added, - "dateClosed": date_closed, - } - - -class DetailedIncidentSerializer(IncidentSerializer): - def __init__(self, expand: list[str] | None = None) -> None: - if expand is None: - expand = [] - if "original_alert_rule" not in expand: - expand.append("original_alert_rule") - super().__init__(expand=expand) - - def serialize( - self, - obj: Incident, - attrs: Mapping[str, Any], - user: User | RpcUser | AnonymousUser, - **kwargs: Any, - ) -> DetailedIncidentSerializerResponse: - base_context = super().serialize(obj, attrs, user) - # The query we should use to get accurate results in Discover. - context = DetailedIncidentSerializerResponse( - **base_context, discoverQuery=self._build_discover_query(obj) - ) - - return context - - def _build_discover_query(self, incident: Incident) -> str: - return apply_dataset_query_conditions( - SnubaQuery.Type(incident.alert_rule.snuba_query.type), - incident.alert_rule.snuba_query.query, - incident.alert_rule.snuba_query.event_types, - discover=True, - ) diff --git a/src/sentry/integrations/github_enterprise/integration.py b/src/sentry/integrations/github_enterprise/integration.py index bef5383e4f20..4d4dbe04c65f 100644 --- a/src/sentry/integrations/github_enterprise/integration.py +++ b/src/sentry/integrations/github_enterprise/integration.py @@ -9,8 +9,11 @@ from django.http.response import HttpResponseBase, HttpResponseRedirect from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from rest_framework.fields import BooleanField, CharField, URLField from sentry import features, http +from sentry.api.serializers.rest_framework.base import CamelSnakeSerializer +from sentry.identity.oauth2 import OAuth2ApiStep from sentry.identity.pipeline import IdentityPipeline from sentry.integrations.base import ( FeatureDescription, @@ -46,7 +49,8 @@ from sentry.models.repository import Repository from sentry.organizations.services.organization import organization_service from sentry.organizations.services.organization.model import RpcOrganization -from sentry.pipeline.views.base import PipelineView +from sentry.pipeline.types import PipelineStepResult +from sentry.pipeline.views.base import ApiPipelineSteps, PipelineView from sentry.pipeline.views.nested import NestedPipelineView from sentry.shared_integrations.constants import ERR_INTERNAL, ERR_UNAUTHORIZED from sentry.shared_integrations.exceptions import ( @@ -554,6 +558,111 @@ def update_organization_config(self, data: MutableMapping[str, Any]) -> None: self.org_integration = org_integration +class GHEInstallationConfigSerializer(CamelSnakeSerializer): + url = URLField(required=True) + id = CharField(required=True) + name = CharField(required=True) + public_link = URLField(required=False, allow_blank=True, default="") + verify_ssl = BooleanField(required=False, default=True) + webhook_secret = CharField(required=True) + private_key = CharField(required=True) + client_id = CharField(required=True) + client_secret = CharField(required=True) + + +class GHEInstallationConfigApiStep: + step_name = "installation_config" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + return { + "defaults": { + "verifySsl": True, + }, + } + + def get_serializer_cls(self) -> type: + return GHEInstallationConfigSerializer + + def handle_post( + self, + validated_data: dict[str, Any], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + validated_data["url"] = urlparse(validated_data["url"]).netloc.lower() + + if validated_data["url"] == "github.com" and not features.has( + "organizations:github-enterprise-github-com-source", + pipeline.organization, + ): + return PipelineStepResult.error( + "Installing on github.com is not enabled for your organization. " + "Contact Sentry support to request access." + ) + + if not validated_data["public_link"]: + validated_data["public_link"] = None + + pipeline.bind_state("installation_data", validated_data) + pipeline.bind_state( + "oauth_config_information", + { + "access_token_url": f"https://{validated_data['url']}/login/oauth/access_token", + "authorize_url": f"https://{validated_data['url']}/login/oauth/authorize", + "client_id": validated_data["client_id"], + "client_secret": validated_data["client_secret"], + "verify_ssl": validated_data["verify_ssl"], + }, + ) + return PipelineStepResult.advance() + + +class GHEAppInstallSerializer(CamelSnakeSerializer): + installation_id = CharField(required=False, allow_blank=True, default="") + + +def _get_app_install_url(installation_data: Mapping[str, Any]) -> str: + if installation_data.get("public_link"): + return installation_data["public_link"] + + url = installation_data.get("url") + name = installation_data.get("name") + # github.com uses /apps/{name}; GHES uses /github-apps/{name}. + if url == "github.com": + return f"https://{url}/apps/{name}" + return f"https://{url}/github-apps/{name}" + + +class GHEAppInstallRedirectApiStep: + step_name = "app_install_redirect" + + def get_step_data(self, pipeline: IntegrationPipeline, request: HttpRequest) -> dict[str, Any]: + installation_data = pipeline.fetch_state("installation_data") + if installation_data is None: + raise AssertionError("pipeline called out of order") + return {"appInstallUrl": _get_app_install_url(installation_data)} + + def get_serializer_cls(self) -> type: + return GHEAppInstallSerializer + + def handle_post( + self, + validated_data: dict[str, Any], + pipeline: IntegrationPipeline, + request: HttpRequest, + ) -> PipelineStepResult: + installation_id = validated_data.get("installation_id") + if not installation_id: + installation_data = pipeline.fetch_state("installation_data") + if installation_data is None: + raise AssertionError("pipeline called out of order") + return PipelineStepResult.stay( + data={"appInstallUrl": _get_app_install_url(installation_data)} + ) + pipeline.bind_state("installation_id", installation_id) + return PipelineStepResult.advance() + + class InstallationForm(forms.Form): url = forms.CharField( label="Installation Url", @@ -738,12 +847,31 @@ def get_pipeline_views( return ( InstallationConfigView(), GitHubEnterpriseInstallationRedirect(), - # The identity provider pipeline should be constructed at execution - # time, this allows for the oauth configuration parameters to be made - # available from the installation config view. lambda: self._make_identity_pipeline_view(), ) + def _make_oauth_api_step(self) -> OAuth2ApiStep: + oauth_info = self.pipeline.fetch_state("oauth_config_information") + if oauth_info is None: + raise AssertionError("pipeline called out of order") + return OAuth2ApiStep( + authorize_url=oauth_info["authorize_url"], + client_id=oauth_info["client_id"], + client_secret=oauth_info["client_secret"], + access_token_url=oauth_info["access_token_url"], + scope="", + redirect_url=absolute_uri("/extensions/github-enterprise/setup/"), + verify_ssl=oauth_info.get("verify_ssl", True), + bind_key="oauth_data", + ) + + def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]: + return [ + GHEInstallationConfigApiStep(), + GHEAppInstallRedirectApiStep(), + lambda: self._make_oauth_api_step(), + ] + def post_install( self, integration: Integration, @@ -792,7 +920,13 @@ def _get_ghe_installation_info(self, installation_data, access_token, installati return None def build_integration(self, state: Mapping[str, Any]) -> IntegrationData: - identity = state["identity"]["data"] + # TODO: legacy views write token data to state["identity"]["data"] via + # NestedPipelineView. API steps write directly to state["oauth_data"]. + # Remove the legacy path once the old views are retired. + if "oauth_data" in state: + identity = state["oauth_data"] + else: + identity = state["identity"]["data"] installation_data = state["installation_data"] user = get_user_info(installation_data["url"], identity["access_token"]) installation = self._get_ghe_installation_info( @@ -838,22 +972,13 @@ def setup(self): class GitHubEnterpriseInstallationRedirect: - def get_app_url(self, installation_data): - if installation_data.get("public_link"): - return installation_data["public_link"] - - url = installation_data.get("url") - name = installation_data.get("name") - # github.com uses /apps/{name}; GHES uses /github-apps/{name}. - if url == "github.com": - return f"https://{url}/apps/{name}" - return f"https://{url}/github-apps/{name}" - def dispatch(self, request: HttpRequest, pipeline: IntegrationPipeline) -> HttpResponseBase: installation_data = pipeline.fetch_state(key="installation_data") + if installation_data is None: + raise AssertionError("pipeline called out of order") if "installation_id" in request.GET: pipeline.bind_state("installation_id", request.GET["installation_id"]) return pipeline.next_step() - return HttpResponseRedirect(self.get_app_url(installation_data)) + return HttpResponseRedirect(_get_app_install_url(installation_data)) diff --git a/src/sentry/integrations/slack/requests/event.py b/src/sentry/integrations/slack/requests/event.py index f29d1463cb51..4a0402b0d3e9 100644 --- a/src/sentry/integrations/slack/requests/event.py +++ b/src/sentry/integrations/slack/requests/event.py @@ -21,8 +21,9 @@ from sentry.models.organizationmember import InviteStatus from sentry.organizations.services.organization.model import RpcUserOrganizationContext from sentry.organizations.services.organization.service import organization_service -from sentry.seer.entrypoints.slack.entrypoint import SlackAgentEntrypoint +from sentry.seer.entrypoints.operator import SeerAgentOperator from sentry.seer.entrypoints.slack.mention import _SLACK_URL_RE, build_thread_context +from sentry.seer.entrypoints.types import SeerEntrypointKey from sentry.silo.base import SiloMode, all_silo_function from sentry.users.services.user.service import user_service @@ -76,15 +77,12 @@ def _resolve_available_organizations( logger.info("_resolve_available_organizations.inactive_org", extra=logging_ctx) continue - # Since the getsentry FeatureHandler does _not_ add subscription context to CONTROL - # evaluations, we need to slim down the check to only cover the feature flag. - # This is actually fine, since after routing, this method is rerun at the CELL. - if SiloMode.get_current_mode() == SiloMode.CONTROL: - if not SlackAgentEntrypoint.has_feature_flag(ctx.organization): - logger.info("_resolve_available_organizations.no_feature_flag", extra=logging_ctx) - continue - else: - if not SlackAgentEntrypoint.has_access(ctx.organization): + # In CONTROL silo the getsentry FeatureHandler does _not_ add subscription + # context, so we skip the full access check — it is re-evaluated at CELL. + if SiloMode.get_current_mode() != SiloMode.CONTROL: + if not SeerAgentOperator.has_access( + organization=ctx.organization, entrypoint_key=SeerEntrypointKey.SLACK + ): logger.info("_resolve_available_organizations.no_access", extra=logging_ctx) continue diff --git a/src/sentry/integrations/source_code_management/commit_context.py b/src/sentry/integrations/source_code_management/commit_context.py index 777ef2fbb2c2..2f0064f6aaf4 100644 --- a/src/sentry/integrations/source_code_management/commit_context.py +++ b/src/sentry/integrations/source_code_management/commit_context.py @@ -594,7 +594,7 @@ def get_top_5_issues_by_count( snuba_results, eap_results, "integrations.pr_comment.get_top_5_issues_by_count", - is_experimental_data_a_null_result=len(eap_results) == 0, + is_experimental_data_nullish=len(eap_results) == 0, reasonable_match_comparator=lambda snuba_rows, eap_rows: keyed_counts_subset_match( snuba_rows, eap_rows, diff --git a/src/sentry/integrations/vercel/integration.py b/src/sentry/integrations/vercel/integration.py index 2c6f2c27d57b..dc96d2032729 100644 --- a/src/sentry/integrations/vercel/integration.py +++ b/src/sentry/integrations/vercel/integration.py @@ -301,71 +301,86 @@ def get_organization_config(self): def update_organization_config(self, data): # data = {"project_mappings": [[sentry_project_id, vercel_project_id]]} - vercel_client = self.get_client() - config = self.org_integration.config + sentry_project_id: int | None = None + vercel_project_id: str | None = None try: - new_mappings = data["project_mappings"] - except KeyError: - raise ValidationError("Failed to update configuration.") + vercel_client = self.get_client() + config = self.org_integration.config + try: + new_mappings = data["project_mappings"] + except KeyError: + raise ValidationError("Failed to update configuration.") - old_mappings = config.get("project_mappings") or [] + old_mappings = config.get("project_mappings") or [] - sentry_projects = {proj.id: proj for proj in self.organization.projects} + sentry_projects = {proj.id: proj for proj in self.organization.projects} - for mapping in new_mappings: - # skip any mappings that already exist - if mapping in old_mappings: - continue + for mapping in new_mappings: + # skip any mappings that already exist + if mapping in old_mappings: + continue - [sentry_project_id, vercel_project_id] = mapping - sentry_project = sentry_projects[sentry_project_id] + [sentry_project_id, vercel_project_id] = mapping + sentry_project = sentry_projects[sentry_project_id] - project_key = project_key_service.get_default_project_key( - organization_id=self.organization_id, project_id=sentry_project_id - ) - if not project_key: - raise ValidationError( - {"project_mappings": ["You must have an enabled DSN to continue!"]} + project_key = project_key_service.get_default_project_key( + organization_id=self.organization_id, project_id=sentry_project_id ) + if not project_key: + raise ValidationError( + {"project_mappings": ["You must have an enabled DSN to continue!"]} + ) - vercel_project = vercel_client.get_project(vercel_project_id) - sentry_auth_token = SentryAppInstallationToken.objects.get_token( - sentry_project.organization_id, - "vercel", - ) + vercel_project = vercel_client.get_project(vercel_project_id) + sentry_auth_token = SentryAppInstallationToken.objects.get_token( + sentry_project.organization_id, + "vercel", + ) - env_var_map = ( - VercelEnvVarMapBuilder() - .with_organization(self.organization) - .with_project(sentry_project) - .with_project_key(project_key) - .with_auth_token(sentry_auth_token) - .with_framework(vercel_project.get("framework")) - .build() - ) + env_var_map = ( + VercelEnvVarMapBuilder() + .with_organization(self.organization) + .with_project(sentry_project) + .with_project_key(project_key) + .with_auth_token(sentry_auth_token) + .with_framework(vercel_project.get("framework")) + .build() + ) - for env_var, details in env_var_map.items(): - # We are logging a message because we potentially have a weird bug where auth tokens disappear from vercel - if env_var == "SENTRY_AUTH_TOKEN" and details["value"] is None: - sentry_sdk.capture_message( - "Setting SENTRY_AUTH_TOKEN env var with None value in Vercel integration" + for env_var, details in env_var_map.items(): + # We are logging a message because we potentially have a weird bug where auth tokens disappear from vercel + if env_var == "SENTRY_AUTH_TOKEN" and details["value"] is None: + sentry_sdk.capture_message( + "Setting SENTRY_AUTH_TOKEN env var with None value in Vercel integration" + ) + + self.create_env_var( + vercel_client, + vercel_project_id, + env_var, + details["value"], + details["type"], + details["target"], ) - - self.create_env_var( - vercel_client, - vercel_project_id, - env_var, - details["value"], - details["type"], - details["target"], - ) - config.update(data) - org_integration = integration_service.update_organization_integration( - org_integration_id=self.org_integration.id, - config=config, - ) - if org_integration is not None: - self.org_integration = org_integration + config.update(data) + org_integration = integration_service.update_organization_integration( + org_integration_id=self.org_integration.id, + config=config, + ) + if org_integration is not None: + self.org_integration = org_integration + except Exception as e: + logger.exception( + "vercel.link_sentry_project.failed", + extra={ + "organization_id": self.organization_id, + "integration_id": self.model.id, + "sentry_project_id": sentry_project_id, + "vercel_project_id": vercel_project_id, + "error_type": type(e).__name__, + }, + ) + raise def create_env_var(self, client, vercel_project_id, key, value, type, target): data = { diff --git a/src/sentry/issues/endpoints/bases/group_search_view.py b/src/sentry/issues/endpoints/bases/group_search_view.py index 26c91ceb7724..70841dbbe885 100644 --- a/src/sentry/issues/endpoints/bases/group_search_view.py +++ b/src/sentry/issues/endpoints/bases/group_search_view.py @@ -35,4 +35,4 @@ def has_object_permission(self, request: Request, view: APIView, obj: object) -> return False - return True + return False diff --git a/src/sentry/issues/endpoints/group_hashes.py b/src/sentry/issues/endpoints/group_hashes.py index b48e575a0a94..aa0dd13ad047 100644 --- a/src/sentry/issues/endpoints/group_hashes.py +++ b/src/sentry/issues/endpoints/group_hashes.py @@ -49,7 +49,7 @@ def get(self, request: Request, group: Group) -> Response: :pparam bool full: If this is set to true, the event payload will include the full event body, including the stacktrace. :auth: required """ - full = request.GET.get("full", True) + full = request.GET.get("full") not in ("0", "false") data_fn = partial( lambda *args, **kwargs: raw_query(*args, **kwargs)["data"], diff --git a/src/sentry/issues/endpoints/project_event_details.py b/src/sentry/issues/endpoints/project_event_details.py index ae2a27425ad7..c781701c78dd 100644 --- a/src/sentry/issues/endpoints/project_event_details.py +++ b/src/sentry/issues/endpoints/project_event_details.py @@ -2,6 +2,7 @@ from typing import Any import sentry_sdk +from drf_spectacular.utils import extend_schema from rest_framework.exceptions import ParseError from rest_framework.request import Request from rest_framework.response import Response @@ -15,6 +16,14 @@ from sentry.api.serializers import IssueEventSerializer, serialize from sentry.api.serializers.models.event import GroupEventDetailsResponse from sentry.api.utils import get_date_range_from_params +from sentry.apidocs.constants import ( + RESPONSE_FORBIDDEN, + RESPONSE_NOT_FOUND, + RESPONSE_UNAUTHORIZED, +) +from sentry.apidocs.examples.event_examples import EventExamples +from sentry.apidocs.parameters import EventParams, GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.exceptions import InvalidParams from sentry.models.project import Project from sentry.ratelimits.config import RateLimitConfig @@ -91,11 +100,12 @@ def wrap_event_response( return event_data +@extend_schema(tags=["Events"]) @cell_silo_endpoint class ProjectEventDetailsEndpoint(ProjectEndpoint): owner = ApiOwner.ISSUES publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, + "GET": ApiPublishStatus.PUBLIC, } rate_limits = RateLimitConfig( @@ -108,21 +118,27 @@ class ProjectEventDetailsEndpoint(ProjectEndpoint): } ) + @extend_schema( + operation_id="Retrieve an Event for a Project", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + GlobalParams.PROJECT_ID_OR_SLUG, + EventParams.EVENT_ID, + GlobalParams.ENVIRONMENT, + ], + responses={ + 200: inline_sentry_response_serializer( + "ProjectEventDetailsResponse", GroupEventDetailsResponse + ), + 401: RESPONSE_UNAUTHORIZED, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=EventExamples.GROUP_EVENT_DETAILS, + ) def get(self, request: Request, project: Project, event_id: str) -> Response: """ - Retrieve an Event for a Project - ``````````````````````````````` - Return details on an individual event. - - :pparam string organization_id_or_slug: the id or slug of the organization the - event belongs to. - :pparam string project_id_or_slug: the id or slug of the project the event - belongs to. - :pparam string event_id: the id of the event to retrieve. - It is the hexadecimal id as - reported by the raven client) - :auth: required """ try: start, end = get_date_range_from_params(request.GET, optional=True) diff --git a/src/sentry/issues/endpoints/project_events.py b/src/sentry/issues/endpoints/project_events.py index 66633af93b56..8226c6e1c4d3 100644 --- a/src/sentry/issues/endpoints/project_events.py +++ b/src/sentry/issues/endpoints/project_events.py @@ -101,8 +101,8 @@ def get(self, request: Request, project: Project) -> Response: event_filter.start = start event_filter.end = end - full = request.GET.get("full", False) - sample = request.GET.get("sample", False) + full = request.GET.get("full") in ("1", "true") + sample = request.GET.get("sample") in ("1", "true") data_fn = partial( eventstore.backend.get_events, diff --git a/src/sentry/issues/escalating/escalating.py b/src/sentry/issues/escalating/escalating.py index 30ec7894d028..c0f7d3aa31b0 100644 --- a/src/sentry/issues/escalating/escalating.py +++ b/src/sentry/issues/escalating/escalating.py @@ -103,7 +103,7 @@ def query_groups_past_counts(groups: Iterable[Group]) -> list[GroupsCountRespons snuba_results, eap_results, "issues.escalating.query_groups_past_counts", - is_experimental_data_a_null_result=len(eap_results) == 0, + is_experimental_data_nullish=len(eap_results) == 0, reasonable_match_comparator=lambda snuba_rows, eap_rows: keyed_counts_subset_match( snuba_rows, eap_rows, diff --git a/src/sentry/issues/ingest.py b/src/sentry/issues/ingest.py index 22d6096d9e4b..45d3541bbdd7 100644 --- a/src/sentry/issues/ingest.py +++ b/src/sentry/issues/ingest.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from datetime import datetime from hashlib import md5 from typing import Any, TypedDict @@ -104,7 +104,7 @@ def process_occurrence_data(data: dict[str, Any]) -> None: data["fingerprint"] = hash_fingerprint(data["fingerprint"]) -def hash_fingerprint(fingerprint: list[str]) -> list[str]: +def hash_fingerprint(fingerprint: Sequence[str]) -> list[str]: return [md5(part.encode("utf-8")).hexdigest() for part in fingerprint] diff --git a/src/sentry/issues/issue_search.py b/src/sentry/issues/issue_search.py index 2341431703cb..80b7060c80eb 100644 --- a/src/sentry/issues/issue_search.py +++ b/src/sentry/issues/issue_search.py @@ -241,7 +241,7 @@ def convert_device_class_value( for device_class in value: device_class_values = DEVICE_CLASS.get(device_class) if not device_class_values: - raise InvalidSearchQuery(f"Invalid type value of '{type}'") + raise InvalidSearchQuery(f"Invalid device class value of '{device_class}'") results.update(device_class_values) return list(results) diff --git a/src/sentry/issues/related/trace_connected.py b/src/sentry/issues/related/trace_connected.py index bdb39a00d078..aa6d106b20c4 100644 --- a/src/sentry/issues/related/trace_connected.py +++ b/src/sentry/issues/related/trace_connected.py @@ -134,7 +134,7 @@ def trace_connected_issues(event: Event | GroupEvent) -> tuple[list[int], dict[s snuba_results, eap_results, "issues.related.trace_connected_issues", - is_experimental_data_a_null_result=len(eap_results) == 0, + is_experimental_data_nullish=len(eap_results) == 0, reasonable_match_comparator=lambda snuba, eap: eap.issubset(snuba), debug_context={ "trace_id": event.trace_id, diff --git a/src/sentry/monitors/utils.py b/src/sentry/monitors/utils.py index 61307c1bb25d..dc0b72b15518 100644 --- a/src/sentry/monitors/utils.py +++ b/src/sentry/monitors/utils.py @@ -277,7 +277,7 @@ def fetch_associated_groups( snuba_result, eap_result, callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=lambda snuba, eap: ( eap.keys() <= snuba.keys() and all(eap[gid] <= snuba[gid] for gid in eap) ), diff --git a/src/sentry/notifications/notification_action/action_handler_registry/webhook_handler.py b/src/sentry/notifications/notification_action/action_handler_registry/webhook_handler.py index 696d9598bc0c..eb186a330daf 100644 --- a/src/sentry/notifications/notification_action/action_handler_registry/webhook_handler.py +++ b/src/sentry/notifications/notification_action/action_handler_registry/webhook_handler.py @@ -3,7 +3,15 @@ from sentry import features from sentry.notifications.notification_action.utils import execute_via_group_type_registry -from sentry.sentry_apps.services.legacy_webhook.service import send_legacy_webhooks_for_invocation +from sentry.options.rollout import in_random_rollout +from sentry.plugins.sentry_webhooks.plugin import WebHooksPlugin +from sentry.sentry_apps.services.legacy_webhook.service import ( + build_legacy_webhook_payload, + get_triggering_rule_name, + send_legacy_webhooks_for_invocation, + send_sentry_app_webhook, +) +from sentry.sentry_apps.services.legacy_webhook.validation import validate_payload_equivalence from sentry.services.eventstore.models import GroupEvent from sentry.workflow_engine.models import Action from sentry.workflow_engine.registry import action_handler_registry @@ -12,6 +20,22 @@ logger = logging.getLogger(__name__) +def _validate_webhook_payloads(invocation: ActionInvocation) -> None: + group = invocation.event_data.group + event = invocation.event_data.event + rule_name = get_triggering_rule_name(invocation) + + old_payload = WebHooksPlugin().get_group_data(group, event, [rule_name]) + new_payload = build_legacy_webhook_payload(invocation) + + validate_payload_equivalence( + old_payload, + new_payload, + organization_id=invocation.detector.project.organization_id, + project_id=invocation.detector.project.id, + ) + + @action_handler_registry.register(Action.Type.WEBHOOK) class WebhookActionHandler(ActionHandler): group = ActionHandler.Group.OTHER @@ -56,4 +80,18 @@ def execute(invocation: ActionInvocation) -> None: ) if new_path and isinstance(invocation.event_data.event, GroupEvent): - send_legacy_webhooks_for_invocation(invocation) + target_identifier = invocation.action.config.get("target_identifier") + if target_identifier == "webhooks": + send_legacy_webhooks_for_invocation(invocation) + + if in_random_rollout("sentry-apps.legacy-webhook-payload-validation.rate"): + try: + _validate_webhook_payloads(invocation) + except Exception: + logger.exception("webhook_action_handler.validation_error") + else: + send_sentry_app_webhook( + group_event=invocation.event_data.event, + sentry_app_slug=target_identifier, + rule_label=get_triggering_rule_name(invocation), + ) diff --git a/src/sentry/options/defaults.py b/src/sentry/options/defaults.py index 5d4375d31528..7cbbc0beb104 100644 --- a/src/sentry/options/defaults.py +++ b/src/sentry/options/defaults.py @@ -3662,6 +3662,13 @@ default=1000, flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Skip workflows that don't belong to the event's organization. +register( + "workflow_engine.filter_cross_org_workflows", + type=Bool, + default=True, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) # Higher opt-in limit for workflows; intended for orgs we know are hitting limits legitimately, # generally set to 'as high as we think we can safely handle for a handful of orgs'. register( @@ -4100,6 +4107,14 @@ flags=FLAG_AUTOMATOR_MODIFIABLE, ) +# Rollout rate for legacy webhook payload validation (0.0 to 1.0) +register( + "sentry-apps.legacy-webhook-payload-validation.rate", + type=Float, + default=0.0, + flags=FLAG_AUTOMATOR_MODIFIABLE, +) + # Killswitch for web vital issue detection register( "issue-detection.web-vitals-detection.enabled", diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py index 6db2a5f64f09..0e189ebf12f2 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot.py @@ -9,6 +9,7 @@ from django.conf import settings from django.db import IntegrityError, router, transaction from django.utils import timezone +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -21,6 +22,10 @@ OrganizationReleasePermission, ) from sentry.api.bases.project import ProjectEndpoint, ProjectReleasePermission +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.examples.preprod_examples import PreprodExamples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff from sentry.models.commitcomparison import CommitComparison from sentry.models.organization import Organization @@ -33,6 +38,10 @@ from sentry.preprod.api.models.project_preprod_build_details_models import ( BuildDetailsVcsInfo, ) +from sentry.preprod.api.models.public.snapshots import ( + SnapshotCreateResponseDict, + SnapshotDetailsResponseDict, +) from sentry.preprod.api.models.snapshots.project_preprod_snapshot_models import ( SnapshotApprover, SnapshotDetailsApiResponse, @@ -180,16 +189,40 @@ def _format_pydantic_error(e: pydantic.ValidationError) -> str: return f"Invalid image metadata: {msg}" +@extend_schema(tags=["Snapshots"]) @cell_silo_endpoint class OrganizationPreprodSnapshotEndpoint(OrganizationEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, - "DELETE": ApiPublishStatus.EXPERIMENTAL, + "GET": ApiPublishStatus.PUBLIC, + "DELETE": ApiPublishStatus.PUBLIC, } permission_classes = (OrganizationReleasePermission,) + @extend_schema( + operation_id="Delete a Snapshot", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + OpenApiParameter( + name="snapshot_id", + type=str, + location="path", + required=True, + description="The ID of the snapshot to delete.", + ), + ], + request=None, + responses={204: None, 403: RESPONSE_FORBIDDEN, 404: RESPONSE_NOT_FOUND}, + ) def delete(self, request: Request, organization: Organization, snapshot_id: str) -> Response: + """ + Delete a snapshot and all associated data (images, comparisons, metrics). + + This is a permanent, irreversible operation. The snapshot and its images + will no longer be accessible after deletion. + + This endpoint requires a bearer token with `project:write` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user ): @@ -246,7 +279,49 @@ def delete(self, request: Request, organization: Organization, snapshot_id: str) return Response(status=204) + @extend_schema( + operation_id="Retrieve Snapshot details", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + OpenApiParameter( + name="snapshot_id", + type=str, + location="path", + required=True, + description="The ID of the snapshot.", + ), + OpenApiParameter( + name="compact_metadata", + type=str, + location="query", + required=False, + description="Set to '1' or 'true' to strip image metadata to display_name, image_file_name, group, and description only.", + ), + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "SnapshotDetailsResponse", SnapshotDetailsResponseDict + ), + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=PreprodExamples.GET_SNAPSHOT_DETAILS, + ) def get(self, request: Request, organization: Organization, snapshot_id: str) -> Response: + """ + Retrieve full details for a snapshot, including categorized image lists + and comparison status. + + When a comparison exists, images are categorized into `changed`, `added`, + `removed`, `renamed`, `unchanged`, `errored`, and `skipped` lists with + counts. Without a comparison, only the `images` list is populated. + + Use `compact_metadata=1` to strip image objects down to `display_name`, + `image_file_name`, `group`, and `description` only. + + This endpoint requires a bearer token with `project:read` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user ): @@ -529,11 +604,12 @@ def get(self, request: Request, organization: Organization, snapshot_id: str) -> return Response(response_data) +@extend_schema(tags=["Snapshots"]) @cell_silo_endpoint class ProjectPreprodSnapshotEndpoint(ProjectEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { - "POST": ApiPublishStatus.EXPERIMENTAL, + "POST": ApiPublishStatus.PUBLIC, } permission_classes = (ProjectReleasePermission,) @@ -545,7 +621,33 @@ class ProjectPreprodSnapshotEndpoint(ProjectEndpoint): } ) + @extend_schema( + operation_id="Upload a Snapshot", + parameters=[GlobalParams.ORG_ID_OR_SLUG, GlobalParams.PROJECT_ID_OR_SLUG], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "SnapshotCreateResponse", SnapshotCreateResponseDict + ), + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + }, + examples=PreprodExamples.CREATE_SNAPSHOT, + ) def post(self, request: Request, project: Project) -> Response: + """ + Upload a new snapshot with image metadata. + + The request body is a JSON object containing `app_id` (required), + `images` (required, a mapping of filenames to image metadata objects), + and optional VCS fields (`head_sha`, `base_sha`, `provider`, + `head_repo_name`, `head_ref`, `base_repo_name`, `base_ref`, `pr_number`). + + When VCS info with a `base_sha` is provided and a matching base snapshot + exists, a comparison is automatically triggered. + + This endpoint requires a bearer token with `project:write` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", project.organization, actor=request.user ): diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_download.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_download.py index 9329f2b00e23..15b5481ccff0 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_download.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_download.py @@ -1,6 +1,10 @@ from __future__ import annotations import logging +import os +import resource +import sys +import time import zipfile from collections import defaultdict from collections.abc import Generator @@ -11,6 +15,7 @@ from django.conf import settings from django.http import StreamingHttpResponse from django.http.response import HttpResponseBase +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -22,6 +27,8 @@ OrganizationEndpoint, OrganizationReleasePermission, ) +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.parameters import GlobalParams from sentry.auth.staff import is_active_staff from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session @@ -78,17 +85,46 @@ def _build_hash_to_filenames( return result +@extend_schema(tags=["Snapshots"]) @cell_silo_endpoint class OrganizationPreprodSnapshotDownloadEndpoint(OrganizationEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, + "GET": ApiPublishStatus.PUBLIC, } permission_classes = (OrganizationReleasePermission,) + @extend_schema( + operation_id="Download Snapshot images as ZIP", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + OpenApiParameter( + name="snapshot_id", + type=str, + location="path", + required=True, + description="The ID of the snapshot to download.", + ), + ], + request=None, + responses={ + 200: OpenApiResponse(description="A ZIP archive containing all snapshot images."), + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + ) def get( self, request: Request, organization: Organization, snapshot_id: str ) -> HttpResponseBase: + """ + Download all images in a snapshot as a ZIP archive. + + The response is a streaming `application/zip` file. Images that share + the same content hash are deduplicated during fetch but written under + their original filenames in the archive. + + This endpoint requires a bearer token with `project:read` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user ): @@ -137,11 +173,34 @@ def _stream_zip() -> Generator[bytes]: buf = _DrainableBuffer() zf = zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) failed_count = 0 + images_written = 0 + bytes_yielded = 0 + batches_completed = 0 + stream_start = time.monotonic() + last_yield_time = stream_start + exit_reason = "unknown" + + def _rss_mb() -> int: + # ru_maxrss is in KB on Linux, bytes on macOS + rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return rss // 1024 if sys.platform == "linux" else rss // (1024 * 1024) + + logger.info( + "preprod_snapshot_download.stream_start", + extra={ + "preprod_artifact_id": artifact.id, + "unique_hashes": len(unique_hashes), + "total_filenames": sum(len(v) for v in hash_to_filenames.values()), + "pid": os.getpid(), + "rss_mb": _rss_mb(), + }, + ) - def fetch_image(image_hash: str) -> tuple[str, bytes | None]: + def fetch_image(image_hash: str) -> tuple[str, bytes | None, int]: + t0 = time.monotonic() try: data = session.get(f"{key_prefix}/{image_hash}").payload.read() - return (image_hash, data) + return (image_hash, data, int((time.monotonic() - t0) * 1000)) except Exception: logger.exception( "preprod_snapshot_download.image_fetch_failed", @@ -150,7 +209,7 @@ def fetch_image(image_hash: str) -> tuple[str, bytes | None]: "image_hash": image_hash, }, ) - return (image_hash, None) + return (image_hash, None, int((time.monotonic() - t0) * 1000)) executor = ContextPropagatingThreadPoolExecutor(max_workers=FETCH_MAX_WORKERS) try: @@ -158,19 +217,31 @@ def fetch_image(image_hash: str) -> tuple[str, bytes | None]: # flowing to the client progressively. for batch_start in range(0, len(unique_hashes), FETCH_BATCH_SIZE): batch = unique_hashes[batch_start : batch_start + FETCH_BATCH_SIZE] + batch_num = batch_start // FETCH_BATCH_SIZE + batch_t0 = time.monotonic() + fetch_durations: list[int] = [] + max_yield_gap_ms = 0 futures = [executor.submit(fetch_image, h) for h in batch] try: # as_completed() streams results as they finish, # timeout caps how long we wait for the whole batch. for future in as_completed(futures, timeout=BATCH_TIMEOUT): - image_hash, data = future.result() + image_hash, data, fetch_ms = future.result() + fetch_durations.append(fetch_ms) if data is None: failed_count += 1 continue for filename in hash_to_filenames[image_hash]: zf.writestr(filename, data) + images_written += 1 chunk = buf.drain() if chunk: + now = time.monotonic() + gap_ms = int((now - last_yield_time) * 1000) + if gap_ms > max_yield_gap_ms: + max_yield_gap_ms = gap_ms + last_yield_time = now + bytes_yielded += len(chunk) yield chunk except TimeoutError: failed_count += len(batch) - sum(1 for f in futures if f.done()) @@ -178,10 +249,46 @@ def fetch_image(image_hash: str) -> tuple[str, bytes | None]: "preprod_snapshot_download.batch_timeout", extra={ "preprod_artifact_id": artifact.id, - "batch_start": batch_start, + "batch_num": batch_num, }, ) + batches_completed += 1 + sorted_durations = sorted(fetch_durations) if fetch_durations else [0] + logger.info( + "preprod_snapshot_download.batch_complete", + extra={ + "preprod_artifact_id": artifact.id, + "batch_num": batch_num, + "batch_size": len(batch), + "batch_duration_ms": int((time.monotonic() - batch_t0) * 1000), + "elapsed_s": round(time.monotonic() - stream_start, 1), + "images_written": images_written, + "bytes_yielded": bytes_yielded, + "max_yield_gap_ms": max_yield_gap_ms, + "fetch_p50_ms": sorted_durations[len(sorted_durations) // 2], + "fetch_p99_ms": sorted_durations[ + min(len(sorted_durations) - 1, int(len(sorted_durations) * 0.99)) + ], + "fetch_max_ms": sorted_durations[-1], + "rss_mb": _rss_mb(), + }, + ) + + exit_reason = "complete" + logger.info( + "preprod_snapshot_download.stream_complete", + extra={ + "preprod_artifact_id": artifact.id, + "images_written": images_written, + "bytes_yielded": bytes_yielded, + "failed_count": failed_count, + "batches_completed": batches_completed, + "duration_s": round(time.monotonic() - stream_start, 1), + "rss_mb": _rss_mb(), + }, + ) + if failed_count: logger.warning( "preprod_snapshot_download.image_fetch_failures", @@ -191,15 +298,50 @@ def fetch_image(image_hash: str) -> tuple[str, bytes | None]: "total_count": len(unique_hashes), }, ) + except GeneratorExit: + exit_reason = "client_disconnect" + raise + except Exception: + exit_reason = "exception" + logger.exception( + "preprod_snapshot_download.stream_error", + extra={ + "preprod_artifact_id": artifact.id, + "images_written": images_written, + "bytes_yielded": bytes_yielded, + "batches_completed": batches_completed, + "elapsed_s": round(time.monotonic() - stream_start, 1), + }, + ) + raise finally: # wait=False so hung objectstore reads from timed-out batches # don't block the generator and eventually the WSGI worker. executor.shutdown(wait=False, cancel_futures=True) zf.close() - chunk = buf.drain() - if chunk: - yield chunk + # Drain the final ZIP central directory bytes for accurate logging + final_chunk = buf.drain() + if final_chunk: + bytes_yielded += len(final_chunk) + + logger.info( + "preprod_snapshot_download.stream_finally", + extra={ + "preprod_artifact_id": artifact.id, + "exit_reason": exit_reason, + "images_written": images_written, + "bytes_yielded": bytes_yielded, + "batches_completed": batches_completed, + "failed_count": failed_count, + "elapsed_s": round(time.monotonic() - stream_start, 1), + "rss_mb": _rss_mb(), + "pid": os.getpid(), + }, + ) + + if final_chunk: + yield final_chunk response = StreamingHttpResponse(_stream_zip(), content_type="application/zip") response["Content-Disposition"] = ( diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py index b29eeb4de706..8585460b122c 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_image_detail.py @@ -4,6 +4,7 @@ import orjson from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -15,9 +16,14 @@ OrganizationEndpoint, OrganizationReleasePermission, ) +from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.examples.preprod_examples import PreprodExamples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session +from sentry.preprod.api.models.public.snapshots import SnapshotImageDetailResponseDict from sentry.preprod.api.models.snapshots.project_preprod_snapshot_models import ( SnapshotImageDetailImageInfo, SnapshotImageDetailResponse, @@ -107,14 +113,44 @@ def _resolve_base_image_info( # Intentionally uses a flat response format (nullable fields, no conditional shapes) # rather than matching the details endpoint's SnapshotDiffPair/SnapshotImageResponse split. # This endpoint is designed for LLM/MCP consumers that benefit from a single uniform shape. +@extend_schema(tags=["Snapshots"]) @cell_silo_endpoint class OrganizationPreprodSnapshotImageDetailEndpoint(OrganizationEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, + "GET": ApiPublishStatus.PUBLIC, } permission_classes = (OrganizationReleasePermission,) + @extend_schema( + operation_id="Retrieve Snapshot image detail", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + OpenApiParameter( + name="snapshot_id", + type=str, + location="path", + required=True, + description="The ID of the snapshot.", + ), + OpenApiParameter( + name="image_identifier", + type=str, + location="path", + required=True, + description="The image filename or content hash.", + ), + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "SnapshotImageDetailResponse", SnapshotImageDetailResponseDict + ), + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=PreprodExamples.GET_SNAPSHOT_IMAGE_DETAIL, + ) def get( self, request: Request, @@ -122,6 +158,19 @@ def get( snapshot_id: str, image_identifier: str, ) -> Response: + """ + Retrieve detailed information for a single image within a snapshot. + + The `image_identifier` can be either the image filename or its content + hash. The response includes head and base image metadata, comparison + status, diff image URL, diff percentage, and previous filename for + renames. + + This endpoint uses a flat response format with nullable fields designed + for LLM/MCP consumers. + + This endpoint requires a bearer token with `project:read` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user ): diff --git a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py index c9a9336928a6..b94c9d702442 100644 --- a/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py +++ b/src/sentry/preprod/api/endpoints/snapshots/preprod_artifact_snapshot_latest_base.py @@ -6,6 +6,7 @@ import orjson from django.conf import settings from django.db.models import Q +from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework.request import Request from rest_framework.response import Response @@ -18,6 +19,10 @@ OrganizationEndpoint, OrganizationReleasePermission, ) +from sentry.apidocs.constants import RESPONSE_BAD_REQUEST, RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND +from sentry.apidocs.examples.preprod_examples import PreprodExamples +from sentry.apidocs.parameters import GlobalParams +from sentry.apidocs.utils import inline_sentry_response_serializer from sentry.auth.staff import is_active_staff from sentry.models.organization import Organization from sentry.objectstore import get_preprod_session @@ -25,6 +30,7 @@ _strip_to_compact, build_snapshot_image_response, ) +from sentry.preprod.api.models.public.snapshots import LatestBaseSnapshotResponseDict from sentry.preprod.models import PreprodArtifact from sentry.preprod.snapshots.manifest import SnapshotManifest @@ -51,19 +57,76 @@ } +@extend_schema(tags=["Snapshots"]) @cell_silo_endpoint class OrganizationPreprodLatestBaseSnapshotEndpoint(OrganizationEndpoint): owner = ApiOwner.EMERGE_TOOLS publish_status = { - "GET": ApiPublishStatus.EXPERIMENTAL, + "GET": ApiPublishStatus.PUBLIC, } permission_classes = (OrganizationReleasePermission,) + @extend_schema( + operation_id="Retrieve latest base Snapshot", + parameters=[ + GlobalParams.ORG_ID_OR_SLUG, + OpenApiParameter( + name="app_id", + type=str, + location="query", + required=True, + description="App identifier to match.", + ), + OpenApiParameter( + name="branch", + type=str, + location="query", + required=False, + description="Git branch name to filter on.", + ), + OpenApiParameter( + name="project", + type=int, + location="query", + required=False, + description="Project ID to scope the lookup.", + ), + OpenApiParameter( + name="compact_metadata", + type=str, + location="query", + required=False, + description="Set to '1' or 'true' to strip image metadata to display_name, image_file_name, group, description, and image_url only.", + ), + ], + request=None, + responses={ + 200: inline_sentry_response_serializer( + "LatestBaseSnapshotResponse", LatestBaseSnapshotResponseDict + ), + 400: RESPONSE_BAD_REQUEST, + 403: RESPONSE_FORBIDDEN, + 404: RESPONSE_NOT_FOUND, + }, + examples=PreprodExamples.GET_LATEST_BASE_SNAPSHOT, + ) def get( self, request: Request, organization: Organization, ) -> Response: + """ + Retrieve the most recent base snapshot for a given app. + + A base snapshot is one uploaded without a `base_sha` (i.e., a snapshot + from a base branch like `main`). Use the optional `branch` and `project` + parameters to narrow the search. + + The response includes the full image list with download URLs. Use + `compact_metadata=1` to reduce image metadata. + + This endpoint requires a bearer token with `project:read` access. + """ if not settings.IS_DEV and not features.has( "organizations:preprod-snapshots", organization, actor=request.user ): diff --git a/src/sentry/preprod/api/models/public/snapshots.py b/src/sentry/preprod/api/models/public/snapshots.py new file mode 100644 index 000000000000..02c9d9231551 --- /dev/null +++ b/src/sentry/preprod/api/models/public/snapshots.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from typing import Literal, TypedDict + + +class VcsInfoResponseDict(TypedDict, total=False): + head_sha: str | None + base_sha: str | None + provider: str | None + head_repo_name: str | None + base_repo_name: str | None + head_ref: str | None + base_ref: str | None + pr_number: int | None + + +class SnapshotImageResponseDict(TypedDict, total=False): + key: str + display_name: str | None + group: str | None + image_file_name: str + width: int + height: int + + +class SnapshotDiffPairResponseDict(TypedDict, total=False): + base_image: SnapshotImageResponseDict + head_image: SnapshotImageResponseDict + diff_image_key: str | None + diff: float | None + + +class SnapshotApproverResponseDict(TypedDict, total=False): + id: str | None + name: str | None + email: str | None + username: str | None + avatar_url: str | None + approved_at: str | None + source: Literal["sentry", "github"] + + +class SnapshotDetailsResponseDict(TypedDict, total=False): + head_artifact_id: str + base_artifact_id: str | None + project_id: str + comparison_type: str + state: str + vcs_info: VcsInfoResponseDict + app_id: str | None + is_selective: bool + images: list[SnapshotImageResponseDict] + image_count: int + added: list[SnapshotImageResponseDict] + added_count: int + removed: list[SnapshotImageResponseDict] + removed_count: int + renamed: list[SnapshotDiffPairResponseDict] + renamed_count: int + changed: list[SnapshotDiffPairResponseDict] + changed_count: int + unchanged: list[SnapshotImageResponseDict] + unchanged_count: int + errored: list[SnapshotDiffPairResponseDict] + errored_count: int + skipped: list[SnapshotImageResponseDict] + skipped_count: int + diff_threshold: float | None + comparison_state: str | None + approval_status: str | None + comparison_error_message: str | None + approvers: list[SnapshotApproverResponseDict] + + +class SnapshotCreateResponseDict(TypedDict): + artifactId: str + snapshotMetricsId: str + imageCount: int + snapshotUrl: str + + +class SnapshotImageDetailImageInfoResponseDict(TypedDict, total=False): + content_hash: str + display_name: str | None + group: str | None + image_file_name: str + width: int + height: int + diff_threshold: float | None + description: str | None + tags: dict[str, str] | None + image_url: str + + +class SnapshotImageDetailResponseDict(TypedDict, total=False): + image_file_name: str + comparison_status: str | None + head_image: SnapshotImageDetailImageInfoResponseDict | None + base_image: SnapshotImageDetailImageInfoResponseDict | None + diff_image_url: str | None + diff_percentage: float | None + previous_image_file_name: str | None + + +class LatestBaseSnapshotImageResponseDict(TypedDict, total=False): + key: str + display_name: str | None + group: str | None + image_file_name: str + width: int + height: int + image_url: str + + +class LatestBaseSnapshotVcsInfoResponseDict(TypedDict, total=False): + head_sha: str | None + base_sha: str | None + head_ref: str | None + base_ref: str | None + head_repo_name: str | None + pr_number: int | None + + +class LatestBaseSnapshotResponseDict(TypedDict, total=False): + head_artifact_id: str + project_id: str + project_slug: str + app_id: str | None + image_count: int + images: list[LatestBaseSnapshotImageResponseDict] + diff_threshold: float | None + date_added: str + vcs_info: LatestBaseSnapshotVcsInfoResponseDict diff --git a/src/sentry/reprocessing2.py b/src/sentry/reprocessing2.py index 81f5f8299c28..381c83aff418 100644 --- a/src/sentry/reprocessing2.py +++ b/src/sentry/reprocessing2.py @@ -664,7 +664,7 @@ def start_group_reprocessing( control_data=snuba_count, experimental_data=eap_count, callsite=callsite, - is_experimental_data_a_null_result=eap_count == 0, + is_experimental_data_nullish=eap_count == 0, reasonable_match_comparator=lambda snuba, eap: eap <= snuba, debug_context={ "organization_id": organization.id, diff --git a/src/sentry/runner/commands/cleanup.py b/src/sentry/runner/commands/cleanup.py index 9c08b979df23..88aa6140b960 100644 --- a/src/sentry/runner/commands/cleanup.py +++ b/src/sentry/runner/commands/cleanup.py @@ -15,7 +15,7 @@ import sentry_sdk from django.conf import settings from django.db import router as db_router -from django.db.models import QuerySet +from django.db.models import Exists, OuterRef, QuerySet from django.utils import timezone from sentry_sdk import capture_exception @@ -526,6 +526,7 @@ def _run_specialized_cleanups( remove_expired_values_for_org_members(is_filtered, days, models_attempted) delete_api_models(is_filtered, models_attempted) exported_data(is_filtered, models_attempted) + remove_old_notification_messages(is_filtered, models_attempted) def _handle_project_organization_cleanup( @@ -1163,3 +1164,38 @@ def cleanup_unused_files() -> None: if File.objects.filter(blob=blob).exists(): continue blob.delete() + + +@continue_on_error("specialized_cleanup_notification_messages") +def remove_old_notification_messages( + is_filtered: Callable[[type[BaseModel]], bool], models_attempted: set[str] +) -> None: + from sentry.notifications.models.notificationmessage import NotificationMessage + + NOTIFICATION_MESSAGE_TTL_IN_DAYS = 90 + NOTIFICATION_MESSAGE_BATCH_SIZE = 1000 + + if is_filtered(NotificationMessage): + debug_output(">> Skipping NotificationMessage") + return + + debug_output("Removing expired values for NotificationMessage") + models_attempted.add(NotificationMessage.__name__.lower()) + + cutoff = timezone.now() - timedelta(days=NOTIFICATION_MESSAGE_TTL_IN_DAYS) + + # only delete rows with no child instance + has_child = NotificationMessage.objects.filter(parent_notification_message_id=OuterRef("id")) + + while True: + ids = list( + NotificationMessage.objects.filter(date_added__lt=cutoff) + .annotate(has_child=Exists(has_child)) + .filter(has_child=False) + .values_list("id", flat=True) + .order_by("id")[:NOTIFICATION_MESSAGE_BATCH_SIZE] + ) + if not ids: + break + + NotificationMessage.objects.filter(id__in=ids).delete() diff --git a/src/sentry/runner/commands/devserver.py b/src/sentry/runner/commands/devserver.py index 2ee49029679a..c26098bf89ca 100644 --- a/src/sentry/runner/commands/devserver.py +++ b/src/sentry/runner/commands/devserver.py @@ -332,8 +332,8 @@ def devserver( kafka_consumers.add("monitors-clock-tasks") kafka_consumers.add("monitors-incident-occurrences") - if settings.SENTRY_USE_PROFILING: - kafka_consumers.add("ingest-profiles") + # ingest-profiles is now handled by taskbroker passthrough mode + # via devservices (STREAM-1041) if settings.SENTRY_USE_SPANS_BUFFER: kafka_consumers.add("process-spans") diff --git a/src/sentry/scm/private/helpers.py b/src/sentry/scm/private/helpers.py index 3e5bbfb36c18..fc9eef14ce95 100644 --- a/src/sentry/scm/private/helpers.py +++ b/src/sentry/scm/private/helpers.py @@ -1,3 +1,4 @@ +import time from typing import cast import sentry_sdk @@ -9,9 +10,10 @@ from sentry.constants import ObjectStatus from sentry.integrations.errors import OrganizationIntegrationNotFound +from sentry.integrations.github.client import GITHUB_RATE_LIMIT_WINDOW, REFERRER_ALLOCATION from sentry.integrations.services.integration.service import integration_service from sentry.models.repository import Repository as RepositoryModel -from sentry.scm.private.rate_limit import RedisRateLimitProvider +from sentry.scm.private.rate_limit import DynamicRateLimiter, RedisRateLimitProvider from sentry.shared_integrations.exceptions import IntegrationError from sentry.utils import metrics @@ -33,13 +35,16 @@ def fetch_service_provider( except (IntegrationError, OrganizationIntegrationNotFound): return None - if integration.provider == "github": - return GitHubProvider( - client, - organization_id, - repository, + if integration.provider in ("github", "github_enterprise"): + rate_limiter = DynamicRateLimiter( + get_time_in_seconds=lambda: int(time.time()), + integration_id=integration.id, + provider=integration.provider, rate_limit_provider=rate_limit_provider or RedisRateLimitProvider(), + rate_limit_window_seconds=GITHUB_RATE_LIMIT_WINDOW, + referrer_allocation=REFERRER_ALLOCATION, ) + return GitHubProvider(client, organization_id, repository, rate_limiter=rate_limiter) elif integration.provider == "gitlab": return GitLabProvider(client, organization_id, repository) else: diff --git a/src/sentry/scm/private/rate_limit.py b/src/sentry/scm/private/rate_limit.py index d24e41aefe40..fd947947fbae 100644 --- a/src/sentry/scm/private/rate_limit.py +++ b/src/sentry/scm/private/rate_limit.py @@ -130,6 +130,10 @@ def is_rate_limited(self, referrer: str) -> bool: return quota_used > referrer_capacity + def update_rate_limit_meta(self, capacity: int, consumed: int, next_window_start: int) -> None: + """Update the store with select rate-limit metadata.""" + self.set_total_capacity(capacity) + def set_total_capacity(self, capacity: int) -> None: """Set the service capacity if it does not match what already exists.""" if capacity != self.recorded_capacity: diff --git a/src/sentry/search/eap/occurrences/rollout_utils.py b/src/sentry/search/eap/occurrences/rollout_utils.py index 55242c9ad05e..4810e1fd885a 100644 --- a/src/sentry/search/eap/occurrences/rollout_utils.py +++ b/src/sentry/search/eap/occurrences/rollout_utils.py @@ -3,3 +3,11 @@ class EAPOccurrencesComparator(SafeRolloutComparator): ROLLOUT_NAME = "occurrences_on_eap" + + +EAP_OCCURRENCES_SHOULD_RUN_EXPERIMENT_OPTION = ( + EAPOccurrencesComparator._should_run_experiment_option() +) +EAP_OCCURRENCES_USE_EXPERIMENTAL_DATA_ALLOWLIST_OPTION = ( + EAPOccurrencesComparator._callsite_use_experimental_data_allowlist_option() +) diff --git a/src/sentry/search/snuba/backend.py b/src/sentry/search/snuba/backend.py index d2edc0a71ea4..5d54321b08d3 100644 --- a/src/sentry/search/snuba/backend.py +++ b/src/sentry/search/snuba/backend.py @@ -264,7 +264,7 @@ def seer_actionability_filter(trigger_values: list[float]) -> Q: seer_fixability_score__gte=0.0, seer_fixability_score__lte=FixabilityScoreThresholds.LOW.value, ) - if val == FixabilityScoreThresholds.LOW.value: + elif val == FixabilityScoreThresholds.LOW.value: query |= Q( seer_fixability_score__gt=FixabilityScoreThresholds.LOW.value, seer_fixability_score__lte=FixabilityScoreThresholds.MEDIUM.value, diff --git a/src/sentry/search/snuba/executors.py b/src/sentry/search/snuba/executors.py index 79fd2c293152..f5a1d2937206 100644 --- a/src/sentry/search/snuba/executors.py +++ b/src/sentry/search/snuba/executors.py @@ -562,8 +562,8 @@ def _run_eap_query() -> tuple[list[tuple[int, Any]], int]: ): try: return EAPOccurrencesComparator.check_and_choose_with_timings( - control_thunk=_run_snuba_query, - experimental_thunk=_run_eap_query, + control_data_func=_run_snuba_query, + experimental_data_func=_run_eap_query, callsite=callsite, null_result_determiner=lambda r: len(r[0]) == 0, reasonable_match_comparator=_reasonable_search_result_match, diff --git a/src/sentry/seer/endpoints/group_ai_autofix.py b/src/sentry/seer/endpoints/group_ai_autofix.py index 8b10d5ef187e..f2cba8afd9b6 100644 --- a/src/sentry/seer/endpoints/group_ai_autofix.py +++ b/src/sentry/seer/endpoints/group_ai_autofix.py @@ -45,7 +45,6 @@ from sentry.seer.autofix.utils import ( AutofixStoppingPoint, CodingAgentProviderType, - has_project_connected_repos, ) from sentry.seer.models import SeerPermissionError from sentry.types.ratelimit import RateLimit, RateLimitCategory @@ -182,12 +181,6 @@ def post(self, request: Request, group: Group) -> Response: The process runs asynchronously, and you can get the state using the GET endpoint. """ - if not has_project_connected_repos(group.organization, group.project): - return Response( - {"detail": "SCM integration is not configured for this project."}, - status=status.HTTP_409_CONFLICT, - ) - serializer = ExplorerAutofixRequestSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/src/sentry/seer/entrypoints/operator.py b/src/sentry/seer/entrypoints/operator.py index 0daceaa2c77b..74a4d14272e9 100644 --- a/src/sentry/seer/entrypoints/operator.py +++ b/src/sentry/seer/entrypoints/operator.py @@ -8,6 +8,7 @@ from sentry.models.activity import Activity from sentry.models.group import Group from sentry.models.organization import Organization +from sentry.organizations.services.organization import RpcOrganization from sentry.seer.agent.client import SeerAgentClient from sentry.seer.agent.client_models import CodingAgentState, SeerRunState from sentry.seer.agent.client_utils import fetch_run_status @@ -55,11 +56,7 @@ SentryAppEventType.SEER_SOLUTION_COMPLETED: ActivityType.SEER_SOLUTION_COMPLETED, SentryAppEventType.SEER_CODING_STARTED: ActivityType.SEER_CODING_STARTED, SentryAppEventType.SEER_CODING_COMPLETED: ActivityType.SEER_CODING_COMPLETED, -} - -SEER_OPERATOR_AUTOFIX_UPDATE_EVENTS: set[SentryAppEventType] = { - *SEER_EVENT_TO_ACTIVITY_TYPE.keys(), - SentryAppEventType.SEER_PR_CREATED, + SentryAppEventType.SEER_PR_CREATED: ActivityType.SEER_PR_CREATED, } logger = logging.getLogger(__name__) @@ -579,6 +576,45 @@ def trigger_autofix_legacy( ) +def has_seer_agent_entrypoint_access( + *, + organization: Organization | RpcOrganization, + entrypoint_key: SeerEntrypointKey | None = None, +) -> bool: + """ + Checks if the organization has access to Seer Agent and at least one agent entrypoint. + If an entrypoint_key is provided, ensures the organization has access to that specific + agent entrypoint. + + Should not be called from the CONTROL silo since ``has_seer_agent_access_with_detail`` + depends on subscription context that getsentry's FlagpoleFeatureHandler does not + populate in control silo. + """ + from sentry.seer.agent.client_utils import has_seer_agent_access_with_detail + + has_access, _ = has_seer_agent_access_with_detail(organization, None) + if not has_access: + return False + + if entrypoint_key: + if entrypoint_key not in agent_entrypoint_registry.registrations: + logger.error( + "seer.operator.invalid_agent_entrypoint_key", + extra={ + "entrypoint_key": str(entrypoint_key), + "organization_id": organization.id, + }, + ) + return False + entrypoint_cls = agent_entrypoint_registry.registrations[entrypoint_key] + return entrypoint_cls.has_access(organization) + + return any( + entrypoint_cls.has_access(organization=organization) + for entrypoint_cls in agent_entrypoint_registry.registrations.values() + ) + + class SeerAgentOperator[CachePayloadT]: """ A class that connects to entrypoint implementations and runs Seer Agent operations. @@ -588,6 +624,17 @@ class SeerAgentOperator[CachePayloadT]: def __init__(self, entrypoint: SeerAgentEntrypoint[CachePayloadT]): self.entrypoint = entrypoint + @classmethod + def has_access( + cls, + *, + organization: Organization | RpcOrganization, + entrypoint_key: SeerEntrypointKey | None = None, + ) -> bool: + return has_seer_agent_entrypoint_access( + organization=organization, entrypoint_key=entrypoint_key + ) + def trigger_agent( self, *, @@ -723,6 +770,10 @@ def _create_seer_activity( solution = event_payload.get("solution") if solution: activity_data["summary"] = solution.get("one_line_summary") + elif event_type == SentryAppEventType.SEER_PR_CREATED: + pull_requests = event_payload.get("pull_requests", []) + if pull_requests: + activity_data["pull_requests"] = pull_requests Activity.objects.create_group_activity( group, @@ -766,7 +817,7 @@ def process_autofix_updates( lifecycle.record_failure(failure_reason="missing_identifiers") return - if event_type not in SEER_OPERATOR_AUTOFIX_UPDATE_EVENTS: + if event_type not in SEER_EVENT_TO_ACTIVITY_TYPE: lifecycle.record_halt(halt_reason="skipped") return @@ -970,6 +1021,10 @@ def execute(cls, organization: Organization, run_id: int) -> None: } ) + if not SeerAgentOperator.has_access(organization=organization): + lifecycle.record_halt(halt_reason="no_operator_access") + return + summary: str | None = None try: state = fetch_run_status(run_id, organization) diff --git a/src/sentry/seer/entrypoints/slack/entrypoint.py b/src/sentry/seer/entrypoints/slack/entrypoint.py index 65380cdfaa8c..db185e352564 100644 --- a/src/sentry/seer/entrypoints/slack/entrypoint.py +++ b/src/sentry/seer/entrypoints/slack/entrypoint.py @@ -3,7 +3,6 @@ import logging from typing import TYPE_CHECKING, Any, TypedDict -from sentry import features from sentry.constants import ObjectStatus from sentry.integrations.services.integration.service import integration_service from sentry.locks import locks @@ -17,7 +16,6 @@ ) from sentry.notifications.utils.actions import BlockKitMessageAction from sentry.organizations.services.organization.model import RpcOrganization -from sentry.seer.agent.client_utils import has_seer_agent_access_with_detail from sentry.seer.autofix.utils import AutofixStoppingPoint, CodingAgentProviderType from sentry.seer.entrypoints.cache import SeerOperatorAutofixCache from sentry.seer.entrypoints.registry import ( @@ -198,7 +196,7 @@ def __init__( @staticmethod def has_access(organization: Organization) -> bool: - return features.has("organizations:seer-slack-workflows", organization) + return True @staticmethod def get_group_link(group: Group) -> str: @@ -504,20 +502,9 @@ def __init__( self.install = SlackIntegration(model=integration, organization_id=organization_id) self.slack_user_id = slack_user_id - @staticmethod - def has_feature_flag(organization: Organization | RpcOrganization) -> bool: - return features.has("organizations:seer-slack-explorer", organization) - @staticmethod def has_access(organization: Organization | RpcOrganization) -> bool: - """ - Determines access to Seer Agent, along with the Slack feature. Shouldn't be called from - the CONTROL silo since `has_explorer_access_with_detail` will not get populated with the - subscription context, and will return False every time. For slim, CONTROL calls, use - the `has_feature_flag` method instead. - """ - has_agent_access, _ = has_seer_agent_access_with_detail(organization, None) - return SlackAgentEntrypoint.has_feature_flag(organization) and has_agent_access + return True def on_trigger_agent_error(self, *, error: str) -> None: send_thread_update( diff --git a/src/sentry/seer/entrypoints/slack/tasks.py b/src/sentry/seer/entrypoints/slack/tasks.py index dad8d2b0046a..c8f9f300d93b 100644 --- a/src/sentry/seer/entrypoints/slack/tasks.py +++ b/src/sentry/seer/entrypoints/slack/tasks.py @@ -77,10 +77,9 @@ def process_mention_for_slack( ``ts`` is the message's own timestamp (always present). ``thread_ts`` is the parent thread's timestamp (None for top-level messages). - Authorization: Access is gated by the org-level ``seer-slack-workflows`` - feature flag and ``has_explorer_access()``. The incoming webhook is - verified by ``SlackDMRequest.validate()``. The Slack user must have a - linked Sentry identity; if not, an ephemeral prompt to link is sent. + Authorization: Access is gated by ``has_explorer_access()``. The incoming + webhook is verified by ``SlackDMRequest.validate()``. The Slack user must + have a linked Sentry identity; if not, an ephemeral prompt to link is sent. """ with SlackEntrypointEventLifecycleMetric( @@ -103,7 +102,9 @@ def process_mention_for_slack( lifecycle.record_failure(failure_reason=ProcessMentionFailureReason.ORG_NOT_FOUND) return - if not SlackAgentEntrypoint.has_access(organization): + if not SeerAgentOperator.has_access( + organization=organization, entrypoint_key=SeerEntrypointKey.SLACK + ): lifecycle.record_failure(failure_reason=ProcessMentionFailureReason.NO_AGENT_ACCESS) return @@ -515,7 +516,9 @@ def process_reaction_for_slack( lifecycle.record_failure(failure_reason=ProcessReactionFailureReason.ORG_NOT_FOUND) return - if not SlackAgentEntrypoint.has_access(organization): + if not SeerAgentOperator.has_access( + organization=organization, entrypoint_key=SeerEntrypointKey.SLACK + ): lifecycle.record_halt(halt_reason=ProcessReactionHaltReason.NO_AGENT_ACCESS) return diff --git a/src/sentry/seer/entrypoints/types.py b/src/sentry/seer/entrypoints/types.py index 706c19074183..c9112075b6cc 100644 --- a/src/sentry/seer/entrypoints/types.py +++ b/src/sentry/seer/entrypoints/types.py @@ -2,6 +2,7 @@ from typing import Any, Literal, Protocol, TypedDict from sentry.models.organization import Organization +from sentry.organizations.services.organization.model import RpcOrganization from sentry.seer.autofix.utils import CodingAgentProviderType from sentry.sentry_apps.metrics import SentryAppEventType @@ -112,11 +113,11 @@ class SeerAgentEntrypoint[CachePayloadT](Protocol): key: SeerEntrypointKey @staticmethod - def has_access(organization: Organization) -> bool: + def has_access(organization: Organization | RpcOrganization) -> bool: """ - Used to gate access unless the organization has access to at least one entrypoint. - The caller will check for seer-access prior to this check, so no need to repeat - that check on the entrypoint. + Entrypoint-specific access gate. The operator checks general Seer Agent access + (``has_seer_agent_access_with_detail``) prior to this, so only entrypoint-specific + conditions belong here. """ ... diff --git a/src/sentry/sentry_apps/services/legacy_webhook/service.py b/src/sentry/sentry_apps/services/legacy_webhook/service.py index 47a0a2a63f61..46efcff2e475 100644 --- a/src/sentry/sentry_apps/services/legacy_webhook/service.py +++ b/src/sentry/sentry_apps/services/legacy_webhook/service.py @@ -6,6 +6,8 @@ from sentry.eventstore.models import GroupEvent from sentry.models.options.project_option import ProjectOption from sentry.models.rule import Rule +from sentry.sentry_apps.services.app import app_service +from sentry.sentry_apps.tasks.sentry_apps import send_alert_webhook_v2 from sentry.workflow_engine.models import AlertRuleWorkflow, Workflow from sentry.workflow_engine.types import ActionInvocation @@ -32,7 +34,7 @@ def split_urls(value: str) -> list[str]: return list(filter(bool, (url.strip() for url in value.splitlines()))) -def _get_triggering_rule_name(invocation: ActionInvocation) -> str: +def get_triggering_rule_name(invocation: ActionInvocation) -> str: try: workflow = Workflow.objects.get(id=invocation.workflow_id) label = workflow.name @@ -60,7 +62,7 @@ def build_legacy_webhook_payload(invocation: ActionInvocation) -> LegacyWebhookP event = invocation.event_data.event if not isinstance(event, GroupEvent): raise TypeError(f"Legacy webhook payload requires a GroupEvent, got {type(event).__name__}") - triggering_rules = [_get_triggering_rule_name(invocation)] + triggering_rules = [get_triggering_rule_name(invocation)] event_data = dict(event.data or {}) data: LegacyWebhookPayload = { "id": str(group.id), @@ -83,6 +85,33 @@ def build_legacy_webhook_payload(invocation: ActionInvocation) -> LegacyWebhookP return data +def send_sentry_app_webhook( + *, + group_event: GroupEvent, + sentry_app_slug: str | None, + rule_label: str, +) -> None: + if not sentry_app_slug: + logger.warning("webhook_action_handler.missing_target_identifier") + return + + sentry_app = app_service.get_sentry_app_by_slug(slug=sentry_app_slug) + if sentry_app is None: + logger.warning( + "webhook_action_handler.sentry_app_not_found", + extra={"sentry_app_slug": sentry_app_slug}, + ) + return + + send_alert_webhook_v2.delay( + rule_label=rule_label, + sentry_app_id=sentry_app.id, + instance_id=group_event.event_id, + group_id=group_event.group_id, + occurrence_id=getattr(group_event, "occurrence_id", None), + ) + + def send_legacy_webhooks_for_invocation(invocation: ActionInvocation) -> None: # Delayed import to avoid circular dependency (tasks imports LegacyWebhookPayload from here) from sentry.sentry_apps.services.legacy_webhook.tasks import send_legacy_webhook_task diff --git a/src/sentry/sentry_apps/services/legacy_webhook/tasks.py b/src/sentry/sentry_apps/services/legacy_webhook/tasks.py index 8c08214013f1..1512a2814e49 100644 --- a/src/sentry/sentry_apps/services/legacy_webhook/tasks.py +++ b/src/sentry/sentry_apps/services/legacy_webhook/tasks.py @@ -58,13 +58,6 @@ def send_legacy_webhook_task(url: str, payload: LegacyWebhookPayload, **kwargs: organization = group.project.organization if features.has("organizations:legacy-webhook-dry-run", organization): - logger.info( - "legacy_webhook.dry_run", - extra={ - "url": url, - "payload": payload, - }, - ) metrics.incr( "legacy_webhook.task.result", tags={"outcome": LegacyWebhookOutcome.DRY_RUN}, diff --git a/src/sentry/sentry_apps/services/legacy_webhook/validation.py b/src/sentry/sentry_apps/services/legacy_webhook/validation.py new file mode 100644 index 000000000000..fcb1cb4326f1 --- /dev/null +++ b/src/sentry/sentry_apps/services/legacy_webhook/validation.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import logging +from collections.abc import Mapping +from typing import Any + +from sentry.utils.payload_comparison import ParityChecker, describe_value + +logger = logging.getLogger("sentry.legacy_webhook") + + +def compare_payloads(old_payload: Mapping[str, Any], new_payload: Mapping[str, Any]) -> list[str]: + comparator = ParityChecker(format_value=describe_value) + comparator.compare(old_payload, new_payload, frozenset()) + return comparator.mismatches + + +def validate_payload_equivalence( + old_payload: Mapping[str, Any], + new_payload: Mapping[str, Any], + organization_id: int, + project_id: int, +) -> None: + logging_context = {"organization_id": organization_id, "project_id": project_id} + + if old_payload == new_payload: + logger.info("legacy_webhook.validation.match", extra=logging_context) + return + + try: + mismatches = compare_payloads(old_payload, new_payload) + except Exception: + logger.exception("legacy_webhook.validation.comparison_error", extra=logging_context) + return + + if mismatches: + logger.warning( + "legacy_webhook.validation.payload_mismatch", + extra={**logging_context, "mismatches": mismatches}, + ) diff --git a/src/sentry/services/eventstore/snuba/backend.py b/src/sentry/services/eventstore/snuba/backend.py index 1dac358f1081..2d677de72d2b 100644 --- a/src/sentry/services/eventstore/snuba/backend.py +++ b/src/sentry/services/eventstore/snuba/backend.py @@ -410,7 +410,7 @@ def __get_events( control_data=control_data, experimental_data=experimental_data, callsite=callsite, - is_experimental_data_a_null_result=eap_results is None, + is_experimental_data_nullish=eap_results is None, reasonable_match_comparator=lambda ctl, exp: exp.issubset(ctl), debug_context={ "project_ids": list(filter.project_ids) if filter.project_ids else [], @@ -584,7 +584,7 @@ def get_event_by_id( control_data=control_group_id, experimental_data=eap_group_id, callsite=callsite, - is_experimental_data_a_null_result=eap_result is None, + is_experimental_data_nullish=eap_result is None, reasonable_match_comparator=lambda snuba, eap: snuba == eap, debug_context={ "project_id": project_id, diff --git a/src/sentry/snuba/rpc_dataset_common.py b/src/sentry/snuba/rpc_dataset_common.py index 37bcdc4dade7..582f3f9bf0fe 100644 --- a/src/sentry/snuba/rpc_dataset_common.py +++ b/src/sentry/snuba/rpc_dataset_common.py @@ -392,7 +392,7 @@ def _run_table_query( table_request = cls.get_table_rpc_request(query) rpc_request = table_request.rpc_request try: - rpc_response = snuba_rpc.table_rpc([rpc_request])[0] + rpc_response = snuba_rpc.table_rpc([rpc_request], debug=debug)[0] except Exception as e: # add the rpc to the error so we can include it in the response if debug: @@ -428,7 +428,9 @@ def run_table_query( @classmethod @sentry_sdk.trace - def run_bulk_table_queries(cls, queries: list[TableQuery]) -> dict[str, EAPResponse]: + def run_bulk_table_queries( + cls, queries: list[TableQuery], debug: str | bool = False + ) -> dict[str, EAPResponse]: """Validate the bulk queries""" names: set[str] = set() for query in queries: diff --git a/src/sentry/snuba/trace.py b/src/sentry/snuba/trace.py index 408c689c7039..16b16b6227b7 100644 --- a/src/sentry/snuba/trace.py +++ b/src/sentry/snuba/trace.py @@ -697,7 +697,7 @@ def query_trace_data( control_data=errors_data, experimental_data=eap_errors_data, callsite=callsite, - is_experimental_data_a_null_result=len(eap_errors_data) == 0, + is_experimental_data_nullish=len(eap_errors_data) == 0, reasonable_match_comparator=lambda snuba, eap: {e["id"] for e in eap}.issubset( {e["id"] for e in snuba} ), diff --git a/src/sentry/spans/buffer.py b/src/sentry/spans/buffer.py index ad1aade2ed21..de0b9f026969 100644 --- a/src/sentry/spans/buffer.py +++ b/src/sentry/spans/buffer.py @@ -101,10 +101,9 @@ import logging import math import time -import uuid from collections.abc import Generator, Mapping, MutableMapping, Sequence from hashlib import blake2b -from typing import Any, NamedTuple, cast +from typing import Any, cast import orjson import zstandard @@ -127,8 +126,12 @@ ) from sentry.spans.buffer_types import ( EvalshaResult, + FlushCandidate, + FlushedSegment, InsertedSubsegment, LoadedSegmentData, + OutputSpan, + QueueKey, Span, Subsegment, ) @@ -143,8 +146,6 @@ from sentry.utils import metrics, redis from sentry.utils.outcomes import Outcome, track_outcome -QueueKey = bytes - logger = logging.getLogger(__name__) @@ -155,9 +156,6 @@ def get_redis_client() -> RedisCluster[bytes] | StrictRedis[bytes]: add_buffer_script = redis.load_redis_script("spans/add-buffer.lua") -type SpanPayload = dict[str, Any] - - def _compute_salt(spans: Sequence[Span]) -> str: return blake2b( b"".join(s.span_id.encode("ascii") for s in spans), @@ -165,63 +163,6 @@ def _compute_salt(spans: Sequence[Span]) -> str: ).hexdigest() -class OutputSpan(NamedTuple): - payload: SpanPayload - - -class FlushedSegment(NamedTuple): - queue_key: QueueKey - spans: list[OutputSpan] - project_id: int # Used to track outcomes - payload_keys: list[PayloadKey] = [] # For cleanup - - def to_messages(self) -> list[dict[str, Any]]: - """ - Build producer messages for this segment. - - If the segment size exceeds `spans.buffer.max_segment_bytes`, the segment is split - into multiple messages with skip_enrichment=True. Otherwise, returns a single message. - - Each message gets a unique flush_id generated at call time, ensuring duplicate - flushes from Redis produce distinct IDs. - """ - max_segment_bytes = options.get("spans.buffer.max-segment-bytes") - - spans: list[SpanPayload] = [span.payload for span in self.spans] - - sizes = [len(orjson.dumps(s)) for s in spans] - if sum(sizes) <= max_segment_bytes: - return [{"flush_id": uuid.uuid4().hex, "spans": spans}] - - messages: list[dict[str, Any]] = [] - current: list[SpanPayload] = [] - current_size = 0 - - for span, size in zip(spans, sizes): - if current and current_size + size > max_segment_bytes: - messages.append( - {"flush_id": uuid.uuid4().hex, "spans": current, "skip_enrichment": True} - ) - current = [] - current_size = 0 - current.append(span) - current_size += size - - if current: - messages.append( - {"flush_id": uuid.uuid4().hex, "spans": current, "skip_enrichment": True} - ) - - if len(messages) > 1: - metrics.timing( - "spans.buffer.oversized_segments_chunked", - len(messages), - ) - metrics.timing("spans.buffer.oversized_segments_size", sum(sizes)) - - return messages - - class SpansBuffer: def __init__(self, assigned_shards: list[int], slice_id: int | None = None): self.assigned_shards = list(assigned_shards) @@ -656,13 +597,71 @@ def _acquire_flush_locks(self, segment_keys: Sequence[SegmentKey]) -> set[Segmen return locks_acquired def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: - cutoff = now + """ + Select queued segments and prepare them for Kafka production. - queue_keys = [] + This orchestrates the flush path: load ready queue entries, acquire + per-segment locks, load payload data, emit loss/flush observability, and + return producer-ready FlushedSegment objects. SpanFlusher produces those + objects to Kafka and calls done_flush_segments after successful delivery. + """ shard_factor = max(1, len(self.assigned_shards)) max_flush_segments = options.get("spans.buffer.max-flush-segments") max_segments_per_shard = math.ceil(max_flush_segments / shard_factor) + flush_candidates, load_ids_latency_ms = self._load_flush_candidates( + cutoff=now, + max_segments_per_shard=max_segments_per_shard, + ) + + flush_candidates = self._acquire_locks_for_flush_candidates(flush_candidates) + segment_keys = [candidate.segment_key for candidate in flush_candidates] + + loaded_segment_data, load_data_latency_ms, decompress_latency_ms = self._load_segment_data( + segment_keys + ) + + segment_to_queue = { + candidate.segment_key: candidate.queue_key for candidate in flush_candidates + } + self._record_segment_loss_metrics( + segment_keys, + segment_to_queue, + now, + loaded_segment_data.payloads, + ) + + flushed_segments, num_has_root_spans, any_shard_at_limit = self._build_flushed_segments( + flush_candidates, + loaded_segment_data, + max_segments_per_shard, + now, + ) + + self._flusher_logger.log_loaded_segments( + segment_keys, + 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(flushed_segments)) + metrics.timing("spans.buffer.flush_segments.has_root_span", num_has_root_spans) + + self.any_shard_at_limit = any_shard_at_limit + return flushed_segments + + def _load_flush_candidates( + self, + cutoff: int, + max_segments_per_shard: int, + ) -> tuple[list[FlushCandidate], int]: + """ + Read ready segment keys from the assigned queue shards. + """ + queue_keys = [] + ids_start = time.monotonic() with metrics.timer("spans.buffer.flush_segments.load_segment_ids"): with self.client.pipeline(transaction=False) as p: @@ -676,43 +675,52 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: result = p.execute() load_ids_latency_ms = int((time.monotonic() - ids_start) * 1000) - segment_keys: list[tuple[int, QueueKey, SegmentKey, float]] = [] + flush_candidates: list[FlushCandidate] = [] for shard, queue_key, keys_with_scores in zip(self.assigned_shards, queue_keys, result): for segment_key, score in keys_with_scores: - segment_keys.append((shard, queue_key, segment_key, score)) - - 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] + flush_candidates.append(FlushCandidate(shard, queue_key, segment_key, score)) - data_start = time.monotonic() - with metrics.timer("spans.buffer.flush_segments.load_segment_data"): - # Pass queue mapping to enable TTL expiration detection - segment_to_queue = { - segment_key: queue_key for _, queue_key, segment_key, _ in segment_keys - } - loaded_segment_data, decompress_latency_ms = self._load_segment_data(segment_key_values) - load_data_latency_ms = int((time.monotonic() - data_start) * 1000) + return flush_candidates, load_ids_latency_ms - self._record_segment_loss_metrics( - segment_key_values, - segment_to_queue, - now, - loaded_segment_data.payloads, + def _acquire_locks_for_flush_candidates( + self, flush_candidates: Sequence[FlushCandidate] + ) -> list[FlushCandidate]: + """ + Acquire per-segment flush locks and keep the candidates that won. + """ + acquired_locks = self._acquire_flush_locks( + [candidate.segment_key for candidate in flush_candidates] ) + return [ + flush_candidate + for flush_candidate in flush_candidates + if flush_candidate.segment_key in acquired_locks + ] - return_segments = {} + def _build_flushed_segments( + self, + flush_candidates: Sequence[FlushCandidate], + loaded_segment_data: LoadedSegmentData, + max_segments_per_shard: int, + now: int, + ) -> tuple[dict[SegmentKey, FlushedSegment], int, bool]: + """ + Convert loaded payload bytes into FlushedSegment objects that contain + the segment metadata and span payloads. + """ + return_segments: dict[SegmentKey, FlushedSegment] = {} num_has_root_spans = 0 any_shard_at_limit = False - for shard, queue_key, segment_key, score in segment_keys: + for flush_candidate in flush_candidates: + segment_key = flush_candidate.segment_key segment_span_id = segment_key_to_span_id(segment_key).decode("ascii") 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 - output_spans = [] + output_spans: list[OutputSpan] = [] has_root_span = False metrics.timing("spans.buffer.flush_segments.num_spans_per_segment", len(segment)) # This incr metric is needed to get a rate overall. @@ -737,10 +745,11 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: output_spans.append(OutputSpan(payload=cast(dict[str, Any], span))) metrics.incr( - "spans.buffer.flush_segments.num_segments_per_shard", tags={"shard_i": shard} + "spans.buffer.flush_segments.num_segments_per_shard", + tags={"shard_i": flush_candidate.shard}, ) return_segments[segment_key] = FlushedSegment( - queue_key=queue_key, + queue_key=flush_candidate.queue_key, spans=output_spans, project_id=int(project_id.decode("ascii")), payload_keys=loaded_segment_data.payload_keys.get(segment_key, []), @@ -752,46 +761,38 @@ def flush_segments(self, now: int) -> dict[SegmentKey, FlushedSegment]: segment_span_id=segment_span_id, has_root_span=has_root_span, num_spans=len(segment), - shard=shard, - queue_key=queue_key, + shard=flush_candidate.shard, + queue_key=flush_candidate.queue_key, timestamp=now, ).emit(self._get_debug_trace_logger) - 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) - - self.any_shard_at_limit = any_shard_at_limit - return return_segments + return return_segments, num_has_root_spans, any_shard_at_limit def _load_segment_data( self, segment_keys: list[SegmentKey], - ) -> tuple[LoadedSegmentData, int]: + ) -> tuple[LoadedSegmentData, int, int]: """ - Loads the segments from Redis, given a list of segment keys. + Load payload keys and span payload bytes for segment keys. - :param segment_keys: List of segment keys to load. - :return: Loaded payloads and payload keys, plus decompression latency. + This is the load-data orchestration step for flushing. It returns the + loaded payload data plus phase latencies for cumulative flusher logging. """ - 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, - ) + data_start = time.monotonic() + with metrics.timer("spans.buffer.flush_segments.load_segment_data"): + 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, + ) + load_data_latency_ms = int((time.monotonic() - data_start) * 1000) return ( LoadedSegmentData(payloads, payload_keys), + load_data_latency_ms, decompress_latency_ms, ) @@ -880,6 +881,9 @@ def _record_segment_loss_metrics( now: int, payloads: dict[SegmentKey, list[bytes]], ) -> None: + """ + Emit loss and expiration metrics for loaded segments. + """ # Fetch ingested counts for all segments to calculate dropped spans with self.client.pipeline(transaction=False) as p: for key in segment_keys: diff --git a/src/sentry/spans/buffer_types.py b/src/sentry/spans/buffer_types.py index 4b18624fd356..ce6d14f0b0b3 100644 --- a/src/sentry/spans/buffer_types.py +++ b/src/sentry/spans/buffer_types.py @@ -1,19 +1,27 @@ """ -Shared value types for the span buffer. +Shared span buffer data structures. -These types describe data passed between buffer pipeline steps. They do not -own Redis operations, logging behavior, or Django model state. +These types describe data passed between buffer pipeline steps. Some include +small representation helpers, but Redis operations and Django model state stay +outside this module. """ from __future__ import annotations +import uuid from collections.abc import Sequence from typing import Any, NamedTuple +import orjson + +from sentry import options from sentry.spans.segment_key import PayloadKey, SegmentKey +from sentry.utils import metrics type DataPoint = tuple[bytes, float] type EvalshaData = list[DataPoint] +type QueueKey = bytes +type SpanPayload = dict[str, Any] # NamedTuples are faster to construct than dataclasses @@ -105,5 +113,92 @@ def is_detached_segment(self) -> bool: class LoadedSegmentData(NamedTuple): + """ + Raw payload data loaded for flush candidates. + """ + payloads: dict[SegmentKey, list[bytes]] payload_keys: dict[SegmentKey, list[PayloadKey]] + + +class OutputSpan(NamedTuple): + """ + A span payload after flush-time segment metadata has been attached. + """ + + payload: SpanPayload + + +class FlushCandidate(NamedTuple): + """ + A queued segment that is ready to be considered for flushing. + + At this stage only queue metadata is known; payload keys and span payloads + are loaded after the flush lock is acquired. + """ + + shard: int + queue_key: QueueKey + segment_key: SegmentKey + score: float + + +class FlushedSegment(NamedTuple): + """ + A buffered segment selected, loaded, and prepared for Kafka production. + + The segment has not been produced to Kafka yet. SpanFlusher calls + `to_messages()` and produces those messages, then cleanup happens through + done_flush_segments. + """ + + queue_key: QueueKey + spans: list[OutputSpan] + project_id: int # Used to track outcomes + payload_keys: list[PayloadKey] = [] # For cleanup + + def to_messages(self) -> list[dict[str, Any]]: + """ + Build producer messages for this segment. + + If the segment size exceeds `spans.buffer.max_segment_bytes`, the segment is split + into multiple messages with skip_enrichment=True. Otherwise, returns a single message. + + Each message gets a unique flush_id generated at call time, ensuring duplicate + flushes from Redis produce distinct IDs. + """ + max_segment_bytes = options.get("spans.buffer.max-segment-bytes") + + spans: list[SpanPayload] = [span.payload for span in self.spans] + + sizes = [len(orjson.dumps(s)) for s in spans] + if sum(sizes) <= max_segment_bytes: + return [{"flush_id": uuid.uuid4().hex, "spans": spans}] + + messages: list[dict[str, Any]] = [] + current: list[SpanPayload] = [] + current_size = 0 + + for span, size in zip(spans, sizes): + if current and current_size + size > max_segment_bytes: + messages.append( + {"flush_id": uuid.uuid4().hex, "spans": current, "skip_enrichment": True} + ) + current = [] + current_size = 0 + current.append(span) + current_size += size + + if current: + messages.append( + {"flush_id": uuid.uuid4().hex, "spans": current, "skip_enrichment": True} + ) + + if len(messages) > 1: + metrics.timing( + "spans.buffer.oversized_segments_chunked", + len(messages), + ) + metrics.timing("spans.buffer.oversized_segments_size", sum(sizes)) + + return messages diff --git a/src/sentry/tagstore/snuba/backend.py b/src/sentry/tagstore/snuba/backend.py index 2048d20eb963..ddb8e1576c56 100644 --- a/src/sentry/tagstore/snuba/backend.py +++ b/src/sentry/tagstore/snuba/backend.py @@ -508,7 +508,7 @@ def serialize_group_tag_key(item: GroupTagKey) -> dict[str, Any]: snuba_output, eap_output, eap_callsite, - is_experimental_data_a_null_result=eap_output.count == 0, + is_experimental_data_nullish=eap_output.count == 0, reasonable_match_comparator=reasonable_group_tag_key_match, debug_context={ "group_id": group.id, @@ -917,7 +917,7 @@ def get_group_list_tag_value( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_group_list_tag_value_match, debug_context={ "project_ids": list(project_ids), @@ -1005,7 +1005,7 @@ def get_generic_group_list_tag_value( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_group_list_tag_value_match, debug_context={ "project_ids": list(project_ids), @@ -1171,7 +1171,7 @@ def get_group_tag_value_count( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=eap_result == 0, + is_experimental_data_nullish=eap_result == 0, reasonable_match_comparator=lambda control, experimental: experimental <= control, debug_context={ "group_id": group.id, @@ -1401,7 +1401,7 @@ def get_release_tags(self, organization_id, project_ids, environment_id, version control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_release_tags_match, debug_context={ "organization_id": organization_id, @@ -1596,7 +1596,7 @@ def get_groups_user_counts( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_user_counts_match, debug_context={ "project_ids": list(project_ids), @@ -1747,7 +1747,7 @@ def get_generic_groups_user_counts( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_user_counts_match, debug_context={ "project_ids": list(project_ids), @@ -2286,7 +2286,7 @@ def get_group_tag_value_iter( control_data=snuba_result, experimental_data=eap_result, callsite=callsite, - is_experimental_data_a_null_result=len(eap_result) == 0, + is_experimental_data_nullish=len(eap_result) == 0, reasonable_match_comparator=_reasonable_group_tag_value_iter_match, debug_context={ "group_id": group.id, diff --git a/src/sentry/tasks/seer/lightweight_rca_cluster.py b/src/sentry/tasks/seer/lightweight_rca_cluster.py index 9d8abcbb4dc7..557fe964e90f 100644 --- a/src/sentry/tasks/seer/lightweight_rca_cluster.py +++ b/src/sentry/tasks/seer/lightweight_rca_cluster.py @@ -3,14 +3,15 @@ from sentry.models.group import Group from sentry.seer.supergroups.lightweight_rca_cluster import trigger_lightweight_rca_cluster from sentry.tasks.base import instrumented_task -from sentry.taskworker.namespaces import ingest_errors_tasks +from sentry.taskworker.namespaces import ingest_errors_postprocess_tasks, ingest_errors_tasks logger = logging.getLogger(__name__) @instrumented_task( name="sentry.tasks.seer.lightweight_rca_cluster.trigger_lightweight_rca_cluster_task", - namespace=ingest_errors_tasks, + namespace=ingest_errors_postprocess_tasks, + alias_namespace=ingest_errors_tasks, ) def trigger_lightweight_rca_cluster_task(group_id: int, **kwargs) -> None: try: diff --git a/src/sentry/tasks/summaries/utils.py b/src/sentry/tasks/summaries/utils.py index f6883de820d6..12e323d022d5 100644 --- a/src/sentry/tasks/summaries/utils.py +++ b/src/sentry/tasks/summaries/utils.py @@ -157,7 +157,7 @@ def project_key_errors( snuba_rows, eap_rows, callsite, - is_experimental_data_a_null_result=len(eap_rows) == 0, + is_experimental_data_nullish=len(eap_rows) == 0, reasonable_match_comparator=lambda snuba, eap: keyed_counts_subset_match( snuba, eap, @@ -352,7 +352,7 @@ def project_key_performance_issues(ctx: OrganizationReportContext, project: Proj snuba_rows, eap_rows, callsite, - is_experimental_data_a_null_result=len(eap_rows) == 0, + is_experimental_data_nullish=len(eap_rows) == 0, reasonable_match_comparator=lambda snuba, eap: keyed_counts_subset_match( snuba, eap, diff --git a/src/sentry/templates/sentry/debug/mail/preview.html b/src/sentry/templates/sentry/debug/mail/preview.html index 605471bc6507..884fcaa4bc95 100644 --- a/src/sentry/templates/sentry/debug/mail/preview.html +++ b/src/sentry/templates/sentry/debug/mail/preview.html @@ -28,7 +28,6 @@ - diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index 288f9ee5f025..b12028c538ae 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -688,6 +688,8 @@ def create_dashboard_widget_query(self, *args, **kwargs): return Factories.create_dashboard_widget_query(*args, **kwargs) def create_workflow(self, *args, **kwargs) -> Workflow: + if "organization" not in kwargs: + kwargs["organization"] = self.organization return Factories.create_workflow(*args, **kwargs) def create_data_source(self, *args, **kwargs) -> DataSource: diff --git a/src/sentry/testutils/helpers/serializer_parity.py b/src/sentry/testutils/helpers/serializer_parity.py index 6729c7d0510e..f418a3f8dfcc 100644 --- a/src/sentry/testutils/helpers/serializer_parity.py +++ b/src/sentry/testutils/helpers/serializer_parity.py @@ -1,9 +1,10 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, field from typing import Any +from sentry.utils.payload_comparison import ParityChecker + def assert_serializer_parity( *, @@ -50,7 +51,7 @@ def assert_serializer_parity( """ known_diffs = frozenset(known_differences or ()) unreliable_fields = frozenset(unreliable or ()) - checker = _ParityChecker() + checker = ParityChecker(format_value=repr) checker.compare(old, new, known_diffs, unreliable=unreliable_fields) assert not checker.mismatches, "Serializer differences:\n" + "\n".join(checker.mismatches) @@ -61,99 +62,3 @@ def assert_serializer_parity( "Unnecessary known_differences (no actual difference found):\n" + "\n".join(sorted(unnecessary)) ) - - -def _qualify(prefix: str, name: str) -> str: - return f"{prefix}.{name}" if prefix else name - - -@dataclass -class _ParityChecker: - mismatches: list[str] = field(default_factory=list) - - # known_diffs entries confirmed to be actual differences. - confirmed: set[str] = field(default_factory=set) - - def _nested_fields(self, field_set: frozenset[str], key: str) -> frozenset[str]: - """Extract child paths for *key* from a dot-separated field set. - - E.g. ``_nested_fields({"activities.id", "title"}, "activities")`` - returns ``{"id"}``. - """ - prefix = key + "." - return frozenset(e[len(prefix) :] for e in field_set if e.startswith(prefix)) - - def compare( - self, - old: Mapping[str, Any], - new: Mapping[str, Any], - known_diffs: frozenset[str], - path: str = "", - diffs_path: str = "", - *, - unreliable: frozenset[str] = frozenset(), - ) -> None: - for key in set(list(old.keys()) + list(new.keys())): - if key in known_diffs: - full_diffs_key = _qualify(diffs_path, key) - if key not in new or key not in old or old[key] != new[key]: - self.confirmed.add(full_diffs_key) - continue - - if key in unreliable: - full_path = _qualify(path, key) - if key not in new: - self.mismatches.append(f"Missing from new: {full_path}") - elif key not in old: - self.mismatches.append(f"Extra in new: {full_path}") - continue - - full_path = _qualify(path, key) - - if key not in new: - self.mismatches.append(f"Missing from new: {full_path}") - continue - if key not in old: - self.mismatches.append(f"Extra in new: {full_path}") - continue - - old_val = old[key] - new_val = new[key] - nested_diffs = self._nested_fields(known_diffs, key) - nested_unreliable = self._nested_fields(unreliable, key) - - if nested_diffs or nested_unreliable: - child_diffs_path = _qualify(diffs_path, key) - if isinstance(old_val, list) and isinstance(new_val, list): - if len(old_val) != len(new_val): - self.mismatches.append( - f"{full_path} count: old={len(old_val)}, new={len(new_val)}" - ) - for i, (old_item, new_item) in enumerate(zip(old_val, new_val)): - item_path = f"{full_path}[{i}]" - if isinstance(old_item, Mapping) and isinstance(new_item, Mapping): - self.compare( - old_item, - new_item, - nested_diffs, - item_path, - child_diffs_path, - unreliable=nested_unreliable, - ) - elif old_item != new_item: - self.mismatches.append( - f"{item_path}: old={old_item!r}, new={new_item!r}" - ) - elif isinstance(old_val, Mapping) and isinstance(new_val, Mapping): - self.compare( - old_val, - new_val, - nested_diffs, - full_path, - child_diffs_path, - unreliable=nested_unreliable, - ) - elif old_val != new_val: - self.mismatches.append(f"{full_path}: old={old_val!r}, new={new_val!r}") - elif old_val != new_val: - self.mismatches.append(f"{full_path}: old={old_val!r}, new={new_val!r}") diff --git a/src/sentry/utils/outcomes.py b/src/sentry/utils/outcomes.py index 7b9c957c0b64..0004a5fd630f 100644 --- a/src/sentry/utils/outcomes.py +++ b/src/sentry/utils/outcomes.py @@ -8,11 +8,14 @@ from enum import IntEnum from threading import Lock +from arroyo.backends.kafka import KafkaPayload, KafkaProducer +from arroyo.types import Topic as ArroyoTopic + from sentry.conf.types.kafka_definition import Topic from sentry.constants import DataCategory from sentry.utils import json, kafka_config, metrics +from sentry.utils.arroyo_producer import SingletonProducer, get_arroyo_producer from sentry.utils.dates import to_datetime -from sentry.utils.pubsub import KafkaPublisher # Aggregation key for grouping outcomes OutcomeKey = namedtuple( @@ -156,8 +159,22 @@ def is_billing(self) -> bool: return self in (Outcome.ACCEPTED, Outcome.RATE_LIMITED) -outcomes_publisher: KafkaPublisher | None = None -billing_publisher: KafkaPublisher | None = None +def _get_outcomes_producer() -> KafkaProducer: + return get_arroyo_producer( + "sentry.utils.outcomes", + Topic.OUTCOMES, + ) + + +def _get_billing_producer() -> KafkaProducer: + return get_arroyo_producer( + "sentry.utils.outcomes.billing", + Topic.OUTCOMES_BILLING, + ) + + +outcomes_producer = SingletonProducer(_get_outcomes_producer) +billing_producer = SingletonProducer(_get_billing_producer) LATE_OUTCOME_THRESHOLD = timedelta(days=1) @@ -183,9 +200,6 @@ def track_outcome( data for SnubaTSDB and RedisSnubaTSDB, such as # of rate-limited/filtered events. """ - global outcomes_publisher - global billing_publisher - if quantity is None: quantity = 1 @@ -205,20 +219,9 @@ def track_outcome( # Create a second producer instance only if the cluster differs. Otherwise, # reuse the same producer and just send to the other topic. if use_billing and billing_config["cluster"] != outcomes_config["cluster"]: - if billing_publisher is None: - cluster_name = billing_config["cluster"] - billing_publisher = KafkaPublisher( - kafka_config.get_kafka_producer_cluster_options(cluster_name) - ) - publisher = billing_publisher - + producer = billing_producer else: - if outcomes_publisher is None: - cluster_name = outcomes_config["cluster"] - outcomes_publisher = KafkaPublisher( - kafka_config.get_kafka_producer_cluster_options(cluster_name) - ) - publisher = outcomes_publisher + producer = outcomes_producer now = to_datetime(time.time()) timestamp = timestamp or now @@ -228,21 +231,24 @@ def track_outcome( billing_config["real_topic_name"] if use_billing else outcomes_config["real_topic_name"] ) - # Send a snuba metrics payload. - publisher.publish( - topic_name, - json.dumps( - { - "timestamp": timestamp, - "org_id": org_id, - "project_id": project_id, - "key_id": key_id, - "outcome": outcome.value, - "reason": reason, - "event_id": event_id, - "category": category, - "quantity": quantity, - } + producer.produce( + ArroyoTopic(topic_name), + KafkaPayload( + None, + json.dumps( + { + "timestamp": timestamp, + "org_id": org_id, + "project_id": project_id, + "key_id": key_id, + "outcome": outcome.value, + "reason": reason, + "event_id": event_id, + "category": category, + "quantity": quantity, + } + ).encode("utf-8"), + [], ), ) diff --git a/src/sentry/utils/payload_comparison.py b/src/sentry/utils/payload_comparison.py new file mode 100644 index 000000000000..eb2f824373d7 --- /dev/null +++ b/src/sentry/utils/payload_comparison.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import Any + + +def _qualify(prefix: str, name: str) -> str: + return f"{prefix}.{name}" if prefix else name + + +def describe_value(value: Any) -> str: + """PII-safe value descriptor for production logging.""" + if value is None: + return "None" + if isinstance(value, str): + return f"str(len={len(value)})" + if isinstance(value, bool): + return f"bool({value})" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, list): + return f"list(len={len(value)})" + if isinstance(value, dict): + return f"dict(keys={sorted(value.keys())})" + return type(value).__name__ + + +@dataclass +class ParityChecker: + """Recursive dict comparator with dot-separated field paths. + + ``format_value`` controls how values appear in mismatch messages. + Use ``repr`` (default) for test output with full values, or + ``describe_value`` for PII-safe structural metadata in production. + """ + + format_value: Callable[[Any], str] = repr + mismatches: list[str] = field(default_factory=list) + + # known_diffs entries confirmed to be actual differences. + confirmed: set[str] = field(default_factory=set) + + def _nested_fields(self, field_set: frozenset[str], key: str) -> frozenset[str]: + """Extract child paths for *key* from a dot-separated field set. + + E.g. ``_nested_fields({"activities.id", "title"}, "activities")`` + returns ``{"id"}``. + """ + prefix = key + "." + return frozenset(e[len(prefix) :] for e in field_set if e.startswith(prefix)) + + def compare( + self, + old: Mapping[str, Any], + new: Mapping[str, Any], + known_diffs: frozenset[str], + path: str = "", + diffs_path: str = "", + *, + unreliable: frozenset[str] = frozenset(), + ) -> None: + for key in set(list(old.keys()) + list(new.keys())): + if key in known_diffs: + full_diffs_key = _qualify(diffs_path, key) + if key not in new or key not in old or old[key] != new[key]: + self.confirmed.add(full_diffs_key) + continue + + if key in unreliable: + full_path = _qualify(path, key) + if key not in new: + self.mismatches.append(f"Missing from new: {full_path}") + elif key not in old: + self.mismatches.append(f"Extra in new: {full_path}") + continue + + full_path = _qualify(path, key) + + if key not in new: + self.mismatches.append(f"Missing from new: {full_path}") + continue + if key not in old: + self.mismatches.append(f"Extra in new: {full_path}") + continue + + old_val = old[key] + new_val = new[key] + nested_diffs = self._nested_fields(known_diffs, key) + nested_unreliable = self._nested_fields(unreliable, key) + + if nested_diffs or nested_unreliable: + child_diffs_path = _qualify(diffs_path, key) + if isinstance(old_val, list) and isinstance(new_val, list): + if len(old_val) != len(new_val): + self.mismatches.append( + f"{full_path} count: old={len(old_val)}, new={len(new_val)}" + ) + for i, (old_item, new_item) in enumerate(zip(old_val, new_val)): + item_path = f"{full_path}[{i}]" + if isinstance(old_item, Mapping) and isinstance(new_item, Mapping): + self.compare( + old_item, + new_item, + nested_diffs, + item_path, + child_diffs_path, + unreliable=nested_unreliable, + ) + elif old_item != new_item: + self.mismatches.append( + f"{item_path}: old={self.format_value(old_item)}, new={self.format_value(new_item)}" + ) + elif isinstance(old_val, Mapping) and isinstance(new_val, Mapping): + self.compare( + old_val, + new_val, + nested_diffs, + full_path, + child_diffs_path, + unreliable=nested_unreliable, + ) + elif old_val != new_val: + self.mismatches.append( + f"{full_path}: old={self.format_value(old_val)}, new={self.format_value(new_val)}" + ) + elif isinstance(old_val, Mapping) and isinstance(new_val, Mapping): + self.compare(old_val, new_val, frozenset(), full_path, _qualify(diffs_path, key)) + elif isinstance(old_val, list) and isinstance(new_val, list): + if len(old_val) != len(new_val): + self.mismatches.append( + f"{full_path} count: old={len(old_val)}, new={len(new_val)}" + ) + for i, (old_item, new_item) in enumerate(zip(old_val, new_val)): + item_path = f"{full_path}[{i}]" + if isinstance(old_item, Mapping) and isinstance(new_item, Mapping): + self.compare( + old_item, new_item, frozenset(), item_path, _qualify(diffs_path, key) + ) + elif old_item != new_item: + self.mismatches.append( + f"{item_path}: old={self.format_value(old_item)}, new={self.format_value(new_item)}" + ) + elif old_val != new_val: + self.mismatches.append( + f"{full_path}: old={self.format_value(old_val)}, new={self.format_value(new_val)}" + ) diff --git a/src/sentry/utils/rollout.py b/src/sentry/utils/rollout.py index 03284686e1c6..b3c2f24f79b5 100644 --- a/src/sentry/utils/rollout.py +++ b/src/sentry/utils/rollout.py @@ -4,145 +4,160 @@ from typing import Any, TypeVar from sentry import options -from sentry.utils import metrics -from sentry.utils.safe import trim - -TData = TypeVar("TData") -logger = logging.getLogger(__name__) - +from sentry.options import register from sentry.options.manager import ( FLAG_ALLOW_EMPTY, FLAG_AUTOMATOR_MODIFIABLE, FLAG_MODIFIABLE_BOOL, FLAG_MODIFIABLE_RATE, ) +from sentry.utils import metrics +from sentry.utils.safe import trim from sentry.utils.types import Bool, Float, Sequence +logger = logging.getLogger(__name__) + +TData = TypeVar("TData") + class SafeRolloutComparator: """ SafeRolloutComparator is a tool designed to help you roll out a change to existing logic safely. - In particular, it can (at a callsite-by-callsite granularity) help to track both the - _exact_ and _reasonable_ rate at which the experimental branch matches the control branch. - Once a callsite looks correct enough, you can switch the code behavior to actually use the - data from the experimental branch. + In particular, it can (with callsite-by-callsite granularity) help to track rate at which the + experimental branch both exactly matches and "reasonably" matches the control branch. (What + counts as a "reasonable" (close enough) match is definable by providing a comparison function.) + Once a callsite looks correct enough, you can switch the code behavior to actually use the data + from the experimental branch by adding the callsite indentifier to the "use experimental data" + allowlist option provided by the class. The flow is generally: - 1. Set up your SafeRolloutComparator class & options. - 2. Add your first callsite. (Further callsites can be added at any time.) - 3. Start rolling out the "evaluate experimental branch" option. - 4. Monitor correctness through standard dashboard. (TODO @cpaul: build dashboard) - 5. Start adding known-good callsites to the "use experimental branch" allowlist. + 1. Set up your `SafeRolloutComparator` subclass (in Sentry) & options (in options automator). + 2. Use the comparator in your first callsite (see example below). (More callsites can be added + at any time.) + 3. Start rolling out the experiment by switching the "should run experiment" option to True + and, if you've set a sample rate option, increasing the sample rate. (If not set, the + sample rate defaults to 100%.) + 4. Monitor correctness using the metrics and optional mismatch logs emitted when the + experimental branch is run. + 5. Start adding known-good callsites to the "use experimental data" allowlist. 6. Complete your migration, secure in your knowledge that it's safe to do so. - 7. Clean up your control branch & SafeRolloutComparator when you're done. Success! + 7. Clean up your control branch & `SafeRolloutComparator` when you're done. Success! Used like: - ```python - callsite = "BarClass::baz" - control_data = old_slow_trustworthy_method() - if FooComparator.should_check_experiment(callsite): - experimental_data = new_fast_risky_method() - data = FooComparator.check_and_choose( - control_data, - experimental_data, - callsite, - len(experimental_data) == 0, - lambda ctl, exp: exp.issubset(ctl) - ) - else: - data = control_data + ``` + # Setup + class FooComparator(SafeRolloutComparator): + ROLLOUT_NAME="some_new_feature" + + # Example callsite: + def some_function(): + # ... + + # A unique identifier for the callsite, for option names and metrics/logs tagging + callsite = "some.module.path.some_function" + + control_data = old_slow_trustworthy_method() + if FooComparator.should_check_experiment(callsite): + experimental_data = new_fast_risky_method() + data = FooComparator.check_and_choose( + control_data, + experimental_data, + callsite, + is_experimental_data_nullish=len(experimental_data) == 0, + reasonable_match_comparator=lambda ctl, exp: exp.issubset(ctl) + ) + else: + data = control_data ``` """ - # This is your rollout, which determines your option names and which you can filter - # the DataDog dashboards to show. - # TODO @cpaul: construct DataDog dashboards once you have a rollout using this. + # This identifies your overall rollout, and is used in option names and metrics/log tagging ROLLOUT_NAME: str @classmethod - def _should_eval_option_name(cls) -> str: + def _should_run_experiment_option(cls) -> str: """ - This is the high-level eval rollout option. If this option is disabled, the - should_check_experiment function will return False. + This is the high-level experiment rollout option. If this option is disabled (the default), + the `should_check_experiment` function will return False. """ return f"dynamic.saferollouts.{cls.ROLLOUT_NAME}.should_eval_experimental" @classmethod - def _callsite_blocklist_option_name(cls) -> str: + def _callsite_experiment_blocklist_option(cls) -> str: """ - This is the callsite-level eval rollout option. If the option contains a callsite, - the should_check_experiment function will return False. (This is useful if you see - one callsite in particular start throwing.) + This is the callsite-level experimemt rollout option. If the option value contains a + callsite, the `should_check_experiment` function will return False. (This is useful if you + see one callsite in particular start throwing.) Defaults to an empty list. """ return f"dynamic.saferollouts.{cls.ROLLOUT_NAME}.eval_callsite_blocklist" @classmethod - def _callsite_allowlist_option_name(cls) -> str: + def _callsite_use_experimental_data_allowlist_option(cls) -> str: """ - This is the callsite-level use-experimental-path rollout option. If the option - contains a callsite, then that callsite will use the experimental-path data. - This should generally only be used once you've determined that there is a high - rate of partial- or exact- match at the callsite. + This is the callsite-level use-experimental-path rollout option. If the option value + contains a callsite, then that callsite will use the experimental-path data. This should + generally only be used once you've determined that there is a high rate of partial- or + exact- match at the callsite. Defaults to an empty list. """ return f"dynamic.saferollouts.{cls.ROLLOUT_NAME}.use_experimental_data_callsite_allowlist" @classmethod - def _sample_rate_option_name(cls) -> str: + def _experiment_sample_rate_option(cls) -> str: """ - This is the sample rate for evaluating the experimental branch. When set to a value - less than 1.0, only that percentage of requests will actually perform the double-read. - This is useful for limiting latency impact on high-traffic callsites while still - collecting representative metrics. Default is 1.0 (100% of requests are evaluated). + This is the sample rate for evaluating the experimental branch. When set to a value less + than 1.0, only that percentage of requests will actually evaluate both branches. This is + useful for limiting latency impact on high-traffic callsites while still collecting + representative metrics. Default is 1.0 (100% of requests are evaluated). """ return f"dynamic.saferollouts.{cls.ROLLOUT_NAME}.eval_experimental_sample_rate" @classmethod - def _mismatch_log_callsite_allowlist_option_name(cls) -> str: + def _callsite_mismatch_log_allowlist_option(cls) -> str: """ - Controls which callsites emit structured mismatch logs. Add a callsite - string to enable logging for it, or ``"*"`` to enable for all callsites. + Controls which callsites emit structured mismatch logs. Add a callsite identifier to enable + logging for it, or set the option to `["*"]` to enable logging for all callsites. Defaults + to an empty list (no mismatch logging). """ return f"dynamic.saferollouts.{cls.ROLLOUT_NAME}.mismatch_log_callsite_allowlist" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) - from sentry.options import register register( - cls._should_eval_option_name(), + cls._should_run_experiment_option(), type=Bool, default=False, flags=FLAG_MODIFIABLE_BOOL | FLAG_AUTOMATOR_MODIFIABLE, ) register( - cls._callsite_blocklist_option_name(), + cls._callsite_experiment_blocklist_option(), type=Sequence, default=[], flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) register( - cls._callsite_allowlist_option_name(), + cls._callsite_use_experimental_data_allowlist_option(), type=Sequence, default=[], flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) register( - cls._sample_rate_option_name(), + cls._experiment_sample_rate_option(), type=Float, default=1.0, flags=FLAG_MODIFIABLE_RATE | FLAG_AUTOMATOR_MODIFIABLE, ) register( - cls._mismatch_log_callsite_allowlist_option_name(), + cls._callsite_mismatch_log_allowlist_option(), type=Sequence, default=[], flags=FLAG_ALLOW_EMPTY | FLAG_AUTOMATOR_MODIFIABLE, ) @classmethod - def should_log_mismatch(cls, callsite: str) -> bool: - allowlist = set(options.get(cls._mismatch_log_callsite_allowlist_option_name())) + def _should_log_mismatch(cls, callsite: str) -> bool: + allowlist = set(options.get(cls._callsite_mismatch_log_allowlist_option())) return "*" in allowlist or callsite in allowlist @classmethod @@ -162,16 +177,16 @@ def _maybe_log_mismatch( cls, *, callsite: str, - use_experimental: bool, - exact_match: bool, - reasonable_match: bool | None, - is_experimental_data_a_null_result: bool | None, + use_experimental_data: bool, + is_exact_match: bool, + is_reasonable_match: bool | None, + is_experimental_data_nullish: bool | None, control_data: TData, experimental_data: TData, debug_context: dict[str, Any] | None, data_serializer: Callable[[TData], Any] | None, ) -> None: - if not cls.should_log_mismatch(callsite): + if not cls._should_log_mismatch(callsite): return serialize = data_serializer or cls._default_serialize_for_log @@ -181,10 +196,10 @@ def _maybe_log_mismatch( extra={ "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, - "source_of_truth": ("experimental" if use_experimental else "control"), - "exact_match": exact_match, - "reasonable_match": reasonable_match, - "is_null_result": is_experimental_data_a_null_result, + "source_of_truth": ("experimental" if use_experimental_data else "control"), + "exact_match": is_exact_match, + "reasonable_match": is_reasonable_match, + "is_null_result": is_experimental_data_nullish, "debug_context": trim(cls._default_serialize_for_log(debug_context)), "control_data_raw": trim(serialize(control_data)), "experimental_data_raw": trim(serialize(experimental_data)), @@ -194,43 +209,46 @@ def _maybe_log_mismatch( @classmethod def should_check_experiment(cls, callsite: str) -> bool: """ - This function should control whether you evaluate your experimental branch at - all. Useful for rolling out by region or blocklisting callsites that throw. + This function controls whether you evaluate your experimental branch at all. Useful for + rolling out by region or blocklisting callsites that throw. The check includes: 1. Global eval option must be enabled 2. Callsite must not be in the blocklist 3. Random sampling based on the sample_rate option (default 1.0 = 100%) """ - if not options.get(cls._should_eval_option_name()): + if not options.get(cls._should_run_experiment_option()): return False - if callsite in options.get(cls._callsite_blocklist_option_name()): + if callsite in options.get(cls._callsite_experiment_blocklist_option()): return False - sample_rate = options.get(cls._sample_rate_option_name()) + sample_rate = options.get(cls._experiment_sample_rate_option()) return random.random() < sample_rate @classmethod - def should_use_experiment(cls, callsite: str) -> bool: + def should_use_experimental_data(cls, callsite: str) -> bool: """ - This function should control whether you use the result of your experimental - data. Useful for allowlisting known-safe callsites. - If you are transitioning from an existing, intended-to-be equivalent dataset, - you should instead use check_and_choose (which standardizes the choice logic - and has better logging). + This function controls whether you use the result of your experimental data. Useful for + allowlisting known-safe callsites. + + Note: If you are transitioning from an existing, intended-to-be-equivalent dataset, you + should instead use `check_and_choose` (which has this check built in and has better + logging). """ - use_experimental = callsite in options.get(cls._callsite_allowlist_option_name()) + use_experimental_data = callsite in options.get( + cls._callsite_use_experimental_data_allowlist_option() + ) tags: dict[str, str] = { "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, - "use_experimental": ("true" if use_experimental else "false"), + "use_experimental": ("true" if use_experimental_data else "false"), } metrics.incr( "SafeRolloutComparator.should_use_experiment", tags=tags, ) - return use_experimental + return use_experimental_data @classmethod def check_and_choose( @@ -238,72 +256,71 @@ def check_and_choose( control_data: TData, experimental_data: TData, callsite: str, - is_experimental_data_a_null_result: bool | None = None, + is_experimental_data_nullish: bool | None = None, reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, debug_context: dict[str, Any] | None = None, data_serializer: Callable[[TData], Any] | None = None, ) -> TData: """ - This function does two things. - First, it compares control & experimental data and logs info to DataDog. - Second, it determines which of the inputs should be returned & used downstream. + This function does two things: + - First, it compares control & experimental data and logs info to DataDog. + - Second, it determines which of the inputs should be returned & used downstream. Inputs: * control_data: Some data from the control branch (e.g. dict[str, str]) * experimental_data: Some data from the experimental branch (of same type as control) - * callsite: A unique string for each place that uses this class. Should be the - same as passed to should_check_experiment. - * is_null_result: Whether the result is a "null result" (e.g. empty array). This - helps to determine whether a "match" is significant. - * reasonable_match_comparator: Optional predicate for semantic correctness - (e.g. subset semantics with retention gaps), returning True if the read is - "reasonable" and False otherwise. An example might be checking whether the - experimental data is a subset of the control data (useful in case of migrating - something where you don't yet have full retention in the experimental branch). + * callsite: A unique string identifying place that uses this class. Should be the same as + what's passed to `should_check_experiment`. + * is_experimental_data_nullish: Whether the result is a "null result" (e.g. empty array). + This helps to determine whether a "match" is significant. + * reasonable_match_comparator: Optional predicate for semantic correctness, returning True + if the data is "reasonably the same" and False otherwise. An example might be checking + whether the experimental data is a subset of the control data (useful in case of + migrating something where you don't yet have full retention in the experimental branch). * debug_context: Optional structured metadata included on mismatch logs. - * data_serializer: Optional serializer for control/experimental payloads in - logs. Defaults to `_default_serialize_for_log`. + * data_serializer: Optional serializer for control/experimental payloads in logs. Defaults + to `_default_serialize_for_log`. """ - use_experimental = cls.should_use_experiment(callsite) - exact_match = control_data == experimental_data - reasonable_match: bool | None = None + use_experimental_data = cls.should_use_experimental_data(callsite) + is_exact_match = control_data == experimental_data + is_reasonable_match: bool | None = None # Part 1: Compare results, log debug info, and emit metrics tags: dict[str, str] = { "rollout_name": cls.ROLLOUT_NAME, "callsite": callsite, - "exact_match": str(exact_match), - "source_of_truth": ("experimental" if use_experimental else "control"), + "exact_match": str(is_exact_match), + "source_of_truth": ("experimental" if use_experimental_data else "control"), } - if is_experimental_data_a_null_result is not None: - tags["is_null_result"] = str(is_experimental_data_a_null_result) + if is_experimental_data_nullish is not None: + tags["is_null_result"] = str(is_experimental_data_nullish) if reasonable_match_comparator is not None: try: - reasonable_match = reasonable_match_comparator(control_data, experimental_data) + is_reasonable_match = reasonable_match_comparator(control_data, experimental_data) except Exception: logger.exception( "saferollout.comparator_error", extra={"rollout_name": cls.ROLLOUT_NAME, "callsite": callsite}, ) - reasonable_match = None + is_reasonable_match = None else: - tags["reasonable_match"] = str(reasonable_match) + tags["reasonable_match"] = str(is_reasonable_match) # Log mismatch only for true mismatches: when a reasonable comparator # exists, only log if it returned False; otherwise log on exact mismatch. - has_mismatch = reasonable_match is False or ( - reasonable_match is None and exact_match is False + has_mismatch = is_reasonable_match is False or ( + is_reasonable_match is None and is_exact_match is False ) if has_mismatch: try: cls._maybe_log_mismatch( callsite=callsite, - use_experimental=use_experimental, - exact_match=exact_match, - reasonable_match=reasonable_match, - is_experimental_data_a_null_result=is_experimental_data_a_null_result, + use_experimental_data=use_experimental_data, + is_exact_match=is_exact_match, + is_reasonable_match=is_reasonable_match, + is_experimental_data_nullish=is_experimental_data_nullish, control_data=control_data, experimental_data=experimental_data, debug_context=debug_context, @@ -321,13 +338,13 @@ def check_and_choose( ) # Part 2: determine which data to return - return experimental_data if use_experimental else control_data + return experimental_data if use_experimental_data else control_data @classmethod def check_and_choose_with_timings( cls, - control_thunk: Callable[[], TData], - experimental_thunk: Callable[[], TData], + control_data_func: Callable[[], TData], + experimental_data_func: Callable[[], TData], callsite: str, null_result_determiner: Callable[[TData], bool] | None = None, reasonable_match_comparator: Callable[[TData, TData], bool] | None = None, @@ -335,15 +352,16 @@ def check_and_choose_with_timings( data_serializer: Callable[[TData], Any] | None = None, ) -> TData: """ - This method is essentially the same as check_and_choose, but captures timing - information around the control/experimental branches. - - This information is captured with Sentry spans, not in Datadog. + This method is a wrapper for `check_and_choose` which also captures timing information for + the control/experimental branches. To enable that, instead of taking the control and + experimental values, it instead takes callbacks which calculate them. It also takes a + callback for determining if the experimental result is nullish, rather than a boolean. All + other parameters match those of `check_and_choose`. """ + # Insurance - this should already have been checked by the caller if not cls.should_check_experiment(callsite): - # Don't bother collecting data in the case where we're only evaluating the - # control branch. - return control_thunk() + # Don't bother collecting data if we're only evaluating the control branch + return control_data_func() with metrics.timer( "SafeRolloutComparator.check_and_choose_with_timings", @@ -353,7 +371,7 @@ def check_and_choose_with_timings( "branch": "control", }, ): - control_data = control_thunk() + control_data = control_data_func() with metrics.timer( "SafeRolloutComparator.check_and_choose_with_timings", @@ -363,17 +381,17 @@ def check_and_choose_with_timings( "branch": "experimental", }, ): - experimental_data = experimental_thunk() + experimental_data = experimental_data_func() - is_experimental_data_a_null_result = None + is_experimental_data_nullish = None if null_result_determiner is not None: - is_experimental_data_a_null_result = null_result_determiner(experimental_data) + is_experimental_data_nullish = null_result_determiner(experimental_data) return cls.check_and_choose( control_data=control_data, experimental_data=experimental_data, callsite=callsite, - is_experimental_data_a_null_result=is_experimental_data_a_null_result, + is_experimental_data_nullish=is_experimental_data_nullish, reasonable_match_comparator=reasonable_match_comparator, debug_context=debug_context, data_serializer=data_serializer, diff --git a/src/sentry/web/client_config.py b/src/sentry/web/client_config.py index 2265a3374b89..87e73d28c768 100644 --- a/src/sentry/web/client_config.py +++ b/src/sentry/web/client_config.py @@ -225,10 +225,6 @@ def enabled_features(self) -> Iterable[str]: yield "relocation:enabled" if features.has("system:multi-region"): yield "system:multi-region" - if self.last_org and features.has( - "organizations:create-org-control", self.last_org, actor=self.user - ): - yield "organizations:create-org-control" @property def needs_upgrade(self) -> bool: diff --git a/src/sentry/web/debug_urls.py b/src/sentry/web/debug_urls.py index ab6a85628948..1922ea05c5a5 100644 --- a/src/sentry/web/debug_urls.py +++ b/src/sentry/web/debug_urls.py @@ -20,7 +20,6 @@ from sentry.web.frontend.debug.debug_error_embed import DebugErrorPageEmbedView from sentry.web.frontend.debug.debug_feedback_issue import DebugFeedbackIssueEmailView from sentry.web.frontend.debug.debug_generic_issue import DebugGenericIssueEmailView -from sentry.web.frontend.debug.debug_incident_trigger_email import DebugIncidentTriggerEmailView from sentry.web.frontend.debug.debug_invalid_identity_email import DebugInvalidIdentityEmailView from sentry.web.frontend.debug.debug_mfa_added_email import DebugMfaAddedEmailView from sentry.web.frontend.debug.debug_mfa_removed_email import DebugMfaRemovedEmailView @@ -139,7 +138,6 @@ re_path( r"^debug/mail/sso-unlinked/no-password/$", DebugSsoUnlinkedNoPasswordEmailView.as_view() ), - re_path(r"^debug/mail/incident-trigger/$", DebugIncidentTriggerEmailView.as_view()), re_path(r"^debug/mail/setup-2fa/$", DebugSetup2faEmailView.as_view()), re_path(r"^debug/embed/error-page/$", DebugErrorPageEmbedView.as_view()), re_path(r"^debug/trigger-error/$", DebugTriggerErrorView.as_view()), diff --git a/src/sentry/web/frontend/debug/debug_incident_trigger_email.py b/src/sentry/web/frontend/debug/debug_incident_trigger_email.py deleted file mode 100644 index 3ebca877b60b..000000000000 --- a/src/sentry/web/frontend/debug/debug_incident_trigger_email.py +++ /dev/null @@ -1,74 +0,0 @@ -from unittest import mock -from uuid import uuid4 - -from sentry.api.serializers import serialize -from sentry.incidents.action_handlers import generate_incident_trigger_email_context -from sentry.incidents.endpoints.serializers.alert_rule import AlertRuleSerializer -from sentry.incidents.endpoints.serializers.incident import DetailedIncidentSerializer -from sentry.incidents.models.alert_rule import AlertRule, AlertRuleTrigger -from sentry.incidents.models.incident import Incident, IncidentStatus, TriggerStatus -from sentry.incidents.typings.metric_detector import ( - AlertContext, - MetricIssueContext, - OpenPeriodContext, -) -from sentry.models.organization import Organization -from sentry.models.project import Project -from sentry.snuba.models import SnubaQuery -from sentry.users.models.user import User -from sentry.web.frontend.base import internal_cell_silo_view - -from .mail import MailPreviewView - - -@internal_cell_silo_view -class DebugIncidentTriggerEmailView(MailPreviewView): - @mock.patch( - "sentry.users.models.user_option.UserOption.objects.get_value", return_value="US/Pacific" - ) - def get_context(self, request, user_option_mock): - organization = Organization(slug="myorg") - project = Project(slug="myproject", organization=organization) - user = User() - - query = SnubaQuery( - time_window=60, query="transaction:/some/transaction", aggregate="count()" - ) - alert_rule = AlertRule(id=1, organization=organization, name="My Alert", snuba_query=query) - incident = Incident( - id=2, - identifier=123, - organization=organization, - title="Something broke", - alert_rule=alert_rule, - status=IncidentStatus.CRITICAL.value, - ) - trigger = AlertRuleTrigger(alert_rule=alert_rule) - - alert_rule_serialized_response = serialize(alert_rule, None, AlertRuleSerializer()) - incident_serialized_response = serialize(incident, None, DetailedIncidentSerializer()) - - return generate_incident_trigger_email_context( - project=project, - organization=organization, - alert_rule_serialized_response=alert_rule_serialized_response, - incident_serialized_response=incident_serialized_response, - metric_issue_context=MetricIssueContext.from_legacy_models( - incident=incident, - new_status=IncidentStatus(incident.status), - ), - alert_context=AlertContext.from_alert_rule_incident(alert_rule), - open_period_context=OpenPeriodContext.from_incident(incident), - trigger_status=TriggerStatus.ACTIVE, - trigger_threshold=trigger.alert_threshold, - user=user, - notification_uuid=str(uuid4()), - ) - - @property - def html_template(self) -> str: - return "sentry/emails/incidents/trigger.html" - - @property - def text_template(self) -> str: - return "sentry/emails/incidents/trigger.txt" diff --git a/src/sentry/web/frontend/group_event_json.py b/src/sentry/web/frontend/group_event_json.py index 867619aa0085..57f1270546eb 100644 --- a/src/sentry/web/frontend/group_event_json.py +++ b/src/sentry/web/frontend/group_event_json.py @@ -20,6 +20,9 @@ def get(self, request: HttpRequest, organization, group_id, event_id_or_latest) except Group.DoesNotExist: raise Http404 + if not request.access.has_project_access(group.project): + raise Http404 + event: Event | GroupEvent | None if event_id_or_latest == "latest": event = group.get_latest_event() diff --git a/src/sentry/workflow_engine/endpoints/organization_workflow_group_history.py b/src/sentry/workflow_engine/endpoints/organization_workflow_group_history.py index a1d43d3713de..e2726ab358cd 100644 --- a/src/sentry/workflow_engine/endpoints/organization_workflow_group_history.py +++ b/src/sentry/workflow_engine/endpoints/organization_workflow_group_history.py @@ -47,12 +47,13 @@ class OrganizationWorkflowGroupHistoryEndpoint(OrganizationWorkflowEndpoint): def get(self, request: Request, organization: Organization, workflow: Workflow) -> Response: per_page = self.get_per_page(request) cursor = self.get_cursor_from_request(request) + sort = request.GET.getlist("sort") try: start, end = get_date_range_from_params(request.GET) except InvalidParams: raise ParseError(detail="Invalid start and end dates") - results = fetch_workflow_groups_paginated(workflow, start, end, cursor, per_page) + results = fetch_workflow_groups_paginated(workflow, start, end, cursor, per_page, sort) response = Response( serialize(results.results, request.user, WorkflowGroupHistorySerializer()) diff --git a/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py b/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py index b429d1e8b84c..e424abfd3ef2 100644 --- a/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py +++ b/src/sentry/workflow_engine/endpoints/serializers/workflow_group_history_serializer.py @@ -4,6 +4,7 @@ from typing import Any, NotRequired, TypedDict, cast from django.db.models import Count, Max, OuterRef, Subquery +from rest_framework.exceptions import ParseError from sentry.api.paginator import OffsetPaginator from sentry.api.serializers import Serializer, serialize @@ -105,12 +106,40 @@ def serialize( return result +_SORT_FIELD_MAP = { + "lastTriggered": "last_triggered", + "count": "count", +} +# `group` is appended as a stable tiebreaker so OffsetPaginator pages don't +# skip or duplicate rows when the user-provided sort fields tie. +_TIEBREAKER = "group" +_DEFAULT_ORDER_BY = ("-last_triggered", "-count", _TIEBREAKER) + + +def _parse_sort(sort: Sequence[str]) -> list[str]: + if not sort: + return list(_DEFAULT_ORDER_BY) + + order_by: list[str] = [] + for s in sort: + if s.startswith("-"): + prefix, field = "-", s[1:] + else: + prefix, field = "", s + if field not in _SORT_FIELD_MAP: + raise ParseError(detail=f"Invalid sort field: {field}") + order_by.append(f"{prefix}{_SORT_FIELD_MAP[field]}") + order_by.append(_TIEBREAKER) + return order_by + + def fetch_workflow_groups_paginated( workflow: Workflow, start: datetime, end: datetime, cursor: Cursor | None = None, per_page: int = 25, + sort: Sequence[str] = (), ) -> CursorResult[WorkflowGroupHistory]: filtered_history = WorkflowFireHistory.objects.filter( workflow=workflow, @@ -138,12 +167,13 @@ def fetch_workflow_groups_paginated( # Count distinct groups for pagination group_count = qs.count() + order_by = _parse_sort(sort) return cast( CursorResult[WorkflowGroupHistory], OffsetPaginator( qs, - order_by=("-count", "-last_triggered"), + order_by=order_by, on_results=convert_results, ).get_result(per_page, cursor, known_hits=group_count), ) diff --git a/src/sentry/workflow_engine/handlers/workflow/workflow_status_update_handler.py b/src/sentry/workflow_engine/handlers/workflow/workflow_status_update_handler.py index 859a922d4fd2..4025f0c62990 100644 --- a/src/sentry/workflow_engine/handlers/workflow/workflow_status_update_handler.py +++ b/src/sentry/workflow_engine/handlers/workflow/workflow_status_update_handler.py @@ -63,7 +63,7 @@ def workflow_status_update_handler( organization = Organization.objects.get_from_cache(pk=activity.project.organization_id) can_process_seer_activities = features.has( - "organization:workflow-engine-evaluate-seer-activities", organization + "organizations:workflow-engine-evaluate-seer-activities", organization ) if activity.type in SEER_ACTIVITIES and not can_process_seer_activities: diff --git a/src/sentry/workflow_engine/processors/workflow.py b/src/sentry/workflow_engine/processors/workflow.py index c0bd22cd3334..b69074aa1868 100644 --- a/src/sentry/workflow_engine/processors/workflow.py +++ b/src/sentry/workflow_engine/processors/workflow.py @@ -7,7 +7,7 @@ import sentry_sdk from django.db.models import Q -from sentry import features +from sentry import features, options from sentry.models.activity import Activity from sentry.models.environment import Environment from sentry.services.eventstore.models import GroupEvent @@ -476,6 +476,20 @@ def process_workflows( log_context.set_verbose(True) workflows = get_workflows_by_detectors(event_detectors.detectors, environment) + wrong_org_workflows = {wf for wf in workflows if wf.organization_id != organization.id} + if wrong_org_workflows: + logger.warning( + "workflow_engine.process_workflows.wrong_organization", + extra={ + "organization_id": organization.id, + "wrong_org_workflow_ids": sorted(wf.id for wf in wrong_org_workflows), + "wrong_org_organization_ids": sorted( + wf.organization_id for wf in wrong_org_workflows + ), + }, + ) + if options.get("workflow_engine.filter_cross_org_workflows"): + workflows = workflows - wrong_org_workflows if workflows: metrics_incr("process_workflows", len(workflows)) diff --git a/src/sentry/workflow_engine/tasks/utils.py b/src/sentry/workflow_engine/tasks/utils.py index 341c3858bec8..47a8bf8a215d 100644 --- a/src/sentry/workflow_engine/tasks/utils.py +++ b/src/sentry/workflow_engine/tasks/utils.py @@ -10,16 +10,12 @@ from sentry.models.organization import Organization from sentry.models.project import Project from sentry.services.eventstore.models import Event, GroupEvent -from sentry.types.activity import ActivityType from sentry.utils import metrics from sentry.utils.retries import ConditionalRetryPolicy, exponential_delay from sentry.workflow_engine.models.workflow import Workflow from sentry.workflow_engine.types import WorkflowEventData from sentry.workflow_engine.utils import log_context, scopedstats -SUPPORTED_ACTIVITIES = [ActivityType.SET_RESOLVED.value] - - logger = log_context.get_logger(__name__) diff --git a/static/app/actionCreators/modal.tsx b/static/app/actionCreators/modal.tsx index f208411930f1..67ab641b8fdd 100644 --- a/static/app/actionCreators/modal.tsx +++ b/static/app/actionCreators/modal.tsx @@ -14,7 +14,6 @@ import type {InviteRow} from 'sentry/components/modals/inviteMembersModal/types' import type {PrivateGamingSdkAccessModalProps} from 'sentry/components/modals/privateGamingSdkAccessModal'; import type {ReprocessEventModalOptions} from 'sentry/components/modals/reprocessEventModal'; import type {AddToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/addToDashboardModal'; -import type {LinkToDashboardModalProps} from 'sentry/components/modals/widgetBuilder/linkToDashboardModal'; import type {ConsoleModalProps} from 'sentry/components/onboarding/consoleModal'; import type {Category} from 'sentry/components/platformPicker'; import {ModalStore} from 'sentry/stores/modalStore'; @@ -261,16 +260,6 @@ export async function openAddToDashboardModal(options: AddToDashboardModalProps) }); } -export async function openLinkToDashboardModal(options: LinkToDashboardModalProps) { - const {LinkToDashboardModal, modalCss} = - await import('sentry/components/modals/widgetBuilder/linkToDashboardModal'); - - openModal(deps => , { - closeEvents: 'escape-key', - modalCss, - }); -} - export async function openImportDashboardFromFileModal( options: ImportDashboardFromFileModalProps ) { diff --git a/static/app/components/activity/note/compact.tsx b/static/app/components/activity/note/compact.tsx index d8cc3d9f0e15..54078f116e17 100644 --- a/static/app/components/activity/note/compact.tsx +++ b/static/app/components/activity/note/compact.tsx @@ -143,7 +143,15 @@ export function CompactNoteInput({ aria-label={existingItem ? t('Edit comment') : t('Add a comment')} aria-errormessage={errorMessage ? errorId : undefined} style={{ - ...mentionStyle({theme, minHeight: 14, streamlined: true}), + ...mentionStyle({ + theme, + minHeight: 14, + inputStyle: { + padding: `${theme.space.md} ${theme.space.lg}`, + border: `1px solid ${theme.tokens.border.primary}`, + borderRadius: theme.radius.md, + }, + }), width: '100%', }} placeholder={placeholder} @@ -237,6 +245,7 @@ const NoteInputForm = styled('form')<{error?: string}>` gap: ${p => p.theme.space.sm}; align-items: flex-end; width: 100%; + min-width: 0; transition: padding 0.2s ease-in-out; ${getNoteInputErrorStyles}; diff --git a/static/app/components/activity/note/input.tsx b/static/app/components/activity/note/input.tsx index 1330ea7f7294..c2fca1e9f9a7 100644 --- a/static/app/components/activity/note/input.tsx +++ b/static/app/components/activity/note/input.tsx @@ -1,7 +1,7 @@ import {useCallback, useId, useState} from 'react'; import {Mention, MentionsInput} from 'react-mentions'; import type {Theme} from '@emotion/react'; -import {useTheme} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {AnimatePresence, motion, useReducedMotion} from 'framer-motion'; import {z} from 'zod'; @@ -56,7 +56,7 @@ const noteInputSchema = z.object({ text: z.string(), }); -function NoteInput({ +export function NoteInput({ text, onCreate, onChange, @@ -270,26 +270,23 @@ function NoteInput({ ); } -export {NoteInput}; - type NotePreviewProps = { minHeight: Props['minHeight']; theme: Theme; }; -// This styles both the note preview and the note editor input -const getNotePreviewCss = (p: NotePreviewProps) => { - const {minHeight, padding, overflow, border} = mentionStyle(p)['&multiLine'].input; - - return ` +const getNotePreviewCss = (p: NotePreviewProps) => css` max-height: 1000px; max-width: 100%; - ${(minHeight && `min-height: ${minHeight}px`) || ''}; - padding: ${padding}; - overflow: ${overflow}; - border: ${border}; + ${p.minHeight + ? css` + min-height: ${p.minHeight}px; + ` + : ''}; + padding: ${p.theme.space.lg} ${p.theme.space.lg}; + overflow: auto; + border: 0; `; -}; const EditorSurface = styled('div')` background: ${p => p.theme.tokens.background.primary}; @@ -308,6 +305,7 @@ const MentionsEditor = styled('div')` const MotionControls = styled(motion.div)` overflow: hidden; + isolation: isolate; `; const ErrorMessage = styled('span')` diff --git a/static/app/components/activity/note/mentionStyle.tsx b/static/app/components/activity/note/mentionStyle.tsx index 90876586ec37..44d7e837d6ad 100644 --- a/static/app/components/activity/note/mentionStyle.tsx +++ b/static/app/components/activity/note/mentionStyle.tsx @@ -1,72 +1,46 @@ +import type {CSSProperties} from 'react'; import type {Theme} from '@emotion/react'; type Options = { theme: Theme; + inputStyle?: CSSProperties; minHeight?: number; - streamlined?: boolean; }; /** - * Note this is an object for `react-mentions` component and - * not a styled component/emotion style + * Returns the `style` object for react-mentions `MentionsInput`. */ -export function mentionStyle({theme, minHeight, streamlined}: Options) { - const inputProps = { +export function mentionStyle({theme, minHeight, inputStyle}: Options) { + const inputProps: CSSProperties = { fontSize: theme.font.size.md, padding: `${theme.space.lg} ${theme.space.lg}`, outline: 0, border: 0, minHeight, overflow: 'auto', - }; - - const streamlinedInputProps = { - fontSize: theme.font.size.md, - padding: `${theme.space.md} ${theme.space.lg}`, - outline: 0, - border: `1px solid ${theme.tokens.border.primary}`, - borderRadius: theme.radius.md, - minHeight, - overflow: 'auto', + overflowWrap: 'break-word', + ...inputStyle, }; return { control: { backgroundColor: 'transparent', - fontSize: 15, - fontWeight: 'normal', + fontSize: theme.font.size.md, + fontWeight: 'normal' as const, }, input: { margin: 0, }, - '&singleLine': { - control: { - display: 'inline-block', - width: 130, - }, - - highlighter: { - padding: 1, - border: '2px inset transparent', - }, - - input: { - padding: 1, - border: '2px inset', - }, - }, - '&multiLine': { control: { fontFamily: theme.font.family.sans, minHeight, }, - // Use the same props for the highliter to keep the phantom text aligned - highlighter: streamlined ? streamlinedInputProps : inputProps, - input: streamlined ? streamlinedInputProps : inputProps, + highlighter: inputProps, + input: inputProps, }, suggestions: { @@ -74,6 +48,7 @@ export function mentionStyle({theme, minHeight, streamlined}: Options) { maxHeight: 142, minWidth: 220, overflow: 'auto', + zIndex: theme.zIndex.dropdown, backgroundColor: theme.tokens.background.primary, border: '1px solid rgba(0,0,0,0.15)', borderRadius: theme.radius.md, diff --git a/static/app/components/commandPalette/ui/commandPalette.tsx b/static/app/components/commandPalette/ui/commandPalette.tsx index 3bb6c5d44cf5..63515961441e 100644 --- a/static/app/components/commandPalette/ui/commandPalette.tsx +++ b/static/app/components/commandPalette/ui/commandPalette.tsx @@ -143,7 +143,8 @@ export function CommandPalette({ const debouncedQuery = useDebouncedValue(state.query, 300); const isFetchingQueries = useIsFetching({predicate: q => q.meta?.cmdk === true}); const isLoading = - (state.query.length > 0 && debouncedQuery !== state.query) || isFetchingQueries > 0; + state.list === 'active' && + ((state.query.length > 0 && debouncedQuery !== state.query) || isFetchingQueries > 0); const isEmptyPromptQuery = state.action?.value.prompt !== undefined && (state.query.length === 0 || isLoading); @@ -153,7 +154,7 @@ export function CommandPalette({ return nodes; }, [store, state.action]); - const [actions, prefixMap, isSeerFallback] = useMemo< + const [computedActions, computedPrefixMap, computedIsSeerFallback] = useMemo< [CMDKFlatItem[], Map, boolean] >(() => { const [scored, scoredPrefixMap] = state.query @@ -227,6 +228,28 @@ export function CommandPalette({ openForm, ]); + const frozenRef = useRef({ + actions: computedActions, + prefixMap: computedPrefixMap, + isSeerFallback: computedIsSeerFallback, + }); + + useEffect(() => { + if (state.list === 'active') { + frozenRef.current = { + actions: computedActions, + prefixMap: computedPrefixMap, + isSeerFallback: computedIsSeerFallback, + }; + } + }, [state.list, computedActions, computedPrefixMap, computedIsSeerFallback]); + + const actions = state.list === 'active' ? computedActions : frozenRef.current.actions; + const prefixMap = + state.list === 'active' ? computedPrefixMap : frozenRef.current.prefixMap; + const isSeerFallback = + state.list === 'active' ? computedIsSeerFallback : frozenRef.current.isSeerFallback; + const analytics = useCommandPaletteAnalytics(isSeerFallback ? 0 : actions.length); const mouseLeftResultsRef = useRef(false); @@ -367,6 +390,10 @@ export function CommandPalette({ } }, onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + dispatch({type: 'freeze list'}); + } + if ( treeState.selectionManager.focusedKey === null && (e.key === 'ArrowDown' || e.key === 'ArrowUp') diff --git a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx index 4eff37b15aa6..7dd26ef26803 100644 --- a/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteGlobalActions.tsx @@ -22,6 +22,7 @@ import { getDsnNavTargets, } from 'sentry/components/search/sources/dsnLookupUtils'; import type {DsnLookupResponse} from 'sentry/components/search/sources/dsnLookupUtils'; +import {DEPLOY_PREVIEW_CONFIG, NODE_ENV} from 'sentry/constants'; import { IconAdd, IconAllProjects, @@ -364,7 +365,7 @@ export function GlobalCommandPaletteActions() { {organization.features.includes('profiling') && ( )} {organization.features.includes('session-replay-ui') && ( @@ -1053,6 +1054,34 @@ export function GlobalCommandPaletteActions() { /> + + {(NODE_ENV === 'development' || DEPLOY_PREVIEW_CONFIG) && ( + }} + keywords={['production', 'prod', 'live']} + onAction={() => { + window.open( + `https://${organization.slug}.sentry.io${location.pathname}${location.search}`, + '_blank', + 'noreferrer' + ); + }} + /> + )} + + {NODE_ENV === 'production' && user.isStaff && ( + }} + keywords={['development', 'dev', 'dev-ui', 'localhost', 'local']} + onAction={() => { + window.open( + `https://${organization.slug}.dev.getsentry.net:7999${location.pathname}${location.search}`, + '_blank', + 'noreferrer' + ); + }} + /> + )} ); } diff --git a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx index bdc20c232588..70f41200d435 100644 --- a/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx +++ b/static/app/components/commandPalette/ui/commandPaletteStateContext.tsx @@ -22,6 +22,10 @@ export type CMDKNavStack = { export type CommandPaletteState = { action: CMDKNavStack | null; input: React.RefObject; + // Controls whether the rendered action list updates from the collection store. + // 'frozen' keeps the visible list stable while the user navigates with the + // keyboard. Any other dispatched action resets to 'active'. + list: 'active' | 'frozen'; open: boolean; // When true, action and query are cleared the next time the modal opens. // Set by 'trigger action' so the close animation plays without a jarring @@ -49,7 +53,8 @@ type CommandPaletteAction = } | {type: 'trigger action'} | {type: 'pop action'} - | {type: 'reset on open'}; + | {type: 'reset on open'} + | {type: 'freeze list'}; const CommandPaletteStateContext = createContext(null); const CommandPaletteDispatchContext = @@ -61,6 +66,8 @@ function commandPaletteReducer( ): CommandPaletteState { const type = action.type; switch (type) { + case 'freeze list': + return {...state, list: 'frozen'}; case 'toggle modal': if (!state.open && state.resetOnOpen) { return { @@ -70,11 +77,13 @@ function commandPaletteReducer( query: '', resetOnOpen: false, pendingReset: false, + list: 'active', }; } return { ...state, open: !state.open, + list: 'active', }; case 'reset': return { @@ -83,11 +92,12 @@ function commandPaletteReducer( query: '', pendingReset: false, resetOnOpen: false, + list: 'active', }; case 'reset on open': - return {...state, resetOnOpen: true}; + return {...state, resetOnOpen: true, list: 'active'}; case 'set query': - return {...state, query: action.query}; + return {...state, query: action.query, list: 'active'}; case 'push action': return { ...state, @@ -101,15 +111,17 @@ function commandPaletteReducer( previous: state.action, }, query: action.query ?? '', + list: 'active', }; case 'pop action': return { ...state, action: state.action?.previous ?? null, query: state.action?.value?.query ?? state.query, + list: 'active', }; case 'trigger action': - return {...state, pendingReset: true}; + return {...state, pendingReset: true, list: 'active'}; default: unreachable(type); return state; @@ -149,6 +161,7 @@ export function CommandPaletteStateProvider({ open: false, pendingReset: false, resetOnOpen: false, + list: 'active', }); return ( diff --git a/static/app/components/core/compactSelect/gridList/index.tsx b/static/app/components/core/compactSelect/gridList/index.tsx index ad4dfde662b7..be07e2d93e91 100644 --- a/static/app/components/core/compactSelect/gridList/index.tsx +++ b/static/app/components/core/compactSelect/gridList/index.tsx @@ -14,6 +14,7 @@ import { SizeLimitMessage, useVirtualizedItems, } from '@sentry/scraps/compactSelect'; +import type {ListItemBase} from '@sentry/scraps/compactSelect/types'; import {Container} from '@sentry/scraps/layout'; import {t} from 'sentry/locale'; @@ -21,7 +22,7 @@ import {t} from 'sentry/locale'; import {GridListOption, type GridListOptionProps} from './option'; import {GridListSection} from './section'; -interface GridListProps +interface GridListProps extends Omit, 'children'>, Omit< @@ -43,13 +44,13 @@ interface GridListProps * Object containing the selection state and focus position, needed for * `useGridList()`. */ - listState: ListState; - children?: CollectionChildren; + listState: ListState; + children?: CollectionChildren; /** * Text label to be rendered as heading on top of grid list. */ label?: React.ReactNode; - size?: GridListOptionProps['size']; + size?: GridListOptionProps['size']; /** * Message to be displayed when some options are hidden due to `sizeLimit`. */ @@ -70,7 +71,7 @@ interface GridListProps * inside. Grid lists allow users to focus on those child elements (using the Arrow * Left/Right keys) and interact with them, which isn't possible with list boxes. */ -function GridList({ +function GridList({ listState, size = 'md', label, @@ -78,7 +79,7 @@ function GridList({ keyDownHandler, virtualized, ...props -}: GridListProps) { +}: GridListProps) { const ref = useRef(null); const labelId = useId(); const {gridProps} = useGridList( diff --git a/static/app/components/core/compactSelect/gridList/option.tsx b/static/app/components/core/compactSelect/gridList/option.tsx index 676b6b0df039..50fece393cdd 100644 --- a/static/app/components/core/compactSelect/gridList/option.tsx +++ b/static/app/components/core/compactSelect/gridList/option.tsx @@ -9,6 +9,7 @@ import type {Node} from '@react-types/shared'; import {Checkbox} from '@sentry/scraps/checkbox'; import {LeadWrap} from '@sentry/scraps/compactSelect'; +import type {ListItemBase} from '@sentry/scraps/compactSelect/types'; import { InnerWrap, MenuListItem, @@ -18,9 +19,11 @@ import { import {IconCheckmark} from 'sentry/icons'; import type {FormSize} from 'sentry/utils/theme'; -export interface GridListOptionProps extends AriaGridListItemOptions { - listState: ListState; - node: Node; +export interface GridListOptionProps< + T extends ListItemBase, +> extends AriaGridListItemOptions { + listState: ListState; + node: Node; size: FormSize; } @@ -28,7 +31,11 @@ export interface GridListOptionProps extends AriaGridListItemOptions { * A
  • element with accessibile behaviors & attributes. * https://react-spectrum.adobe.com/react-aria/useGridList.html */ -export function GridListOption({node, listState, size}: GridListOptionProps) { +export function GridListOption({ + node, + listState, + size, +}: GridListOptionProps) { const ref = useRef(null); const { label, @@ -73,7 +80,7 @@ export function GridListOption({node, listState, size}: GridListOptionProps) { [label] ); - const leadingItems: MenuListItemProps['leadingItems'] = node.props.leadingItems; + const leadingItems = (node.props as MenuListItemProps).leadingItems; const leadingItemsMemo = useMemo(() => { const checkboxSize = size === 'xs' ? 'xs' : 'sm'; diff --git a/static/app/components/core/compactSelect/gridList/section.tsx b/static/app/components/core/compactSelect/gridList/section.tsx index 228c1295e65e..838b05cc4849 100644 --- a/static/app/components/core/compactSelect/gridList/section.tsx +++ b/static/app/components/core/compactSelect/gridList/section.tsx @@ -12,26 +12,31 @@ import { SectionWrap, SelectFilterContext, } from '@sentry/scraps/compactSelect'; +import type {ListItemBase} from '@sentry/scraps/compactSelect/types'; import {GridListOption, type GridListOptionProps} from './option'; -interface GridListSectionProps { - listState: ListState; - node: Node; - size: GridListOptionProps['size']; +interface GridListSectionProps { + listState: ListState; + node: Node; + size: GridListOptionProps['size']; } /** * A
  • element that functions as a grid list section (renders a nested
      * inside). https://react-spectrum.adobe.com/react-aria/useGridList.html */ -export function GridListSection({node, listState, size}: GridListSectionProps) { +export function GridListSection({ + node, + listState, + size, +}: GridListSectionProps) { const titleId = useId(); const {separatorProps} = useSeparator({elementType: 'li'}); const showToggleAllButton = listState.selectionManager.selectionMode === 'multiple' && - node.value.showToggleAllButton; + node.value?.showToggleAllButton; const hiddenOptions = useContext(SelectFilterContext); const childNodes = useMemo( diff --git a/static/app/components/core/compactSelect/list.tsx b/static/app/components/core/compactSelect/list.tsx index df69c281a15d..f15f844f0cf2 100644 --- a/static/app/components/core/compactSelect/list.tsx +++ b/static/app/components/core/compactSelect/list.tsx @@ -12,6 +12,7 @@ import {ControlContext} from './control'; import {GridList} from './gridList'; import {ListBox} from './listBox'; import type { + ListItemBase, SelectKey, SelectOption, SelectOptionOrSectionWithKey, @@ -184,7 +185,7 @@ export function List({ /** * Props to be passed into useListState() */ - const listStateProps = useMemo>>(() => { + const listStateProps = useMemo>>(() => { const disabledKeys = [ ...getDisabledOptions(items, isOptionDisabled), ...hiddenOptions, @@ -374,7 +375,7 @@ export function List({ )} {multiple && sections.map(section => - section.value.showToggleAllButton ? ( + section.value?.showToggleAllButton ? ( requires an index signature -// eslint-disable-next-line @typescript-eslint/no-restricted-types -type ObjectLike = object; - -interface ListBoxProps +interface ListBoxProps extends Omit< React.HTMLAttributes, @@ -122,7 +119,7 @@ const DEFAULT_KEY_DOWN_HANDLER = () => true; * If interactive children are necessary, consider using grid lists instead (by setting * the `grid` prop on CompactSelect to true). */ -export function ListBox({ +export function ListBox({ ref, listState, size = 'md', @@ -298,7 +295,7 @@ const heightEstimations = { */ const listPaddingVertical = 4; -function useVirtualizedItems({ +function useVirtualizedItems({ listItems, virtualized = false, size, @@ -317,6 +314,7 @@ function useVirtualizedItems({ getScrollElement: () => scrollElementRef?.current, estimateSize: index => { const item = listItems[index]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (item?.props?.details) { return heightEstimation.large; } diff --git a/static/app/components/core/compactSelect/listBox/option.tsx b/static/app/components/core/compactSelect/listBox/option.tsx index 7cfb7b1d794f..3c39e6d5f98e 100644 --- a/static/app/components/core/compactSelect/listBox/option.tsx +++ b/static/app/components/core/compactSelect/listBox/option.tsx @@ -65,7 +65,7 @@ export function ListBoxOption({ [labelProps.id, label] ); - const leadingItems: MenuListItemProps['leadingItems'] = item.props.leadingItems; + const leadingItems = (item.props as MenuListItemProps).leadingItems; const leadingItemsMemo = useMemo(() => { const checkboxSize = size === 'xs' ? 'xs' : 'sm'; diff --git a/static/app/components/core/compactSelect/listBox/section.tsx b/static/app/components/core/compactSelect/listBox/section.tsx index 66a9e5430dbe..a301b445df2d 100644 --- a/static/app/components/core/compactSelect/listBox/section.tsx +++ b/static/app/components/core/compactSelect/listBox/section.tsx @@ -14,13 +14,14 @@ import { SectionWrap, } from '@sentry/scraps/compactSelect'; import type {SelectKey} from '@sentry/scraps/compactSelect'; +import type {ListItemBase} from '@sentry/scraps/compactSelect/types'; import {ListBoxOption, type ListBoxOptionProps} from './option'; -interface ListBoxSectionProps extends AriaListBoxSectionProps { +interface ListBoxSectionProps extends AriaListBoxSectionProps { hiddenOptions: Set; - item: Node; - listState: ListState; + item: Node; + listState: ListState; showSectionHeaders: boolean; size: ListBoxOptionProps['size']; 'data-index'?: number; @@ -32,7 +33,7 @@ interface ListBoxSectionProps extends AriaListBoxSectionProps { * A
    • element that functions as a list box section (renders a nested
        * inside). https://react-spectrum.adobe.com/react-aria/useListBox.html */ -export function ListBoxSection({ +export function ListBoxSection({ item, listState, size, @@ -41,7 +42,7 @@ export function ListBoxSection({ showDetails = true, ref, 'data-index': dataIndex, -}: ListBoxSectionProps) { +}: ListBoxSectionProps) { const {itemProps, headingProps, groupProps} = useListBoxSection({ heading: item.rendered, 'aria-label': item['aria-label'], @@ -51,7 +52,7 @@ export function ListBoxSection({ const showToggleAllButton = listState.selectionManager.selectionMode === 'multiple' && - item.value.showToggleAllButton; + item.value?.showToggleAllButton; const childItems = useMemo( () => [...item.childNodes].filter(child => !hiddenOptions.has(child.key)), diff --git a/static/app/components/core/compactSelect/types.tsx b/static/app/components/core/compactSelect/types.tsx index 5a33ae55542c..5bad32ef9e21 100644 --- a/static/app/components/core/compactSelect/types.tsx +++ b/static/app/components/core/compactSelect/types.tsx @@ -1,5 +1,8 @@ import type {SelectValue} from 'sentry/types/core'; +// explicitly using object here because Record requires an index signature +// eslint-disable-next-line @typescript-eslint/no-restricted-types +export type ListItemBase = object & {showToggleAllButton?: boolean}; export type SelectKey = string | number; export interface SelectOption extends SelectValue { diff --git a/static/app/components/core/layout/container.tsx b/static/app/components/core/layout/container.tsx index 3f939d44b2ed..b6855d3983b0 100644 --- a/static/app/components/core/layout/container.tsx +++ b/static/app/components/core/layout/container.tsx @@ -241,11 +241,13 @@ const omitContainerProps = new Set([ export const Container = styled( ( - props: ContainerProps | ContainerPropsWithRenderFunction + props: (ContainerProps | ContainerPropsWithRenderFunction) & { + className?: string; + } ) => { if (typeof props.children === 'function') { // When using render prop, only pass className to the child function - return props.children({className: (props as any).className}); + return props.children({className: props.className ?? ''}); } const {as, ...rest} = props; diff --git a/static/app/components/core/layout/surface.tsx b/static/app/components/core/layout/surface.tsx index 1ee39900f929..2f0f0929221c 100644 --- a/static/app/components/core/layout/surface.tsx +++ b/static/app/components/core/layout/surface.tsx @@ -83,16 +83,17 @@ function isRenderFunction( export const Surface = styled( ( - props: + props: ( | SurfaceProps | FlatSurfacePropsWithRenderFunction | OverlaySurfacePropsWithRenderFunction + ) & {className?: string} ) => { if (isRenderFunction(props)) { // When using render prop, only pass className to the child function. T // The className in this case is internally generated by emotion, and not part of the // passed props. - return props.children({className: (props as any).className ?? ''}); + return props.children({className: props.className ?? ''}); } const {variant, elevation: _, ...rest} = props; diff --git a/static/app/components/core/segmentedControl/segmentedControl.tsx b/static/app/components/core/segmentedControl/segmentedControl.tsx index 3f6c73423bd7..4f7a0624618f 100644 --- a/static/app/components/core/segmentedControl/segmentedControl.tsx +++ b/static/app/components/core/segmentedControl/segmentedControl.tsx @@ -138,6 +138,7 @@ export function SegmentedControl({ nextKey={option.nextKey} prevKey={option.prevKey} value={String(option.key)} + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access isDisabled={option.props.disabled || disabled} state={state} size={size} diff --git a/static/app/components/core/select/option.tsx b/static/app/components/core/select/option.tsx index 4ba6daab332d..3428d94b1c30 100644 --- a/static/app/components/core/select/option.tsx +++ b/static/app/components/core/select/option.tsx @@ -57,9 +57,11 @@ export function SelectOption(props: Props) { innerWrapProps={{'data-test-id': value}} labelProps={{as: typeof label === 'string' ? 'p' : 'div'}} leadingItems={ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access itemProps.__isNew__ ? ( + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */} {data.leadingItems} ) : ( @@ -72,6 +74,7 @@ export function SelectOption(props: Props) { /> )} + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */} {data.leadingItems} ) diff --git a/static/app/components/core/select/select.tsx b/static/app/components/core/select/select.tsx index 4d26d276502d..7b603a3b3650 100644 --- a/static/app/components/core/select/select.tsx +++ b/static/app/components/core/select/select.tsx @@ -79,7 +79,7 @@ const getStylesConfig = ({ // Unfortunately we cannot use emotions `css` helper here, since react-select // *requires* object styles, which the css helper cannot produce. const indicatorStyles: StylesConfig['clearIndicator'] & - StylesConfig['loadingIndicator'] = (provided, state: any) => ({ + StylesConfig['loadingIndicator'] = (provided, state: {isDisabled?: boolean}) => ({ ...provided, padding: '0 4px 0 4px', alignItems: 'center', @@ -639,13 +639,16 @@ export function Select !!opt.disabled} showDividers={props.showDividers} options={options || (choicesOrOptions as OptionsType)} openMenuOnFocus={props.openMenuOnFocus} + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access blurInputOnSelect={!props.multiple && !anyProps.multi} + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access closeMenuOnSelect={!(props.multiple || anyProps.multi)} hideSelectedOptions={false} tabSelectsValue={false} diff --git a/static/app/components/core/tabs/tabList.tsx b/static/app/components/core/tabs/tabList.tsx index 9dc606320a53..f51041e501e1 100644 --- a/static/app/components/core/tabs/tabList.tsx +++ b/static/app/components/core/tabs/tabList.tsx @@ -254,21 +254,23 @@ function BaseTabList({outerWrapStyles, variant = 'flat', ...props}: BaseTabListP (a, b) => sortedKeys.indexOf(a) - sortedKeys.indexOf(b) ); - return sortedOverflowTabs.flatMap>(key => { + return sortedOverflowTabs.flatMap(key => { const item = state.collection.getItem(key); if (!item) { return []; } + const itemProps: TabListItemProps = item.props; + return { value: key, - label: item.props.children, - disabled: item.props.disabled, - tooltip: item.props.tooltip?.title, - tooltipOptions: item.props.tooltip, + label: itemProps.children, + disabled: itemProps.disabled, + tooltip: itemProps.tooltip?.title, + tooltipOptions: itemProps.tooltip, textValue: item.textValue, - }; + } satisfies SelectOption; }); }, [state.collection, overflowTabs]); @@ -288,7 +290,7 @@ function BaseTabList({outerWrapStyles, variant = 'flat', ...props}: BaseTabListP orientation={orientation} size={size} overflowing={orientation === 'horizontal' && overflowTabs.includes(item.key)} - tooltipProps={item.props.tooltip} + tooltipProps={(item.props as TabListItemProps).tooltip} ref={element => { tabItemsRef.current[item.key] = element; }} diff --git a/static/app/components/core/tabs/tabPanels.tsx b/static/app/components/core/tabs/tabPanels.tsx index 862cc6273ff3..9299c47c7602 100644 --- a/static/app/components/core/tabs/tabPanels.tsx +++ b/static/app/components/core/tabs/tabPanels.tsx @@ -49,6 +49,7 @@ export function TabPanels(props: TabPanelsProps) { orientation={orientation} key={tabListState?.selectedKey} > + {/* eslint-disable-next-line @typescript-eslint/no-unsafe-member-access */} {selectedPanel?.props.children} ); diff --git a/static/app/components/core/text/heading.tsx b/static/app/components/core/text/heading.tsx index 2fadf3be7f2a..b588e8771288 100644 --- a/static/app/components/core/text/heading.tsx +++ b/static/app/components/core/text/heading.tsx @@ -56,10 +56,10 @@ export type HeadingPropsWithRenderFunction = BaseHeadingProps & >; export const Heading = styled( - (props: HeadingProps | HeadingPropsWithRenderFunction) => { + (props: (HeadingProps | HeadingPropsWithRenderFunction) & {className?: string}) => { if (typeof props.children === 'function') { // When using render prop, only pass className to the child function - return props.children({className: (props as any).className}); + return props.children({className: props.className ?? ''}); } const {children, as, ...rest} = props as HeadingProps; const HeadingComponent = as; diff --git a/static/app/components/core/text/text.tsx b/static/app/components/core/text/text.tsx index fe918b898c6c..5f69583e2186 100644 --- a/static/app/components/core/text/text.tsx +++ b/static/app/components/core/text/text.tsx @@ -184,11 +184,11 @@ export type TextPropsWithRenderFunction = export const Text = styled( ( - props: TextProps | TextPropsWithRenderFunction + props: (TextProps | TextPropsWithRenderFunction) & {className?: string} ) => { if (typeof props.children === 'function') { // When using render prop, only pass className to the child function - return props.children({className: (props as any).className}); + return props.children({className: props.className ?? ''}); } const {children, ...rest} = props as TextProps; const Component = props.as || 'span'; diff --git a/static/app/components/events/contexts/knownContext/profile.spec.tsx b/static/app/components/events/contexts/knownContext/profile.spec.tsx index 7fa609f08510..146014d66fa6 100644 --- a/static/app/components/events/contexts/knownContext/profile.spec.tsx +++ b/static/app/components/events/contexts/knownContext/profile.spec.tsx @@ -49,7 +49,7 @@ describe('ProfileContext', () => { subject: 'Profile ID', value: PROFILE_ID, action: { - link: `/organizations/${organization.slug}/explore/profiling/profile/${project.slug}/${PROFILE_ID}/flamegraph/`, + link: `/organizations/${organization.slug}/explore/profiles/profile/${project.slug}/${PROFILE_ID}/flamegraph/`, }, }, { @@ -58,7 +58,7 @@ describe('ProfileContext', () => { value: PROFILER_ID, action: { link: { - pathname: `/organizations/${organization.slug}/explore/profiling/profile/${project.slug}/flamegraph/`, + pathname: `/organizations/${organization.slug}/explore/profiles/profile/${project.slug}/flamegraph/`, query: { profilerId: PROFILER_ID, eventId: event.id, diff --git a/static/app/components/events/interfaces/frame/context.spec.tsx b/static/app/components/events/interfaces/frame/context.spec.tsx index d5179d1915bc..609c05b92092 100644 --- a/static/app/components/events/interfaces/frame/context.spec.tsx +++ b/static/app/components/events/interfaces/frame/context.spec.tsx @@ -6,10 +6,8 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Frame} from 'sentry/types/event'; -import type {LineCoverage} from 'sentry/types/integrations'; -import {CodecovStatusCode, Coverage} from 'sentry/types/integrations'; -import {Context, getLineCoverage} from './context'; +import {Context} from './context'; describe('Frame - Context', () => { const org = OrganizationFixture(); @@ -22,50 +20,8 @@ describe('Frame - Context', () => { ProjectsStore.loadInitialData([project]); }); - const lines: Array<[number, string]> = [ - [231, 'this is line 231'], - [232, 'this is line 232'], - [233, 'this is line 233'], - [234, 'this is line 234'], - ]; - - const lineCoverage: LineCoverage[] = [ - [230, Coverage.PARTIAL], - [231, Coverage.PARTIAL], - [232, Coverage.COVERED], - [234, Coverage.NOT_COVERED], - ]; - - it("gets coverage data for the frame's lines", () => { - expect(getLineCoverage(lines, lineCoverage)).toEqual([ - [Coverage.PARTIAL, Coverage.COVERED, undefined, Coverage.NOT_COVERED], - true, - ]); - }); - - it("doesn't query stacktrace coverage if the flag is off", () => { - const mock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-coverage/`, - body: {status: CodecovStatusCode.NO_COVERAGE_DATA}, - }); - render(, { - organization: org, - }); - - expect(mock).not.toHaveBeenCalled(); - }); - describe('syntax highlighting', () => { it('renders code correctly when context lines end in newline characters', () => { - const organization = { - ...org, - codecovAccess: true, - }; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-coverage/`, - body: {status: CodecovStatusCode.NO_COVERAGE_DATA}, - }); - const testFrame: Frame = { ...frame, lineNo: 2, @@ -85,7 +41,7 @@ describe('Frame - Context', () => { registers={{}} components={[]} />, - {organization} + {organization: org} ); expect(screen.getAllByTestId('context-line')).toHaveLength(3); diff --git a/static/app/components/events/interfaces/frame/context.tsx b/static/app/components/events/interfaces/frame/context.tsx index 55ef63c5064b..4b5c0736a9e1 100644 --- a/static/app/components/events/interfaces/frame/context.tsx +++ b/static/app/components/events/interfaces/frame/context.tsx @@ -1,6 +1,5 @@ import {Fragment, useMemo} from 'react'; import styled from '@emotion/styled'; -import keyBy from 'lodash/keyBy'; import {ClippedBox} from 'sentry/components/clippedBox'; import {parseAssembly} from 'sentry/components/events/interfaces/utils'; @@ -9,16 +8,13 @@ import {IconFlag} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Event, Frame} from 'sentry/types/event'; import type { - LineCoverage, SentryAppComponent, SentryAppSchemaStacktraceLink, } from 'sentry/types/integrations'; -import {CodecovStatusCode, Coverage} from 'sentry/types/integrations'; import type {PlatformKey} from 'sentry/types/project'; import type {StacktraceType} from 'sentry/types/stacktrace'; import {defined} from 'sentry/utils'; import {getFileExtension} from 'sentry/utils/fileExtension'; -import {useRouteAnalyticsParams} from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useProjects} from 'sentry/utils/useProjects'; @@ -28,7 +24,6 @@ import {FrameRegisters} from './frameRegisters'; import {FrameVariables} from './frameVariables'; import {usePrismTokensSourceContext} from './usePrismTokensSourceContext'; import {useSourceContext} from './useSourceContext'; -import {useStacktraceCoverage} from './useStacktraceCoverage'; import {hasPotentialSourceContext} from './utils'; type Props = { @@ -50,21 +45,6 @@ type Props = { registersMeta?: Record; }; -export function getLineCoverage( - lines: Frame['context'], - lineCov: LineCoverage[] -): [Array, boolean] { - const keyedCoverage = keyBy(lineCov, 0); - const lineCoverage = lines.map( - ([lineNo]) => keyedCoverage[lineNo]?.[1] - ); - const hasCoverage = lineCoverage.some( - coverage => coverage !== Coverage.NOT_APPLICABLE && coverage !== undefined - ); - - return [lineCoverage, hasCoverage]; -} - export function Context({ hasContextVars = false, hasContextSource = false, @@ -117,22 +97,6 @@ export function Context({ const effectiveContext = hasContextSource ? frame?.context : scmContext; const effectiveHasContextSource = hasContextSource || !!scmContext?.length; - const {data: coverage, isPending: isLoadingCoverage} = useStacktraceCoverage( - { - event, - frame, - orgSlug: organization?.slug || '', - projectSlug: project?.slug, - }, - { - enabled: - defined(organization) && - defined(project) && - !!organization.codecovAccess && - isExpanded, - } - ); - /** * frame.lineNo is the highlighted frame in the middle of the context */ @@ -141,22 +105,6 @@ export function Context({ ? effectiveContext : effectiveContext?.filter(l => l[0] === activeLineNumber); - const hasCoverageData = - !isLoadingCoverage && coverage?.status === CodecovStatusCode.COVERAGE_EXISTS; - - const [lineCoverage = [], hasCoverage] = - hasCoverageData && coverage?.lineCoverage && !!activeLineNumber! && contextLines - ? getLineCoverage(contextLines, coverage.lineCoverage) - : []; - - useRouteAnalyticsParams( - hasCoverageData - ? { - has_line_coverage: hasCoverage, - } - : {} - ); - const fileExtension = getFileExtension(frame.filename || frame.absPath || '') ?? ''; const lines = usePrismTokensSourceContext({ contextLines, @@ -211,7 +159,6 @@ export function Context({ {line.map((token, key) => ( diff --git a/static/app/components/events/interfaces/frame/contextLineNumber.tsx b/static/app/components/events/interfaces/frame/contextLineNumber.tsx index 4ef2b623df1c..14d8b5e41b85 100644 --- a/static/app/components/events/interfaces/frame/contextLineNumber.tsx +++ b/static/app/components/events/interfaces/frame/contextLineNumber.tsx @@ -1,41 +1,15 @@ import styled from '@emotion/styled'; -import classNames from 'classnames'; - -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {t} from 'sentry/locale'; -import {Coverage} from 'sentry/types/integrations'; interface Props { isActive: boolean; lineNumber: number; children?: React.ReactNode; - coverage?: Coverage; } -const coverageText: Record = { - [Coverage.NOT_COVERED]: t('Line uncovered by tests'), - [Coverage.COVERED]: t('Line covered by tests'), - [Coverage.PARTIAL]: t('Line partially covered by tests'), - [Coverage.NOT_APPLICABLE]: undefined, -}; -const coverageClass: Record = { - [Coverage.NOT_COVERED]: 'uncovered', - [Coverage.COVERED]: 'covered', - [Coverage.PARTIAL]: 'partial', - [Coverage.NOT_APPLICABLE]: undefined, -}; - -export function ContextLineNumber({ - lineNumber, - isActive, - coverage = Coverage.NOT_APPLICABLE, -}: Props) { +export function ContextLineNumber({lineNumber, isActive}: Props) { return ( - - -
        {lineNumber}
        -
        + +
        {lineNumber}
        ); } @@ -65,38 +39,7 @@ const Wrapper = styled('div')` user-select: none; } - &.covered .line-number { - background: ${p => p.theme.colors.green100}; - border-right: 3px solid ${p => p.theme.tokens.border.success.vibrant}; - } - - &.uncovered .line-number { - background: ${p => p.theme.colors.red100}; - border-right: 3px solid ${p => p.theme.tokens.border.danger.vibrant}; - } - - &.partial .line-number { - background: ${p => p.theme.colors.yellow100}; - border-right: 3px dashed ${p => p.theme.tokens.border.warning.vibrant}; - } - &.active { background: none; } - - &.active.partial .line-number { - mix-blend-mode: screen; - background: ${p => p.theme.colors.yellow200}; - } - - &.active.covered .line-number { - mix-blend-mode: screen; - background: ${p => p.theme.colors.green200}; - } - - &.active.uncovered .line-number { - color: ${p => p.theme.colors.white}; - mix-blend-mode: screen; - background: ${p => p.theme.colors.red400}; - } `; diff --git a/static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx b/static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx index ca01246d9be5..c9c2742e27ac 100644 --- a/static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx +++ b/static/app/components/events/interfaces/frame/stacktraceLink.spec.tsx @@ -7,12 +7,10 @@ import {ReleaseFixture} from 'sentry-fixture/release'; import {RepositoryFixture} from 'sentry-fixture/repository'; import {RepositoryProjectPathConfigFixture} from 'sentry-fixture/repositoryProjectPathConfig'; -import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; +import {render, screen, waitFor} from 'sentry-test/reactTestingLibrary'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {Frame} from 'sentry/types/event'; -import {CodecovStatusCode} from 'sentry/types/integrations'; -import * as analytics from 'sentry/utils/analytics'; import {StacktraceLink} from './stacktraceLink'; @@ -31,8 +29,6 @@ describe('StacktraceLink', () => { const frame = {filename: '/sentry/app.py', lineNo: 233, inApp: true} as Frame; const config = RepositoryProjectPathConfigFixture({project, repo, integration}); - const analyticsSpy = jest.spyOn(analytics, 'trackAnalytics'); - beforeEach(() => { jest.clearAllMocks(); MockApiClient.clearMockResponses(); @@ -121,105 +117,6 @@ describe('StacktraceLink', () => { expect(await screen.findByText('Set up Code Mapping')).toBeInTheDocument(); }); - it('renders the codecov link', async () => { - const organization = { - ...org, - codecovAccess: true, - features: ['codecov-integration'], - }; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`, - body: { - config, - sourceUrl: 'https://github.com/username/path/to/file.py', - integrations: [integration], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-coverage/`, - body: { - status: CodecovStatusCode.COVERAGE_EXISTS, - lineCoverage: [[233, 0]], - coverageUrl: 'https://app.codecov.io/gh/path/to/file.py', - }, - }); - render( - , - { - organization, - } - ); - - const link = await screen.findByRole('button', {name: 'Open in Codecov'}); - expect(link).toHaveAttribute( - 'href', - 'https://app.codecov.io/gh/path/to/file.py#L233' - ); - - await userEvent.click(link); - expect(analyticsSpy).toHaveBeenCalledWith( - 'integrations.stacktrace_codecov_link_clicked', - expect.anything() - ); - }); - - it('renders the missing coverage warning', async () => { - const organization = { - ...org, - codecovAccess: true, - features: ['codecov-integration'], - }; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`, - body: { - config, - sourceUrl: 'https://github.com/username/path/to/file.py', - integrations: [integration], - }, - }); - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-coverage/`, - body: {status: CodecovStatusCode.NO_COVERAGE_DATA}, - }); - render( - , - { - organization, - } - ); - expect(await screen.findByText('Code Coverage not found')).toBeInTheDocument(); - }); - - it('skips codecov when the feature is disabled at org level', async () => { - const organization = { - ...org, - codecovAccess: false, - features: ['codecov-integration'], - }; - MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-link/`, - body: { - config, - sourceUrl: 'https://github.com/username/path/to/file.py', - integrations: [integration], - }, - }); - const stacktraceCoverageMock = MockApiClient.addMockResponse({ - url: `/projects/${org.slug}/${project.slug}/stacktrace-coverage/`, - body: {status: CodecovStatusCode.NO_COVERAGE_DATA}, - }); - render( - , - { - organization, - } - ); - expect( - await screen.findByRole('button', {name: 'Open this line in GitHub'}) - ).toBeInTheDocument(); - expect(stacktraceCoverageMock).not.toHaveBeenCalled(); - }); - it('renders the link using a valid sourceLink for a .NET project', async () => { const dotnetFrame = { filename: 'path/to/file.py', diff --git a/static/app/components/events/interfaces/frame/stacktraceLink.tsx b/static/app/components/events/interfaces/frame/stacktraceLink.tsx index b27642786aec..05772a0e74fd 100644 --- a/static/app/components/events/interfaces/frame/stacktraceLink.tsx +++ b/static/app/components/events/interfaces/frame/stacktraceLink.tsx @@ -5,19 +5,12 @@ import styled from '@emotion/styled'; import {Button, LinkButton, type LinkButtonProps} from '@sentry/scraps/button'; import {Flex} from '@sentry/scraps/layout'; import {useModal} from '@sentry/scraps/modal'; -import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {CopyFrameLink} from 'sentry/components/events/interfaces/frame/copyFrameLink'; -import {useStacktraceCoverage} from 'sentry/components/events/interfaces/frame/useStacktraceCoverage'; import {hasFileExtension} from 'sentry/components/events/interfaces/frame/utils'; -import {Placeholder} from 'sentry/components/placeholder'; -import {IconWarning} from 'sentry/icons'; import {t} from 'sentry/locale'; import type {Event, Frame} from 'sentry/types/event'; -import type {StacktraceLinkResult} from 'sentry/types/integrations'; -import {CodecovStatusCode} from 'sentry/types/integrations'; -import type {Organization} from 'sentry/types/organization'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getAnalyticsDataForEvent} from 'sentry/utils/events'; import {getIntegrationIcon, getIntegrationSourceUrl} from 'sentry/utils/integrationUtil'; @@ -89,21 +82,6 @@ export function StacktraceLink({frame, event, line, disableSetup}: StacktraceLin enabled: isQueryEnabled, // The query will not run until `isQueryEnabled` is true } ); - const coverageEnabled = - isQueryEnabled && - organization.codecovAccess && - organization.features.includes('codecov-integration'); - const {data: coverage, isPending: isLoadingCoverage} = useStacktraceCoverage( - { - event, - frame, - orgSlug: organization.slug, - projectSlug: project?.slug, - }, - { - enabled: coverageEnabled, - } - ); useRouteAnalyticsParams( match @@ -202,17 +180,6 @@ export function StacktraceLink({frame, event, line, disableSetup}: StacktraceLin icon={getIntegrationIcon(match.config.provider.key, DEFAULT_ICON_SIZE)} /> - {coverageEnabled && isLoadingCoverage ? ( - - ) : coverage && - shouldShowCodecovFeatures(organization, match, coverage.status) ? ( - - ) : null} ); } @@ -239,17 +206,6 @@ export function StacktraceLink({frame, event, line, disableSetup}: StacktraceLin icon={getIntegrationIcon('github', DEFAULT_ICON_SIZE)} /> - {coverageEnabled && isLoadingCoverage ? ( - - ) : coverage && - shouldShowCodecovFeatures(organization, match, coverage.status) ? ( - - ) : null} ); } @@ -317,72 +273,12 @@ export function StacktraceLink({frame, event, line, disableSetup}: StacktraceLin return null; } -function shouldShowCodecovFeatures( - organization: Organization, - match: StacktraceLinkResult, - codecovStatus: CodecovStatusCode -) { - return ( - codecovStatus && - codecovStatus !== CodecovStatusCode.NO_INTEGRATION && - organization.codecovAccess && - match.config?.provider.key === 'github' - ); -} - // This should never have been set, as the icons inside buttons already auto adjust // depending on the button size, however the reason it cannot be removed is that the icon // function initializes a default argument for the icon size to md, meaning we cannot simply remove it. const DEFAULT_ICON_SIZE = 'xs'; const DEFAULT_BUTTON_SIZE = 'xs'; -interface CodecovLinkProps { - event: Event; - organization: Organization; - coverageUrl?: string; - status?: CodecovStatusCode; -} - -function CodecovLink({ - coverageUrl, - status = CodecovStatusCode.COVERAGE_EXISTS, - organization, - event, -}: CodecovLinkProps) { - if (status === CodecovStatusCode.NO_COVERAGE_DATA) { - return ( - - {t('Code Coverage not found')} - - - ); - } - - if (status !== CodecovStatusCode.COVERAGE_EXISTS || !coverageUrl) { - return null; - } - - const onOpenCodecovLink = (e: React.MouseEvent) => { - e.stopPropagation(); - trackAnalytics('integrations.stacktrace_codecov_link_clicked', { - view: 'stacktrace_issue_details', - organization, - group_id: event.groupID ? parseInt(event.groupID, 10) : -1, - ...getAnalyticsDataForEvent(event), - }); - }; - - return ( - - - - ); -} const fadeIn = keyframes` from { opacity: 0; } to { opacity: 1; } diff --git a/static/app/components/events/interfaces/frame/useStacktraceCoverage.tsx b/static/app/components/events/interfaces/frame/useStacktraceCoverage.tsx deleted file mode 100644 index cf7c2261fc41..000000000000 --- a/static/app/components/events/interfaces/frame/useStacktraceCoverage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import {buildStacktraceLinkQuery} from 'sentry/components/events/interfaces/frame/useStacktraceLink'; -import type {Event, Frame} from 'sentry/types/event'; -import type {CodecovResponse} from 'sentry/types/integrations'; -import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import type {ApiQueryKey, UseApiQueryOptions} from 'sentry/utils/queryClient'; -import {useApiQuery} from 'sentry/utils/queryClient'; - -interface UseStacktraceCoverageProps { - event: Partial>; - frame: Partial< - Pick - >; - orgSlug: string; - projectSlug: string | undefined; -} - -const stacktraceCoverageQueryKey = ( - orgSlug: string, - projectSlug: string | undefined, - query: ReturnType -): ApiQueryKey => [ - getApiUrl('/projects/$organizationIdOrSlug/$projectIdOrSlug/stacktrace-coverage/', { - path: { - organizationIdOrSlug: orgSlug, - projectIdOrSlug: projectSlug!, - }, - }), - {query}, -]; - -export function useStacktraceCoverage( - {event, frame, orgSlug, projectSlug}: UseStacktraceCoverageProps, - options: Partial> = {} -) { - const query = buildStacktraceLinkQuery(event, frame); - return useApiQuery( - stacktraceCoverageQueryKey(orgSlug, projectSlug, query), - { - staleTime: Infinity, - retry: false, - ...options, - } - ); -} diff --git a/static/app/components/events/profileEventEvidence.spec.tsx b/static/app/components/events/profileEventEvidence.spec.tsx index e632b24f7b6e..d57954e19d50 100644 --- a/static/app/components/events/profileEventEvidence.spec.tsx +++ b/static/app/components/events/profileEventEvidence.spec.tsx @@ -51,7 +51,7 @@ describe('ProfileEventEvidence', () => { expect(screen.getByRole('button', {name: 'View Profile'})).toHaveAttribute( 'href', - '/organizations/org-slug/explore/profiling/profile/project-slug/profile-id/flamegraph/?frameName=some_func&framePackage=something.dll&referrer=issue' + '/organizations/org-slug/explore/profiles/profile/project-slug/profile-id/flamegraph/?frameName=some_func&framePackage=something.dll&referrer=issue' ); }); diff --git a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx index 844dae53e5ff..b4c0da07e298 100644 --- a/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx +++ b/static/app/components/modals/widgetBuilder/addToDashboardModal.tsx @@ -384,24 +384,27 @@ function AddToDashboardModal({ disabled: hasReachedDashboardLimit || isLoading, tooltip: hasReachedDashboardLimit ? limitMessage : undefined, tooltipOptions: {position: 'right', isHoverable: true}, - }, + } satisfies SelectValue, ...dashboards .filter(dashboard => // if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options currentDashboardId ? dashboard.id !== currentDashboardId : true ) - .map(({title, id, widgetDisplay}) => ({ - label: title, - value: id, - disabled: widgetDisplay.length + widgets.length >= MAX_WIDGETS, - tooltip: - widgetDisplay.length + widgets.length >= MAX_WIDGETS && - tct('Max widgets ([maxWidgets]) per dashboard reached.', { - maxWidgets: MAX_WIDGETS, - }), - tooltipOptions: {position: 'right'}, - })), - ].filter(Boolean) as Array>; + .map( + ({title, id, widgetDisplay}) => + ({ + label: title, + value: id, + disabled: widgetDisplay.length + widgets.length >= MAX_WIDGETS, + tooltip: + widgetDisplay.length + widgets.length >= MAX_WIDGETS && + tct('Max widgets ([maxWidgets]) per dashboard reached.', { + maxWidgets: MAX_WIDGETS, + }), + tooltipOptions: {position: 'right'}, + }) satisfies SelectValue + ), + ].filter(Boolean); }, [currentDashboardId, dashboards, widgets.length] ); diff --git a/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx b/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx deleted file mode 100644 index a079286c0a63..000000000000 --- a/static/app/components/modals/widgetBuilder/linkToDashboardModal.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import {Fragment, useCallback, useEffect, useState, type ReactNode} from 'react'; -import {css} from '@emotion/react'; -import styled from '@emotion/styled'; - -import {Button} from '@sentry/scraps/button'; -import {Flex, Grid, type GridProps, Container} from '@sentry/scraps/layout'; -import {Select} from '@sentry/scraps/select'; -import {Tooltip} from '@sentry/scraps/tooltip'; - -import {fetchDashboard, fetchDashboards} from 'sentry/actionCreators/dashboards'; -import type {ModalRenderProps} from 'sentry/actionCreators/modal'; -import {Spinner} from 'sentry/components/forms/spinner'; -import {IconInfo} from 'sentry/icons'; -import {t, tct} from 'sentry/locale'; -import type {SelectValue} from 'sentry/types/core'; -import {useApi} from 'sentry/utils/useApi'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useParams} from 'sentry/utils/useParams'; -import {DashboardCreateLimitWrapper} from 'sentry/views/dashboards/createLimitWrapper'; -import { - DashboardFilter, - MAX_WIDGETS, - type DashboardDetails, - type DashboardListItem, - type LinkedDashboard, -} from 'sentry/views/dashboards/types'; -import {NEW_DASHBOARD_ID} from 'sentry/views/dashboards/widgetBuilder/utils'; - -export type LinkToDashboardModalProps = { - currentLinkedDashboard?: LinkedDashboard; - onLink?: (dashboardId: string) => void; - // TODO: perhpas make this an enum - source?: string; -}; - -type Props = ModalRenderProps & LinkToDashboardModalProps; - -const SELECT_DASHBOARD_MESSAGE = t('Select a dashboard'); - -export function LinkToDashboardModal({ - Header, - Body, - Footer, - closeModal, - onLink, - currentLinkedDashboard, -}: Props) { - const api = useApi(); - const organization = useOrganization(); - const [dashboards, setDashboards] = useState(null); - const [_, setSelectedDashboard] = useState(null); - const [isDashboardListLoading, setIsDashboardListLoading] = useState(false); - const [selectedDashboardId, setSelectedDashboardId] = useState(null); - - const {dashboardId: currentDashboardId} = useParams<{dashboardId: string}>(); - - useEffect(() => { - // Track mounted state so we dont call setState on unmounted components - let unmounted = false; - - setIsDashboardListLoading(true); - - const dashboardListPromise = fetchDashboards(api, organization.slug, { - filter: DashboardFilter.SHOW_HIDDEN, - }); - const linkedDashboardPromise = currentLinkedDashboard?.dashboardId - ? fetchDashboard(api, organization.slug, currentLinkedDashboard.dashboardId).catch( - () => null - ) - : Promise.resolve(null); - - Promise.all([dashboardListPromise, linkedDashboardPromise]) - .then(([dashboardList, linkedDashboard]) => { - if (unmounted) { - return; - } - - if (linkedDashboard) { - const alreadyIncluded = dashboardList.some(d => d.id === linkedDashboard.id); - if (!alreadyIncluded) { - // Converts dashboard details to dashboard list item - dashboardList.push({ - id: linkedDashboard.id, - title: linkedDashboard.title, - filters: linkedDashboard.filters, - projects: linkedDashboard.projects ?? [], - environment: linkedDashboard.environment ?? [], - widgetDisplay: linkedDashboard.widgets.map(w => w.displayType), - widgetPreview: linkedDashboard.widgets.map(w => ({ - displayType: w.displayType, - layout: w.layout ?? null, - })), - dateCreated: linkedDashboard.dateCreated, - createdBy: linkedDashboard.createdBy, - }); - } - setSelectedDashboardId(linkedDashboard.id); - } - - setDashboards(dashboardList); - }) - .finally(() => { - setIsDashboardListLoading(false); - }); - - return () => { - unmounted = true; - }; - }, [api, organization.slug, currentLinkedDashboard?.dashboardId]); - - useEffect(() => { - // Track mounted state so we dont call setState on unmounted components - let unmounted = false; - - if (selectedDashboardId === NEW_DASHBOARD_ID || selectedDashboardId === null) { - setSelectedDashboard(null); - } else { - fetchDashboard(api, organization.slug, selectedDashboardId).then(response => { - // If component has unmounted, dont set state - if (unmounted) { - return; - } - - setSelectedDashboard(response); - }); - } - - return () => { - unmounted = true; - }; - }, [api, organization.slug, selectedDashboardId]); - - const canSubmit = selectedDashboardId !== null; - - const getOptions = useCallback( - ( - hasReachedDashboardLimit: boolean, - isLoading: boolean, - limitMessage: ReactNode | null - ) => { - if (dashboards === null) { - return []; - } - - return [ - { - label: t('+ Create New Dashboard'), - value: 'new', - disabled: hasReachedDashboardLimit || isLoading, - tooltip: hasReachedDashboardLimit ? limitMessage : undefined, - tooltipOptions: {position: 'right', isHoverable: true}, - }, - ...dashboards - .filter(dashboard => - // if adding from a dashboard, currentDashboardId will be set and we'll remove it from the list of options - currentDashboardId ? dashboard.id !== currentDashboardId : true - ) - .map(({title, id, widgetDisplay}) => ({ - label: title, - value: id, - disabled: widgetDisplay.length >= MAX_WIDGETS, - tooltip: - widgetDisplay.length >= MAX_WIDGETS && - tct('Max widgets ([maxWidgets]) per dashboard reached.', { - maxWidgets: MAX_WIDGETS, - }), - tooltipOptions: {position: 'right'}, - })), - ].filter(Boolean) as Array>; - }, - [currentDashboardId, dashboards] - ); - - function linkToDashboard() { - // TODO: Update the local state of the widget to include the links - // When the user clicks `save widget` we should update the dashboard widget link on the backend - if (selectedDashboardId) { - onLink?.(selectedDashboardId); - } - closeModal(); - } - - return ( - -
        - - {t('Link to Dashboard')} - - - - -
        - - - - {({hasReachedDashboardLimit, isLoading, limitMessage}) => { - if (isDashboardListLoading) { - return ; - } - return ( -